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 чтобы ядро не запускалось без сервиса
This commit is contained in:
1 parent
3d59e5f322
commit
f3b33bcf74
3 files changed
+72
-11
No files matched your search
@@ -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())
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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") } },
|
||||
|
||||
Reference in new issue
Block a user