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:
@@ -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 {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
Reference in New Issue
Block a user