OXYGENHUB
# 1b55d7f47d9f oxygenhub-android / app / src / main / java / io / oxygenhub / android / alerts / PushAlerts.kt
5.0 KiB · last changed This phone as the second factor: a Keystore key opened by fingerprint, face or PIN approves sign-ins and sensitive changes on the web by the number the browser shows (closes #32) 4 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.content.Context
8
import android.content.Intent
9
import android.net.Uri
10
import android.util.Base64
11
import android.util.Log
12
import com.google.firebase.FirebaseApp
13
import com.google.firebase.FirebaseOptions
14
import com.google.firebase.messaging.FirebaseMessaging
15
import io.oxygenhub.android.BuildConfig
16
import io.oxygenhub.android.MainActivity
17
import io.oxygenhub.android.R
18
import io.oxygenhub.android.api.HubApi
19
import kotlinx.coroutines.suspendCancellableCoroutine
20
import kotlinx.serialization.Serializable
21
import kotlinx.serialization.json.Json
22
import uniffi.rust_core.alertOpen
23
import kotlin.coroutines.resume
24
import kotlin.coroutines.resumeWithException
25
26
/**
27
 * Alerts from the hub (#16): each notification the account is told reaches
28
 * the phone through Firebase Cloud Messaging, sealed to the phone's
29
 * [AlertKey], so Google carries only ciphertext; the app opens it and shows
30
 * it. Firebase is started by hand from the build's settings (BuildConfig,
31
 * from google-services.json), so a build without them takes no alerts.
32
 */
33
object PushAlerts {
34
    private const val TAG = "PushAlerts"
35
    private const val CHANNEL = "hub-alerts"
36
37
    /** What the hub seals: the notification's kind, its title and its link. */
38
    @Serializable
39
    data class Alert(val kind: String = "", val title: String = "", val url: String = "")
40
41
    private val json = Json { ignoreUnknownKeys = true }
42
43
    /** Whether this build can take alerts. */
44
    val available: Boolean get() = BuildConfig.FIREBASE_APP_ID.isNotEmpty()
45
46
    /** Start Firebase, once, when this build has its settings. */
47
    fun start(context: Context) {
48
        if (!available || FirebaseApp.getApps(context).isNotEmpty()) return
49
        val options = FirebaseOptions.Builder()
50
            .setApplicationId(BuildConfig.FIREBASE_APP_ID)
51
            .setProjectId(BuildConfig.FIREBASE_PROJECT_ID)
52
            .setGcmSenderId(BuildConfig.FIREBASE_SENDER_ID)
53
            .setApiKey(BuildConfig.FIREBASE_API_KEY)
54
            .build()
55
        FirebaseApp.initializeApp(context, options)
56
    }
57
58
    /** Register this phone with the hub: its Cloud Messaging token and its alert key. */
59
    suspend fun register(context: Context, api: HubApi, token: String? = null): Boolean {
60
        if (!available) return false
61
        start(context)
62
        val fcm = token ?: suspendCancellableCoroutine { k ->
63
            FirebaseMessaging.getInstance().token
64
                .addOnSuccessListener { k.resume(it) }
65
                .addOnFailureListener { k.resumeWithException(it) }
66
        }
67
        return api.registerPush(fcm, AlertKey.publicKeyHex(context))
68
    }
69
70
    /** Open a sealed alert (base64url) with this phone's key; null when it is not for it. */
71
    fun open(context: Context, sealed: String): Alert? {
72
        val bytes = try {
73
            Base64.decode(sealed, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
74
        } catch (_: IllegalArgumentException) {
75
            return null
76
        }
77
        val plain = alertOpen(AlertKey.secret(context), bytes) ?: return null
78
        return try {
79
            json.decodeFromString(Alert.serializer(), plain.decodeToString())
80
        } catch (e: IllegalArgumentException) {
81
            Log.w(TAG, "an alert that does not read: ${e.message}")
82
            null
83
        }
84
    }
85
86
    /** Show [alert]; a tap opens its link. */
87
    fun show(context: Context, alert: Alert) {
88
        val manager = context.getSystemService(NotificationManager::class.java) ?: return
89
        manager.createNotificationChannel(
90
            NotificationChannel(CHANNEL, "Alerts from the hub", NotificationManager.IMPORTANCE_DEFAULT).apply {
91
                description = "What your inbox on the hub is told: builds, reviews, mentions"
92
            },
93
        )
94
        val id = (alert.kind + alert.url).hashCode()
95
        // An approval (#32) opens the app, which shows it with its numbers.
96
        val open = if (alert.kind == "approval") {
97
            Intent(context, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
98
        } else {
99
            Intent(Intent.ACTION_VIEW, Uri.parse(alert.url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
100
        }
101
        val tap = PendingIntent.getActivity(context, id, open, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
102
        val notification = Notification.Builder(context, CHANNEL)
103
            .setSmallIcon(R.drawable.ic_launcher_monochrome)
104
            .setContentTitle(if (alert.kind == "approval") "Is this you? Open to approve" else alert.title.substringBefore(": ", alert.title))
105
            .setContentText(alert.title.substringAfter(": ", alert.title))
106
            .setStyle(Notification.BigTextStyle().bigText(alert.title.substringAfter(": ", alert.title)))
107
            .setContentIntent(tap)
108
            .setAutoCancel(true)
109
            .build()
110
        manager.notify(id, notification)
111
    }
112
}