Auto-update + Notifications + Admin panel #22

Merged
rafaga21 merged 1 commits from feat/android-app-redesign into master 2026-06-09 17:22:56 -04:00
9 changed files with 305 additions and 0 deletions

View File

@@ -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")

View File

@@ -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"

View File

@@ -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"

View File

@@ -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 {

View File

@@ -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) { }
}
}
}
}
}

View File

@@ -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()
}
}
}

View File

@@ -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() }
}

View File

@@ -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.