feat(devices): BLE LED controller support (SP110E/Triones/Zengge/Govee)
Build Android APK / build-android (push) Failing after 1m44s
Lint & Test / test (push) Successful in 4m22s

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:
2026-04-21 14:58:35 +03:00
parent d3a6416a1d
commit 2b5dac2c42
54 changed files with 3412 additions and 174 deletions
+13
View File
@@ -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")
+23 -2
View File
@@ -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>