package com.peachpasswords.updates import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent import android.os.Build import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.hilt.work.HiltWorker import androidx.work.BackoffPolicy import androidx.work.Constraints import androidx.work.CoroutineWorker import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.ExistingWorkPolicy import androidx.work.NetworkType import androidx.work.OneTimeWorkRequestBuilder import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters import com.peachpasswords.MainActivity import com.peachpasswords.R import dagger.assisted.Assisted import dagger.assisted.AssistedInject import java.util.concurrent.TimeUnit /** Private action set only by the update notification's PendingIntent. */ const val ACTION_OPEN_UPDATE = "com.peachpasswords.OPEN_UPDATE" const val EXTRA_OPEN_UPDATE = "peach_open_update" const val EXTRA_OPEN_UPDATE_TOKEN = "peach_open_update_token" private const val UPDATE_INTENT_PREFS = "peach_update_intent" private const val UPDATE_INTENT_TOKEN = "token" fun issueUpdateOpenToken(context: Context): String { val token = java.util.UUID.randomUUID().toString() check( context.getSharedPreferences(UPDATE_INTENT_PREFS, Context.MODE_PRIVATE) .edit().putString(UPDATE_INTENT_TOKEN, token).commit(), ) { "update_intent_token_persist_failed" } return token } fun consumeUpdateOpenToken(context: Context, token: String?): Boolean { val prefs = context.getSharedPreferences(UPDATE_INTENT_PREFS, Context.MODE_PRIVATE) if (token == null || token != prefs.getString(UPDATE_INTENT_TOKEN, null)) return false return prefs.edit().remove(UPDATE_INTENT_TOKEN).commit() } /** * The quiet half of the update flow: ask Kepr what the latest release is and, if * it is newer than this build, say so once in a low-importance notification. * * Kepr has no push mechanism, so this poll is the only way the app learns about a * release. It never downloads anything — tapping through is what starts that. */ @HiltWorker class UpdateCheckWorker @AssistedInject constructor( @Assisted appContext: Context, @Assisted params: WorkerParameters, private val updates: UpdateRepository, ) : CoroutineWorker(appContext, params) { override suspend fun doWork(): Result { val outcome = try { updates.check() } catch (e: Exception) { // Offline, DNS failure, a repo not registered yet — all transient from // here. Never surface a failed background check to the user. return Result.retry() } val manifest = (outcome as? UpdateCheckResult.Accepted)?.manifest ?: return Result.success() if (updates.shouldNotifyFor(manifest.version) && postUpdateNotification(applicationContext, manifest.version) ) { updates.markNotified(manifest.version) } return Result.success() } companion object { private const val PERIODIC_WORK = "peach_update_check" private const val ONE_SHOT_WORK = "peach_update_check_now" private val NETWORK_ONLY = Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) .build() /** Once a day is plenty for a manually-installed beta; KEEP so reinstalling the schedule doesn't reset it. */ fun schedulePeriodic(context: Context) { WorkManager.getInstance(context).enqueueUniquePeriodicWork( PERIODIC_WORK, ExistingPeriodicWorkPolicy.KEEP, PeriodicWorkRequestBuilder(1, TimeUnit.DAYS) .setConstraints(NETWORK_ONLY) .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.MINUTES) .build(), ) } /** The on-app-open check. REPLACE so an explicit "check now" isn't swallowed by a queued one. */ fun checkNow(context: Context) { WorkManager.getInstance(context).enqueueUniqueWork( ONE_SHOT_WORK, ExistingWorkPolicy.REPLACE, OneTimeWorkRequestBuilder() .setConstraints(NETWORK_ONLY) .build(), ) } } } private const val CHANNEL_ID = "peach_updates" private const val NOTIFICATION_ID = 4301 /** * Post the "an update exists" notification. Returns false when the user has not * granted POST_NOTIFICATIONS (API 33+) or has muted the channel — the check still * counts as done, but it must not be recorded as notified, or the grant arriving * later would never produce a notification for this version. */ fun postUpdateNotification(context: Context, version: String): Boolean { val manager = NotificationManagerCompat.from(context) if (!manager.areNotificationsEnabled()) return false if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val system = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager system.createNotificationChannel( NotificationChannel(CHANNEL_ID, "Peach updates", NotificationManager.IMPORTANCE_LOW).apply { description = "Tells you when a newer version of Peach is available." setShowBadge(false) }, ) } val open = PendingIntent.getActivity( context, 0, Intent(context, MainActivity::class.java) .setAction(ACTION_OPEN_UPDATE) .putExtra(EXTRA_OPEN_UPDATE, true) .putExtra(EXTRA_OPEN_UPDATE_TOKEN, issueUpdateOpenToken(context)) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, ) val notification = NotificationCompat.Builder(context, CHANNEL_ID) .setContentTitle("Peach update available — v$version") .setContentText("Tap to see what changed and install it.") .setSmallIcon(R.drawable.ic_launcher_monochrome) .setPriority(NotificationCompat.PRIORITY_LOW) .setAutoCancel(true) .setContentIntent(open) .build() return try { manager.notify(NOTIFICATION_ID, notification) true } catch (e: SecurityException) { // areNotificationsEnabled() and the actual permission can disagree on some // OEM builds; a missing notification must never take the app down. false } }