UpdateCheckWorker.kt

6.6 KB · raw

1package com.peachpasswords.updates23import android.app.NotificationChannel4import android.app.NotificationManager5import android.app.PendingIntent6import android.content.Context7import android.content.Intent8import android.os.Build9import androidx.core.app.NotificationCompat10import androidx.core.app.NotificationManagerCompat11import androidx.hilt.work.HiltWorker12import androidx.work.BackoffPolicy13import androidx.work.Constraints14import androidx.work.CoroutineWorker15import androidx.work.ExistingPeriodicWorkPolicy16import androidx.work.ExistingWorkPolicy17import androidx.work.NetworkType18import androidx.work.OneTimeWorkRequestBuilder19import androidx.work.PeriodicWorkRequestBuilder20import androidx.work.WorkManager21import androidx.work.WorkerParameters22import com.peachpasswords.MainActivity23import com.peachpasswords.R24import dagger.assisted.Assisted25import dagger.assisted.AssistedInject26import java.util.concurrent.TimeUnit2728/** Private action set only by the update notification's PendingIntent. */29const val ACTION_OPEN_UPDATE = "com.peachpasswords.OPEN_UPDATE"30const val EXTRA_OPEN_UPDATE = "peach_open_update"31const val EXTRA_OPEN_UPDATE_TOKEN = "peach_open_update_token"3233private const val UPDATE_INTENT_PREFS = "peach_update_intent"34private const val UPDATE_INTENT_TOKEN = "token"3536fun issueUpdateOpenToken(context: Context): String {37    val token = java.util.UUID.randomUUID().toString()38    check(39        context.getSharedPreferences(UPDATE_INTENT_PREFS, Context.MODE_PRIVATE)40            .edit().putString(UPDATE_INTENT_TOKEN, token).commit(),41    ) { "update_intent_token_persist_failed" }42    return token43}4445fun consumeUpdateOpenToken(context: Context, token: String?): Boolean {46    val prefs = context.getSharedPreferences(UPDATE_INTENT_PREFS, Context.MODE_PRIVATE)47    if (token == null || token != prefs.getString(UPDATE_INTENT_TOKEN, null)) return false48    return prefs.edit().remove(UPDATE_INTENT_TOKEN).commit()49}50/**51 * The quiet half of the update flow: ask Kepr what the latest release is and, if52 * it is newer than this build, say so once in a low-importance notification.53 *54 * Kepr has no push mechanism, so this poll is the only way the app learns about a55 * release. It never downloads anything — tapping through is what starts that.56 */57@HiltWorker58class UpdateCheckWorker @AssistedInject constructor(59    @Assisted appContext: Context,60    @Assisted params: WorkerParameters,61    private val updates: UpdateRepository,62) : CoroutineWorker(appContext, params) {6364    override suspend fun doWork(): Result {65        val outcome = try {66            updates.check()67        } catch (e: Exception) {68            // Offline, DNS failure, a repo not registered yet — all transient from69            // here. Never surface a failed background check to the user.70            return Result.retry()71        }72        val manifest = (outcome as? UpdateCheckResult.Accepted)?.manifest ?: return Result.success()73        if (updates.shouldNotifyFor(manifest.version) &&74            postUpdateNotification(applicationContext, manifest.version)75        ) {76            updates.markNotified(manifest.version)77        }78        return Result.success()79    }8081    companion object {82        private const val PERIODIC_WORK = "peach_update_check"83        private const val ONE_SHOT_WORK = "peach_update_check_now"8485        private val NETWORK_ONLY = Constraints.Builder()86            .setRequiredNetworkType(NetworkType.CONNECTED)87            .build()8889        /** Once a day is plenty for a manually-installed beta; KEEP so reinstalling the schedule doesn't reset it. */90        fun schedulePeriodic(context: Context) {91            WorkManager.getInstance(context).enqueueUniquePeriodicWork(92                PERIODIC_WORK,93                ExistingPeriodicWorkPolicy.KEEP,94                PeriodicWorkRequestBuilder<UpdateCheckWorker>(1, TimeUnit.DAYS)95                    .setConstraints(NETWORK_ONLY)96                    .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.MINUTES)97                    .build(),98            )99        }100101        /** The on-app-open check. REPLACE so an explicit "check now" isn't swallowed by a queued one. */102        fun checkNow(context: Context) {103            WorkManager.getInstance(context).enqueueUniqueWork(104                ONE_SHOT_WORK,105                ExistingWorkPolicy.REPLACE,106                OneTimeWorkRequestBuilder<UpdateCheckWorker>()107                    .setConstraints(NETWORK_ONLY)108                    .build(),109            )110        }111    }112}113114private const val CHANNEL_ID = "peach_updates"115private const val NOTIFICATION_ID = 4301116117/**118 * Post the "an update exists" notification. Returns false when the user has not119 * granted POST_NOTIFICATIONS (API 33+) or has muted the channel — the check still120 * counts as done, but it must not be recorded as notified, or the grant arriving121 * later would never produce a notification for this version.122 */123fun postUpdateNotification(context: Context, version: String): Boolean {124    val manager = NotificationManagerCompat.from(context)125    if (!manager.areNotificationsEnabled()) return false126127    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {128        val system = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager129        system.createNotificationChannel(130            NotificationChannel(CHANNEL_ID, "Peach updates", NotificationManager.IMPORTANCE_LOW).apply {131                description = "Tells you when a newer version of Peach is available."132                setShowBadge(false)133            },134        )135    }136137    val open = PendingIntent.getActivity(138        context,139        0,140        Intent(context, MainActivity::class.java)141            .setAction(ACTION_OPEN_UPDATE)142            .putExtra(EXTRA_OPEN_UPDATE, true)143            .putExtra(EXTRA_OPEN_UPDATE_TOKEN, issueUpdateOpenToken(context))144            .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP),145        PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,146    )147148    val notification = NotificationCompat.Builder(context, CHANNEL_ID)149        .setContentTitle("Peach update available — v$version")150        .setContentText("Tap to see what changed and install it.")151        .setSmallIcon(R.drawable.ic_launcher_monochrome)152        .setPriority(NotificationCompat.PRIORITY_LOW)153        .setAutoCancel(true)154        .setContentIntent(open)155        .build()156157    return try {158        manager.notify(NOTIFICATION_ID, notification)159        true160    } catch (e: SecurityException) {161        // areNotificationsEnabled() and the actual permission can disagree on some162        // OEM builds; a missing notification must never take the app down.163        false164    }165}