From e1d02a8a5585051eb74f52b0fd746746c85d6ef9 Mon Sep 17 00:00:00 2001 From: Nedifinita Date: Sat, 26 Jul 2025 00:19:56 +0800 Subject: [PATCH] feat: add background keep-alive service and related setting functions --- app/build.gradle.kts | 2 +- app/src/main/AndroidManifest.xml | 19 ++- .../java/org/noxylva/lbjconsole/BLEClient.kt | 2 + .../noxylva/lbjconsole/BackgroundService.kt | 123 ++++++++++++++++++ .../org/noxylva/lbjconsole/MainActivity.kt | 21 ++- .../noxylva/lbjconsole/SettingsActivity.kt | 63 +++++++++ .../lbjconsole/ui/screens/SettingsScreen.kt | 69 ++++++++++ .../org/noxylva/lbjconsole/ui/theme/Theme.kt | 2 +- app/src/main/res/layout/activity_settings.xml | 55 ++++++++ app/src/main/res/values/themes.xml | 2 +- 10 files changed, 348 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/org/noxylva/lbjconsole/BackgroundService.kt create mode 100644 app/src/main/java/org/noxylva/lbjconsole/SettingsActivity.kt create mode 100644 app/src/main/res/layout/activity_settings.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5ecbc5a..93d9afc 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -81,7 +81,7 @@ dependencies { debugImplementation(libs.androidx.ui.test.manifest) implementation("org.json:json:20231013") implementation("androidx.compose.material:material-icons-extended:1.5.4") - + implementation("androidx.appcompat:appcompat:1.6.1") implementation("org.osmdroid:osmdroid-android:6.1.16") implementation("org.osmdroid:osmdroid-mapsforge:6.1.16") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8e5c4a7..344884f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -11,6 +11,8 @@ + + @@ -22,14 +24,14 @@ android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" - android:theme="@style/Theme.LBJReceiver" + android:theme="@style/Theme.LBJConsole" android:usesCleartextTraffic="true" tools:targetApi="31"> + android:theme="@style/Theme.LBJConsole"> @@ -37,6 +39,19 @@ + + + + if (autoReconnect) { @@ -819,6 +820,7 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() { } } bluetoothGatt = null + bluetoothLeScanner = null deviceAddress = null connectionAttempts = 0 diff --git a/app/src/main/java/org/noxylva/lbjconsole/BackgroundService.kt b/app/src/main/java/org/noxylva/lbjconsole/BackgroundService.kt new file mode 100644 index 0000000..3b1da88 --- /dev/null +++ b/app/src/main/java/org/noxylva/lbjconsole/BackgroundService.kt @@ -0,0 +1,123 @@ +package org.noxylva.lbjconsole + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.IBinder +import android.os.PowerManager +import androidx.core.app.NotificationCompat + +class BackgroundService : Service() { + + companion object { + private const val NOTIFICATION_ID = 1001 + private const val CHANNEL_ID = "background_service_channel" + private const val CHANNEL_NAME = "Background Service" + + fun startService(context: Context) { + try { + val intent = Intent(context, BackgroundService::class.java) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent) + } else { + context.startService(intent) + } + } catch (e: Exception) { + // Service start failed, ignore silently + } + } + + fun stopService(context: Context) { + val intent = Intent(context, BackgroundService::class.java) + context.stopService(intent) + } + } + + private var wakeLock: PowerManager.WakeLock? = null + + override fun onCreate() { + super.onCreate() + createNotificationChannel() + acquireWakeLock() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + try { + val notification = createNotification() + startForeground(NOTIFICATION_ID, notification) + } catch (e: Exception) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + stopSelf() + return START_NOT_STICKY + } + } + return START_STICKY + } + + override fun onDestroy() { + super.onDestroy() + releaseWakeLock() + } + + override fun onBind(intent: Intent?): IBinder? { + return null + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + CHANNEL_NAME, + NotificationManager.IMPORTANCE_LOW + ).apply { + description = "Keep app running in background" + setShowBadge(false) + } + + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(channel) + } + } + + private fun createNotification(): Notification { + val intent = Intent(this, MainActivity::class.java) + val pendingIntent = PendingIntent.getActivity( + this, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + return NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("LBJ Console") + .setContentText("Running in background") + .setSmallIcon(R.drawable.ic_launcher_foreground) + .setContentIntent(pendingIntent) + .setOngoing(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build() + } + + private fun acquireWakeLock() { + val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager + wakeLock = powerManager.newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, + "LBJConsole::BackgroundWakeLock" + ) + wakeLock?.acquire() + } + + private fun releaseWakeLock() { + wakeLock?.let { + if (it.isHeld) { + it.release() + } + } + wakeLock = null + } +} \ No newline at end of file diff --git a/app/src/main/java/org/noxylva/lbjconsole/MainActivity.kt b/app/src/main/java/org/noxylva/lbjconsole/MainActivity.kt index a9e03d7..06ade01 100644 --- a/app/src/main/java/org/noxylva/lbjconsole/MainActivity.kt +++ b/app/src/main/java/org/noxylva/lbjconsole/MainActivity.kt @@ -47,6 +47,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.platform.LocalContext import androidx.core.content.ContextCompat import androidx.core.content.FileProvider import kotlinx.coroutines.delay @@ -61,7 +62,7 @@ import org.noxylva.lbjconsole.ui.screens.HistoryScreen import org.noxylva.lbjconsole.ui.screens.MapScreen import org.noxylva.lbjconsole.ui.screens.SettingsScreen -import org.noxylva.lbjconsole.ui.theme.LBJReceiverTheme +import org.noxylva.lbjconsole.ui.theme.LBJConsoleTheme import org.noxylva.lbjconsole.util.LocoInfoUtil import java.util.* import androidx.lifecycle.lifecycleScope @@ -256,7 +257,7 @@ class MainActivity : ComponentActivity() { tileDownloadThreads = 4 tileFileSystemThreads = 4 - setUserAgentValue("LBJReceiver/1.0") + setUserAgentValue("LBJConsole/1.0") } Log.d(TAG, "OSM cache configured") @@ -266,13 +267,17 @@ class MainActivity : ComponentActivity() { saveSettings() + if (SettingsActivity.isBackgroundServiceEnabled(this)) { + BackgroundService.startService(this) + } + enableEdgeToEdge() WindowCompat.getInsetsController(window, window.decorView).apply { isAppearanceLightStatusBars = false } setContent { - LBJReceiverTheme { + LBJConsoleTheme { val scope = rememberCoroutineScope() Surface(modifier = Modifier.fillMaxSize()) { @@ -406,7 +411,11 @@ class MainActivity : ComponentActivity() { Log.d(TAG, "Applied settings deviceName=${settingsDeviceName}") }, appVersion = getAppVersion(), - locoInfoUtil = locoInfoUtil + locoInfoUtil = locoInfoUtil, + onOpenSettings = { + val intent = Intent(this@MainActivity, SettingsActivity::class.java) + startActivity(intent) + } ) if (showConnectionDialog) { @@ -879,7 +888,9 @@ fun MainContent( mapCenterPosition: Pair?, mapZoomLevel: Double, mapRailwayLayerVisible: Boolean, - onMapStateChange: (Pair?, Double, Boolean) -> Unit + onMapStateChange: (Pair?, Double, Boolean) -> Unit, + + onOpenSettings: () -> Unit ) { val statusColor = if (isConnected) Color(0xFF4CAF50) else Color(0xFFFF5722) diff --git a/app/src/main/java/org/noxylva/lbjconsole/SettingsActivity.kt b/app/src/main/java/org/noxylva/lbjconsole/SettingsActivity.kt new file mode 100644 index 0000000..4c9f564 --- /dev/null +++ b/app/src/main/java/org/noxylva/lbjconsole/SettingsActivity.kt @@ -0,0 +1,63 @@ +package org.noxylva.lbjconsole + +import android.content.Context +import android.content.SharedPreferences +import android.os.Bundle +import android.widget.Switch +import androidx.appcompat.app.AppCompatActivity + +class SettingsActivity : AppCompatActivity() { + + companion object { + private const val PREFS_NAME = "lbj_console_settings" + private const val KEY_BACKGROUND_SERVICE = "background_service_enabled" + + fun isBackgroundServiceEnabled(context: Context): Boolean { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + return prefs.getBoolean(KEY_BACKGROUND_SERVICE, false) + } + + fun setBackgroundServiceEnabled(context: Context, enabled: Boolean) { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + prefs.edit().putBoolean(KEY_BACKGROUND_SERVICE, enabled).apply() + } + } + + private lateinit var backgroundServiceSwitch: Switch + private lateinit var prefs: SharedPreferences + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_settings) + + supportActionBar?.setDisplayHomeAsUpEnabled(true) + supportActionBar?.title = "Settings" + + prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + initViews() + setupListeners() + } + + private fun initViews() { + backgroundServiceSwitch = findViewById(R.id.switch_background_service) + backgroundServiceSwitch.isChecked = isBackgroundServiceEnabled(this) + } + + private fun setupListeners() { + backgroundServiceSwitch.setOnCheckedChangeListener { _, isChecked -> + setBackgroundServiceEnabled(this, isChecked) + + if (isChecked) { + BackgroundService.startService(this) + } else { + BackgroundService.stopService(this) + } + } + } + + override fun onSupportNavigateUp(): Boolean { + onBackPressed() + return true + } +} \ No newline at end of file diff --git a/app/src/main/java/org/noxylva/lbjconsole/ui/screens/SettingsScreen.kt b/app/src/main/java/org/noxylva/lbjconsole/ui/screens/SettingsScreen.kt index db82a13..8663481 100644 --- a/app/src/main/java/org/noxylva/lbjconsole/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/org/noxylva/lbjconsole/ui/screens/SettingsScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -17,6 +18,8 @@ import androidx.compose.ui.unit.dp import org.noxylva.lbjconsole.model.MergeSettings import org.noxylva.lbjconsole.model.GroupBy import org.noxylva.lbjconsole.model.TimeWindow +import org.noxylva.lbjconsole.SettingsActivity +import org.noxylva.lbjconsole.BackgroundService import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.LaunchedEffect @@ -164,6 +167,72 @@ fun SettingsScreen( } } + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + ), + shape = RoundedCornerShape(16.dp) + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + Text( + "应用设置", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + } + + val context = LocalContext.current + var backgroundServiceEnabled by remember { + mutableStateOf(SettingsActivity.isBackgroundServiceEnabled(context)) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text( + "后台保活服务", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium + ) + Text( + "保持应用在后台运行", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch( + checked = backgroundServiceEnabled, + onCheckedChange = { enabled -> + backgroundServiceEnabled = enabled + SettingsActivity.setBackgroundServiceEnabled(context, enabled) + + if (enabled) { + BackgroundService.startService(context) + } else { + BackgroundService.stopService(context) + } + } + ) + } + } + } + Card( modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors( diff --git a/app/src/main/java/org/noxylva/lbjconsole/ui/theme/Theme.kt b/app/src/main/java/org/noxylva/lbjconsole/ui/theme/Theme.kt index 63e93b2..ffb8c63 100644 --- a/app/src/main/java/org/noxylva/lbjconsole/ui/theme/Theme.kt +++ b/app/src/main/java/org/noxylva/lbjconsole/ui/theme/Theme.kt @@ -24,7 +24,7 @@ private val LightColorScheme = lightColorScheme( ) @Composable -fun LBJReceiverTheme( +fun LBJConsoleTheme( darkTheme: Boolean = true, dynamicColor: Boolean = true, diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml new file mode 100644 index 0000000..e6718ef --- /dev/null +++ b/app/src/main/res/layout/activity_settings.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 08a9d46..59712bc 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,5 +1,5 @@ -