feat(android): foreground service for real-time notification polling #60
@@ -12,8 +12,8 @@ android {
|
||||
applicationId = "com.devlab.app"
|
||||
minSdk = 26
|
||||
targetSdk = 37
|
||||
versionCode = 11
|
||||
versionName = "1.7.2"
|
||||
versionCode = 12
|
||||
versionName = "1.8.0"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.DOWNLOAD_WITHOUT_NOTIFICATION" />
|
||||
<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" />
|
||||
|
||||
<application
|
||||
@@ -35,6 +37,11 @@
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<service
|
||||
android:name=".service.NotificationForegroundService"
|
||||
android:foregroundServiceType="dataSync"
|
||||
android:exported="false" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
|
||||
@@ -61,7 +61,6 @@ class MainActivity : ComponentActivity() {
|
||||
enableEdgeToEdge()
|
||||
requestNotificationPermission()
|
||||
checkForUpdates()
|
||||
NotificationWorker.checkNow(this)
|
||||
|
||||
lifecycle.addObserver(LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
@@ -136,12 +135,6 @@ fun AppRoot() {
|
||||
var isLoggedIn by remember { mutableStateOf(false) }
|
||||
var unreadCount by remember { mutableIntStateOf(0) }
|
||||
|
||||
LaunchedEffect(isLoggedIn) {
|
||||
if (isLoggedIn) {
|
||||
NotificationWorker.checkNow(context)
|
||||
}
|
||||
}
|
||||
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentDestination = navBackStackEntry?.destination
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.devlab.app
|
||||
|
||||
import android.app.Application
|
||||
import com.devlab.app.service.NotificationForegroundService
|
||||
import com.devlab.app.util.NotificationHelper
|
||||
import com.devlab.app.util.NotificationPoller
|
||||
import com.devlab.app.worker.NotificationWorker
|
||||
@@ -14,6 +15,7 @@ class ServerManagerApp : Application() {
|
||||
instance = this
|
||||
NotificationHelper.createChannels(this)
|
||||
NotificationWorker.schedule(this)
|
||||
NotificationForegroundService.start(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.R
|
||||
import com.devlab.app.data.model.AppNotification
|
||||
import com.devlab.app.service.NotificationForegroundService
|
||||
|
||||
object NotificationHelper {
|
||||
|
||||
private const val CHANNEL_GENERAL = "notifications_general"
|
||||
private const val CHANNEL_ALERTS = "notifications_alerts"
|
||||
private const val CHANNEL_SERVICE = "service_status"
|
||||
|
||||
fun createChannels(context: Context) {
|
||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
@@ -37,8 +39,17 @@ object NotificationHelper {
|
||||
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(alerts)
|
||||
manager.createNotificationChannel(service)
|
||||
}
|
||||
|
||||
fun showNotification(context: Context, notification: AppNotification) {
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user