From 8dc10bb54ff851f95c445a9f31d812e3103d0804 Mon Sep 17 00:00:00 2001 From: evgeny Date: Thu, 29 Oct 2026 13:51:59 +0100 Subject: [PATCH] Keep Bluetooth settings writes alive and contain Android launch failures --- .../main/java/com/utun/chat/data/BleRadio.kt | 21 +++++++---- .../chat/ui/screens/BluetoothSettingsTab.kt | 37 +++++++++++-------- 2 files changed, 36 insertions(+), 22 deletions(-) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/BleRadio.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/BleRadio.kt index 79ab659b..c5dc2c11 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/BleRadio.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/BleRadio.kt @@ -13,15 +13,18 @@ import com.utun.chat.ChatApplication import kotlinx.coroutines.flow.MutableStateFlow import java.util.UUID -/** Только Android radio I/O. Протокол, сборка пакетов и ll_queue находятся в общем C transport. */ +/** Android radio I/O на одном HandlerThread; протокол/ll_queue принадлежат C worker. + * Application передаёт подтверждённый снимок настроек; callbacks SDK привязаны к generation. + * applySettings без аргумента повторяет последний снимок при SERVICE_STARTED/смене Bluetooth. */ @Suppress("MissingPermission", "DEPRECATION") object BleRadio { private val serviceUuid = UUID.fromString("f61a9020-4abc-45ac-98e1-761df4c13811") private val rxUuid = UUID.fromString("f61a9021-4abc-45ac-98e1-761df4c13811") private val txUuid = UUID.fromString("f61a9022-4abc-45ac-98e1-761df4c13811") private val cccUuid = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") - private val thread = HandlerThread("vibechat-ble").apply { start() } - private val worker = Handler(thread.looper) + private val thread by lazy { HandlerThread("vibechat-ble").apply { start() } } + private val worker by lazy { Handler(thread.looper) } + @Volatile private var configuredFlags: Int? = null private val context get() = ChatApplication.instance private val adapter get() = context.getSystemService(BluetoothManager::class.java).adapter val state = MutableStateFlow(2) // BLE_CLOSED; тексты выбирает UI. @@ -50,16 +53,20 @@ object BleRadio { else arrayOf(Manifest.permission.ACCESS_FINE_LOCATION) fun permitted() = permissions().all { ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED } fun init() { + worker // Ошибка запуска потока возвращается Application до установки callback в C. nativeInstall() val receiver = object : BroadcastReceiver() { - override fun onReceive(c: Context, intent: Intent) { applySettings() } + override fun onReceive(c: Context, intent: Intent) { + try { applySettings() } catch (e: Exception) { log("ERROR", "Bluetooth state application failed: $e") } + } } ContextCompat.registerReceiver(context, receiver, IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED), ContextCompat.RECEIVER_EXPORTED) } - fun applySettings() { - val flags = context.configProvider.getBleFlags() + fun applySettings(flags: Int? = configuredFlags) { + if (flags == null) return // Пока нет подтверждённого чтения DataStore, ничего не включать. + configuredFlags = flags val effective = try { if (permitted() && adapter?.isEnabled == true) flags else 0 } - catch (e: SecurityException) { log("WARN", "Permission revoked: ${e.message}"); 0 } + catch (e: RuntimeException) { log("WARN", "Bluetooth state unavailable: $e"); 0 } if (NativeLib.isReady() && !nativeConfigure(effective)) log("WARN", "Settings could not be queued") if (effective == 0) state.value = 2 } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/BluetoothSettingsTab.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/BluetoothSettingsTab.kt index 7baa88e9..aba5e4e6 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/BluetoothSettingsTab.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/BluetoothSettingsTab.kt @@ -15,30 +15,31 @@ import com.utun.chat.ChatApplication import com.utun.chat.R import com.utun.chat.data.BleRadio import com.utun.chat.data.LogManager -import kotlinx.coroutines.launch -import kotlinx.coroutines.CancellationException +/** Снимок DataStore без чтения диска на main; принятые записи принадлежат Application. + * Разрешения/включение адаптера подтверждаются Android, ошибки запуска показываются в этой вкладке. */ @Composable fun BluetoothSettingsTab() { val app = ChatApplication.instance val provider = app.configProvider - val scope = rememberCoroutineScope() - var flags by remember { mutableStateOf(provider.getBleFlags()) } + val settings = remember(provider) { provider.changes.observe("Bluetooth settings", provider.bluetoothSettings) } + val confirmedFlags by settings.collectAsState(initial = null) + val flags = confirmedFlags ?: 0 var denied by remember { mutableStateOf(false) } var failed by remember { mutableStateOf(false) } + val settingFailed by provider.changes.failed.collectAsState() val state by BleRadio.state.collectAsState() val advertisingState by BleRadio.advertisingState.collectAsState() - fun save(value: Int) { scope.launch { - try { provider.setBleFlags(value); flags = value; BleRadio.applySettings(); failed = false } - catch (e: CancellationException) { throw e } - catch (e: Exception) { failed = true; LogManager.addLog("ERROR", "discovery", "BLE settings: ${e.message}") } - } } + fun save(value: Int) { provider.changes.submit("Bluetooth settings") { provider.setBleFlags(value) } } val enable = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { if (it.resultCode == android.app.Activity.RESULT_OK) { denied = false; save(flags or 1) } else denied = true } fun activate() { - if (app.getSystemService(BluetoothManager::class.java).adapter?.isEnabled == true) { denied = false; save(flags or 1) } - else enable.launch(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)) + try { + failed = false + if (app.getSystemService(BluetoothManager::class.java)?.adapter?.isEnabled == true) { denied = false; save(flags or 1) } + else enable.launch(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)) + } catch (e: Exception) { failed = true; LogManager.addLog("ERROR", "discovery", "Bluetooth enable request failed: $e") } } val permissions = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { if (BleRadio.permitted()) activate() else denied = true @@ -46,20 +47,26 @@ fun BluetoothSettingsTab() { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Text(stringResource(R.string.ble_enabled)) - Switch(checked = flags and 1 != 0, onCheckedChange = { - if (!it) save(flags and 1.inv()) - else if (BleRadio.permitted()) activate() else permissions.launch(BleRadio.permissions()) + Switch(enabled = confirmedFlags != null, checked = flags and 1 != 0, onCheckedChange = { + try { + failed = false + if (!it) save(flags and 1.inv()) + else if (BleRadio.permitted()) activate() else permissions.launch(BleRadio.permissions()) + } catch (e: Exception) { failed = true; LogManager.addLog("ERROR", "discovery", "Bluetooth permission request failed: $e") } }) } listOf(2 to R.string.ble_advertise, 4 to R.string.ble_incoming).forEach { (bit, label) -> Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Text(stringResource(label), Modifier.weight(1f)) - Switch(checked = flags and bit != 0, onCheckedChange = { save(if (it) flags or bit else flags and bit.inv()) }) + Switch(enabled = confirmedFlags != null, checked = flags and bit != 0, + onCheckedChange = { save(if (it) flags or bit else flags and bit.inv()) }) } } Text(stringResource(R.string.ble_hint), style = MaterialTheme.typography.bodySmall) Text(stringResource(when (state) { 0 -> R.string.ble_starting; 1 -> R.string.ble_ready; else -> R.string.ble_stopped })) if (denied) Text(stringResource(R.string.ble_permission), color = MaterialTheme.colorScheme.error) + if (confirmedFlags == null) LinearProgressIndicator(Modifier.fillMaxWidth()) + if (settingFailed) Text(stringResource(R.string.settings_update_failed), color = MaterialTheme.colorScheme.error) if (failed || (state == 1 && flags and 2 != 0 && advertisingState == 2)) Text(stringResource(R.string.ble_failed), color = MaterialTheme.colorScheme.error) } }