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:
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user