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