diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index b962782..08bace4 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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 { diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 97e81d7..43c2fba 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -6,6 +6,8 @@ + + + + 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 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 7ff58b8..79511f5 100644 --- a/android/app/src/main/java/com/devlab/app/ServerManagerApp.kt +++ b/android/app/src/main/java/com/devlab/app/ServerManagerApp.kt @@ -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) } diff --git a/android/app/src/main/java/com/devlab/app/service/NotificationForegroundService.kt b/android/app/src/main/java/com/devlab/app/service/NotificationForegroundService.kt new file mode 100644 index 0000000..d4e02b0 --- /dev/null +++ b/android/app/src/main/java/com/devlab/app/service/NotificationForegroundService.kt @@ -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") + } + } +} 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 index 877f4b2..ae44543 100644 --- a/android/app/src/main/java/com/devlab/app/util/NotificationHelper.kt +++ b/android/app/src/main/java/com/devlab/app/util/NotificationHelper.kt @@ -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) { diff --git a/public/sysadmin.apk b/public/sysadmin.apk index 53888b6..6ce0c72 100644 Binary files a/public/sysadmin.apk and b/public/sysadmin.apk differ