OXYGENHUB

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