feat(devices): BLE LED controller support (SP110E/Triones/Zengge/Govee)
End-to-end BLE streaming: provider + client + per-protocol wire encoders with whole-strip averaging, desktop (bleak) and Android (Kotlin BleBridge via Chaquopy) transports, discovery with protocol-family detection that auto-fills the UI, throttled not-connected warning + 10 s reconnect cooldown so a dropped link no longer stalls the pipeline at ~30 s/frame, and an explicit asyncio.wait_for wrapper around bleak connect() since the WinRT backend doesn't always honor the timeout kwarg. Also rewrites server/restart.ps1 to be parameterized (-Port / -Module / -PythonVersion / timeouts / -Quiet), pick the right interpreter via the py launcher, pre-flight the target module, poll port readiness on both shutdown and startup, redirect child stdout/stderr so Start-Process doesn't hang on inherited Git-Bash handles, and return proper exit codes. Rolls in concurrent work: Android BLE permissions + launcher icons + ru/zh resources, Chaquopy-safe value_stream psutil fallback, setup-required modal, asset-store test coverage, and misc system/config touch-ups.
This commit is contained in:
@@ -12,6 +12,8 @@ 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
|
||||
versionName = "0.3.0"
|
||||
|
||||
@@ -23,6 +25,17 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
// Per-ABI APK splits — reduces download size by ~60% vs universal APK.
|
||||
// Each split contains only one native ABI's shared libraries + wheels.
|
||||
splits {
|
||||
abi {
|
||||
isEnable = true
|
||||
reset()
|
||||
include("arm64-v8a", "x86_64", "x86")
|
||||
isUniversalApk = true // also produce a fat APK for sideloading
|
||||
}
|
||||
}
|
||||
|
||||
// Signing config from env vars (CI) — only registered when all four are set.
|
||||
// Local release builds fall back to the debug signing config.
|
||||
val ciKeystorePath = System.getenv("ANDROID_KEYSTORE_PATH")
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!-- BLE scanning and connecting — API ≥31 uses granular permissions;
|
||||
older releases need BLUETOOTH + ACCESS_FINE_LOCATION for scanning.
|
||||
neverForLocation avoids the location permission dialog on API 31+. -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH"
|
||||
android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"
|
||||
android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"
|
||||
android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
|
||||
android:usesPermissionFlags="neverForLocation"
|
||||
tools:targetApi="s" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
|
||||
<!-- BLE hardware — required=false so non-BT boxes still install. -->
|
||||
<uses-feature
|
||||
android:name="android.hardware.bluetooth_le"
|
||||
android:required="false" />
|
||||
|
||||
<!-- Network access for WLED HTTP/UDP, web UI, MQTT -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
@@ -30,9 +50,10 @@
|
||||
<application
|
||||
android:name=".LedGrabApp"
|
||||
android:allowBackup="false"
|
||||
android:icon="@drawable/ic_launcher"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:banner="@drawable/ic_launcher"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:theme="@style/Theme.LedGrab">
|
||||
|
||||
<!-- TV launcher activity -->
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
package com.ledgrab.android
|
||||
|
||||
import android.bluetooth.BluetoothGatt
|
||||
import android.bluetooth.BluetoothGattCallback
|
||||
import android.bluetooth.BluetoothGattCharacteristic
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.bluetooth.BluetoothProfile
|
||||
import android.bluetooth.le.ScanCallback
|
||||
import android.bluetooth.le.ScanResult
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.util.Log
|
||||
import java.util.Collections
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
|
||||
/**
|
||||
* Android BLE bridge exposed to the Python server via Chaquopy.
|
||||
*
|
||||
* Wraps the Android BluetoothGatt / BluetoothLeScanner APIs into
|
||||
* synchronous, blocking calls that can be safely invoked from
|
||||
* a Python thread (Chaquopy proxy threads are real OS threads).
|
||||
*
|
||||
* All GATT callbacks run on a private [HandlerThread] so they don't
|
||||
* block the main looper. [runBlocking] is used to bridge callback
|
||||
* completions back to the calling Python thread.
|
||||
*
|
||||
* Python callers access the singleton via
|
||||
* `BleBridge.INSTANCE.scan()` etc. — see
|
||||
* `server/src/ledgrab/core/devices/android_ble_transport.py`.
|
||||
*/
|
||||
object BleBridge {
|
||||
private const val TAG = "BleBridge"
|
||||
private const val CONNECT_TIMEOUT_MS = 18_000L // connect + service discovery
|
||||
private const val WRITE_TIMEOUT_MS = 5_000L
|
||||
|
||||
@Volatile private var appContext: Context? = null
|
||||
|
||||
private val handleSeq = AtomicInteger(1)
|
||||
|
||||
// Dedicated looper thread so BLE callbacks don't land on the main thread.
|
||||
private val bleHandlerThread = HandlerThread("LedGrab-BLE").also { it.start() }
|
||||
private val bleHandler = Handler(bleHandlerThread.looper)
|
||||
|
||||
private data class GattHandle(
|
||||
val gatt: BluetoothGatt,
|
||||
val writeChar: BluetoothGattCharacteristic,
|
||||
)
|
||||
|
||||
private val handles = ConcurrentHashMap<Int, GattHandle>()
|
||||
|
||||
// Write completion futures, keyed by handle. Only populated for
|
||||
// WRITE_TYPE_DEFAULT (with-response) writes.
|
||||
private val pendingWrites = ConcurrentHashMap<Int, CompletableDeferred<Boolean>>()
|
||||
|
||||
/** Called once from [LedGrabApp.onCreate] to bind the application context. */
|
||||
@JvmStatic
|
||||
fun init(context: Context) {
|
||||
appContext = context.applicationContext
|
||||
}
|
||||
|
||||
private fun ctx(): Context =
|
||||
appContext ?: error("BleBridge.init() not called — app context unavailable")
|
||||
|
||||
private fun adapter() =
|
||||
ctx().getSystemService(BluetoothManager::class.java)?.adapter
|
||||
|
||||
// ─── Public API ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Scan for BLE peripherals for [timeoutMs] milliseconds.
|
||||
*
|
||||
* Returns a list of `"address|name|rssi"` strings. Addresses are
|
||||
* deduplicated — only the last-seen RSSI for each address is kept.
|
||||
* Returns an empty list if Bluetooth is off or the permission is denied.
|
||||
*/
|
||||
@JvmStatic
|
||||
@JvmOverloads
|
||||
fun scan(timeoutMs: Long = 4_000L): List<String> {
|
||||
val adapter = adapter() ?: return emptyList()
|
||||
if (!adapter.isEnabled) return emptyList()
|
||||
val scanner = try { adapter.bluetoothLeScanner } catch (_: SecurityException) { null }
|
||||
?: return emptyList()
|
||||
|
||||
val seen = Collections.synchronizedMap(LinkedHashMap<String, String>())
|
||||
val callback = object : ScanCallback() {
|
||||
override fun onScanResult(callbackType: Int, result: ScanResult) {
|
||||
val address = result.device.address ?: return
|
||||
val name = result.scanRecord?.deviceName ?: result.device.name ?: ""
|
||||
seen[address] = "$address|$name|${result.rssi}"
|
||||
}
|
||||
|
||||
override fun onScanFailed(errorCode: Int) {
|
||||
Log.w(TAG, "BLE scan failed with error $errorCode")
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
bleHandler.post { scanner.startScan(callback) }
|
||||
Thread.sleep(timeoutMs)
|
||||
} catch (_: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
} finally {
|
||||
try { bleHandler.post { scanner.stopScan(callback) } } catch (_: SecurityException) {}
|
||||
}
|
||||
return seen.values.toList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the BLE peripheral at [address] and locate the GATT
|
||||
* characteristic identified by [writeCharUuid] across all services.
|
||||
*
|
||||
* Blocks until connected + services discovered, or returns -1 on failure.
|
||||
* The returned integer is an opaque handle passed to [write]/[disconnect].
|
||||
*/
|
||||
@JvmStatic
|
||||
fun connect(address: String, writeCharUuid: String): Int {
|
||||
val adapter = adapter() ?: return -1
|
||||
val device = try { adapter.getRemoteDevice(address) } catch (e: Exception) {
|
||||
Log.e(TAG, "Invalid BLE address '$address': ${e.message}")
|
||||
return -1
|
||||
}
|
||||
|
||||
val readyDeferred = CompletableDeferred<Boolean>()
|
||||
|
||||
val callback = object : BluetoothGattCallback() {
|
||||
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
|
||||
when {
|
||||
newState == BluetoothProfile.STATE_CONNECTED
|
||||
&& status == BluetoothGatt.GATT_SUCCESS -> {
|
||||
Log.d(TAG, "GATT connected to $address, discovering services")
|
||||
gatt.discoverServices()
|
||||
}
|
||||
newState == BluetoothProfile.STATE_DISCONNECTED -> {
|
||||
Log.w(TAG, "GATT disconnected from $address (status=$status)")
|
||||
readyDeferred.complete(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
|
||||
readyDeferred.complete(status == BluetoothGatt.GATT_SUCCESS)
|
||||
}
|
||||
|
||||
override fun onCharacteristicWrite(
|
||||
gatt: BluetoothGatt,
|
||||
characteristic: BluetoothGattCharacteristic,
|
||||
status: Int,
|
||||
) {
|
||||
val h = handles.entries.firstOrNull { it.value.gatt === gatt }?.key ?: return
|
||||
pendingWrites.remove(h)?.complete(status == BluetoothGatt.GATT_SUCCESS)
|
||||
}
|
||||
}
|
||||
|
||||
val gatt: BluetoothGatt = try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
device.connectGatt(
|
||||
ctx(), false, callback,
|
||||
android.bluetooth.BluetoothDevice.TRANSPORT_LE,
|
||||
android.bluetooth.BluetoothDevice.PHY_LE_1M_MASK,
|
||||
bleHandler,
|
||||
)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
device.connectGatt(
|
||||
ctx(), false, callback,
|
||||
android.bluetooth.BluetoothDevice.TRANSPORT_LE,
|
||||
)
|
||||
}
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "BLUETOOTH_CONNECT permission denied for $address", e)
|
||||
return -1
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "connectGatt failed for $address", e)
|
||||
return -1
|
||||
}
|
||||
|
||||
val ready = try {
|
||||
runBlocking { withTimeout(CONNECT_TIMEOUT_MS) { readyDeferred.await() } }
|
||||
} catch (_: TimeoutCancellationException) {
|
||||
Log.e(TAG, "BLE connect+discovery timed out for $address")
|
||||
runCatching { gatt.close() }
|
||||
return -1
|
||||
}
|
||||
|
||||
if (!ready) {
|
||||
runCatching { gatt.close() }
|
||||
return -1
|
||||
}
|
||||
|
||||
val charUuid = try { UUID.fromString(writeCharUuid) } catch (e: Exception) {
|
||||
Log.e(TAG, "Invalid characteristic UUID '$writeCharUuid'")
|
||||
gatt.disconnect(); gatt.close()
|
||||
return -1
|
||||
}
|
||||
val writeChar = gatt.services.flatMap { it.characteristics }
|
||||
.firstOrNull { it.uuid == charUuid }
|
||||
|
||||
if (writeChar == null) {
|
||||
Log.e(TAG, "Characteristic $writeCharUuid not found on $address")
|
||||
gatt.disconnect(); gatt.close()
|
||||
return -1
|
||||
}
|
||||
|
||||
val handle = handleSeq.getAndIncrement()
|
||||
handles[handle] = GattHandle(gatt, writeChar)
|
||||
Log.i(TAG, "BLE connected: address=$address char=$writeCharUuid handle=$handle")
|
||||
return handle
|
||||
}
|
||||
|
||||
/**
|
||||
* Write [data] to the characteristic associated with [handle].
|
||||
*
|
||||
* [withResponse] controls the GATT write type:
|
||||
* - `true` → Write Request (waits for device ACK, slower but reliable)
|
||||
* - `false` → Write Command (fire-and-forget, faster, used by SP110E/Triones/Zengge)
|
||||
*
|
||||
* Returns `true` on success, `false` on any error.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun write(handle: Int, data: ByteArray, withResponse: Boolean): Boolean {
|
||||
val entry = handles[handle] ?: return false
|
||||
val gatt = entry.gatt
|
||||
val char = entry.writeChar
|
||||
val writeType = if (withResponse)
|
||||
BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
|
||||
else
|
||||
BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
|
||||
|
||||
return if (withResponse) {
|
||||
val deferred = CompletableDeferred<Boolean>()
|
||||
pendingWrites[handle] = deferred
|
||||
|
||||
val initiated = gattWrite(gatt, char, data, writeType)
|
||||
if (!initiated) {
|
||||
pendingWrites.remove(handle)
|
||||
return false
|
||||
}
|
||||
try {
|
||||
runBlocking { withTimeout(WRITE_TIMEOUT_MS) { deferred.await() } }
|
||||
} catch (_: TimeoutCancellationException) {
|
||||
pendingWrites.remove(handle)
|
||||
Log.w(TAG, "BLE write-with-response timed out on handle $handle")
|
||||
false
|
||||
}
|
||||
} else {
|
||||
gattWrite(gatt, char, data, writeType)
|
||||
}
|
||||
}
|
||||
|
||||
/** Disconnect and close the GATT connection for [handle]. */
|
||||
@JvmStatic
|
||||
fun disconnect(handle: Int) {
|
||||
val entry = handles.remove(handle) ?: return
|
||||
pendingWrites.remove(handle)?.complete(false)
|
||||
runCatching {
|
||||
entry.gatt.disconnect()
|
||||
entry.gatt.close()
|
||||
}.onFailure { Log.w(TAG, "BLE disconnect error for handle $handle: ${it.message}") }
|
||||
Log.i(TAG, "BLE disconnected handle=$handle")
|
||||
}
|
||||
|
||||
// ─── Internal helpers ─────────────────────────────────────────────────────
|
||||
|
||||
private fun gattWrite(
|
||||
gatt: BluetoothGatt,
|
||||
char: BluetoothGattCharacteristic,
|
||||
data: ByteArray,
|
||||
writeType: Int,
|
||||
): Boolean = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
gatt.writeCharacteristic(char, data, writeType) ==
|
||||
android.bluetooth.BluetoothStatusCodes.SUCCESS
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
char.writeType = writeType
|
||||
@Suppress("DEPRECATION")
|
||||
char.value = data
|
||||
@Suppress("DEPRECATION")
|
||||
gatt.writeCharacteristic(char)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.ledgrab.android
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
@@ -33,6 +34,12 @@ class CaptureService : Service() {
|
||||
private const val CAPTURE_HEIGHT = 270
|
||||
private const val CAPTURE_FPS = 30
|
||||
|
||||
/** True while the service is alive. Survives activity recreation. */
|
||||
@Volatile
|
||||
@JvmStatic
|
||||
var isRunning: Boolean = false
|
||||
private set
|
||||
|
||||
fun createIntent(
|
||||
context: Context,
|
||||
resultCode: Int,
|
||||
@@ -65,6 +72,7 @@ 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"
|
||||
@@ -139,10 +147,23 @@ class CaptureService : Service() {
|
||||
}
|
||||
mediaProjection = projection
|
||||
|
||||
val windowManager = getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
val metrics = DisplayMetrics()
|
||||
@Suppress("DEPRECATION")
|
||||
windowManager.defaultDisplay.getRealMetrics(metrics)
|
||||
val metrics = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
val windowMetrics = (getSystemService(Context.WINDOW_SERVICE) as WindowManager)
|
||||
.currentWindowMetrics
|
||||
DisplayMetrics().apply {
|
||||
val bounds = windowMetrics.bounds
|
||||
widthPixels = bounds.width()
|
||||
heightPixels = bounds.height()
|
||||
// densityDpi is still needed for VirtualDisplay; read from resources.
|
||||
densityDpi = resources.displayMetrics.densityDpi
|
||||
}
|
||||
} else {
|
||||
val windowManager = getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
DisplayMetrics().also { m ->
|
||||
@Suppress("DEPRECATION")
|
||||
windowManager.defaultDisplay.getRealMetrics(m)
|
||||
}
|
||||
}
|
||||
|
||||
val newBridge = PythonBridge(this).also { b ->
|
||||
b.configureCapture(CAPTURE_WIDTH, CAPTURE_HEIGHT)
|
||||
@@ -163,6 +184,8 @@ class CaptureService : Service() {
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
isRunning = false
|
||||
|
||||
screenCapture?.stop()
|
||||
screenCapture = null
|
||||
|
||||
@@ -194,10 +217,19 @@ class CaptureService : Service() {
|
||||
}
|
||||
|
||||
private fun buildNotification(url: String): Notification {
|
||||
val tapIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
Intent(this, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
},
|
||||
PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle("LedGrab Running")
|
||||
.setContentText("Web UI: $url")
|
||||
.setSmallIcon(R.drawable.ic_launcher)
|
||||
.setContentIntent(tapIntent)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -4,9 +4,16 @@ import android.app.Application
|
||||
import android.util.Log
|
||||
import com.chaquo.python.Python
|
||||
import com.chaquo.python.android.AndroidPlatform
|
||||
import java.io.File
|
||||
import java.io.PrintWriter
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Application class — initializes the Chaquopy Python runtime.
|
||||
* Application class — initializes the Chaquopy Python runtime and
|
||||
* installs a global uncaught exception handler that persists crash
|
||||
* logs to app-private storage.
|
||||
*
|
||||
* `Python.start()` must be called once before any Python code runs.
|
||||
* It loads libpython, extracts stdlib + pip packages from APK assets
|
||||
@@ -21,6 +28,7 @@ class LedGrabApp : Application() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
installCrashLogger()
|
||||
try {
|
||||
if (!Python.isStarted()) {
|
||||
Python.start(AndroidPlatform(this))
|
||||
@@ -28,7 +36,7 @@ class LedGrabApp : Application() {
|
||||
} catch (t: Throwable) {
|
||||
// Don't crash here — MainActivity will render a failure
|
||||
// screen with a Copy log button so the user can report it.
|
||||
Log.e("LedGrabApp", "Python.start() failed", t)
|
||||
Log.e(TAG, "Python.start() failed", t)
|
||||
initError = t
|
||||
return
|
||||
}
|
||||
@@ -36,5 +44,40 @@ class LedGrabApp : Application() {
|
||||
// can enumerate and open USB-to-TTL adapters without needing
|
||||
// an Activity reference.
|
||||
UsbSerialBridge.init(this)
|
||||
// Bind application context for the BLE bridge so Python can
|
||||
// scan and connect to BLE LED controllers.
|
||||
BleBridge.init(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a global uncaught exception handler that writes the
|
||||
* stack trace to `files/crash-<timestamp>.log` before letting
|
||||
* the default handler terminate the process. Logs survive app
|
||||
* restarts and can be pulled via `adb pull` for diagnostics.
|
||||
*/
|
||||
private fun installCrashLogger() {
|
||||
val defaultHandler = Thread.getDefaultUncaughtExceptionHandler()
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
try {
|
||||
val ts = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date())
|
||||
val logFile = File(filesDir, "crash-$ts.log")
|
||||
PrintWriter(logFile).use { pw ->
|
||||
pw.println("LedGrab crash at $ts")
|
||||
pw.println("Thread: ${thread.name}")
|
||||
pw.println()
|
||||
throwable.printStackTrace(pw)
|
||||
}
|
||||
Log.e(TAG, "Crash log written to ${logFile.absolutePath}")
|
||||
} catch (_: Exception) {
|
||||
// Best effort — don't crash inside the crash handler.
|
||||
}
|
||||
// Chain to the default handler so Android shows the crash dialog
|
||||
// and terminates the process.
|
||||
defaultHandler?.uncaughtException(thread, throwable)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "LedGrabApp"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package com.ledgrab.android
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Bitmap
|
||||
import android.media.projection.MediaProjectionManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
@@ -36,6 +39,7 @@ class MainActivity : Activity() {
|
||||
private const val TAG = "MainActivity"
|
||||
private const val SERVER_PORT = 8080
|
||||
private const val REQUEST_MEDIA_PROJECTION = 1001
|
||||
private const val REQUEST_POST_NOTIFICATIONS = 1002
|
||||
}
|
||||
|
||||
private lateinit var stoppedPanel: View
|
||||
@@ -46,7 +50,6 @@ class MainActivity : Activity() {
|
||||
private lateinit var toggleButton: Button
|
||||
private lateinit var stopButtonRunning: Button
|
||||
private lateinit var versionText: TextView
|
||||
private var serviceRunning = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
@@ -118,8 +121,8 @@ class MainActivity : Activity() {
|
||||
}
|
||||
|
||||
private fun startRootCaptureService() {
|
||||
ensureNotificationPermission()
|
||||
startForegroundService(CaptureService.createRootIntent(this))
|
||||
serviceRunning = true
|
||||
updateUI()
|
||||
}
|
||||
|
||||
@@ -137,20 +140,19 @@ class MainActivity : Activity() {
|
||||
}
|
||||
|
||||
private fun startCaptureService(resultCode: Int, resultData: Intent) {
|
||||
ensureNotificationPermission()
|
||||
val intent = CaptureService.createIntent(this, resultCode, resultData)
|
||||
startForegroundService(intent)
|
||||
serviceRunning = true
|
||||
updateUI()
|
||||
}
|
||||
|
||||
private fun stopCaptureService() {
|
||||
stopService(Intent(this, CaptureService::class.java))
|
||||
serviceRunning = false
|
||||
updateUI()
|
||||
}
|
||||
|
||||
private fun updateUI() {
|
||||
if (serviceRunning) {
|
||||
if (CaptureService.isRunning) {
|
||||
val localIp = NetworkUtils.getLocalIpAddress(this) ?: "unknown"
|
||||
val url = "http://$localIp:$SERVER_PORT"
|
||||
|
||||
@@ -161,7 +163,7 @@ class MainActivity : Activity() {
|
||||
uiScope.launch(Dispatchers.Default) {
|
||||
val bitmap = generateQrCode(url)
|
||||
withContext(Dispatchers.Main) {
|
||||
if (serviceRunning && urlText.text == url) {
|
||||
if (CaptureService.isRunning && urlText.text == url) {
|
||||
qrImage.setImageBitmap(bitmap)
|
||||
}
|
||||
}
|
||||
@@ -235,4 +237,23 @@ class MainActivity : Activity() {
|
||||
container.addView(scroll)
|
||||
setContentView(container)
|
||||
}
|
||||
|
||||
/**
|
||||
* Request POST_NOTIFICATIONS permission on Android 13+ so the
|
||||
* foreground service notification is visible. On older API levels
|
||||
* this is a no-op.
|
||||
*/
|
||||
private fun ensureNotificationPermission() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
|
||||
!= PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
@Suppress("DEPRECATION")
|
||||
requestPermissions(
|
||||
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
||||
REQUEST_POST_NOTIFICATIONS,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Adaptive icon foreground: TV with LED glow strips.
|
||||
Centered in the 108dp safe zone (inner 72dp is guaranteed visible). -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- TV body -->
|
||||
<path
|
||||
android:fillColor="#1c2333"
|
||||
android:pathData="M26,32 L82,32 Q86,32 86,36 L86,68 Q86,72 82,72 L26,72 Q22,72 22,68 L22,36 Q22,32 26,32 Z" />
|
||||
<!-- TV screen -->
|
||||
<path
|
||||
android:fillColor="#161b22"
|
||||
android:pathData="M28,35 L80,35 Q82,35 82,37 L82,66 Q82,68 80,68 L28,68 Q26,68 26,66 L26,37 Q26,35 28,35 Z" />
|
||||
<!-- LED glow - top (teal) -->
|
||||
<path
|
||||
android:fillColor="#64ffda"
|
||||
android:fillAlpha="0.7"
|
||||
android:pathData="M30,28 L78,28 L78,30 L30,30 Z" />
|
||||
<!-- LED glow - left (purple) -->
|
||||
<path
|
||||
android:fillColor="#bb86fc"
|
||||
android:fillAlpha="0.6"
|
||||
android:pathData="M18,34 L20,34 L20,70 L18,70 Z" />
|
||||
<!-- LED glow - right (red) -->
|
||||
<path
|
||||
android:fillColor="#ff6b6b"
|
||||
android:fillAlpha="0.6"
|
||||
android:pathData="M88,34 L90,34 L90,70 L88,70 Z" />
|
||||
<!-- LED glow - bottom (yellow) -->
|
||||
<path
|
||||
android:fillColor="#ffd93d"
|
||||
android:fillAlpha="0.6"
|
||||
android:pathData="M30,74 L78,74 L78,76 L30,76 Z" />
|
||||
<!-- TV stand -->
|
||||
<path
|
||||
android:fillColor="#1c2333"
|
||||
android:pathData="M44,72 L44,78 L64,78 L64,72" />
|
||||
<path
|
||||
android:fillColor="#1c2333"
|
||||
android:pathData="M38,78 L70,78 L70,80 L38,80 Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/bg_navy" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">LedGrab</string>
|
||||
<string name="tagline">Фоновая подсветка для телевизора</string>
|
||||
<string name="btn_start">Начать захват</string>
|
||||
<string name="btn_stop">Стоп</string>
|
||||
<string name="status_running">Работает</string>
|
||||
<string name="label_web_ui">Адрес веб-интерфейса</string>
|
||||
<string name="scan_to_configure">Сканируйте для настройки</string>
|
||||
<string name="qr_description">QR-код для веб-интерфейса</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">LedGrab</string>
|
||||
<string name="tagline">电视氛围灯光</string>
|
||||
<string name="btn_start">开始捕获</string>
|
||||
<string name="btn_stop">停止</string>
|
||||
<string name="status_running">运行中</string>
|
||||
<string name="label_web_ui">Web界面地址</string>
|
||||
<string name="scan_to_configure">扫码配置</string>
|
||||
<string name="qr_description">Web界面二维码</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
LedGrab communicates with WLED controllers, Home Assistant, and MQTT
|
||||
brokers on the local network via plain HTTP/UDP. Cleartext traffic
|
||||
must be allowed for these connections to work on Android 9+.
|
||||
-->
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="true" />
|
||||
</network-security-config>
|
||||
Reference in New Issue
Block a user