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.datastore:datastore-preferences:1.1.3")
|
||||||
|
|
||||||
implementation("androidx.core:core-splashscreen:1.0.1")
|
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-tooling")
|
||||||
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
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.ACCESS_NETWORK_STATE" />
|
||||||
<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.RECEIVE_BOOT_COMPLETED" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".ServerManagerApp"
|
android:name=".ServerManagerApp"
|
||||||
@@ -25,6 +27,14 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".util.NotificationActionReceiver"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="com.devlab.app.MARK_READ" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
android:authorities="${applicationId}.fileprovider"
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
package com.devlab.app
|
package com.devlab.app
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
import android.app.AlertDialog
|
import android.app.AlertDialog
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import androidx.core.app.ActivityCompat
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.devlab.app.util.AppUpdater
|
import com.devlab.app.util.AppUpdater
|
||||||
import com.devlab.app.util.UpdateChecker
|
import com.devlab.app.util.UpdateChecker
|
||||||
|
import com.devlab.app.worker.NotificationWorker
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
@@ -45,7 +50,9 @@ class MainActivity : ComponentActivity() {
|
|||||||
installSplashScreen()
|
installSplashScreen()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
requestNotificationPermission()
|
||||||
checkForUpdates()
|
checkForUpdates()
|
||||||
|
NotificationWorker.checkNow(this)
|
||||||
setContent {
|
setContent {
|
||||||
ServerManagerTheme {
|
ServerManagerTheme {
|
||||||
AppRoot()
|
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() {
|
private fun checkForUpdates() {
|
||||||
val versionName = try {
|
val versionName = try {
|
||||||
packageManager.getPackageInfo(packageName, 0).versionName ?: "1.0.0"
|
packageManager.getPackageInfo(packageName, 0).versionName ?: "1.0.0"
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
package com.devlab.app
|
package com.devlab.app
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import com.devlab.app.util.NotificationHelper
|
||||||
|
import com.devlab.app.worker.NotificationWorker
|
||||||
|
|
||||||
class ServerManagerApp : Application() {
|
class ServerManagerApp : Application() {
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
instance = this
|
instance = this
|
||||||
|
NotificationHelper.createChannels(this)
|
||||||
|
NotificationWorker.schedule(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
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 android.content.Context
|
||||||
import androidx.datastore.preferences.core.edit
|
import androidx.datastore.preferences.core.edit
|
||||||
import androidx.datastore.preferences.core.intPreferencesKey
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.longPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import androidx.datastore.preferences.preferencesDataStore
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@@ -19,6 +20,7 @@ class PreferencesManager(private val context: Context) {
|
|||||||
private val KEY_EMAIL = stringPreferencesKey("email")
|
private val KEY_EMAIL = stringPreferencesKey("email")
|
||||||
private val KEY_ROLE = stringPreferencesKey("role")
|
private val KEY_ROLE = stringPreferencesKey("role")
|
||||||
private val KEY_USER_ID = intPreferencesKey("user_id")
|
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 ->
|
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 getUsername(): String = context.dataStore.data.first()[KEY_USERNAME] ?: ""
|
||||||
suspend fun getEmail(): String = context.dataStore.data.first()[KEY_EMAIL] ?: ""
|
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() {
|
suspend fun clear() {
|
||||||
context.dataStore.edit { it.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