From f3b33bcf74ebfeb864a3d1053a4161ecff298880 Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 17 Aug 2026 14:37:54 +0300 Subject: [PATCH] =?UTF-8?q?android:=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA=D0=B8?= =?UTF-8?q?=20start/stop=20=D1=81=D0=B5=D1=80=D0=B2=D0=B8=D1=81=D0=B0=20?= =?UTF-8?q?=D1=81=20=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=D0=BC=20=D1=81=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F,=20=D1=8F=D0=B4=D1=80=D0=BE=20=D0=BD=D0=B5=20=D0=B2?= =?UTF-8?q?=D0=BE=D1=81=D0=BA=D1=80=D0=B5=D1=81=D0=B0=D0=B5=D1=82=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=20=D0=BE=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=BD=D0=BE=D0=BC=20=D1=81=D0=B5=D1=80=D0=B2=D0=B8?= =?UTF-8?q?=D1=81=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ConfigProvider: флаг service_enabled (DataStore), flow/getter/setter - MainActivity: автозапуск сервиса только при service_enabled; edge-to-edge со светлыми иконками статус-бара - SettingsScreen: секция Service (start/stop), stop/exit сохраняет состояние; NativeLib.restart гейтится по isReady чтобы ядро не запускалось без сервиса --- .../main/java/com/utun/chat/MainActivity.kt | 15 ++++- .../java/com/utun/chat/data/ConfigProvider.kt | 13 +++++ .../utun/chat/ui/screens/SettingsScreen.kt | 55 ++++++++++++++++--- 3 files changed, 72 insertions(+), 11 deletions(-) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt index 483eb6f1..f88feab1 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt +++ b/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()) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt index 02fff92c..d8de7fc4 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt +++ b/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 = 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 = emptyList()): List { diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt index ef58162b..a5a2aa96 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt +++ b/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") } },