Alerts from the hub: the phone registers its Cloud Messaging token and an X25519 key kept by the Keystore, and opens and shows what the hub seals to it with the Rust core; Firebase from google-services.json (the release job's firebase-config secret); pins OxygenFlow main (refs #16) aa21eb5e0e5b
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key cfb5ece97d0d
- When
- · 2026-10-27 20:27 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
aa21eb5e0e5bda913680e3675118c64e1dd2dfb8897e87f739623c2856547850- Depends on
- 26530d9758ee 4aee5293461d 57c0824300db 5ae4af48ddd2 9481bfa7c5d0 98dbd1220929 bfa2eff05d35 db00632a61d6 e0004d61958c e0e4a1c48a77 ff65ea456ff1
- Depended on by
- 263c6cad5e51 0b152017dee3 b610bc6eeab7 23279b95b5b2 af9f2e514c72 40ab4df121e0
- In bookmarks
- main
- In tags
- v0.1.0 v0.1.1 v0.1.2 v0.1.3 v0.1.4 v0.1.5 v0.1.6 v0.1.7 v0.1.8 v0.1.9
- Edits
- 34 atoms, 3 tree operations, 20 files
Browse the tree at this change
added app/src/main/java/io/oxygenhub/android/alerts/AlertKey.kt +90 −0
| 1 | + | package io.oxygenhub.android.alerts |
|
| 2 | + | ||
| 3 | + | import android.content.Context |
|
| 4 | + | import android.security.keystore.KeyGenParameterSpec |
|
| 5 | + | import android.security.keystore.KeyProperties |
|
| 6 | + | import android.security.keystore.StrongBoxUnavailableException |
|
| 7 | + | import android.util.Base64 |
|
| 8 | + | import androidx.core.content.edit |
|
| 9 | + | import java.security.GeneralSecurityException |
|
| 10 | + | import java.security.KeyStore |
|
| 11 | + | import javax.crypto.Cipher |
|
| 12 | + | import javax.crypto.KeyGenerator |
|
| 13 | + | import javax.crypto.SecretKey |
|
| 14 | + | import javax.crypto.spec.GCMParameterSpec |
|
| 15 | + | import uniffi.rust_core.alertNewSecret |
|
| 16 | + | import uniffi.rust_core.alertPublicKey |
|
| 17 | + | ||
| 18 | + | /** |
|
| 19 | + | * The phone's alert key (#16): an X25519 secret, made by the Rust core, |
|
| 20 | + | * that the hub seals each alert to. Kept in app-private preferences only as |
|
| 21 | + | * ciphertext under an AES-GCM key in the Android Keystore (StrongBox where |
|
| 22 | + | * the phone has one), as the session's token is; out of backups, since a |
|
| 23 | + | * restored copy could not be opened anyway. A lost key just means a new one |
|
| 24 | + | * and registering again. |
|
| 25 | + | */ |
|
| 26 | + | object AlertKey { |
|
| 27 | + | private const val PREFS = "alerts" |
|
| 28 | + | private const val SEALED = "secret.sealed" |
|
| 29 | + | private const val KEYSTORE = "AndroidKeyStore" |
|
| 30 | + | private const val ALIAS = "io.oxygenhub.android.alerts" |
|
| 31 | + | private const val TRANSFORMATION = "AES/GCM/NoPadding" |
|
| 32 | + | ||
| 33 | + | /** The secret, made and kept the first time it is asked for. */ |
|
| 34 | + | @Synchronized |
|
| 35 | + | fun secret(context: Context): ByteArray { |
|
| 36 | + | val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) |
|
| 37 | + | prefs.getString(SEALED, null)?.let { sealed -> |
|
| 38 | + | try { |
|
| 39 | + | return open(sealed) |
|
| 40 | + | } catch (_: GeneralSecurityException) { |
|
| 41 | + | // The Keystore key is gone: a new secret, registered again. |
|
| 42 | + | } catch (_: IllegalArgumentException) { |
|
| 43 | + | } |
|
| 44 | + | } |
|
| 45 | + | val fresh = alertNewSecret() |
|
| 46 | + | prefs.edit { putString(SEALED, seal(fresh)) } |
|
| 47 | + | return fresh |
|
| 48 | + | } |
|
| 49 | + | ||
| 50 | + | /** The public key, hex, for the hub. */ |
|
| 51 | + | fun publicKeyHex(context: Context): String = alertPublicKey(secret(context)).joinToString("") { "%02x".format(it) } |
|
| 52 | + | ||
| 53 | + | private fun seal(plain: ByteArray): String { |
|
| 54 | + | val cipher = Cipher.getInstance(TRANSFORMATION) |
|
| 55 | + | cipher.init(Cipher.ENCRYPT_MODE, key()) |
|
| 56 | + | return b64(cipher.iv) + ":" + b64(cipher.doFinal(plain)) |
|
| 57 | + | } |
|
| 58 | + | ||
| 59 | + | private fun open(sealed: String): ByteArray { |
|
| 60 | + | val parts = sealed.split(':', limit = 2) |
|
| 61 | + | require(parts.size == 2) { "not a sealed secret" } |
|
| 62 | + | val key = existingKey() ?: throw GeneralSecurityException("no key") |
|
| 63 | + | val cipher = Cipher.getInstance(TRANSFORMATION) |
|
| 64 | + | cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(128, Base64.decode(parts[0], Base64.NO_WRAP))) |
|
| 65 | + | return cipher.doFinal(Base64.decode(parts[1], Base64.NO_WRAP)) |
|
| 66 | + | } |
|
| 67 | + | ||
| 68 | + | private fun b64(bytes: ByteArray): String = Base64.encodeToString(bytes, Base64.NO_WRAP) |
|
| 69 | + | ||
| 70 | + | private fun existingKey(): SecretKey? { |
|
| 71 | + | val ks = KeyStore.getInstance(KEYSTORE).apply { load(null) } |
|
| 72 | + | return (ks.getEntry(ALIAS, null) as? KeyStore.SecretKeyEntry)?.secretKey |
|
| 73 | + | } |
|
| 74 | + | ||
| 75 | + | private fun key(): SecretKey = existingKey() ?: try { |
|
| 76 | + | generate(strongBox = true) |
|
| 77 | + | } catch (_: StrongBoxUnavailableException) { |
|
| 78 | + | generate(strongBox = false) |
|
| 79 | + | } |
|
| 80 | + | ||
| 81 | + | private fun generate(strongBox: Boolean): SecretKey { |
|
| 82 | + | val spec = KeyGenParameterSpec.Builder(ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT) |
|
| 83 | + | .setBlockModes(KeyProperties.BLOCK_MODE_GCM) |
|
| 84 | + | .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) |
|
| 85 | + | .setKeySize(256) |
|
| 86 | + | .setIsStrongBoxBacked(strongBox) |
|
| 87 | + | .build() |
|
| 88 | + | return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE).apply { init(spec) }.generateKey() |
|
| 89 | + | } |
|
| 90 | + | } |
added app/src/main/java/io/oxygenhub/android/alerts/AlertService.kt +31 −0
| 1 | + | package io.oxygenhub.android.alerts |
|
| 2 | + | ||
| 3 | + | import android.util.Log |
|
| 4 | + | import com.google.firebase.messaging.FirebaseMessagingService |
|
| 5 | + | import com.google.firebase.messaging.RemoteMessage |
|
| 6 | + | import io.oxygenhub.android.OxygenHubApp |
|
| 7 | + | import kotlinx.coroutines.CoroutineScope |
|
| 8 | + | import kotlinx.coroutines.Dispatchers |
|
| 9 | + | import kotlinx.coroutines.SupervisorJob |
|
| 10 | + | import kotlinx.coroutines.launch |
|
| 11 | + | ||
| 12 | + | /** Cloud Messaging's side of the hub's alerts (#16): a new token, or a sealed alert. */ |
|
| 13 | + | class AlertService : FirebaseMessagingService() { |
|
| 14 | + | private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) |
|
| 15 | + | ||
| 16 | + | /** The phone's registration changed: tell the hub, when signed in. */ |
|
| 17 | + | override fun onNewToken(token: String) { |
|
| 18 | + | val api = (application as OxygenHubApp).container.sessions.api() ?: return |
|
| 19 | + | scope.launch { |
|
| 20 | + | runCatching { PushAlerts.register(applicationContext, api, token) } |
|
| 21 | + | .onFailure { Log.w("AlertService", "registering with the hub: ${it.message}") } |
|
| 22 | + | } |
|
| 23 | + | } |
|
| 24 | + | ||
| 25 | + | /** Only data messages come: open the sealed alert and show it. */ |
|
| 26 | + | override fun onMessageReceived(message: RemoteMessage) { |
|
| 27 | + | val sealed = message.data["sealed"] ?: return |
|
| 28 | + | val alert = PushAlerts.open(applicationContext, sealed) ?: return |
|
| 29 | + | PushAlerts.show(applicationContext, alert) |
|
| 30 | + | } |
|
| 31 | + | } |
added app/src/main/java/io/oxygenhub/android/alerts/PushAlerts.kt +106 −0
| 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 | + | } |
modified .flow/pipeline.kdl +4 −0
| … | 70 unchanged lines |
||
| 71 | 71 | secret "android-keystore-password" { |
|
| 72 | 72 | mount-path "/run/secrets/android-keystore-password" |
|
| 73 | 73 | } |
|
| 74 | + | // Firebase's client settings (google-services.json) for alerts (#16). |
|
| 75 | + | secret "firebase-config" { |
|
| 76 | + | mount-path "/run/secrets/firebase-config" |
|
| 77 | + | } |
|
| 74 | 78 | exec "bash" "scripts/release.sh" |
|
| 75 | 79 | output { |
|
| 76 | 80 | artifact "oxygenhub-android-universal.apk" { |
|
| … | 7 unchanged lines |
modified .oxyignore +2 −0
| … | 27 unchanged lines |
||
| 28 | 28 | /junit.xml |
|
| 29 | 29 | /coverage.xml |
|
| 30 | 30 | /dist/ |
|
| 31 | + | # Firebase client settings (#16): the release job writes it from a secret. |
|
| 32 | + | /app/google-services.json |
modified CHANGELOG.md +1 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | ## 0.1.0 (2026-10-27) |
|
| 6 | 6 | ||
| 7 | + | - **Alerts from the hub (#16):** what your inbox on the hub is told (builds broken and fixed, reviews, mentions…) also reaches the phone through Firebase Cloud Messaging, sealed to an X25519 key the phone made (kept wrapped by the Android Keystore), so Google carries only ciphertext; the app opens it with the Rust core (`flow_core::alert`, as the hub seals it) and shows it, and a tap opens its link. Each signed-in session registers the phone (`PUT /user/push`); signing out ends it. Firebase starts by hand from `google-services.json` (the release job's `firebase-config` secret), with no Google Gradle plugin and only `firebase-messaging`; a build without it takes no alerts. The hub sends once it runs on GCP. |
|
| 7 | 8 | - **The app says when it is out of date (#7):** Account shows the app's version and, signed in to oxygenhub.io, whether a later full release of andymccall/oxygenhub-android is out, with its APK to download; opening the file updates the app, since every release is signed with the same key. Pre-releases are not offered. |
|
| 8 | 9 | - **Releases built by OxygenFlow (#7):** the app's version lives in `gradle.properties` (`oxygenhub.version`; the version code follows from it), and when it changes the pipeline's `release` job builds a release APK signed with the app's own key (a PKCS12 keystore and its password, the repository's `android-keystore` and `android-keystore-password` secrets, mounted as files), checks it with apksigner and keeps it with its `SHA256SUMS` for a release on oxygenhub.io. `scripts/release.sh` does the same locally with the key's files. The key's SHA-256 certificate fingerprint is `B0:99:57:36:44:42:AF:B2:27:28:B7:EF:93:F8:23:2D:F1:41:17:DA:50:28:6D:45:93:04:0F:9C:9F:A2:2A:48`; a release-signed app replaces a debug build only after uninstalling it. |
|
| 9 | 10 | ||
| … | 13 unchanged lines |
modified app/build.gradle.kts +29 −0
| … | 50 unchanged lines |
||
| 51 | 51 | ||
| 52 | 52 | tasks.named("preBuild") { dependsOn(generateRustBindings) } |
|
| 53 | 53 | ||
| 54 | + | // The Firebase client settings alerts need (#16), read from |
|
| 55 | + | // app/google-services.json without Google's Gradle plugin: none, no alerts. |
|
| 56 | + | val firebase: Map<String, String> = run { |
|
| 57 | + | val file = File(projectDir, "google-services.json") |
|
| 58 | + | if (!file.isFile) return@run emptyMap() |
|
| 59 | + | @Suppress("UNCHECKED_CAST") |
|
| 60 | + | val json = groovy.json.JsonSlurper().parse(file) as Map<String, Any?> |
|
| 61 | + | val project = json["project_info"] as Map<String, Any?> |
|
| 62 | + | val client = (json["client"] as List<Map<String, Any?>>).first { |
|
| 63 | + | ((it["client_info"] as Map<String, Any?>)["android_client_info"] as Map<String, Any?>)["package_name"] == "io.oxygenhub.android" |
|
| 64 | + | } |
|
| 65 | + | mapOf( |
|
| 66 | + | "app_id" to (client["client_info"] as Map<String, Any?>)["mobilesdk_app_id"].toString(), |
|
| 67 | + | "project_id" to project["project_id"].toString(), |
|
| 68 | + | "sender_id" to project["project_number"].toString(), |
|
| 69 | + | "api_key" to ((client["api_key"] as List<Map<String, Any?>>).first()["current_key"]).toString(), |
|
| 70 | + | ) |
|
| 71 | + | } |
|
| 72 | + | ||
| 54 | 73 | // The version, from gradle.properties (#7). |
|
| 55 | 74 | val appVersion: String = providers.gradleProperty("oxygenhub.version").get() |
|
| 56 | 75 | val appVersionCode: Int = appVersion.split(".").map { it.toInt() }.let { (a, b, c) -> a * 10000 + b * 100 + c } |
|
| … | 19 unchanged lines |
||
| 76 | 95 | versionName = appVersion |
|
| 77 | 96 | ||
| 78 | 97 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" |
|
| 98 | + | // Firebase for alerts (#16), from google-services.json when the |
|
| 99 | + | // build has one (the release job writes it from a secret); empty, |
|
| 100 | + | // the app takes no alerts. |
|
| 101 | + | buildConfigField("String", "FIREBASE_APP_ID", "\"${firebase["app_id"].orEmpty()}\"") |
|
| 102 | + | buildConfigField("String", "FIREBASE_PROJECT_ID", "\"${firebase["project_id"].orEmpty()}\"") |
|
| 103 | + | buildConfigField("String", "FIREBASE_SENDER_ID", "\"${firebase["sender_id"].orEmpty()}\"") |
|
| 104 | + | buildConfigField("String", "FIREBASE_API_KEY", "\"${firebase["api_key"].orEmpty()}\"") |
|
| 79 | 105 | } |
|
| 80 | 106 | ||
| 81 | 107 | signingConfigs { |
|
| … | 32 unchanged lines |
||
| 114 | 140 | } |
|
| 115 | 141 | buildFeatures { |
|
| 116 | 142 | compose = true |
|
| 143 | + | buildConfig = true |
|
| 117 | 144 | } |
|
| 118 | 145 | // README screenshots, rendered from previews without an emulator: |
|
| 119 | 146 | // ./gradlew :app:updateDebugScreenshotTest (src/screenshotTest). |
|
| … | 5 unchanged lines |
||
| 125 | 152 | dependencies { |
|
| 126 | 153 | implementation(libs.jna) { artifact { type = "aar" } } |
|
| 127 | 154 | implementation(platform(libs.androidx.compose.bom)) |
|
| 155 | + | implementation(platform(libs.firebase.bom)) |
|
| 156 | + | implementation(libs.firebase.messaging) |
|
| 128 | 157 | implementation(libs.androidx.activity.compose) |
|
| 129 | 158 | implementation(libs.androidx.compose.material3) |
|
| 130 | 159 | implementation(libs.androidx.compose.material3.navigation.suite) |
|
| … | 30 unchanged lines |
modified app/src/main/AndroidManifest.xml +8 −0
| … | 48 unchanged lines |
||
| 49 | 49 | <action android:name="android.intent.action.MY_PACKAGE_REPLACED" /> |
|
| 50 | 50 | </intent-filter> |
|
| 51 | 51 | </receiver> |
|
| 52 | + | <!-- Alerts from the hub (#16): sealed data messages through Cloud Messaging. --> |
|
| 52 | 53 | <service |
|
| 54 | + | android:name=".alerts.AlertService" |
|
| 55 | + | android:exported="false"> |
|
| 56 | + | <intent-filter> |
|
| 57 | + | <action android:name="com.google.firebase.MESSAGING_EVENT" /> |
|
| 58 | + | </intent-filter> |
|
| 59 | + | </service> |
|
| 60 | + | <service |
|
| 53 | 61 | android:name=".alerts.LandCheckJob" |
|
| 54 | 62 | android:exported="false" |
|
| 55 | 63 | android:permission="android.permission.BIND_JOB_SERVICE" /> |
|
| … | 3 unchanged lines |
modified app/src/main/java/io/oxygenhub/android/OxygenHubApp.kt +20 −0
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | import android.app.Application |
|
| 4 | 4 | import android.os.Build |
|
| 5 | + | import android.util.Log |
|
| 6 | + | import io.oxygenhub.android.alerts.PushAlerts |
|
| 5 | 7 | import io.oxygenhub.android.api.DeviceAuth |
|
| 6 | 8 | import io.oxygenhub.android.api.HubClient |
|
| 7 | 9 | import io.oxygenhub.android.api.HubDeviceAuth |
|
| 8 | 10 | import io.oxygenhub.android.api.hubHttpClient |
|
| 9 | 11 | import io.oxygenhub.android.auth.KeystoreSessionStore |
|
| 10 | 12 | import io.oxygenhub.android.auth.SessionManager |
|
| 13 | + | import kotlinx.coroutines.CoroutineScope |
|
| 14 | + | import kotlinx.coroutines.Dispatchers |
|
| 15 | + | import kotlinx.coroutines.SupervisorJob |
|
| 16 | + | import kotlinx.coroutines.flow.distinctUntilChanged |
|
| 17 | + | import kotlinx.coroutines.flow.filterNotNull |
|
| 18 | + | import kotlinx.coroutines.launch |
|
| 11 | 19 | import okhttp3.OkHttpClient |
|
| 12 | 20 | ||
| 13 | 21 | /** The hub the app signs in to unless told otherwise. */ |
|
| … | 20 unchanged lines |
||
| 34 | 42 | override fun onCreate() { |
|
| 35 | 43 | super.onCreate() |
|
| 36 | 44 | container = AppContainer(this) |
|
| 45 | + | // Alerts from the hub (#16): each signed-in session registers this |
|
| 46 | + | // phone; signing out revokes the token, which ends it on the hub. |
|
| 47 | + | if (PushAlerts.available) { |
|
| 48 | + | PushAlerts.start(this) |
|
| 49 | + | CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { |
|
| 50 | + | container.sessions.session.filterNotNull().distinctUntilChanged().collect { |
|
| 51 | + | val api = container.sessions.api() ?: return@collect |
|
| 52 | + | runCatching { PushAlerts.register(this@OxygenHubApp, api) } |
|
| 53 | + | .onFailure { Log.w("OxygenHubApp", "alerts: registering with the hub: ${it.message}") } |
|
| 54 | + | } |
|
| 55 | + | } |
|
| 56 | + | } |
|
| 37 | 57 | } |
|
| 38 | 58 | } |
modified app/src/main/java/io/oxygenhub/android/api/HubClient.kt +13 −0
| … | 41 unchanged lines |
||
| 42 | 42 | suspend fun revokeToken() |
|
| 43 | 43 | ||
| 44 | 44 | /** |
|
| 45 | + | * This phone takes alerts (#16): its Cloud Messaging [fcmToken] and its |
|
| 46 | + | * alert key's [publicKey] (hex), for the token it signed in with |
|
| 47 | + | * (`PUT /user/push`). Whether the hub sends alerts at all. |
|
| 48 | + | */ |
|
| 49 | + | suspend fun registerPush(fcmToken: String, publicKey: String): Boolean |
|
| 50 | + | ||
| 51 | + | /** |
|
| 45 | 52 | * The proposals the caller should act on, across repositories (the hub's |
|
| 46 | 53 | * dashboard); with [landable], also those it can land now (#20, oxygenhub#311). |
|
| 47 | 54 | */ |
|
| … | 241 unchanged lines |
||
| 289 | 296 | override suspend fun revokeToken() { |
|
| 290 | 297 | send("DELETE", url("user", "token"), notifyUnauthorized = false) |
|
| 291 | 298 | } |
|
| 299 | + | ||
| 300 | + | override suspend fun registerPush(fcmToken: String, publicKey: String): Boolean = |
|
| 301 | + | sendJson("PUT", url("user", "push"), buildJsonObject { |
|
| 302 | + | put("fcm_token", fcmToken) |
|
| 303 | + | put("public_key", publicKey) |
|
| 304 | + | }, PushRegistered.serializer()).alerts |
|
| 292 | 305 | ||
| 293 | 306 | override suspend fun avatar(name: String): Avatar? { |
|
| 294 | 307 | val url = root.newBuilder().addPathSegment("avatar").addPathSegment(name).build() |
|
| … | 90 unchanged lines |
modified app/src/main/java/io/oxygenhub/android/api/Models.kt +4 −0
| … | 446 unchanged lines |
||
| 447 | 447 | ||
| 448 | 448 | override fun toString(): String = "TokenGrant(username=$username, scope=$scope, accessToken=<redacted>)" |
|
| 449 | 449 | } |
|
| 450 | + | ||
| 451 | + | /** What `PUT /user/push` answers (#16): whether the hub sends alerts at all. */ |
|
| 452 | + | @Serializable |
|
| 453 | + | data class PushRegistered(val alerts: Boolean = false) |
modified app/src/main/res/xml/backup_rules.xml +1 −0
| … | 4 unchanged lines |
||
| 5 | 5 | could not be opened elsewhere, so it is not backed up. --> |
|
| 6 | 6 | <full-backup-content> |
|
| 7 | 7 | <exclude domain="sharedpref" path="session.xml" /> |
|
| 8 | + | <exclude domain="sharedpref" path="alerts.xml" /> |
|
| 8 | 9 | </full-backup-content> |
modified app/src/main/res/xml/data_extraction_rules.xml +2 −0
| … | 4 unchanged lines |
||
| 5 | 5 | <data-extraction-rules> |
|
| 6 | 6 | <cloud-backup> |
|
| 7 | 7 | <exclude domain="sharedpref" path="session.xml" /> |
|
| 8 | + | <exclude domain="sharedpref" path="alerts.xml" /> |
|
| 8 | 9 | </cloud-backup> |
|
| 9 | 10 | <device-transfer> |
|
| 10 | 11 | <exclude domain="sharedpref" path="session.xml" /> |
|
| 12 | + | <exclude domain="sharedpref" path="alerts.xml" /> |
|
| 11 | 13 | </device-transfer> |
|
| 12 | 14 | </data-extraction-rules> |
modified app/src/test/java/io/oxygenhub/android/FakeHubApi.kt +1 −0
| … | 49 unchanged lines |
||
| 50 | 50 | override suspend fun issues(owner: String, name: String, state: IssueState): List<Issue> = |
|
| 51 | 51 | issues.invoke(state).also { calls += "issues $owner/$name ${state.query}" } |
|
| 52 | 52 | override suspend fun inbox(limit: Int): Inbox = inbox.invoke().also { calls += "inbox" } |
|
| 53 | + | override suspend fun registerPush(fcmToken: String, publicKey: String): Boolean = true.also { calls += "push" } |
|
| 53 | 54 | override suspend fun markRead(id: Long) { |
|
| 54 | 55 | calls += "read $id" |
|
| 55 | 56 | markRead.invoke(id) |
|
| … | 114 unchanged lines |
modified gradle/libs.versions.toml +4 −0
| … | 17 unchanged lines |
||
| 18 | 18 | # The code browser (#3): Markdown rendered in Compose, and syntax highlighting. |
|
| 19 | 19 | markdownRenderer = "0.38.1" |
|
| 20 | 20 | highlights = "1.1.0" |
|
| 21 | + | # Phone alerts (#16): Cloud Messaging only, no analytics. |
|
| 22 | + | firebaseBom = "34.19.0" |
|
| 21 | 23 | ||
| 22 | 24 | [libraries] |
|
| 25 | + | firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" } |
|
| 26 | + | firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging" } |
|
| 23 | 27 | markdown-renderer-m3 = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-m3", version.ref = "markdownRenderer" } |
|
| 24 | 28 | highlights = { group = "dev.snipme", name = "highlights", version.ref = "highlights" } |
|
| 25 | 29 | androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } |
|
| … | 34 unchanged lines |
replaced oxygenflow +0 −0
No line changes.
modified rust-core/Cargo.lock +3 −0
| … | 923 unchanged lines |
||
| 924 | 924 | version = "0.5.0" |
|
| 925 | 925 | dependencies = [ |
|
| 926 | 926 | "blake3", |
|
| 927 | + | "curve25519-dalek", |
|
| 927 | 928 | "globset", |
|
| 928 | 929 | "hex", |
|
| 929 | 930 | "kdl", |
|
| 930 | 931 | "miette", |
|
| 931 | 932 | "oxy-core", |
|
| 933 | + | "ring", |
|
| 932 | 934 | "serde", |
|
| 933 | 935 | "serde_json", |
|
| 934 | 936 | "thiserror 1.0.69", |
|
| … | 999 unchanged lines |
||
| 1934 | 1936 | dependencies = [ |
|
| 1935 | 1937 | "anyhow", |
|
| 1936 | 1938 | "flow", |
|
| 1939 | + | "flow-core", |
|
| 1937 | 1940 | "oxy-core", |
|
| 1938 | 1941 | "uniffi", |
|
| 1939 | 1942 | ] |
|
| … | 1524 unchanged lines |
modified rust-core/Cargo.toml +2 −0
| … | 10 unchanged lines |
||
| 11 | 11 | oxy-core = { path = "../oxygenflow/deps/oxygen/crates/oxy-core" } |
|
| 12 | 12 | # OxygenFlow's runner, run in the app's own process (oxygenhub-android#8). |
|
| 13 | 13 | flow = { path = "../oxygenflow/crates/flow" } |
|
| 14 | + | # Sealed alerts (#16): the phone's key and opening what the hub sends. |
|
| 15 | + | flow-core = { path = "../oxygenflow/crates/flow-core" } |
|
| 14 | 16 | uniffi = { version = "0.28", features = ["cli"] } |
|
| 15 | 17 | anyhow = "1" |
|
| 16 | 18 | ||
| … | 2 unchanged lines |
modified rust-core/src/lib.rs +22 −0
| … | 81 unchanged lines |
||
| 82 | 82 | Err(_) => vec![], |
|
| 83 | 83 | } |
|
| 84 | 84 | } |
|
| 85 | + | ||
| 86 | + | /// A new X25519 secret for this phone's alerts (#16), to keep wrapped by |
|
| 87 | + | /// the Android Keystore. |
|
| 88 | + | #[uniffi::export] |
|
| 89 | + | pub fn alert_new_secret() -> Vec<u8> { |
|
| 90 | + | flow_core::alert::new_secret().to_vec() |
|
| 91 | + | } |
|
| 92 | + | ||
| 93 | + | /// The public key of an alert secret, for the hub; empty for a secret that |
|
| 94 | + | /// is not 32 bytes. |
|
| 95 | + | #[uniffi::export] |
|
| 96 | + | pub fn alert_public_key(secret: Vec<u8>) -> Vec<u8> { |
|
| 97 | + | <[u8; 32]>::try_from(secret.as_slice()).map(|s| flow_core::alert::public_key(&s).to_vec()).unwrap_or_default() |
|
| 98 | + | } |
|
| 99 | + | ||
| 100 | + | /// Open an alert the hub sealed to this phone; `None` when it was not |
|
| 101 | + | /// sealed to `secret` or was changed on the way. |
|
| 102 | + | #[uniffi::export] |
|
| 103 | + | pub fn alert_open(secret: Vec<u8>, sealed: Vec<u8>) -> Option<Vec<u8>> { |
|
| 104 | + | let s = <[u8; 32]>::try_from(secret.as_slice()).ok()?; |
|
| 105 | + | flow_core::alert::open(&s, &sealed) |
|
| 106 | + | } |
modified scripts/release.sh +12 −2
| … | 4 unchanged lines |
||
| 5 | 5 | # |
|
| 6 | 6 | # The key comes from the job's secrets, mounted as files and never in the |
|
| 7 | 7 | # environment: android-keystore (the PKCS12 keystore, base64) and |
|
| 8 | - | # android-keystore-password. Locally, give the same two files: |
|
| 9 | - | # KEYSTORE_B64=path PASSWORD_FILE=path scripts/release.sh |
|
| 8 | + | # android-keystore-password; and firebase-config (google-services.json, for |
|
| 9 | + | # alerts). Locally, give the same files: |
|
| 10 | + | # KEYSTORE_B64=path PASSWORD_FILE=path [FIREBASE_CONFIG=path] scripts/release.sh |
|
| 10 | 11 | set -euo pipefail |
|
| 11 | 12 | cd "$(dirname "$0")/.." |
|
| 12 | 13 | b64=${KEYSTORE_B64:-/run/secrets/android-keystore} |
|
| 13 | 14 | password=${PASSWORD_FILE:-/run/secrets/android-keystore-password} |
|
| 15 | + | # Firebase's client settings for alerts (#16): google-services.json, the |
|
| 16 | + | # repository's firebase-config secret; without it the release takes no alerts. |
|
| 17 | + | firebase=${FIREBASE_CONFIG:-/run/secrets/firebase-config} |
|
| 14 | 18 | for f in "$b64" "$password"; do |
|
| 15 | 19 | [ -s "$f" ] || { echo "release: no $f: the job needs the android-keystore secrets" >&2; exit 1; } |
|
| 16 | 20 | done |
|
| … | 16 unchanged lines |
||
| 33 | 37 | chmod 700 "$keys" |
|
| 34 | 38 | base64 -d "$b64" > "$keys/release.p12" |
|
| 35 | 39 | export OXYGENHUB_KEYSTORE="$keys/release.p12" OXYGENHUB_KEYSTORE_PASSWORD_FILE="$password" |
|
| 40 | + | if [ -s "$firebase" ]; then |
|
| 41 | + | cp "$firebase" app/google-services.json |
|
| 42 | + | trap 'rm -rf "$keys" app/google-services.json' EXIT |
|
| 43 | + | else |
|
| 44 | + | echo "release: no $firebase: this release takes no alerts" >&2 |
|
| 45 | + | fi |
|
| 36 | 46 | gradle=$(command -v gradle || echo ./gradlew) |
|
| 37 | 47 | "$gradle" --offline --no-daemon :app:assembleRelease |
|
| 38 | 48 | ||
| … | 13 unchanged lines |