package com.shukria.kiosklauncher.util import android.content.Context import android.content.pm.PackageManager import android.os.Build import android.os.Environment import android.util.Log import com.shukria.kiosklauncher.network.RetrofitClient import com.shukria.kiosklauncher.service.LogPushService import com.shukria.kiosklauncher.service.RemoteControlService import com.shukria.kiosklauncher.service.ScreenShareService import com.shukria.kiosklauncher.ui.ScreenCaptureActivity import com.shukria.kiosklauncher.util.YSDKManager import okhttp3.Request as OkRequest import java.io.File import org.json.JSONObject import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import java.util.TimeZone object CommandDispatcher { private const val TAG = "CommandDispatcher" private var appContext: Context? = null fun dispatch(context: Context, serialNumber: String, command: String, payload: JSONObject) { appContext = context.applicationContext Log.i(TAG, "TMS command received: '$command'") when (command) { // Phase 2 — log streaming "debug_log_upload_start" -> handleLogStart(serialNumber, payload) "debug_log_upload_stop" -> handleLogStop(serialNumber, payload) // Phase 4 — screen share "screen_share_start" -> handleScreenShareStart(context, serialNumber, payload) "screen_share_stop" -> handleScreenShareStop(context, serialNumber, payload) // Phase 5 — remote touch injection "touch_event" -> handleTouchEvent(serialNumber, payload) // Phase 3 — app marketplace / OTA install "UPDATE_APPLICATION" -> handleAppUpdate(context, serialNumber, payload) else -> { Log.w(TAG, "Unrecognised command: '$command'") ack(serialNumber, payload.optString("request_id"), "error", "Unknown command: $command") } } } // ------------------------------------------------------------------------- // Phase 2 — Log streaming // ------------------------------------------------------------------------- private fun handleLogStart(sn: String, payload: JSONObject) { val reqId = payload.optString("request_id") val mode = payload.optString("mode", "REALTIME") val lastLinesCount = payload.optInt("last_lines_count", 100) val frequencySend = payload.optInt("frequency_send", 15) val logLevel = payload.optString("log_level", "ALL") Log.d(TAG, "Log start: mode=$mode lastLines=$lastLinesCount freq=${frequencySend}s") ack(sn, reqId, "success", "Log streaming started in $mode mode") LogPushService.start( sn = sn, mode = mode, lastLinesCount = lastLinesCount, frequencySend = frequencySend, requestId = reqId ) } private fun handleLogStop(sn: String, payload: JSONObject) { val reqId = payload.optString("request_id") Log.d(TAG, "Log stop requested") LogPushService.stop(sn) ack(sn, reqId, "success", "Log streaming stopped") } // ------------------------------------------------------------------------- // Phase 4 — Screen share // ------------------------------------------------------------------------- private fun handleScreenShareStart(context: Context, sn: String, payload: JSONObject) { val reqId = payload.optString("request_id") if (ScreenShareService.isRunning()) { Log.d(TAG, "Screen share already running") ack(sn, reqId, "success", "Screen share already active") return } Log.i(TAG, "Starting ScreenShareService FGS, then launching consent activity") ack(sn, reqId, "success", "Screen share starting") // Step 1: start FGS first (Android 14 requires FGS running before getMediaProjection) ScreenShareService.prepare(context, sn) // Step 2: show consent dialog ScreenCaptureActivity.start(context, sn) } private fun handleScreenShareStop(context: Context, sn: String, payload: JSONObject) { val reqId = payload.optString("request_id") Log.i(TAG, "Stopping screen share") ScreenShareService.stop(context) ack(sn, reqId, "success", "Screen share stopped") } // ------------------------------------------------------------------------- // Phase 5 — Remote touch injection // ------------------------------------------------------------------------- private fun handleTouchEvent(sn: String, payload: JSONObject) { val x = payload.optDouble("x", -1.0).toFloat() val y = payload.optDouble("y", -1.0).toFloat() if (x < 0f || x > 1f || y < 0f || y > 1f) { Log.w(TAG, "touch_event: coords out of range x=$x y=$y") return } Log.d(TAG, "touch_event: injecting tap at x=${"%.3f".format(x)} y=${"%.3f".format(y)}") RemoteControlService.injectTap(x, y) } // ------------------------------------------------------------------------- // Phase 3 — App OTA install // ------------------------------------------------------------------------- private fun handleAppUpdate(context: Context, sn: String, payload: JSONObject) { val reqId = payload.optString("request_id") val batchCode = payload.optString("batch_code", "") val downloadUrl = payload.optString("downloadUrl") val version = payload.optString("version", "unknown") val packageName = payload.optString("packageName", "") val versionCode = payload.optInt("versionCode", -1) val forceUpgrade = payload.optBoolean("forceUpgrade", true) if (downloadUrl.isBlank()) { Log.e(TAG, "UPDATE_APPLICATION missing downloadUrl") ack(sn, reqId, "failed", "Missing downloadUrl") return } if (packageName.isBlank()) { Log.e(TAG, "UPDATE_APPLICATION missing packageName") ack(sn, reqId, "failed", "Missing packageName") return } if (versionCode <= 0) { Log.e(TAG, "UPDATE_APPLICATION invalid versionCode: $versionCode") ack(sn, reqId, "failed", "Missing or invalid versionCode") return } val installed = installedVersionCode(context, packageName) Log.i( TAG, "UPDATE_APPLICATION: batch=$batchCode pkg=$packageName ver=$version " + "versionCode=$versionCode installed=$installed force=$forceUpgrade url=$downloadUrl" ) // Version gate — applies to both silent and manual modes. -1 = not installed, so any version wins. if (versionCode < installed) { Log.i(TAG, "Skipping $packageName: $versionCode is lower than installed $installed") ack(sn, reqId, "skipped_lower", "Command versionCode $versionCode is lower than installed $installed") return } if (versionCode == installed) { Log.i(TAG, "Skipping $packageName: versionCode $versionCode already installed") ack(sn, reqId, "skipped_same", "versionCode $versionCode already installed") return } val fileName = "$packageName-$version.apk" val destDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) val destFile = File(destDir, "ota/$fileName").also { it.parentFile?.mkdirs() } Thread { if (!downloadApk(downloadUrl, destFile, packageName, sn, reqId)) return@Thread if (forceUpgrade) { installSilently(context, destFile, packageName, sn, reqId) } else { promptManualInstall(context, destFile, packageName, version, sn, reqId) } }.start() } /** Installed versionCode for [packageName], or -1 when the package is not installed. */ private fun installedVersionCode(context: Context, packageName: String): Int = try { val pi = context.packageManager.getPackageInfo(packageName, 0) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { pi.longVersionCode.toInt() } else { @Suppress("DEPRECATION") pi.versionCode } } catch (e: PackageManager.NameNotFoundException) { -1 } /** ACKs "failed" itself on any error. @return true when the APK landed on disk. */ private fun downloadApk( downloadUrl: String, destFile: File, packageName: String, sn: String, reqId: String ): Boolean { return try { val response = RetrofitClient.okHttpClient.newCall(OkRequest.Builder().url(downloadUrl).build()).execute() if (!response.isSuccessful) { Log.e(TAG, "HTTP ${response.code()} downloading $packageName") ack(sn, reqId, "failed", "Download failed: HTTP ${response.code()}") return false } val body = response.body() ?: run { ack(sn, reqId, "failed", "Download failed: empty response") return false } body.byteStream().use { input -> destFile.outputStream().use { output -> input.copyTo(output) } } Log.i(TAG, "Downloaded $packageName to ${destFile.absolutePath} (${destFile.length()} bytes)") true } catch (e: Exception) { Log.e(TAG, "Download error for $packageName: ${e.message}") ack(sn, reqId, "failed", "Download error: ${e.message}") false } } /** forceUpgrade = true — silent YSDK install, success ACK comes from the PACKAGE_ADDED broadcast. */ private fun installSilently(context: Context, apkFile: File, packageName: String, sn: String, reqId: String) { // Register before installing so a fast install cannot beat the receiver val receiver = awaitInstallConfirmation(context, packageName, sn, reqId) if (YSDKManager.installApp(apkFile.absolutePath, packageName)) { Log.i(TAG, "YSDK installApp called for $packageName — awaiting PACKAGE_ADDED for confirmation") } else { Log.e(TAG, "YSDK not connected — cannot install $packageName") unregister(context, receiver) ack(sn, reqId, "failed", "Install unavailable: YSDK not connected") } } /** * forceUpgrade = false — notify the user and ACK "queued_manual" straight away so the * server stops re-pushing; "success" follows when the user actually installs. */ private fun promptManualInstall( context: Context, apkFile: File, packageName: String, version: String, sn: String, reqId: String ) { val receiver = awaitInstallConfirmation(context, packageName, sn, reqId) if (AppUpdateNotifier.showManualInstallPrompt(context, apkFile, packageName, version)) { ack(sn, reqId, "queued_manual", "Downloaded $packageName v$version — waiting for user to install") } else { unregister(context, receiver) ack(sn, reqId, "failed", "Could not show install prompt for $packageName") } } /** One-shot receiver that ACKs "success" once the system confirms the install. */ private fun awaitInstallConfirmation( context: Context, packageName: String, sn: String, reqId: String ): android.content.BroadcastReceiver { val receiver = object : android.content.BroadcastReceiver() { override fun onReceive(ctx: android.content.Context, intent: android.content.Intent) { val installedPkg = intent.data?.schemeSpecificPart ?: return if (installedPkg != packageName) return Log.i(TAG, "Install confirmed by system: $packageName") AppUpdateNotifier.cancel(ctx, packageName) ack(sn, reqId, "success", "Installed $packageName — confirmed by system") unregister(ctx, this) } } val filter = android.content.IntentFilter().apply { addAction(android.content.Intent.ACTION_PACKAGE_ADDED) addAction(android.content.Intent.ACTION_PACKAGE_REPLACED) addDataScheme("package") } context.applicationContext.registerReceiver(receiver, filter) return receiver } private fun unregister(context: Context, receiver: android.content.BroadcastReceiver) { try { context.applicationContext.unregisterReceiver(receiver) } catch (e: IllegalArgumentException) { // already unregistered — nothing to do } } // ------------------------------------------------------------------------- // ACK helper — matches TMS ota/ack/+ handler: expects "oid" and "sn" fields // ------------------------------------------------------------------------- private fun ack(serialNumber: String, requestId: String, status: String, message: String) { val json = JSONObject().apply { put("oid", requestId) put("sn", serialNumber) put("status", status) put("message", message) put("timestamp", utcNow()) } MQTTManager.publishJson(MQTTConfig.ackTopic(serialNumber), json, qos = 1) } private fun utcNow(): String = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US) .apply { timeZone = TimeZone.getTimeZone("UTC") } .format(Date()) }