| 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 |
} |