diff --git a/TODO.md b/TODO.md
index 43614c0..d02a243 100644
--- a/TODO.md
+++ b/TODO.md
@@ -72,6 +72,23 @@ MediaProjection shows a mandatory system overlay/indicator while capturing — u
Known projects using the screenrecord approach for reference: scrcpy (over ADB), scrcpy-hidden-api, shizuku.
+## Android Autostart on Boot
+
+Boot-time, zero-interaction startup so LedGrab always has display capture and control on rooted TV boxes.
+
+- [x] Manifest: declare `RECEIVE_BOOT_COMPLETED`, `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS`, `WAKE_LOCK`; register `.BootReceiver` for `BOOT_COMPLETED` / `LOCKED_BOOT_COMPLETED` / `MY_PACKAGE_REPLACED`.
+- [x] `BootReceiver.kt` — gated by `AutostartPrefs` + `Root.looksRooted()`; dispatches `CaptureService.createRootIntent()` via `ContextCompat.startForegroundService`. Unrooted devices are a no-op because MediaProjection consent cannot be bypassed silently.
+- [x] `AutostartPrefs.kt` — thin SharedPreferences wrapper, defaults to enabled. Shown as a CheckBox on the stopped panel; greyed out on unrooted devices.
+- [x] `CaptureService` returns `START_REDELIVER_INTENT` for root-mode intents so the OS can cleanly restart the service after being killed (token-free path). MediaProjection-mode keeps `START_NOT_STICKY` — restart is pointless with a dead consent token.
+- [x] `isRunning` race: moved assignment to after `startForeground` succeeds, resets on exception; `onStartCommand` wraps `startForeground` in try/catch and stops the service cleanly if the FG transition fails.
+- [x] Root-capture watchdog: coroutine on `serviceScope` checks `RootScreenrecord.framesDelivered` every 5s after a 5s grace. Respawns the pipeline (reusing the existing Python bridge) on stall, caps at 3 consecutive restarts before giving up.
+- [x] `RootScreenrecord.framesDelivered` exposed as a property backed by `AtomicInteger` (was `@Volatile var framesDelivered = 0` with non-atomic `+= 1`).
+- [x] `ScreenCapture` accepts `onProjectionStopped` lambda — `MediaProjection.Callback.onStop` now tears the whole service down instead of leaving a stale FG notification.
+- [x] `MainActivity` wires the autostart toggle to `AutostartPrefs`; enabling it prompts `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` so Doze doesn't kill the FG service on phones.
+- [x] `versionCode` derived from `git rev-list --count HEAD` (or `ANDROID_VERSION_CODE` env var in CI). Was stuck at 1 — sideload updates were silently refusing to install.
+- [ ] Real-hardware test pending — need to verify on the user's Magisk'd TV box that: (1) boot-time autostart dispatches the service without UI, (2) capture indicator still absent under root mode post-reboot, (3) watchdog respawns the pipeline when `screenrecord` is externally killed, (4) sideload upgrade installs cleanly after the versionCode bump.
+- [ ] Optional follow-up: "kiosk" mode — add `` to `MainActivity` so power users can set LedGrab as the default TV launcher for truly always-running behavior.
+
## Android USB Serial Support
Drive USB LED controllers (APA102, WS2812) connected directly to the Android TV box via USB-to-serial adapters.
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 542aef3..4dc9b41 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -1,9 +1,33 @@
+import java.util.concurrent.TimeUnit
+
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("com.chaquo.python")
}
+// Derive versionCode from git commit count so sideload/Play upgrades
+// always see a strictly-greater value without anyone remembering to
+// bump by hand. ANDROID_VERSION_CODE env var wins for CI pipelines
+// that prefer explicit values; fallback is `1` when neither is
+// available (e.g. building from a tarball with no .git).
+val ledgrabVersionCode: Int = run {
+ System.getenv("ANDROID_VERSION_CODE")?.toIntOrNull()?.let { return@run it }
+ try {
+ val process = ProcessBuilder("git", "rev-list", "--count", "HEAD")
+ .directory(rootDir)
+ .redirectErrorStream(true)
+ .start()
+ if (process.waitFor(5, TimeUnit.SECONDS) && process.exitValue() == 0) {
+ process.inputStream.bufferedReader().readText().trim().toIntOrNull() ?: 1
+ } else {
+ 1
+ }
+ } catch (_: Exception) {
+ 1
+ }
+}
+
android {
namespace = "com.ledgrab.android"
compileSdk = 34
@@ -12,9 +36,10 @@ android {
applicationId = "com.ledgrab.android"
minSdk = 24 // Android 7.0 — covers nearly all TV boxes
targetSdk = 34
- // Bump versionCode on every release (Play Store and sideload
- // updates both require a strictly increasing value).
- versionCode = 1
+ // Derived from git commit count (or ANDROID_VERSION_CODE env var
+ // in CI). See ledgrabVersionCode above. Was stuck at 1 before —
+ // sideload updates silently refused to install.
+ versionCode = ledgrabVersionCode
versionName = "0.3.0"
ndk {
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 0e19c63..7eb3823 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -33,6 +33,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/java/com/ledgrab/android/AutostartPrefs.kt b/android/app/src/main/java/com/ledgrab/android/AutostartPrefs.kt
new file mode 100644
index 0000000..46603a1
--- /dev/null
+++ b/android/app/src/main/java/com/ledgrab/android/AutostartPrefs.kt
@@ -0,0 +1,26 @@
+package com.ledgrab.android
+
+import android.content.Context
+
+/**
+ * Thin SharedPreferences wrapper for the boot-autostart toggle.
+ *
+ * Default is `true`: [BootReceiver] still bails out when the device
+ * isn't rooted, so a true default is safe — it only takes effect on
+ * boxes where we can actually honor it silently.
+ */
+class AutostartPrefs(context: Context) {
+
+ private val prefs = context.applicationContext
+ .getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+
+ var isEnabled: Boolean
+ get() = prefs.getBoolean(KEY_ENABLED, DEFAULT_ENABLED)
+ set(value) { prefs.edit().putBoolean(KEY_ENABLED, value).apply() }
+
+ companion object {
+ private const val PREFS_NAME = "ledgrab_autostart"
+ private const val KEY_ENABLED = "autostart_enabled"
+ private const val DEFAULT_ENABLED = true
+ }
+}
diff --git a/android/app/src/main/java/com/ledgrab/android/BootReceiver.kt b/android/app/src/main/java/com/ledgrab/android/BootReceiver.kt
new file mode 100644
index 0000000..2bf3b57
--- /dev/null
+++ b/android/app/src/main/java/com/ledgrab/android/BootReceiver.kt
@@ -0,0 +1,62 @@
+package com.ledgrab.android
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.util.Log
+import androidx.core.content.ContextCompat
+
+/**
+ * Starts the LedGrab capture service automatically on device boot and
+ * after the app is updated.
+ *
+ * Autostart is best-effort and deliberately limited:
+ * - Requires root. MediaProjection consent cannot be granted silently,
+ * so we can't resume capture on unrooted boxes without the user
+ * walking up to the TV and tapping Start.
+ * - Gated behind [AutostartPrefs] so the user can opt out.
+ *
+ * Receivers declared with BOOT_COMPLETED must be fast — we hand off to
+ * a foreground service within milliseconds and let the service do the
+ * heavy lifting (Chaquopy bootstrap, pipeline setup).
+ */
+class BootReceiver : BroadcastReceiver() {
+
+ override fun onReceive(context: Context, intent: Intent) {
+ val action = intent.action
+ Log.i(TAG, "Boot event: $action")
+
+ if (action != Intent.ACTION_BOOT_COMPLETED
+ && action != Intent.ACTION_LOCKED_BOOT_COMPLETED
+ && action != Intent.ACTION_MY_PACKAGE_REPLACED
+ ) {
+ return
+ }
+
+ if (!AutostartPrefs(context).isEnabled) {
+ Log.i(TAG, "Autostart disabled — skipping")
+ return
+ }
+
+ // Cheap check first (no process spawn). The real `su -c id` probe
+ // would block and may not complete before the OS reclaims us.
+ if (!Root.looksRooted()) {
+ Log.i(TAG, "Device not rooted — cannot autostart without MediaProjection consent")
+ return
+ }
+
+ try {
+ ContextCompat.startForegroundService(
+ context,
+ CaptureService.createRootIntent(context),
+ )
+ Log.i(TAG, "LedGrab capture service dispatched on boot")
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to start service on boot", e)
+ }
+ }
+
+ companion object {
+ private const val TAG = "BootReceiver"
+ }
+}
diff --git a/android/app/src/main/java/com/ledgrab/android/CaptureService.kt b/android/app/src/main/java/com/ledgrab/android/CaptureService.kt
index 2a5a8e5..1647eb1 100644
--- a/android/app/src/main/java/com/ledgrab/android/CaptureService.kt
+++ b/android/app/src/main/java/com/ledgrab/android/CaptureService.kt
@@ -15,6 +15,14 @@ import android.util.DisplayMetrics
import android.util.Log
import android.view.WindowManager
import androidx.core.app.NotificationCompat
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
/**
* Foreground service that runs the Python LedGrab server and captures
@@ -34,6 +42,13 @@ class CaptureService : Service() {
private const val CAPTURE_HEIGHT = 270
private const val CAPTURE_FPS = 30
+ // Watchdog: if no new root-capture frames arrive inside this
+ // window the pipeline is considered stalled and respawned.
+ // Grace gives screenrecord time to produce its first I-frame.
+ private const val WATCHDOG_GRACE_MS = 5_000L
+ private const val WATCHDOG_CHECK_MS = 5_000L
+ private const val WATCHDOG_MAX_RESTARTS = 3
+
/** True while the service is alive. Survives activity recreation. */
@Volatile
@JvmStatic
@@ -64,6 +79,11 @@ class CaptureService : Service() {
private var rootCapture: RootScreenrecord? = null
private var mediaProjection: MediaProjection? = null
+ // Service-scoped coroutine scope for the root-capture watchdog.
+ // SupervisorJob so a watchdog crash doesn't tear down other children.
+ private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+ private var watchdogJob: Job? = null
+
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
@@ -72,32 +92,52 @@ class CaptureService : Service() {
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
- isRunning = true
// CRITICAL: startForeground must be called IMMEDIATELY —
// before any other work, especially before getMediaProjection().
val localIp = NetworkUtils.getLocalIpAddress(this) ?: "unknown"
val url = "http://$localIp:$SERVER_PORT"
- startForeground(NOTIFICATION_ID, buildNotification(url))
+ try {
+ startForeground(NOTIFICATION_ID, buildNotification(url))
+ } catch (e: Exception) {
+ // Most common cause: missing foregroundServiceType permission
+ // or denied POST_NOTIFICATIONS on API 34+.
+ Log.e(TAG, "startForeground failed — service cannot run", e)
+ stopSelf()
+ return START_NOT_STICKY
+ }
+ // Only flip the public flag once the FG transition has succeeded,
+ // otherwise `isRunning=true` sticks forever when startForeground throws.
+ isRunning = true
- if (intent == null) {
- Log.w(TAG, "Service restarted without intent — stopping")
+ val useRoot = intent?.getBooleanExtra(EXTRA_USE_ROOT, false) ?: false
+
+ if (intent == null && !useRoot) {
+ // MediaProjection mode can't recover from a redelivery —
+ // the consent token in the original intent is single-use.
+ Log.w(TAG, "Service restarted without intent (MediaProjection mode) — stopping")
+ isRunning = false
stopSelf()
return START_NOT_STICKY
}
- val useRoot = intent.getBooleanExtra(EXTRA_USE_ROOT, false)
try {
if (useRoot) {
startRootCapture(url)
} else {
- startMediaProjectionCapture(intent, url)
+ startMediaProjectionCapture(intent!!, url)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to start capture", e)
+ isRunning = false
stopSelf()
}
- return START_NOT_STICKY
+ // Root mode can be cleanly restarted from the original intent
+ // because it carries no perishable consent token. MediaProjection
+ // mode cannot — a restart there would silently start the service
+ // with a dead projection. START_NOT_STICKY there, the user has
+ // to tap Start again.
+ return if (useRoot) START_REDELIVER_INTENT else START_NOT_STICKY
}
private fun startRootCapture(url: String) {
@@ -119,9 +159,78 @@ class CaptureService : Service() {
return
}
rootCapture = pipeline
+ startWatchdog()
Log.i(TAG, "LedGrab service started (root mode) — web UI at $url")
}
+ /**
+ * Replace the active root pipeline with a fresh instance, reusing
+ * the existing Python bridge (no server restart). Returns true if
+ * the new pipeline launched, false otherwise.
+ */
+ private fun restartRootPipeline(): Boolean {
+ val currentBridge = bridge ?: return false
+ val old = rootCapture
+ rootCapture = null
+ runCatching { old?.stop() }
+
+ val next = RootScreenrecord(
+ bridge = currentBridge,
+ width = CAPTURE_WIDTH,
+ height = CAPTURE_HEIGHT,
+ fps = CAPTURE_FPS,
+ )
+ if (!next.start()) {
+ Log.e(TAG, "Root capture failed to restart")
+ return false
+ }
+ rootCapture = next
+ return true
+ }
+
+ /**
+ * Monitor the root pipeline's frame counter. If no new frames
+ * arrive within one check window, respawn the pipeline. Caps at
+ * [WATCHDOG_MAX_RESTARTS] consecutive failures before giving up so
+ * we don't burn battery forever on a device that genuinely can't
+ * produce frames.
+ */
+ private fun startWatchdog() {
+ watchdogJob?.cancel()
+ watchdogJob = serviceScope.launch {
+ delay(WATCHDOG_GRACE_MS)
+ var last = rootCapture?.framesDelivered ?: 0
+ var restartAttempts = 0
+ while (isActive) {
+ delay(WATCHDOG_CHECK_MS)
+ val pipe = rootCapture ?: break
+ val current = pipe.framesDelivered
+ if (current == last) {
+ restartAttempts += 1
+ Log.w(
+ TAG,
+ "Root capture stalled (no new frames in ${WATCHDOG_CHECK_MS}ms); " +
+ "restart attempt $restartAttempts/$WATCHDOG_MAX_RESTARTS",
+ )
+ if (restartAttempts > WATCHDOG_MAX_RESTARTS) {
+ Log.e(TAG, "Watchdog gave up after $WATCHDOG_MAX_RESTARTS restarts")
+ stopSelf()
+ return@launch
+ }
+ if (!restartRootPipeline()) {
+ stopSelf()
+ return@launch
+ }
+ last = rootCapture?.framesDelivered ?: 0
+ } else {
+ // Forward progress — forgive earlier glitches.
+ restartAttempts = 0
+ last = current
+ }
+ }
+ }
+ }
+
private fun startMediaProjectionCapture(intent: Intent, url: String) {
val resultCode = intent.getIntExtra(EXTRA_RESULT_CODE, 0)
@Suppress("DEPRECATION")
@@ -178,6 +287,10 @@ class CaptureService : Service() {
targetWidth = CAPTURE_WIDTH,
targetHeight = CAPTURE_HEIGHT,
targetFps = CAPTURE_FPS,
+ // If the user taps the system Cast/Screen-capture stop banner,
+ // MediaProjection.Callback.onStop fires — tear the whole
+ // service down so the notification/Python server don't linger.
+ onProjectionStopped = { stopSelf() },
).also { it.start() }
Log.i(TAG, "LedGrab service started (MediaProjection) — web UI at $url")
@@ -186,6 +299,10 @@ class CaptureService : Service() {
override fun onDestroy() {
isRunning = false
+ watchdogJob?.cancel()
+ watchdogJob = null
+ serviceScope.cancel()
+
screenCapture?.stop()
screenCapture = null
diff --git a/android/app/src/main/java/com/ledgrab/android/MainActivity.kt b/android/app/src/main/java/com/ledgrab/android/MainActivity.kt
index fa81d8a..ab22dd3 100644
--- a/android/app/src/main/java/com/ledgrab/android/MainActivity.kt
+++ b/android/app/src/main/java/com/ledgrab/android/MainActivity.kt
@@ -1,18 +1,24 @@
package com.ledgrab.android
import android.Manifest
+import android.annotation.SuppressLint
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.media.projection.MediaProjectionManager
+import android.net.Uri
import android.os.Build
import android.os.Bundle
+import android.os.PowerManager
+import android.provider.Settings
import android.util.Log
import android.view.View
import android.widget.Button
+import android.widget.CheckBox
import android.widget.ImageView
import android.widget.TextView
import android.app.Activity
+import androidx.core.content.ContextCompat
import com.google.zxing.BarcodeFormat
import com.google.zxing.qrcode.QRCodeWriter
import kotlinx.coroutines.CoroutineScope
@@ -50,6 +56,8 @@ class MainActivity : Activity() {
private lateinit var toggleButton: Button
private lateinit var stopButtonRunning: Button
private lateinit var versionText: TextView
+ private lateinit var autostartCheck: CheckBox
+ private lateinit var autostartPrefs: AutostartPrefs
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -71,10 +79,25 @@ class MainActivity : Activity() {
toggleButton = findViewById(R.id.toggle_button)
stopButtonRunning = findViewById(R.id.stop_button_running)
versionText = findViewById(R.id.version_text)
+ autostartCheck = findViewById(R.id.autostart_check)
val versionName = packageManager
.getPackageInfo(packageName, 0).versionName
- versionText.text = "v${versionName ?: "?"}"
+ versionText.text = getString(R.string.version_prefix, versionName ?: "?")
+
+ autostartPrefs = AutostartPrefs(this)
+ autostartCheck.isChecked = autostartPrefs.isEnabled
+ // Autostart only takes effect on rooted devices — grey it out
+ // on unrooted hardware so users don't expect magic. Cheap probe
+ // (file-existence only, no process spawn).
+ if (!Root.looksRooted()) {
+ autostartCheck.isEnabled = false
+ autostartCheck.text = getString(R.string.autostart_unavailable)
+ }
+ autostartCheck.setOnCheckedChangeListener { _, isChecked ->
+ autostartPrefs.isEnabled = isChecked
+ if (isChecked) ensureIgnoringBatteryOptimizations()
+ }
toggleButton.setOnClickListener { startCapture() }
stopButtonRunning.setOnClickListener { stopCaptureService() }
@@ -122,7 +145,7 @@ class MainActivity : Activity() {
private fun startRootCaptureService() {
ensureNotificationPermission()
- startForegroundService(CaptureService.createRootIntent(this))
+ ContextCompat.startForegroundService(this, CaptureService.createRootIntent(this))
updateUI()
}
@@ -142,7 +165,7 @@ class MainActivity : Activity() {
private fun startCaptureService(resultCode: Int, resultData: Intent) {
ensureNotificationPermission()
val intent = CaptureService.createIntent(this, resultCode, resultData)
- startForegroundService(intent)
+ ContextCompat.startForegroundService(this, intent)
updateUI()
}
@@ -238,6 +261,38 @@ class MainActivity : Activity() {
setContentView(container)
}
+ /**
+ * Prompt the user to exempt LedGrab from battery optimization. On
+ * TV boxes this is usually a no-op, but on phones Doze/App Standby
+ * will kill the foreground service after a few hours of sleep. We
+ * only ask when autostart is turned on. No-op on pre-M or when
+ * already exempt.
+ *
+ * Play Store flags REQUEST_IGNORE_BATTERY_OPTIMIZATIONS by default
+ * — LedGrab's ambient-capture use case falls under the documented
+ * acceptable-use exceptions (a foreground service that must not be
+ * killed to fulfill its primary function).
+ */
+ @SuppressLint("BatteryLife")
+ private fun ensureIgnoringBatteryOptimizations() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return
+ val pm = getSystemService(POWER_SERVICE) as PowerManager
+ if (pm.isIgnoringBatteryOptimizations(packageName)) return
+ try {
+ val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
+ data = Uri.parse("package:$packageName")
+ }
+ startActivity(intent)
+ } catch (e: Exception) {
+ // Some TV-box OEM builds strip this intent. Fall back to the
+ // generic settings screen so the user can find it manually.
+ Log.w(TAG, "Direct exemption intent unavailable: ${e.message}")
+ runCatching {
+ startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS))
+ }
+ }
+ }
+
/**
* Request POST_NOTIFICATIONS permission on Android 13+ so the
* foreground service notification is visible. On older API levels
diff --git a/android/app/src/main/java/com/ledgrab/android/RootScreenrecord.kt b/android/app/src/main/java/com/ledgrab/android/RootScreenrecord.kt
index 30e1f7d..4b8754d 100644
--- a/android/app/src/main/java/com/ledgrab/android/RootScreenrecord.kt
+++ b/android/app/src/main/java/com/ledgrab/android/RootScreenrecord.kt
@@ -8,6 +8,7 @@ import android.os.Handler
import android.os.HandlerThread
import android.util.Log
import java.io.InputStream
+import java.util.concurrent.atomic.AtomicInteger
/**
* Root-only screen capture backed by `/system/bin/screenrecord`.
@@ -46,9 +47,12 @@ class RootScreenrecord(
private var inputThread: Thread? = null
private var outputThread: Thread? = null
@Volatile private var running = false
- @Volatile private var framesDelivered = 0
+ private val framesDeliveredCounter = AtomicInteger(0)
@Volatile private var stopped = false
+ /** Monotonic count of frames pushed to the Python bridge. */
+ val framesDelivered: Int get() = framesDeliveredCounter.get()
+
/** True once at least one frame has reached the Python bridge. */
val hasProducedFrame: Boolean get() = framesDelivered > 0
@@ -119,7 +123,7 @@ class RootScreenrecord(
runCatching { process?.destroy() }
process = null
- Log.i(TAG, "Root capture pipeline stopped (frames delivered: $framesDelivered)")
+ Log.i(TAG, "Root capture pipeline stopped (frames delivered: ${framesDelivered})")
}
private fun buildImageReader(): ImageReader {
@@ -148,7 +152,7 @@ class RootScreenrecord(
}
}
bridge.pushRootFrame(bytes, width, height)
- framesDelivered += 1
+ framesDeliveredCounter.incrementAndGet()
} catch (e: Exception) {
Log.w(TAG, "Root frame delivery failed: ${e.message}")
} finally {
diff --git a/android/app/src/main/java/com/ledgrab/android/ScreenCapture.kt b/android/app/src/main/java/com/ledgrab/android/ScreenCapture.kt
index 6e3806c..6838433 100644
--- a/android/app/src/main/java/com/ledgrab/android/ScreenCapture.kt
+++ b/android/app/src/main/java/com/ledgrab/android/ScreenCapture.kt
@@ -27,6 +27,7 @@ class ScreenCapture(
private val targetWidth: Int = 480,
private val targetHeight: Int = 270,
private val targetFps: Int = 30,
+ private val onProjectionStopped: () -> Unit = {},
) {
companion object {
private const val TAG = "ScreenCapture"
@@ -54,8 +55,13 @@ class ScreenCapture(
// Android 14+ requires registering a callback before createVirtualDisplay
projection.registerCallback(object : MediaProjection.Callback() {
override fun onStop() {
- Log.i(TAG, "MediaProjection stopped")
+ Log.i(TAG, "MediaProjection stopped (external)")
stop()
+ // Notify the service so the foreground notification /
+ // Python server get torn down too — otherwise a stale
+ // "Running" notification lingers after the user taps
+ // Android's system Cast/Screen-capture stop banner.
+ onProjectionStopped()
}
}, captureHandler)
diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml
index 0b9d19e..6f135e7 100644
--- a/android/app/src/main/res/layout/activity_main.xml
+++ b/android/app/src/main/res/layout/activity_main.xml
@@ -52,6 +52,18 @@
android:textSize="22sp"
android:focusable="true"
android:focusableInTouchMode="true" />
+
+
diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml
index 3ee993b..7474e48 100644
--- a/android/app/src/main/res/values-ru/strings.xml
+++ b/android/app/src/main/res/values-ru/strings.xml
@@ -8,4 +8,7 @@
Адрес веб-интерфейса
Сканируйте для настройки
QR-код для веб-интерфейса
+ v%1$s
+ Запускать при загрузке (только с root)
+ Запуск при загрузке — недоступно (нужен root)
diff --git a/android/app/src/main/res/values-zh/strings.xml b/android/app/src/main/res/values-zh/strings.xml
index be004f6..61ed074 100644
--- a/android/app/src/main/res/values-zh/strings.xml
+++ b/android/app/src/main/res/values-zh/strings.xml
@@ -8,4 +8,7 @@
Web界面地址
扫码配置
Web界面二维码
+ v%1$s
+ 开机自启(仅限 root)
+ 开机自启 — 不可用(需要 root)
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 2896672..97b5eaf 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -8,4 +8,7 @@
Web UI address
Scan to configure
QR code for web UI
+ v%1$s
+ Start on boot (root only)
+ Start on boot — unavailable (root required)