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