Keep Bluetooth settings writes alive and contain Android launch failures

This commit is contained in:
evgeny committed 2026-10-29 13:51:59 +01:00
1 parent ae4f524448
commit 8dc10bb54f
2 files changed
+36 -22

No files matched your search

@@ -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
}
@@ -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)
}
}