From 5e3408be9a5e84f94d88baf1b1667053b19b4b34 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Tue, 9 Jun 2026 16:14:08 -0500 Subject: [PATCH] fix(android): OTA install never completed; auto-confirm for kiosks (1.7.10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OTA downloaded + verified the new APK and committed a PackageInstaller session, but never handled STATUS_PENDING_USER_ACTION (which Android 13+ returns for non-device-owner installers) — so the session stalled and the update never installed. Reproduced on an Android 13 emulator: device stayed on the old version. - UpdateChecker: register a receiver for the session's INSTALL_COMPLETE broadcast; on PENDING_USER_ACTION launch the system confirm dialog (and log SUCCESS). - PowerAccessibilityService: when the package-installer dialog appears, auto-click the confirm button (by id, then label) so unattended kiosk screens update without a human tap. Scoped strictly to the package installer. Verified end-to-end on Android 13: device auto-updated 1.7.10 -> 1.7.11 with no interaction (receiver launched the dialog, accessibility confirmed it). Ships as 1.7.10 (also carries the Android 14+ crash + YouTube 152 fixes). NOTE: existing 1.7.7 devices still need a one-time manual reinstall to reach a build that has this fix; from 1.7.10 onward OTA is fully automatic. --- VERSION | 2 +- android/app/build.gradle.kts | 4 +- .../service/PowerAccessibilityService.kt | 49 ++++++++++++++++++- .../player/service/UpdateChecker.kt | 37 ++++++++++++++ 4 files changed, 88 insertions(+), 4 deletions(-) diff --git a/VERSION b/VERSION index f65dc1e..a412349 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.7.9 +1.7.10 diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index d541e91..c66ac14 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "com.remotedisplay.player" minSdk = 26 targetSdk = 34 - versionCode = 12 - versionName = "1.7.9" + versionCode = 13 + versionName = "1.7.10" } signingConfigs { diff --git a/android/app/src/main/java/com/remotedisplay/player/service/PowerAccessibilityService.kt b/android/app/src/main/java/com/remotedisplay/player/service/PowerAccessibilityService.kt index 9f53713..acc6701 100644 --- a/android/app/src/main/java/com/remotedisplay/player/service/PowerAccessibilityService.kt +++ b/android/app/src/main/java/com/remotedisplay/player/service/PowerAccessibilityService.kt @@ -8,6 +8,7 @@ import android.util.DisplayMetrics import android.util.Log import android.view.WindowManager import android.view.accessibility.AccessibilityEvent +import android.view.accessibility.AccessibilityNodeInfo class PowerAccessibilityService : AccessibilityService() { @@ -22,7 +23,53 @@ class PowerAccessibilityService : AccessibilityService() { Log.i(TAG, "Service connected") } - override fun onAccessibilityEvent(event: AccessibilityEvent?) {} + private var lastConfirm = 0L + + override fun onAccessibilityEvent(event: AccessibilityEvent?) { + val pkg = event?.packageName?.toString() ?: return + // Auto-confirm the system app-update dialog so OTA updates apply unattended + // on kiosk screens (no one is there to tap "Update"). Scoped to the package + // installer only, so this never touches anything else. + if (!pkg.contains("packageinstaller", ignoreCase = true)) return + if (event.eventType != AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED && + event.eventType != AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED) return + autoConfirmInstall() + } + + private fun autoConfirmInstall() { + val now = System.currentTimeMillis() + if (now - lastConfirm < 1500) return // debounce repeated content events + val root = rootInActiveWindow ?: return + // Positive button by resource id first (locale-independent), then by label. + val ids = listOf( + "com.google.android.packageinstaller:id/ok_button", + "com.android.packageinstaller:id/ok_button", + "android:id/button1" + ) + for (id in ids) { + for (n in root.findAccessibilityNodeInfosByViewId(id)) { + if (clickButton(n)) { lastConfirm = now; Log.i(TAG, "Auto-confirmed install via $id"); return } + } + } + for (label in listOf("Update", "Install", "Reinstall", "Continue")) { + for (n in root.findAccessibilityNodeInfosByText(label)) { + if (clickButton(n)) { lastConfirm = now; Log.i(TAG, "Auto-confirmed install via '$label'"); return } + } + } + } + + // Click the node or its nearest clickable+enabled ancestor (the button). + private fun clickButton(node: AccessibilityNodeInfo?): Boolean { + var cur = node + var depth = 0 + while (cur != null && depth < 4) { + if (cur.isClickable && cur.isEnabled) return cur.performAction(AccessibilityNodeInfo.ACTION_CLICK) + cur = cur.parent + depth++ + } + return false + } + override fun onInterrupt() {} // Global actions diff --git a/android/app/src/main/java/com/remotedisplay/player/service/UpdateChecker.kt b/android/app/src/main/java/com/remotedisplay/player/service/UpdateChecker.kt index 2042afa..46694f4 100644 --- a/android/app/src/main/java/com/remotedisplay/player/service/UpdateChecker.kt +++ b/android/app/src/main/java/com/remotedisplay/player/service/UpdateChecker.kt @@ -37,8 +37,45 @@ class UpdateChecker(private val context: Context) { // Check every 30 minutes private val CHECK_INTERVAL = 30 * 60 * 1000L + private var installReceiverRegistered = false + + // The PackageInstaller session reports its status (incl. STATUS_PENDING_USER_ACTION, + // which Android 13+ returns for non-device-owner installers) via this broadcast. + // Without handling it the committed session just stalls and the update never + // installs. On the action prompt we launch the confirm dialog; the accessibility + // service auto-confirms it on kiosks. + private fun ensureInstallReceiver() { + if (installReceiverRegistered) return + val receiver = object : BroadcastReceiver() { + override fun onReceive(ctx: Context, intent: Intent) { + when (intent.getIntExtra(android.content.pm.PackageInstaller.EXTRA_STATUS, -999)) { + android.content.pm.PackageInstaller.STATUS_PENDING_USER_ACTION -> { + val confirm = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) + intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java) + else @Suppress("DEPRECATION") intent.getParcelableExtra(Intent.EXTRA_INTENT) + if (confirm != null) { + confirm.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + try { context.startActivity(confirm); Log.i(TAG, "Launched install confirmation") } + catch (e: Exception) { Log.e(TAG, "Confirm launch failed: ${e.message}") } + } + } + android.content.pm.PackageInstaller.STATUS_SUCCESS -> Log.i(TAG, "Update installed successfully") + else -> Log.w(TAG, "Install status: ${intent.getStringExtra(android.content.pm.PackageInstaller.EXTRA_STATUS_MESSAGE)}") + } + } + } + val filter = IntentFilter("com.remotedisplay.player.INSTALL_COMPLETE") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED) + } else { + @Suppress("UnspecifiedRegisterReceiverFlag") context.registerReceiver(receiver, filter) + } + installReceiverRegistered = true + } + fun startPeriodicCheck() { stopPeriodicCheck() + ensureInstallReceiver() checkTimer = object : Runnable { override fun run() { checkForUpdate()