From 2c980caa00ec4d7196b68e671bfe61f3afe48d3d Mon Sep 17 00:00:00 2001 From: Gaurav Chaudhary Date: Fri, 18 Sep 2026 17:48:04 +0000 Subject: [PATCH] Added a source-buildable F-Droid Android distribution (#527) --- .gitignore | 3 + .../AndroidPushFallbackPlugin.kt | 25 ++++++- .../AndroidPushFallbackWorker.kt | 3 +- .../FallbackSecp256k1Provider.kt | 5 ++ fdroid/README.md | 68 ++++++++++++++++++ fdroid/app.gradle | 16 +++++ fdroid/crypto/argon/index.js | 20 ++++++ fdroid/crypto/argon/package.json | 6 ++ fdroid/crypto/nostr/index.js | 8 +++ fdroid/crypto/nostr/package.json | 6 ++ fdroid/overlay/FallbackSecp256k1Provider.kt | 70 +++++++++++++++++++ fdroid/overlay/analytics.ts | 3 + fdroid/overlay/capacitor.ts | 12 ++++ fdroid/prepare.mjs | 67 ++++++++++++++++++ fdroid/tests/FallbackSecp256k1Test.kt | 68 ++++++++++++++++++ package.json | 2 +- scripts/build-fdroid-assets.sh | 11 +++ scripts/prepare-fdroid-source.sh | 29 ++++++++ src/app/analytics.ts | 2 + src/app/push/adapters/android.ts | 5 +- src/app/push/adapters/capacitor.ts | 60 +++++++++++----- src/app/push/adapters/common.ts | 62 ++-------------- src/app/push/index.ts | 5 +- src/routes/+layout.svelte | 2 +- src/routes/settings/privacy/+page.svelte | 25 ++++--- tsconfig.json | 5 +- 26 files changed, 495 insertions(+), 93 deletions(-) create mode 100644 android/app/src/main/java/social/flotilla/notifications/FallbackSecp256k1Provider.kt create mode 100644 fdroid/README.md create mode 100644 fdroid/app.gradle create mode 100644 fdroid/crypto/argon/index.js create mode 100644 fdroid/crypto/argon/package.json create mode 100644 fdroid/crypto/nostr/index.js create mode 100644 fdroid/crypto/nostr/package.json create mode 100644 fdroid/overlay/FallbackSecp256k1Provider.kt create mode 100644 fdroid/overlay/analytics.ts create mode 100644 fdroid/overlay/capacitor.ts create mode 100644 fdroid/prepare.mjs create mode 100644 fdroid/tests/FallbackSecp256k1Test.kt create mode 100755 scripts/build-fdroid-assets.sh create mode 100755 scripts/prepare-fdroid-source.sh diff --git a/.gitignore b/.gitignore index ccd3b6c3..4929318a 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,9 @@ ios/App/Podfile.lock ios/DerivedData/ android/app/src/main/assets/public/ +# F-Droid preparation output +.fdroid/ + # Web/JavaScript node_modules/ .pnpm-store/ diff --git a/android/app/src/main/java/social/flotilla/notifications/AndroidPushFallbackPlugin.kt b/android/app/src/main/java/social/flotilla/notifications/AndroidPushFallbackPlugin.kt index d394c984..b497a836 100644 --- a/android/app/src/main/java/social/flotilla/notifications/AndroidPushFallbackPlugin.kt +++ b/android/app/src/main/java/social/flotilla/notifications/AndroidPushFallbackPlugin.kt @@ -1,7 +1,9 @@ package social.flotilla.notifications +import android.Manifest import android.content.Context import android.content.SharedPreferences +import android.os.Build import androidx.work.Constraints import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.ExistingWorkPolicy @@ -11,16 +13,22 @@ import androidx.work.OutOfQuotaPolicy import androidx.work.PeriodicWorkRequest import androidx.work.WorkManager import com.getcapacitor.JSObject +import com.getcapacitor.PermissionState import com.getcapacitor.Plugin import com.getcapacitor.PluginCall import com.getcapacitor.PluginMethod import com.getcapacitor.annotation.CapacitorPlugin +import com.getcapacitor.annotation.Permission +import com.getcapacitor.annotation.PermissionCallback import org.json.JSONArray import org.json.JSONException import org.json.JSONObject import java.util.concurrent.TimeUnit -@CapacitorPlugin(name = "AndroidPushFallback") +@CapacitorPlugin( + name = "AndroidPushFallback", + permissions = [Permission(alias = "notifications", strings = [Manifest.permission.POST_NOTIFICATIONS])], +) class AndroidPushFallbackPlugin : Plugin() { companion object { const val PREFS_NAME = "CapacitorStorage" @@ -33,6 +41,21 @@ class AndroidPushFallbackPlugin : Plugin() { return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) } + @PluginMethod + fun requestNotificationPermission(call: PluginCall) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || getPermissionState("notifications") == PermissionState.GRANTED) { + call.resolve(JSObject().put("receive", "granted")) + } else { + requestPermissionForAlias("notifications", call, "permissionCallback") + } + } + + @PermissionCallback + private fun permissionCallback(call: PluginCall) { + val receive = if (getPermissionState("notifications") == PermissionState.GRANTED) "granted" else "denied" + call.resolve(JSObject().put("receive", receive)) + } + @PluginMethod fun syncState(call: PluginCall) { val state: JSObject? = call.getObject("state") diff --git a/android/app/src/main/java/social/flotilla/notifications/AndroidPushFallbackWorker.kt b/android/app/src/main/java/social/flotilla/notifications/AndroidPushFallbackWorker.kt index 119c01a3..0bd79e4f 100644 --- a/android/app/src/main/java/social/flotilla/notifications/AndroidPushFallbackWorker.kt +++ b/android/app/src/main/java/social/flotilla/notifications/AndroidPushFallbackWorker.kt @@ -18,7 +18,6 @@ import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat import androidx.work.Worker import androidx.work.WorkerParameters -import fr.acinq.secp256k1.Secp256k1 import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response @@ -47,7 +46,7 @@ class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Wo private const val REJECTED = "__REJECTED__" private const val KIND_RELAY_AUTH = 22242 private const val KIND_NIP46_RPC = 24133 - private val SECP = Secp256k1.get() + private val SECP = fallbackSecp256k1 } private val prefs: SharedPreferences = diff --git a/android/app/src/main/java/social/flotilla/notifications/FallbackSecp256k1Provider.kt b/android/app/src/main/java/social/flotilla/notifications/FallbackSecp256k1Provider.kt new file mode 100644 index 00000000..4bbcbe26 --- /dev/null +++ b/android/app/src/main/java/social/flotilla/notifications/FallbackSecp256k1Provider.kt @@ -0,0 +1,5 @@ +package social.flotilla.notifications + +import fr.acinq.secp256k1.Secp256k1 + +val fallbackSecp256k1: Secp256k1 = Secp256k1.get() diff --git a/fdroid/README.md b/fdroid/README.md new file mode 100644 index 00000000..e52f152e --- /dev/null +++ b/fdroid/README.md @@ -0,0 +1,68 @@ +# F-Droid distribution + +F-Droid builds use the application ID `social.flotilla.fdroid`. Preparation removes +Firebase, Google Services, Plausible, and the ACINQ native secp256k1 library. +Welshman's optional `nostr-wasm` accelerator uses its pure JavaScript verifier. +The normal Play/Zapstore build is unchanged. + +Background notifications use `AndroidPushFallbackWorker`: WorkManager polls the +configured relays about every 15 minutes and posts local notifications. Android +Doze and background scheduling can delay delivery further. + +Pomade login, registration, and key recovery use the JavaScript Argon2 adapter. +With Pomade's parameters (t=3, m=64 MiB, p=2, 32-byte output), a benchmark on a +2-core x86 machine measured 426 ms with `hash-wasm` versus 4.0 s with the adapter, +with matching digests. Physical-device timing has not yet been measured. + +## Build + +Preparation changes source and dependencies, so run it in a disposable checkout +with the Node, pnpm, Java, Android SDK, and Gradle versions pinned by this repository. + +```sh +corepack enable +./scripts/prepare-fdroid-source.sh +./scripts/build-fdroid-assets.sh +cd android +./gradlew assembleFdroidRelease +``` + +After preparation and the asset build, run the BouncyCastle key-operation and +BIP-340 vector tests from `android/` with: + +```sh +./gradlew :app:testFdroidDebugUnitTest --tests social.flotilla.notifications.FallbackSecp256k1Test +``` + +`fdroid/app.gradle` registers `fdroid/tests` only in the prepared checkout. + +The unsigned APK is written to +`android/app/build/outputs/apk/fdroid/release/app-fdroid-release-unsigned.apk`. +F-Droid should run preparation before its source scan, run the asset build afterward, +and use its configured Gradle runner for `assembleFdroidRelease`. + +The F-Droid recipe can use: + +```yaml +subdir: android/app +gradle: + - fdroid +prebuild: + - ../../scripts/prepare-fdroid-source.sh +build: + - ../../scripts/build-fdroid-assets.sh +``` + +## Updates + +Stable releases use bare version tags such as `1.9.1`. Proposed update metadata: + +```yaml +UpdateCheckMode: Tags ^[0-9]+\.[0-9]+\.[0-9]+$ +UpdateCheckData: 'android/app/build.gradle|(?m)^\s*versionCode\s+(\d+)\s*$|.|(?m)^\s*versionName\s+"([^"]+)"\s*$' +AutoUpdateMode: Version +``` + +The initial `fdroiddata` submission must still review scanner exceptions for FLOSS +build tools, optional OpenRouter use for a possible `NonFreeNet` declaration, and +the final F-Droid signing certificate for Android App Links. diff --git a/fdroid/app.gradle b/fdroid/app.gradle new file mode 100644 index 00000000..530845c0 --- /dev/null +++ b/fdroid/app.gradle @@ -0,0 +1,16 @@ +android { + flavorDimensions "distribution" + productFlavors { + fdroid { + dimension "distribution" + applicationIdSuffix ".fdroid" + } + } + sourceSets { + test.java.srcDir '../../fdroid/tests' + } +} + +dependencies { + implementation "org.bouncycastle:bcprov-jdk18on:1.86" +} diff --git a/fdroid/crypto/argon/index.js b/fdroid/crypto/argon/index.js new file mode 100644 index 00000000..a35bba6e --- /dev/null +++ b/fdroid/crypto/argon/index.js @@ -0,0 +1,20 @@ +import {argon2idAsync} from "@noble/hashes/argon2.js" + +// Pomade's main module and worker both use this binary-output API. +export const argon2id = ({ + password, + salt, + iterations, + memorySize, + parallelism, + hashLength, + outputType, +}) => { + if (outputType !== "binary") throw new Error("Expected Pomade's binary Argon2id output") + return argon2idAsync(password, salt, { + t: iterations, + m: memorySize, + p: parallelism, + dkLen: hashLength, + }) +} diff --git a/fdroid/crypto/argon/package.json b/fdroid/crypto/argon/package.json new file mode 100644 index 00000000..a0a724b4 --- /dev/null +++ b/fdroid/crypto/argon/package.json @@ -0,0 +1,6 @@ +{ + "name": "@flotilla/fdroid-argon", + "private": true, + "type": "module", + "exports": "./index.js" +} diff --git a/fdroid/crypto/nostr/index.js b/fdroid/crypto/nostr/index.js new file mode 100644 index 00000000..2f7cc527 --- /dev/null +++ b/fdroid/crypto/nostr/index.js @@ -0,0 +1,8 @@ +import {verifyEvent} from "nostr-tools/pure" + +// Welshman's optional accelerator expects verification to throw on invalid events. +export const initNostrWasm = async () => ({ + verifyEvent(event) { + if (!verifyEvent(event)) throw new Error("Invalid Nostr event") + }, +}) diff --git a/fdroid/crypto/nostr/package.json b/fdroid/crypto/nostr/package.json new file mode 100644 index 00000000..8e03509e --- /dev/null +++ b/fdroid/crypto/nostr/package.json @@ -0,0 +1,6 @@ +{ + "name": "@flotilla/fdroid-nostr", + "private": true, + "type": "module", + "exports": "./index.js" +} diff --git a/fdroid/overlay/FallbackSecp256k1Provider.kt b/fdroid/overlay/FallbackSecp256k1Provider.kt new file mode 100644 index 00000000..6cfd0622 --- /dev/null +++ b/fdroid/overlay/FallbackSecp256k1Provider.kt @@ -0,0 +1,70 @@ +package social.flotilla.notifications + +import java.math.BigInteger +import java.security.MessageDigest +import org.bouncycastle.asn1.sec.SECNamedCurves + +class BouncyCastleFallbackSecp256k1 { + private val curve = SECNamedCurves.getByName("secp256k1") + + fun secKeyVerify(secretKey: ByteArray) = + secretKey.size == 32 && scalar(secretKey).let { it.signum() > 0 && it < curve.n } + + fun pubkeyCreate(secretKey: ByteArray): ByteArray { + require(secKeyVerify(secretKey)) + return curve.g.multiply(scalar(secretKey)).normalize().getEncoded(false) + } + + fun pubKeyTweakMul(publicKey: ByteArray, tweak: ByteArray): ByteArray { + require(tweak.size == 32) + val scalar = scalar(tweak) + require(scalar.signum() > 0 && scalar < curve.n) + val point = curve.curve.decodePoint(publicKey).multiply(scalar).normalize() + require(!point.isInfinity) + return point.getEncoded(false) + } + + fun signSchnorr( + message: ByteArray, + secretKey: ByteArray, + auxiliaryRandomness: ByteArray, + ): ByteArray { + require(message.size == 32 && auxiliaryRandomness.size == 32 && secKeyVerify(secretKey)) + + val d0 = scalar(secretKey) + val publicKey = curve.g.multiply(d0).normalize() + val d = if (publicKey.affineYCoord.toBigInteger().testBit(0)) curve.n.subtract(d0) else d0 + val publicKeyX = bytes32(publicKey.affineXCoord.toBigInteger()) + val maskedSecret = xor(bytes32(d), taggedHash("BIP0340/aux", auxiliaryRandomness)) + val nonce = scalar(taggedHash("BIP0340/nonce", maskedSecret + publicKeyX + message)).mod(curve.n) + require(nonce.signum() > 0) + + val noncePoint = curve.g.multiply(nonce).normalize() + val k = if (noncePoint.affineYCoord.toBigInteger().testBit(0)) curve.n.subtract(nonce) else nonce + val nonceX = bytes32(noncePoint.affineXCoord.toBigInteger()) + val challenge = scalar(taggedHash("BIP0340/challenge", nonceX + publicKeyX + message)).mod(curve.n) + return nonceX + bytes32(k.add(challenge.multiply(d)).mod(curve.n)) + } + + private fun taggedHash(tag: String, input: ByteArray): ByteArray { + val digest = MessageDigest.getInstance("SHA-256") + val tagHash = digest.digest(tag.toByteArray(Charsets.UTF_8)) + return digest.digest(tagHash + tagHash + input) + } + + private fun scalar(bytes: ByteArray) = BigInteger(1, bytes) + + private fun bytes32(value: BigInteger): ByteArray { + val bytes = value.toByteArray() + return when { + bytes.size == 32 -> bytes + bytes.size < 32 -> ByteArray(32 - bytes.size) + bytes + else -> bytes.copyOfRange(bytes.size - 32, bytes.size) + } + } + + private fun xor(left: ByteArray, right: ByteArray) = + ByteArray(left.size) { index -> (left[index].toInt() xor right[index].toInt()).toByte() } +} + +val fallbackSecp256k1 = BouncyCastleFallbackSecp256k1() diff --git a/fdroid/overlay/analytics.ts b/fdroid/overlay/analytics.ts new file mode 100644 index 00000000..1c075b32 --- /dev/null +++ b/fdroid/overlay/analytics.ts @@ -0,0 +1,3 @@ +export const analyticsAvailable = false + +export const setupAnalytics = () => () => {} diff --git a/fdroid/overlay/capacitor.ts b/fdroid/overlay/capacitor.ts new file mode 100644 index 00000000..fdae9211 --- /dev/null +++ b/fdroid/overlay/capacitor.ts @@ -0,0 +1,12 @@ +import type {IPushAdapter} from "@app/push/adapters/common" + +// Resolves the static import after the push plugin is removed; Android selects the fallback adapter. +export class CapacitorNotifications implements IPushAdapter { + async request() { + return "denied" + } + + async enable() {} + + async disable() {} +} diff --git a/fdroid/prepare.mjs b/fdroid/prepare.mjs new file mode 100644 index 00000000..e0258530 --- /dev/null +++ b/fdroid/prepare.mjs @@ -0,0 +1,67 @@ +import assert from "node:assert/strict" +import {appendFile, cp, readFile, rm, writeFile} from "node:fs/promises" + +const replace = async (file, pattern, replacement) => { + const source = await readFile(file, "utf8") + assert.equal([...source.matchAll(pattern)].length, 1, `Expected one match in ${file}`) + await writeFile(file, source.replace(pattern, replacement)) +} + +// Drop prebuilt native accelerators with JS fallbacks, including gradle-parse via @capacitor/assets. +await replace( + "pnpm-workspace.yaml", + /^overrides:$/gm, + `overrides: + nostr-wasm: link:./fdroid/crypto/nostr + hash-wasm: link:./fdroid/crypto/argon + cbor-extract: '-' + '@trapezedev/gradle-parse': '-'`, +) + +await cp("fdroid/overlay/analytics.ts", "src/app/analytics.ts") +await cp("fdroid/overlay/capacitor.ts", "src/app/push/adapters/capacitor.ts") +await cp( + "fdroid/overlay/FallbackSecp256k1Provider.kt", + "android/app/src/main/java/social/flotilla/notifications/FallbackSecp256k1Provider.kt", +) +for (const target of [ + "android/app/google-services.json", + "android/app/src/main/assets", + "android/capacitor-cordova-android-plugins", + "android/app/build", + "android/build", + "android/.gradle", + "build", + ".svelte-kit", + "node_modules", + "ios", +]) { + await rm(target, {recursive: true, force: true}) +} +await replace( + "android/build.gradle", + / {8}classpath 'com\.google\.gms:google-services:[^']+'\n/g, + "", +) +await replace( + "android/app/build.gradle", + / {4}implementation "fr\.acinq\.secp256k1:secp256k1-kmp-jni-android:[^"]+"\n/g, + "", +) +await replace( + "android/app/build.gradle", + /\ntry \{\n {4}def servicesJSON = file\('google-services\.json'\)\n {4}if \(servicesJSON\.text\) \{\n {8}apply plugin: 'com\.google\.gms\.google-services'\n {4}\}\n\} catch\(Exception e\) \{\n {4}logger\.info\("google-services\.json not found, google-services plugin not applied\. Push Notifications won't work"\)\n\}\n/g, + "\n", +) +await appendFile("android/app/build.gradle", "\napply from: '../../fdroid/app.gradle'\n") +await replace( + "android/app/src/main/AndroidManifest.xml", + /\n {8}