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)