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.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
@ -31,8 +33,17 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
/* Start uTun headless service (must be from foreground Activity on API 34+) */ /* Edge-to-edge with dark status/navigation bar icons (light app theme) */
ContextCompat.startForegroundService(this, Intent(this, HeadlessService::class.java)) 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 { setContent {
val vm: ChatViewModel = viewModel(factory = ChatViewModel.Factory()) 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 chatStandbyMinSleepSec = intPreferencesKey("chat_standby_min_sleep_sec")
private val firstLaunchDone = booleanPreferencesKey("first_launch_done") private val firstLaunchDone = booleanPreferencesKey("first_launch_done")
private val connectionModeKey = stringPreferencesKey("connection_mode") private val connectionModeKey = stringPreferencesKey("connection_mode")
private val serviceEnabledKey = booleanPreferencesKey("service_enabled")
private fun keysFilePath(): String { private fun keysFilePath(): String {
val dir = runBlocking { context.dataStore.data.first()[dbPath] } ?: context.filesDir.absolutePath 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 } 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 ── */ /* ── Server entries ── */
suspend fun detectInterfaces(saved: List<ServerEntry> = emptyList()): List<ServerEntry> { 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.NativeLib
import com.utun.chat.data.ServerEntry import com.utun.chat.data.ServerEntry
import com.utun.chat.data.LocalSocketState import com.utun.chat.data.LocalSocketState
import com.utun.chat.headless.HeadlessService
import com.utun.chat.viewmodel.ChatViewModel import com.utun.chat.viewmodel.ChatViewModel
import android.content.Intent
import androidx.core.content.ContextCompat
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.util.UUID import java.util.UUID
@ -47,7 +51,7 @@ fun SettingsScreen(vm: ChatViewModel, onBack: () -> Unit, onExit: () -> Unit = {
val tabs = listOf("Settings", "Audio", "Storage") val tabs = listOf("Settings", "Audio", "Storage")
val handleBack = { val handleBack = {
if (dirty) { if (dirty && NativeLib.isReady()) {
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
val path = provider.getString("db.path") ?: "utun_chat.db" val path = provider.getString("db.path") ?: "utun_chat.db"
val port = provider.getInt("control.port") 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") } var connectionMode by remember { mutableStateOf("manual") }
val localSocketStates by vm.localSockets.collectAsState() val localSocketStates by vm.localSockets.collectAsState()
val serviceEnabled by provider.serviceEnabledFlow.collectAsState(initial = true)
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
nodeName = provider.getString("node.name") ?: provider.getDeviceName() nodeName = provider.getString("node.name") ?: provider.getDeviceName()
@ -144,8 +149,9 @@ private fun GeneralSettingsTab(vm: ChatViewModel, dirty: Boolean, onDirty: (Bool
connectionMode = mode; onDirty(true) connectionMode = mode; onDirty(true)
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
provider.setConnectionMode(mode) provider.setConnectionMode(mode)
NativeLib.restart(provider.getString("db.path") ?: "", if (NativeLib.isReady())
provider.getInt("control.port"), provider.buildConfigText()) NativeLib.restart(provider.getString("db.path") ?: "",
provider.getInt("control.port"), provider.buildConfigText())
} }
} }
val setDebug = { level: String -> val setDebug = { level: String ->
@ -360,16 +366,43 @@ private fun GeneralSettingsTab(vm: ChatViewModel, dirty: Boolean, onDirty: (Bool
} }
Spacer(Modifier.height(16.dp)) 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 = { Button(onClick = {
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
val path = provider.getString("db.path") ?: ""; val port = provider.getInt("control.port") if (NativeLib.isReady()) {
NativeLib.restart(path, port, provider.buildConfigText()) 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)) { }, 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") Icon(Icons.Default.Refresh, null, modifier = Modifier.size(18.dp)); Spacer(Modifier.width(8.dp)); Text("Restart instance")
} }
Spacer(Modifier.height(8.dp)) 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)) { 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") 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 = { confirmButton = { Button(onClick = {
showClearDb = false; clearingDb = true showClearDb = false; clearingDb = true
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
vm.clearState(); NativeLib.stop(); NativeLib.clearDatabase() vm.clearState()
val path = provider.getString("db.path") ?: ""; val port = provider.getInt("control.port") val wasRunning = NativeLib.isReady()
NativeLib.restart(path, port, provider.buildConfigText()) 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 } withContext(Dispatchers.Main) { clearingDb = false }
} }
}, enabled = !clearingDb, colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)) { Text("Delete All") } }, }, enabled = !clearingDb, colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)) { Text("Delete All") } },

Loading…
Cancel
Save