feat: add background keep-alive service and related setting functions

This commit is contained in:
Nedifinita
2025-07-26 00:19:56 +08:00
parent aaf414d384
commit e1d02a8a55
10 changed files with 348 additions and 10 deletions

View File

@@ -11,6 +11,8 @@
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true"/>
@@ -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">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.LBJReceiver">
android:theme="@style/Theme.LBJConsole">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@@ -37,6 +39,19 @@
</intent-filter>
</activity>
<activity
android:name=".SettingsActivity"
android:exported="false"
android:label="Settings"
android:parentActivityName=".MainActivity"
android:theme="@style/Theme.LBJConsole" />
<service
android:name=".BackgroundService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="dataSync" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"

View File

@@ -437,6 +437,7 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
try {
gatt.close()
bluetoothGatt = null
bluetoothLeScanner = null
deviceAddress?.let { address ->
if (autoReconnect) {
@@ -819,6 +820,7 @@ class BLEClient(private val context: Context) : BluetoothGattCallback() {
}
}
bluetoothGatt = null
bluetoothLeScanner = null
deviceAddress = null
connectionAttempts = 0

View File

@@ -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
}
}

View File

@@ -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<Double, Double>?,
mapZoomLevel: Double,
mapRailwayLayerVisible: Boolean,
onMapStateChange: (Pair<Double, Double>?, Double, Boolean) -> Unit
onMapStateChange: (Pair<Double, Double>?, Double, Boolean) -> Unit,
onOpenSettings: () -> Unit
) {
val statusColor = if (isConnected) Color(0xFF4CAF50) else Color(0xFFFF5722)

View File

@@ -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
}
}

View File

@@ -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(

View File

@@ -24,7 +24,7 @@ private val LightColorScheme = lightColorScheme(
)
@Composable
fun LBJReceiverTheme(
fun LBJConsoleTheme(
darkTheme: Boolean = true,
dynamicColor: Boolean = true,

View File

@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:padding="16dp"
android:background="?android:attr/selectableItemBackground"
android:clickable="true">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Background Service"
android:textSize="16sp"
android:textColor="@android:color/black"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Keep app running in background"
android:textSize="14sp"
android:textColor="@android:color/darker_gray"
android:layout_marginTop="4dp" />
</LinearLayout>
<Switch
android:id="@+id/switch_background_service"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp" />
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="@android:color/darker_gray"
android:layout_marginHorizontal="16dp" />
</LinearLayout>

View File

@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.LBJReceiver" parent="android:Theme.Material.Light.NoActionBar" />
<style name="Theme.LBJConsole" parent="android:Theme.Material.Light.NoActionBar" />
</resources>