Browse Source

android: кнопки start/stop сервиса с сохранением состояния, ядро не воскресает при остановленном сервисе

- ConfigProvider: флаг service_enabled (DataStore), flow/getter/setter
- MainActivity: автозапуск сервиса только при service_enabled; edge-to-edge со светлыми иконками статус-бара
- SettingsScreen: секция Service (start/stop), stop/exit сохраняет состояние; NativeLib.restart гейтится по isReady чтобы ядро не запускалось без сервиса
topo_upd
evgeny 2 months ago
parent
commit
f3b33bcf74
  1. 15
      tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt
  2. 13
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt
  3. 55
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt

15
tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt

@ -3,8 +3,10 @@ package com.utun.chat
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBarsPadding
@ -31,8 +33,17 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
/* Start uTun headless service (must be from foreground Activity on API 34+) */
ContextCompat.startForegroundService(this, Intent(this, HeadlessService::class.java))
/* Edge-to-edge with dark status/navigation bar icons (light app theme) */
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.light(android.graphics.Color.TRANSPARENT, android.graphics.Color.TRANSPARENT),
navigationBarStyle = SystemBarStyle.light(android.graphics.Color.TRANSPARENT, android.graphics.Color.TRANSPARENT)
)
/* Start uTun headless service (must be from foreground Activity on API 34+).
Only if it was running when the app last exited (remembered via DataStore). */
if (ChatApplication.instance.configProvider.getServiceEnabled()) {
ContextCompat.startForegroundService(this, Intent(this, HeadlessService::class.java))
}
setContent {
val vm: ChatViewModel = viewModel(factory = ChatViewModel.Factory())

13
tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt

@ -47,6 +47,7 @@ class ConfigProvider(private val context: Context) {
private val chatStandbyMinSleepSec = intPreferencesKey("chat_standby_min_sleep_sec")
private val firstLaunchDone = booleanPreferencesKey("first_launch_done")
private val connectionModeKey = stringPreferencesKey("connection_mode")
private val serviceEnabledKey = booleanPreferencesKey("service_enabled")
private fun keysFilePath(): String {
val dir = runBlocking { context.dataStore.data.first()[dbPath] } ?: context.filesDir.absolutePath
@ -207,6 +208,18 @@ class ConfigProvider(private val context: Context) {
context.dataStore.edit { prefs -> prefs[connectionModeKey] = mode }
}
/* ── Background service enabled/disabled ── */
val serviceEnabledFlow: Flow<Boolean> = context.dataStore.data.map { it[serviceEnabledKey] ?: true }
fun getServiceEnabled(): Boolean = runBlocking {
context.dataStore.data.first()[serviceEnabledKey] ?: true
}
suspend fun setServiceEnabled(enabled: Boolean) {
context.dataStore.edit { prefs -> prefs[serviceEnabledKey] = enabled }
}
/* ── Server entries ── */
suspend fun detectInterfaces(saved: List<ServerEntry> = emptyList()): List<ServerEntry> {

55
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt

@ -19,10 +19,14 @@ import com.utun.chat.ChatApplication
import com.utun.chat.data.NativeLib
import com.utun.chat.data.ServerEntry
import com.utun.chat.data.LocalSocketState
import com.utun.chat.headless.HeadlessService
import com.utun.chat.viewmodel.ChatViewModel
import android.content.Intent
import androidx.core.content.ContextCompat
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import java.util.UUID
@ -47,7 +51,7 @@ fun SettingsScreen(vm: ChatViewModel, onBack: () -> Unit, onExit: () -> Unit = {
val tabs = listOf("Settings", "Audio", "Storage")
val handleBack = {
if (dirty) {
if (dirty && NativeLib.isReady()) {
scope.launch(Dispatchers.IO) {
val path = provider.getString("db.path") ?: "utun_chat.db"
val port = provider.getInt("control.port")
@ -115,6 +119,7 @@ private fun GeneralSettingsTab(vm: ChatViewModel, dirty: Boolean, onDirty: (Bool
var connectionMode by remember { mutableStateOf("manual") }
val localSocketStates by vm.localSockets.collectAsState()
val serviceEnabled by provider.serviceEnabledFlow.collectAsState(initial = true)
LaunchedEffect(Unit) {
nodeName = provider.getString("node.name") ?: provider.getDeviceName()
@ -144,8 +149,9 @@ private fun GeneralSettingsTab(vm: ChatViewModel, dirty: Boolean, onDirty: (Bool
connectionMode = mode; onDirty(true)
scope.launch(Dispatchers.IO) {
provider.setConnectionMode(mode)
NativeLib.restart(provider.getString("db.path") ?: "",
provider.getInt("control.port"), provider.buildConfigText())
if (NativeLib.isReady())
NativeLib.restart(provider.getString("db.path") ?: "",
provider.getInt("control.port"), provider.buildConfigText())
}
}
val setDebug = { level: String ->
@ -360,16 +366,43 @@ private fun GeneralSettingsTab(vm: ChatViewModel, dirty: Boolean, onDirty: (Bool
}
Spacer(Modifier.height(16.dp))
/* ── Background service ── */
HorizontalDivider(Modifier.padding(vertical = 8.dp))
Text("Service", style = MaterialTheme.typography.titleMedium)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(if (serviceEnabled) "Running" else "Stopped", modifier = Modifier.weight(1f),
color = if (serviceEnabled) Color(0xFF27AE60) else MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium)
Button(onClick = {
runBlocking { provider.setServiceEnabled(true) }
ContextCompat.startForegroundService(ChatApplication.instance,
Intent(ChatApplication.instance, HeadlessService::class.java))
}, enabled = !serviceEnabled) { Text("Start service") }
Button(onClick = {
runBlocking { provider.setServiceEnabled(false) }
ChatApplication.instance.stopService(Intent(ChatApplication.instance, HeadlessService::class.java))
}, enabled = serviceEnabled,
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)) { Text("Stop service") }
}
HorizontalDivider(Modifier.padding(vertical = 8.dp))
Button(onClick = {
scope.launch(Dispatchers.IO) {
val path = provider.getString("db.path") ?: ""; val port = provider.getInt("control.port")
NativeLib.restart(path, port, provider.buildConfigText())
if (NativeLib.isReady()) {
val path = provider.getString("db.path") ?: ""; val port = provider.getInt("control.port")
NativeLib.restart(path, port, provider.buildConfigText())
}
}
}, modifier = Modifier.fillMaxWidth(), colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary)) {
Icon(Icons.Default.Refresh, null, modifier = Modifier.size(18.dp)); Spacer(Modifier.width(8.dp)); Text("Restart instance")
}
Spacer(Modifier.height(8.dp))
Button(onClick = { NativeLib.stop(); onExit() }, modifier = Modifier.fillMaxWidth(),
Button(onClick = {
runBlocking { provider.setServiceEnabled(false) }
ChatApplication.instance.stopService(Intent(ChatApplication.instance, HeadlessService::class.java))
onExit()
}, modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)) {
Icon(Icons.Default.PowerSettingsNew, null, modifier = Modifier.size(18.dp)); Spacer(Modifier.width(8.dp)); Text("Stop All & Exit")
}
@ -389,9 +422,13 @@ private fun GeneralSettingsTab(vm: ChatViewModel, dirty: Boolean, onDirty: (Bool
confirmButton = { Button(onClick = {
showClearDb = false; clearingDb = true
scope.launch(Dispatchers.IO) {
vm.clearState(); NativeLib.stop(); NativeLib.clearDatabase()
val path = provider.getString("db.path") ?: ""; val port = provider.getInt("control.port")
NativeLib.restart(path, port, provider.buildConfigText())
vm.clearState()
val wasRunning = NativeLib.isReady()
NativeLib.stop(); NativeLib.clearDatabase()
if (wasRunning) {
val path = provider.getString("db.path") ?: ""; val port = provider.getInt("control.port")
NativeLib.restart(path, port, provider.buildConfigText())
}
withContext(Dispatchers.Main) { clearingDb = false }
}
}, enabled = !clearingDb, colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)) { Text("Delete All") } },

Loading…
Cancel
Save