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