feat: push notifications via WorkManager background polling
- NotificationWorker checks for new notifications every 15 min (WorkManager) - Shows Android system notifications with Mark Read action - Notification channels for general + alerts - Permission request on Android 13+ - Boot persistence via WorkManager - On-app-open immediate notification check
This commit is contained in:
@@ -63,6 +63,7 @@ dependencies {
|
||||
implementation("androidx.datastore:datastore-preferences:1.1.3")
|
||||
|
||||
implementation("androidx.core:core-splashscreen:1.0.1")
|
||||
implementation("androidx.work:work-runtime-ktx:2.10.0")
|
||||
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<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.RECEIVE_BOOT_COMPLETED" />
|
||||
|
||||
<application
|
||||
android:name=".ServerManagerApp"
|
||||
@@ -25,6 +27,14 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<receiver
|
||||
android:name=".util.NotificationActionReceiver"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.devlab.app.MARK_READ" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
package com.devlab.app
|
||||
|
||||
import android.Manifest
|
||||
import android.app.AlertDialog
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.devlab.app.util.AppUpdater
|
||||
import com.devlab.app.util.UpdateChecker
|
||||
import com.devlab.app.worker.NotificationWorker
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
@@ -45,7 +50,9 @@ class MainActivity : ComponentActivity() {
|
||||
installSplashScreen()
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
requestNotificationPermission()
|
||||
checkForUpdates()
|
||||
NotificationWorker.checkNow(this)
|
||||
setContent {
|
||||
ServerManagerTheme {
|
||||
AppRoot()
|
||||
@@ -53,6 +60,20 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestNotificationPermission() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
|
||||
!= PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
ActivityCompat.requestPermissions(
|
||||
this,
|
||||
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
||||
1001
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkForUpdates() {
|
||||
val versionName = try {
|
||||
packageManager.getPackageInfo(packageName, 0).versionName ?: "1.0.0"
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
package com.devlab.app
|
||||
|
||||
import android.app.Application
|
||||
import com.devlab.app.util.NotificationHelper
|
||||
import com.devlab.app.worker.NotificationWorker
|
||||
|
||||
class ServerManagerApp : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
instance = this
|
||||
NotificationHelper.createChannels(this)
|
||||
NotificationWorker.schedule(this)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.devlab.app.util
|
||||
|
||||
import android.app.NotificationManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class NotificationActionReceiver : BroadcastReceiver() {
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action == "com.devlab.app.MARK_READ") {
|
||||
val notificationId = intent.getIntExtra("notification_id", 0)
|
||||
if (notificationId > 0) {
|
||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
manager.cancel(notificationId)
|
||||
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
val service = com.devlab.app.data.api.RetrofitClient.getApiService()
|
||||
service.markNotificationRead(notificationId)
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package com.devlab.app.util
|
||||
|
||||
import android.Manifest
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.devlab.app.MainActivity
|
||||
import com.devlab.app.R
|
||||
import com.devlab.app.data.model.AppNotification
|
||||
|
||||
object NotificationHelper {
|
||||
|
||||
private const val CHANNEL_GENERAL = "notifications_general"
|
||||
private const val CHANNEL_ALERTS = "notifications_alerts"
|
||||
|
||||
fun createChannels(context: Context) {
|
||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
val general = NotificationChannel(
|
||||
CHANNEL_GENERAL, "Notifications",
|
||||
NotificationManager.IMPORTANCE_DEFAULT
|
||||
).apply {
|
||||
description = "Server notifications and alerts"
|
||||
}
|
||||
|
||||
val alerts = NotificationChannel(
|
||||
CHANNEL_ALERTS, "Alerts",
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
).apply {
|
||||
description = "Important alerts requiring attention"
|
||||
}
|
||||
|
||||
manager.createNotificationChannel(general)
|
||||
manager.createNotificationChannel(alerts)
|
||||
}
|
||||
|
||||
fun showNotification(context: Context, notification: AppNotification) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
if (ContextCompat.checkSelfPermission(
|
||||
context, Manifest.permission.POST_NOTIFICATIONS
|
||||
) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
val intent = Intent(context, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
putExtra("open_notification", notification.id)
|
||||
}
|
||||
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
context, notification.id, intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
val markReadIntent = Intent(context, NotificationActionReceiver::class.java).apply {
|
||||
action = "com.devlab.app.MARK_READ"
|
||||
putExtra("notification_id", notification.id)
|
||||
}
|
||||
val markReadPendingIntent = PendingIntent.getBroadcast(
|
||||
context, notification.id + 1000, markReadIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
val channel = when (notification.type) {
|
||||
"error" -> CHANNEL_ALERTS
|
||||
"warning" -> CHANNEL_ALERTS
|
||||
else -> CHANNEL_GENERAL
|
||||
}
|
||||
|
||||
val icon = when (notification.type) {
|
||||
"success" -> android.R.drawable.ic_dialog_info
|
||||
"error" -> android.R.drawable.ic_dialog_alert
|
||||
"warning" -> android.R.drawable.ic_dialog_alert
|
||||
else -> android.R.drawable.ic_dialog_info
|
||||
}
|
||||
|
||||
val built = NotificationCompat.Builder(context, channel)
|
||||
.setSmallIcon(icon)
|
||||
.setContentTitle(notification.title)
|
||||
.setContentText(notification.message)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(notification.message))
|
||||
.setPriority(
|
||||
when (notification.type) {
|
||||
"error" -> NotificationCompat.PRIORITY_HIGH
|
||||
"warning" -> NotificationCompat.PRIORITY_DEFAULT
|
||||
else -> NotificationCompat.PRIORITY_DEFAULT
|
||||
}
|
||||
)
|
||||
.setContentIntent(pendingIntent)
|
||||
.addAction(
|
||||
android.R.drawable.ic_input_add,
|
||||
"Mark Read",
|
||||
markReadPendingIntent
|
||||
)
|
||||
.setAutoCancel(true)
|
||||
.setWhen(parseTime(notification.created_at))
|
||||
|
||||
NotificationManagerCompat.from(context).notify(notification.id, built.build())
|
||||
}
|
||||
|
||||
private fun parseTime(time: String?): Long {
|
||||
if (time == null) return System.currentTimeMillis()
|
||||
return try {
|
||||
java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US).apply {
|
||||
timeZone = java.util.TimeZone.getTimeZone("UTC")
|
||||
}.parse(time)?.time ?: System.currentTimeMillis()
|
||||
} catch (_: Exception) {
|
||||
System.currentTimeMillis()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.devlab.app.util
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -19,6 +20,7 @@ class PreferencesManager(private val context: Context) {
|
||||
private val KEY_EMAIL = stringPreferencesKey("email")
|
||||
private val KEY_ROLE = stringPreferencesKey("role")
|
||||
private val KEY_USER_ID = intPreferencesKey("user_id")
|
||||
private val KEY_LAST_NOTIFICATION_CHECK = longPreferencesKey("last_notification_check")
|
||||
}
|
||||
|
||||
val token: Flow<String> = context.dataStore.data.map { prefs ->
|
||||
@@ -68,6 +70,16 @@ class PreferencesManager(private val context: Context) {
|
||||
suspend fun getUsername(): String = context.dataStore.data.first()[KEY_USERNAME] ?: ""
|
||||
suspend fun getEmail(): String = context.dataStore.data.first()[KEY_EMAIL] ?: ""
|
||||
|
||||
suspend fun getLastNotificationCheck(): Long {
|
||||
return context.dataStore.data.first()[KEY_LAST_NOTIFICATION_CHECK] ?: 0L
|
||||
}
|
||||
|
||||
suspend fun setLastNotificationCheck(time: Long) {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[KEY_LAST_NOTIFICATION_CHECK] = time
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clear() {
|
||||
context.dataStore.edit { it.clear() }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.devlab.app.worker
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.*
|
||||
import com.devlab.app.data.api.RetrofitClient
|
||||
import com.devlab.app.data.model.AppNotification
|
||||
import com.devlab.app.util.NotificationHelper
|
||||
import com.devlab.app.util.PreferencesManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class NotificationWorker(
|
||||
context: Context,
|
||||
workerParams: WorkerParameters
|
||||
) : CoroutineWorker(context, workerParams) {
|
||||
|
||||
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val prefs = PreferencesManager(applicationContext)
|
||||
val token = prefs.getToken()
|
||||
|
||||
if (token.isBlank()) {
|
||||
return@withContext Result.success()
|
||||
}
|
||||
|
||||
val lastCheck = prefs.getLastNotificationCheck()
|
||||
RetrofitClient.setToken(token)
|
||||
val service = RetrofitClient.getApiService()
|
||||
|
||||
try {
|
||||
val response = service.getNotifications(page = 1, perPage = 50)
|
||||
if (response.success) {
|
||||
val newNotifications = response.data.filter { note ->
|
||||
val noteTime = parseTime(note.created_at)
|
||||
noteTime > lastCheck && note.is_read == 0
|
||||
}
|
||||
|
||||
for (notification in newNotifications) {
|
||||
NotificationHelper.showNotification(applicationContext, notification)
|
||||
}
|
||||
|
||||
val lastTime = newNotifications.maxOfOrNull {
|
||||
parseTime(it.created_at)
|
||||
} ?: System.currentTimeMillis()
|
||||
|
||||
prefs.setLastNotificationCheck(
|
||||
if (newNotifications.isNotEmpty()) lastTime else System.currentTimeMillis()
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
|
||||
Result.success()
|
||||
} catch (_: Exception) {
|
||||
Result.retry()
|
||||
}
|
||||
}
|
||||
|
||||
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 WORK_NAME = "notification_check"
|
||||
|
||||
fun schedule(context: Context) {
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
|
||||
val request = PeriodicWorkRequestBuilder<NotificationWorker>(
|
||||
15, TimeUnit.MINUTES
|
||||
)
|
||||
.setConstraints(constraints)
|
||||
.setBackoffCriteria(
|
||||
BackoffPolicy.EXPONENTIAL,
|
||||
1, TimeUnit.MINUTES
|
||||
)
|
||||
.build()
|
||||
|
||||
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
||||
WORK_NAME,
|
||||
ExistingPeriodicWorkPolicy.KEEP,
|
||||
request
|
||||
)
|
||||
}
|
||||
|
||||
fun checkNow(context: Context) {
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
|
||||
val request = OneTimeWorkRequestBuilder<NotificationWorker>()
|
||||
.setConstraints(constraints)
|
||||
.build()
|
||||
|
||||
WorkManager.getInstance(context).enqueue(request)
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Reference in New Issue
Block a user