feat(android): boot-time autostart, capture watchdog, versionCode from git

Boot-time startup so LedGrab has display capture and control without user
interaction on rooted TV boxes. Also folds in a batch of review findings
from the Android package audit.

Autostart
- BootReceiver fires on BOOT_COMPLETED / LOCKED_BOOT_COMPLETED /
  MY_PACKAGE_REPLACED, gated by AutostartPrefs and Root.looksRooted().
  Dispatches CaptureService.createRootIntent via
  ContextCompat.startForegroundService. Unrooted devices are a no-op
  because MediaProjection consent cannot be bypassed silently.
- AutostartPrefs: thin SharedPreferences wrapper, defaults to enabled.
  Exposed as a CheckBox on the stopped panel; greyed out when not rooted.
- Manifest: RECEIVE_BOOT_COMPLETED, REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
  WAKE_LOCK permissions + the new BootReceiver.
- MainActivity prompts for battery-optimization exemption on first opt-in
  so Doze/App Standby doesn't kill the FG service on phones.

Service stability
- onStartCommand now flips isRunning only after startForeground succeeds
  (was stuck=true forever if the FG transition threw) and resets on
  exception. Returns START_REDELIVER_INTENT for root mode so the OS can
  restart the service with the original intent (no consent token to
  invalidate); MediaProjection mode keeps START_NOT_STICKY.
- Watchdog coroutine monitors RootScreenrecord.framesDelivered. Respawns
  the pipeline on stall (reusing the existing Python bridge — no server
  restart), caps at 3 consecutive restarts before giving up.
- RootScreenrecord.framesDelivered is now an AtomicInteger, exposed as a
  public property for the watchdog.
- ScreenCapture takes an onProjectionStopped lambda; when the user taps
  the system Cast/Screen-capture stop banner, the whole service is torn
  down instead of leaving a stale FG notification.
- MainActivity's two startForegroundService calls switch to
  ContextCompat.startForegroundService, clearing pre-existing NewApi lint
  errors (minSdk=24 < API 26 native method).

Build
- versionCode derived from git rev-list --count HEAD (or the
  ANDROID_VERSION_CODE env var for CI). Was pinned to 1 — sideload
  upgrades were silently refusing to install.
- New i18n strings (autostart_label, autostart_unavailable, version_prefix)
  in en/ru/zh; version_text now uses the resource instead of string
  concat.

TODO.md: new "Android Autostart on Boot" section tracking done/pending
items; real-hardware verification on a Magisk'd TV box is the remaining
checkbox.
This commit is contained in:
2026-04-21 18:53:27 +03:00
parent 45f93fd30e
commit b3775b2f98
13 changed files with 375 additions and 17 deletions
+17
View File
@@ -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 `<category android:name="android.intent.category.HOME" />` 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.
+28 -3
View File
@@ -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 {
+25
View File
@@ -33,6 +33,16 @@
<!-- POST_NOTIFICATIONS for Android 13+ foreground service notification -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Autostart on boot — BootReceiver spawns CaptureService in root
mode so capture resumes without the user touching the remote. -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- Exempt from Doze/App Standby so the FG service isn't killed
overnight on phones; essentially a no-op on TV boxes. -->
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<!-- Optional wake lock for sustained-performance boxes that aggressively
sleep the CPU with the display off. -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Android TV declarations -->
<uses-feature
android:name="android.software.leanback"
@@ -71,6 +81,21 @@
android:name=".CaptureService"
android:foregroundServiceType="mediaProjection"
android:exported="false" />
<!-- Autostart — fires on device boot (and package replace).
On rooted devices, launches CaptureService directly so capture
resumes without the user tapping Start. Unrooted devices are
no-op because MediaProjection consent cannot be bypassed. -->
<receiver
android:name=".BootReceiver"
android:exported="true"
android:enabled="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
</application>
</manifest>
@@ -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
}
}
@@ -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"
}
}
@@ -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
@@ -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
@@ -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 {
@@ -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)
@@ -52,6 +52,18 @@
android:textSize="22sp"
android:focusable="true"
android:focusableInTouchMode="true" />
<CheckBox
android:id="@+id/autostart_check"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
android:text="@string/autostart_label"
android:textColor="@color/text_secondary"
android:textSize="20sp"
android:buttonTint="@color/teal_accent"
android:focusable="true"
android:focusableInTouchMode="true" />
</LinearLayout>
<!-- Version at bottom -->
@@ -8,4 +8,7 @@
<string name="label_web_ui">Адрес веб-интерфейса</string>
<string name="scan_to_configure">Сканируйте для настройки</string>
<string name="qr_description">QR-код для веб-интерфейса</string>
<string name="version_prefix">v%1$s</string>
<string name="autostart_label">Запускать при загрузке (только с root)</string>
<string name="autostart_unavailable">Запуск при загрузке — недоступно (нужен root)</string>
</resources>
@@ -8,4 +8,7 @@
<string name="label_web_ui">Web界面地址</string>
<string name="scan_to_configure">扫码配置</string>
<string name="qr_description">Web界面二维码</string>
<string name="version_prefix">v%1$s</string>
<string name="autostart_label">开机自启(仅限 root)</string>
<string name="autostart_unavailable">开机自启 — 不可用(需要 root)</string>
</resources>
@@ -8,4 +8,7 @@
<string name="label_web_ui">Web UI address</string>
<string name="scan_to_configure">Scan to configure</string>
<string name="qr_description">QR code for web UI</string>
<string name="version_prefix">v%1$s</string>
<string name="autostart_label">Start on boot (root only)</string>
<string name="autostart_unavailable">Start on boot — unavailable (root required)</string>
</resources>