OXYGENHUB

Coverage / app/src/main/java/io/oxygenhub/android/alerts/PushAlerts.kt 0.0%

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