feat(android): foreground service for real-time notification polling

- New NotificationForegroundService: persistent foreground coroutine-based
  polling every 30s, keeps app alive in background
- Manifest: FOREGROUND_SERVICE + FOREGROUND_SERVICE_DATA_SYNC permissions,
  service declaration with foregroundServiceType=dataSync
- NotificationHelper: added silent channel for persistent service notification
- ServerManagerApp: starts foreground service on app launch
- MainActivity: removed redundant NotificationWorker.checkNow (service handles
  background polling; NotificationPoller still handles in-app refresh)
- Bump to v1.8.0 (versionCode 12)
This commit is contained in:
2026-06-13 12:20:09 -04:00
parent 71d6078329
commit 297a0153e0
7 changed files with 170 additions and 9 deletions

View File

@@ -12,8 +12,8 @@ android {
applicationId = "com.devlab.app" applicationId = "com.devlab.app"
minSdk = 26 minSdk = 26
targetSdk = 37 targetSdk = 37
versionCode = 11 versionCode = 12
versionName = "1.7.2" versionName = "1.8.0"
} }
buildTypes { buildTypes {

View File

@@ -6,6 +6,8 @@
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.DOWNLOAD_WITHOUT_NOTIFICATION" /> <uses-permission android:name="android.permission.DOWNLOAD_WITHOUT_NOTIFICATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application <application
@@ -35,6 +37,11 @@
</intent-filter> </intent-filter>
</receiver> </receiver>
<service
android:name=".service.NotificationForegroundService"
android:foregroundServiceType="dataSync"
android:exported="false" />
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider" android:authorities="${applicationId}.fileprovider"

View File

@@ -61,7 +61,6 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge() enableEdgeToEdge()
requestNotificationPermission() requestNotificationPermission()
checkForUpdates() checkForUpdates()
NotificationWorker.checkNow(this)
lifecycle.addObserver(LifecycleEventObserver { _, event -> lifecycle.addObserver(LifecycleEventObserver { _, event ->
when (event) { when (event) {
@@ -136,12 +135,6 @@ fun AppRoot() {
var isLoggedIn by remember { mutableStateOf(false) } var isLoggedIn by remember { mutableStateOf(false) }
var unreadCount by remember { mutableIntStateOf(0) } var unreadCount by remember { mutableIntStateOf(0) }
LaunchedEffect(isLoggedIn) {
if (isLoggedIn) {
NotificationWorker.checkNow(context)
}
}
val navBackStackEntry by navController.currentBackStackEntryAsState() val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination val currentDestination = navBackStackEntry?.destination

View File

@@ -1,6 +1,7 @@
package com.devlab.app package com.devlab.app
import android.app.Application import android.app.Application
import com.devlab.app.service.NotificationForegroundService
import com.devlab.app.util.NotificationHelper import com.devlab.app.util.NotificationHelper
import com.devlab.app.util.NotificationPoller import com.devlab.app.util.NotificationPoller
import com.devlab.app.worker.NotificationWorker import com.devlab.app.worker.NotificationWorker
@@ -14,6 +15,7 @@ class ServerManagerApp : Application() {
instance = this instance = this
NotificationHelper.createChannels(this) NotificationHelper.createChannels(this)
NotificationWorker.schedule(this) NotificationWorker.schedule(this)
NotificationForegroundService.start(this)
notificationPoller = NotificationPoller(this) notificationPoller = NotificationPoller(this)
} }

View File

@@ -0,0 +1,148 @@
package com.devlab.app.service
import android.app.Notification
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import com.devlab.app.MainActivity
import com.devlab.app.R
import com.devlab.app.data.api.RetrofitClient
import com.devlab.app.util.NotificationHelper
import com.devlab.app.util.PreferencesManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.util.concurrent.TimeUnit
class NotificationForegroundService : android.app.Service() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var pollingJob: Job? = null
private var lastCheck: Long = 0L
override fun onCreate() {
super.onCreate()
Log.d(TAG, "Service created")
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.d(TAG, "onStartCommand")
val notification = buildPersistentNotification()
startForeground(NOTIFICATION_ID, notification)
if (pollingJob?.isActive != true) {
lastCheck = System.currentTimeMillis()
pollingJob = scope.launch {
while (isActive) {
try {
pollNotifications()
} catch (_: Exception) { }
delay(POLL_INTERVAL_MS)
}
}
Log.d(TAG, "Polling started every ${POLL_INTERVAL_MS / 1000}s")
}
return START_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onDestroy() {
super.onDestroy()
pollingJob?.cancel()
scope.cancel()
Log.d(TAG, "Service destroyed")
}
private fun buildPersistentNotification(): Notification {
val openIntent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
}
val pendingOpen = PendingIntent.getActivity(
this, 0, openIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
return NotificationCompat.Builder(this, CHANNEL_SERVICE)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle(getString(R.string.app_name))
.setContentText("Monitoring for notifications")
.setContentIntent(pendingOpen)
.setOngoing(true)
.setSilent(true)
.setPriority(NotificationCompat.PRIORITY_MIN)
.build()
}
private suspend fun pollNotifications() {
val prefs = PreferencesManager(this)
val token = prefs.getToken()
if (token.isBlank()) return
val now = System.currentTimeMillis()
RetrofitClient.setToken(token)
val service = RetrofitClient.getApiService()
val response = service.getNotifications(page = 1, perPage = 10)
if (!response.success) return
val newNotifications = response.data.filter { note ->
val noteTime = parseTime(note.created_at)
noteTime > lastCheck && note.is_read == 0
}
if (newNotifications.isNotEmpty()) {
Log.d(TAG, "Found ${newNotifications.size} new notifications")
for (notification in newNotifications) {
NotificationHelper.showNotification(this, notification)
}
lastCheck = newNotifications.maxOf { parseTime(it.created_at) }
}
}
private fun parseTime(time: String?): Long {
if (time == null) return 0L
return try {
val sdf = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US).apply {
timeZone = java.util.TimeZone.getTimeZone("UTC")
}
sdf.parse(time)?.time ?: 0L
} catch (_: Exception) {
0L
}
}
companion object {
private const val TAG = "NotificationFgSvc"
private const val NOTIFICATION_ID = 1001
const val CHANNEL_SERVICE = "service_status"
private const val POLL_INTERVAL_MS = 30_000L
fun start(context: Context) {
val intent = Intent(context, NotificationForegroundService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
Log.d(TAG, "Start command sent")
}
fun stop(context: Context) {
context.stopService(Intent(context, NotificationForegroundService::class.java))
Log.d(TAG, "Stop command sent")
}
}
}

View File

@@ -14,11 +14,13 @@ import androidx.core.content.ContextCompat
import com.devlab.app.MainActivity import com.devlab.app.MainActivity
import com.devlab.app.R import com.devlab.app.R
import com.devlab.app.data.model.AppNotification import com.devlab.app.data.model.AppNotification
import com.devlab.app.service.NotificationForegroundService
object NotificationHelper { object NotificationHelper {
private const val CHANNEL_GENERAL = "notifications_general" private const val CHANNEL_GENERAL = "notifications_general"
private const val CHANNEL_ALERTS = "notifications_alerts" private const val CHANNEL_ALERTS = "notifications_alerts"
private const val CHANNEL_SERVICE = "service_status"
fun createChannels(context: Context) { fun createChannels(context: Context) {
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
@@ -37,8 +39,17 @@ object NotificationHelper {
description = "Important alerts requiring attention" description = "Important alerts requiring attention"
} }
val service = NotificationChannel(
CHANNEL_SERVICE, "Service Status",
NotificationManager.IMPORTANCE_MIN
).apply {
description = "Background monitoring service indicator"
setShowBadge(false)
}
manager.createNotificationChannel(general) manager.createNotificationChannel(general)
manager.createNotificationChannel(alerts) manager.createNotificationChannel(alerts)
manager.createNotificationChannel(service)
} }
fun showNotification(context: Context, notification: AppNotification) { fun showNotification(context: Context, notification: AppNotification) {

Binary file not shown.