Coverage / app/src/main/java/io/oxygenhub/android/alerts/LandAlerts.kt 9.3%
5 of 54 lines run, 3 of 10 functions, 2 of 12 branches · run #1665 on main · run missed
| 1 | package io.oxygenhub.android.alerts | |
| 2 | ||
| 3 | import android.app.Notification | |
| 4 | import android.app.NotificationChannel | |
| 5 | import android.app.NotificationManager | |
| 6 | import android.app.PendingIntent | |
| 7 | import android.app.job.JobInfo | |
| 8 | import android.app.job.JobScheduler | |
| 9 | import android.content.ComponentName | |
| 10 | import android.content.Context | |
| 11 | import android.content.Intent | |
| 12 | import androidx.core.content.edit | |
| 13 | import io.oxygenhub.android.MainActivity | |
| 14 | import io.oxygenhub.android.R | |
| 15 | import io.oxygenhub.android.api.ProposalBrief | |
| 16 | import kotlinx.coroutines.flow.MutableStateFlow | |
| 17 | import kotlinx.coroutines.flow.StateFlow | |
| 18 | import kotlinx.coroutines.flow.asStateFlow | |
| 19 | ||
| 20 | /** | |
| 21 | * Notifications when a proposal can be landed (#20): every 15 minutes the | |
| 22 | * phone asks the hub what the account can land now (oxygenhub#311) and | |
| 23 | * says which proposals have become landable since it last asked. Polling | |
| 24 | * until FCM (#16) can push the same. | |
| 25 | */ | |
| 26 | object LandAlerts { | |
| 27 | private const val PREFS = "alerts" | |
| 28 | private const val ON = "land.on" | |
| 29 | private const val SEEN = "land.seen" | |
| 30 | private const val JOB = 20 | |
| 31 | private const val CHANNEL = "landable" | |
| 32 | private const val EVERY_MS = 15 * 60 * 1000L | |
| 33 | ||
| 34 | /** Extras on the intent a notification opens: the proposal to show. */ | |
| 35 | const val EXTRA_REPOSITORY = "io.oxygenhub.android.proposal.repository" | |
| 36 | const val EXTRA_NUMBER = "io.oxygenhub.android.proposal.number" | |
| 37 | ||
| 38 | private val _on = MutableStateFlow<Boolean?>(null) | |
| 39 | ||
| 40 | /** Whether the alerts are on, once [refresh] has read it. */ | |
| 41 | val on: StateFlow<Boolean?> = _on.asStateFlow() | |
| 42 | ||
| 43 | fun refresh(context: Context) { | |
| 44 | _on.value = prefs(context).getBoolean(ON, false) | |
| 45 | } | |
| 46 | ||
| 47 | /** | |
| 48 | * Turn the alerts on: the check runs every 15 minutes on any network, | |
| 49 | * across reboots. What is landable already is said at the first check. | |
| 50 | */ | |
| 51 | fun enable(context: Context) { | |
| 52 | prefs(context).edit { | |
| 53 | putBoolean(ON, true) | |
| 54 | remove(SEEN) | |
| 55 | } | |
| 56 | val job = JobInfo.Builder(JOB, ComponentName(context, LandCheckJob::class.java)) | |
| 57 | .setPeriodic(EVERY_MS) | |
| 58 | .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY) | |
| 59 | .setPersisted(true) | |
| 60 | .build() | |
| 61 | context.getSystemService(JobScheduler::class.java)?.schedule(job) | |
| 62 | _on.value = true | |
| 63 | } | |
| 64 | ||
| 65 | fun disable(context: Context) { | |
| 66 | context.getSystemService(JobScheduler::class.java)?.cancel(JOB) | |
| 67 | prefs(context).edit { | |
| 68 | putBoolean(ON, false) | |
| 69 | remove(SEEN) | |
| 70 | } | |
| 71 | _on.value = false | |
| 72 | } | |
| 73 | ||
| 74 | /** The proposals to say now, and what to remember for the next check. */ | |
| 75 | fun fresh(seen: Set<String>, landable: List<ProposalBrief>): Pair<List<ProposalBrief>, Set<String>> { | |
| 76 | val now = landable.associateBy(::key) | |
| 77 | // Only what is landable now is remembered, so one that stops being | |
| 78 | // landable and becomes so again is said again. | |
| 79 | return now.filterKeys { it !in seen }.values.toList() to now.keys | |
| 80 | } | |
| 81 | ||
| 82 | /** Say what has become landable since the last check, and remember what is landable now. */ | |
| 83 | fun check(context: Context, landable: List<ProposalBrief>) { | |
| 84 | val prefs = prefs(context) | |
| 85 | if (!prefs.getBoolean(ON, false)) return | |
| 86 | val (say, keep) = fresh(prefs.getStringSet(SEEN, emptySet()).orEmpty(), landable) | |
| 87 | say.forEach { notify(context, it) } | |
| 88 | prefs.edit { putStringSet(SEEN, keep) } | |
| 89 | } | |
| 90 | ||
| 91 | private fun key(p: ProposalBrief) = "${p.repository}#${p.number}" | |
| 92 | ||
| 93 | private fun notify(context: Context, p: ProposalBrief) { | |
| 94 | val manager = context.getSystemService(NotificationManager::class.java) ?: return | |
| 95 | manager.createNotificationChannel( | |
| 96 | NotificationChannel(CHANNEL, "Ready to land", NotificationManager.IMPORTANCE_DEFAULT).apply { | |
| 97 | description = "A proposal you can land: its checks and reviews are settled" | |
| 98 | }, | |
| 99 | ) | |
| 100 | val open = Intent(context, MainActivity::class.java) | |
| 101 | .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) | |
| 102 | .putExtra(EXTRA_REPOSITORY, p.repository) | |
| 103 | .putExtra(EXTRA_NUMBER, p.number) | |
| 104 | val id = key(p).hashCode() | |
| 105 | val tap = PendingIntent.getActivity(context, id, open, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) | |
| 106 | val notification = Notification.Builder(context, CHANNEL) | |
| 107 | .setSmallIcon(R.drawable.ic_launcher_monochrome) | |
| 108 | .setContentTitle("Ready to land: ${p.repository}#${p.number}") | |
| 109 | .setContentText(p.title) | |
| 110 | .setStyle(Notification.BigTextStyle().bigText("${p.title}\nby ${p.author}")) | |
| 111 | .setContentIntent(tap) | |
| 112 | .setAutoCancel(true) | |
| 113 | .build() | |
| 114 | manager.notify(id, notification) | |
| 115 | } | |
| 116 | ||
| 117 | private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) | |
| 118 | } |