diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index e8ca6aa..2a0e8e6 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -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")
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 68623b2..97e81d7 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -5,6 +5,8 @@
+
+
+
+
+
+
+
+
= 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"
diff --git a/android/app/src/main/java/com/devlab/app/ServerManagerApp.kt b/android/app/src/main/java/com/devlab/app/ServerManagerApp.kt
index 4a288b3..8809692 100644
--- a/android/app/src/main/java/com/devlab/app/ServerManagerApp.kt
+++ b/android/app/src/main/java/com/devlab/app/ServerManagerApp.kt
@@ -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 {
diff --git a/android/app/src/main/java/com/devlab/app/util/NotificationActionReceiver.kt b/android/app/src/main/java/com/devlab/app/util/NotificationActionReceiver.kt
new file mode 100644
index 0000000..29d3355
--- /dev/null
+++ b/android/app/src/main/java/com/devlab/app/util/NotificationActionReceiver.kt
@@ -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) { }
+ }
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/devlab/app/util/NotificationHelper.kt b/android/app/src/main/java/com/devlab/app/util/NotificationHelper.kt
new file mode 100644
index 0000000..877f4b2
--- /dev/null
+++ b/android/app/src/main/java/com/devlab/app/util/NotificationHelper.kt
@@ -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()
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/devlab/app/util/PreferencesManager.kt b/android/app/src/main/java/com/devlab/app/util/PreferencesManager.kt
index 067fe82..d032bfd 100644
--- a/android/app/src/main/java/com/devlab/app/util/PreferencesManager.kt
+++ b/android/app/src/main/java/com/devlab/app/util/PreferencesManager.kt
@@ -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 = 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() }
}
diff --git a/android/app/src/main/java/com/devlab/app/worker/NotificationWorker.kt b/android/app/src/main/java/com/devlab/app/worker/NotificationWorker.kt
new file mode 100644
index 0000000..4d500ea
--- /dev/null
+++ b/android/app/src/main/java/com/devlab/app/worker/NotificationWorker.kt
@@ -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(
+ 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()
+ .setConstraints(constraints)
+ .build()
+
+ WorkManager.getInstance(context).enqueue(request)
+ }
+ }
+}
diff --git a/public/sysadmin.apk b/public/sysadmin.apk
index 1c28a16..7bb9be0 100644
Binary files a/public/sysadmin.apk and b/public/sysadmin.apk differ