OXYGENHUB
# dce8ebd2482a oxygenhub-android / app / src / main / java / io / oxygenhub / android / alerts / LandAlerts.kt
4.8 KiB · last changed Notifications when a proposal can be landed; the runner comes back after an update or restart (closes #20, refs #8) 10 days ago source blame history raw
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
}