Coverage / app/src/main/java/io/oxygenhub/android/alerts/PushAlerts.kt 0.0%
0 of 51 lines run, 0 of 15 functions, 0 of 20 branches · run #1657 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.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 | } |