Added a source-buildable F-Droid Android distribution (#527)

This commit is contained in:
Gaurav Chaudhary 2026-09-18 17:48:04 +00:00 committed by hodlbod
parent 5d40a9bdc5
commit 2c980caa00
26 changed files with 495 additions and 93 deletions

3
.gitignore vendored
View file

@ -34,6 +34,9 @@ ios/App/Podfile.lock
ios/DerivedData/ ios/DerivedData/
android/app/src/main/assets/public/ android/app/src/main/assets/public/
# F-Droid preparation output
.fdroid/
# Web/JavaScript # Web/JavaScript
node_modules/ node_modules/
.pnpm-store/ .pnpm-store/

View file

@ -1,7 +1,9 @@
package social.flotilla.notifications package social.flotilla.notifications
import android.Manifest
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.os.Build
import androidx.work.Constraints import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy import androidx.work.ExistingWorkPolicy
@ -11,16 +13,22 @@ import androidx.work.OutOfQuotaPolicy
import androidx.work.PeriodicWorkRequest import androidx.work.PeriodicWorkRequest
import androidx.work.WorkManager import androidx.work.WorkManager
import com.getcapacitor.JSObject import com.getcapacitor.JSObject
import com.getcapacitor.PermissionState
import com.getcapacitor.Plugin import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin import com.getcapacitor.annotation.CapacitorPlugin
import com.getcapacitor.annotation.Permission
import com.getcapacitor.annotation.PermissionCallback
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONException import org.json.JSONException
import org.json.JSONObject import org.json.JSONObject
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@CapacitorPlugin(name = "AndroidPushFallback") @CapacitorPlugin(
name = "AndroidPushFallback",
permissions = [Permission(alias = "notifications", strings = [Manifest.permission.POST_NOTIFICATIONS])],
)
class AndroidPushFallbackPlugin : Plugin() { class AndroidPushFallbackPlugin : Plugin() {
companion object { companion object {
const val PREFS_NAME = "CapacitorStorage" const val PREFS_NAME = "CapacitorStorage"
@ -33,6 +41,21 @@ class AndroidPushFallbackPlugin : Plugin() {
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) 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 @PluginMethod
fun syncState(call: PluginCall) { fun syncState(call: PluginCall) {
val state: JSObject? = call.getObject("state") val state: JSObject? = call.getObject("state")

View file

@ -18,7 +18,6 @@ import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.work.Worker import androidx.work.Worker
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import fr.acinq.secp256k1.Secp256k1
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
@ -47,7 +46,7 @@ class AndroidPushFallbackWorker(context: Context, params: WorkerParameters) : Wo
private const val REJECTED = "__REJECTED__" private const val REJECTED = "__REJECTED__"
private const val KIND_RELAY_AUTH = 22242 private const val KIND_RELAY_AUTH = 22242
private const val KIND_NIP46_RPC = 24133 private const val KIND_NIP46_RPC = 24133
private val SECP = Secp256k1.get() private val SECP = fallbackSecp256k1
} }
private val prefs: SharedPreferences = private val prefs: SharedPreferences =

View file

@ -0,0 +1,5 @@
package social.flotilla.notifications
import fr.acinq.secp256k1.Secp256k1
val fallbackSecp256k1: Secp256k1 = Secp256k1.get()

68
fdroid/README.md Normal file
View file

@ -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.

16
fdroid/app.gradle Normal file
View file

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

View file

@ -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,
})
}

View file

@ -0,0 +1,6 @@
{
"name": "@flotilla/fdroid-argon",
"private": true,
"type": "module",
"exports": "./index.js"
}

View file

@ -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")
},
})

View file

@ -0,0 +1,6 @@
{
"name": "@flotilla/fdroid-nostr",
"private": true,
"type": "module",
"exports": "./index.js"
}

View file

@ -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()

View file

@ -0,0 +1,3 @@
export const analyticsAvailable = false
export const setupAnalytics = () => () => {}

View file

@ -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() {}
}

67
fdroid/prepare.mjs Normal file
View file

@ -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}<!-- FCM uses[\s\S]*?android:value="@string\/default_notification_channel_id"\n {8}\/>/g,
"",
)
await replace(
"src/app.html",
/\s*<script\s+defer\s+data-domain="[^"]*"\s+src="https:\/\/plausible\.coracle\.social\/[^"]*"><\/script>/g,
"",
)
await replace("svelte.config.js", /"https:\/\/plausible\.coracle\.social", /g, "")

View file

@ -0,0 +1,68 @@
package social.flotilla.notifications
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class FallbackSecp256k1Test {
@Test
fun signsOfficialBip340Vectors() {
val vectors = listOf(
listOf(
"0000000000000000000000000000000000000000000000000000000000000003",
"0000000000000000000000000000000000000000000000000000000000000000",
"0000000000000000000000000000000000000000000000000000000000000000",
"E907831F80848D1069A5371B402410364BDF1C5F8307B0084C55F1CE2DCA821525F66A4A85EA8B71E482A74F382D2CE5EBEEE8FDB2172F477DF4900D310536C0",
),
listOf(
"B7E151628AED2A6ABF7158809CF4F3C762E7160F38B4DA56A784D9045190CFEF",
"0000000000000000000000000000000000000000000000000000000000000001",
"243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89",
"6896BD60EEAE296DB48A229FF71DFE071BDE413E6D43F917DC8DCF8C78DE33418906D11AC976ABCCB20B091292BFF4EA897EFCB639EA871CFA95F6DE339E4B0A",
),
listOf(
"C90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74020BBEA63B14E5C9",
"C87AA53824B4D7AE2EB035A2B5BBBCCC080E76CDC6D1692C4B0B62D798E6D906",
"7E2D58D8B3BCDF1ABADEC7829054F90DDA9805AAB56C77333024B9D0A508B75C",
"5831AAEED7B44BB74E5EAB94BA9D4294C49BCF2A60728D8B4C200F50DD313C1BAB745879A5AD954A72C45A91C3A51D3C7ADEA98D82F8481E0E1E03674A6F3FB7",
),
listOf(
"0B432B2677937381AEF05BB02A66ECD012773062CF3FA2549E44F58ED2401710",
"FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF",
"FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF",
"7EB0509757E246F19449885651611CB965ECC1A187DD51B64FDA1EDC9637D5EC97582B9CB13DB3933705B32BA982AF5AF25FD78881EBB32771FC5922EFC66EA3",
),
)
for ((secretKey, auxiliaryRandomness, message, expectedSignature) in vectors) {
assertArrayEquals(
expectedSignature.hexBytes(),
fallbackSecp256k1.signSchnorr(
message.hexBytes(),
secretKey.hexBytes(),
auxiliaryRandomness.hexBytes(),
),
)
}
}
@Test
fun matchesSecp256k1KeyOperations() {
val secretKey = "03".padStart(64, '0').hexBytes()
val publicKey = fallbackSecp256k1.pubkeyCreate(secretKey)
assertTrue(fallbackSecp256k1.secKeyVerify(secretKey))
assertFalse(fallbackSecp256k1.secKeyVerify(ByteArray(32)))
assertArrayEquals(
"04F9308A019258C31049344F85F89D5229B531C845836F99B08601F113BCE036F9388F7B0F632DE8140FE337E62A37F3566500A99934C2231B6CB9FD7584B8E672".hexBytes(),
publicKey,
)
assertArrayEquals(
"04D7924D4F7D43EA965A465AE3095FF41131E5946F3C85F79E44ADBCF8E27E080E581E2872A86C72A683842EC228CC6DEFEA40AF2BD896D3A5C504DC9FF6A26B58".hexBytes(),
fallbackSecp256k1.pubKeyTweakMul(publicKey, "05".padStart(64, '0').hexBytes()),
)
}
private fun String.hexBytes() = chunked(2).map { it.toInt(16).toByte() }.toByteArray()
}

View file

@ -18,7 +18,7 @@
"bump": "node scripts/bump-version.mjs", "bump": "node scripts/bump-version.mjs",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check src e2e packages playwright.config.ts capacitor.config.ts electron/*.ts electron/electron-builder.config.mjs scripts/dev-desktop.mjs scripts/package-desktop.mjs scripts/publish-android-release.mjs && eslint src e2e packages capacitor.config.ts electron/*.ts electron/electron-builder.config.mjs scripts/dev-desktop.mjs scripts/package-desktop.mjs scripts/publish-android-release.mjs", "lint": "prettier --check src e2e packages fdroid playwright.config.ts capacitor.config.ts electron/*.ts electron/electron-builder.config.mjs scripts/dev-desktop.mjs scripts/package-desktop.mjs scripts/publish-android-release.mjs && eslint src e2e packages fdroid capacitor.config.ts electron/*.ts electron/electron-builder.config.mjs scripts/dev-desktop.mjs scripts/package-desktop.mjs scripts/publish-android-release.mjs",
"test": "playwright test", "test": "playwright test",
"test:desktop": "playwright test --config e2e/desktop/playwright.config.ts", "test:desktop": "playwright test --config e2e/desktop/playwright.config.ts",
"test:ui": "playwright test --ui", "test:ui": "playwright test --ui",

11
scripts/build-fdroid-assets.sh Executable file
View file

@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
cd "$root"
[[ -f .fdroid/prepared ]]
rm -rf build .svelte-kit
pnpm exec tsc --module esnext --moduleResolution bundler --target es2020 \
--declaration --skipLibCheck --outDir node_modules/nostr-signer-capacitor-plugin/dist/esm \
.fdroid/signer/src/index.ts
export VITE_BUILD_HASH=$(cat .fdroid/build-hash)
source scripts/build.sh

View file

@ -0,0 +1,29 @@
#!/usr/bin/env bash
set -euo pipefail
cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.."
if [[ -f .fdroid/prepared ]]; then
exit 0
fi
store=$(mktemp -d)
trap 'rm -rf "$store"' EXIT
node fdroid/prepare.mjs
# Let pnpm update its own lockfile; suppress package lifecycle scripts during preparation.
pnpm --config.store-dir="$store/store" --config.cache-dir="$store/cache" remove --lockfile-only --config.ignore-scripts=true @capacitor/push-notifications capacitor-set-version
pnpm --config.store-dir="$store/store" --config.cache-dir="$store/cache" install --frozen-lockfile --ignore-scripts --config.side-effects-cache=false
# Git package lifecycle scripts normally build the signer and discard its TS source.
# Acquire that source at pnpm's resolved revision, without a second pin or nested npm install.
signer_url=$(pnpm --config.store-dir="$store/store" list nostr-signer-capacitor-plugin --json | node --input-type=module -e '
import {readFileSync} from "node:fs"
const [project] = JSON.parse(readFileSync(0, "utf8"))
const {resolved} = project.dependencies["nostr-signer-capacitor-plugin"]
if (!resolved.startsWith("https://")) throw new Error("Expected a resolved HTTPS signer source archive")
console.log(resolved)
')
curl --fail --location "$signer_url" -o "$store/signer.tar.gz"
mkdir -p .fdroid/signer android/app/src/main/assets/public
tar -xzf "$store/signer.tar.gz" --strip-components=1 -C .fdroid/signer --wildcards '*/src/*' '*/LICENSE'
printf '%s\n' "${VITE_BUILD_HASH:-$(git rev-parse --short HEAD)}" > .fdroid/build-hash
pnpm exec cap update android
touch .fdroid/prepared

View file

@ -3,6 +3,8 @@
import {page} from "$app/stores" import {page} from "$app/stores"
import {getSetting} from "@app/settings" import {getSetting} from "@app/settings"
export const analyticsAvailable = true
const w = window as any const w = window as any
w.plausible = w.plausible =

View file

@ -7,7 +7,7 @@ import type {Session} from "@welshman/app"
import {app, session} from "@app/core" import {app, session} from "@app/core"
import {pushState} from "@app/push/adapters/common" import {pushState} from "@app/push/adapters/common"
import type {IPushAdapter} from "@app/push/adapters/common" import type {IPushAdapter} from "@app/push/adapters/common"
import {requestPermissions, syncRelaySubscriptions} from "@app/push/adapters/common" import {syncRelaySubscriptions} from "@app/push/adapters/common"
type AndroidFallbackSubscription = { type AndroidFallbackSubscription = {
relay: string relay: string
@ -26,6 +26,7 @@ type AndroidPushFallbackState = {
} }
type AndroidPushFallbackPlugin = { type AndroidPushFallbackPlugin = {
requestNotificationPermission: () => Promise<{receive: string}>
syncState: (args: {state: AndroidPushFallbackState}) => Promise<void> syncState: (args: {state: AndroidPushFallbackState}) => Promise<void>
} }
@ -37,7 +38,7 @@ export class AndroidFallbackNotifications implements IPushAdapter {
_activeSince = now() _activeSince = now()
async request() { async request() {
return requestPermissions() return (await AndroidPushFallback.requestNotificationPermission()).receive
} }
async enable() { async enable() {

View file

@ -1,6 +1,6 @@
import {Capacitor} from "@capacitor/core" import {Capacitor} from "@capacitor/core"
import {PushNotifications} from "@capacitor/push-notifications" import {PushNotifications} from "@capacitor/push-notifications"
import {assoc, hash, maybe} from "@welshman/lib" import {assoc, hash, maybe, ms, poll} from "@welshman/lib"
import type {Filter} from "@welshman/util" import type {Filter} from "@welshman/util"
import {Address, DELETE, makeEvent} from "@welshman/util" import {Address, DELETE, makeEvent} from "@welshman/util"
import {Relays, User} from "@welshman/app" import {Relays, User} from "@welshman/app"
@ -10,28 +10,54 @@ import {device} from "@app/device"
import {PUSH_BRIDGE, PUSH_SERVER} from "@app/env" import {PUSH_BRIDGE, PUSH_SERVER} from "@app/env"
import {pushState} from "@app/push/adapters/common" import {pushState} from "@app/push/adapters/common"
import type {IPushAdapter} from "@app/push/adapters/common" import type {IPushAdapter} from "@app/push/adapters/common"
import { import {onPushNotificationAction, syncRelaySubscriptions} from "@app/push/adapters/common"
onPushNotificationAction,
syncRelaySubscriptions, const requestPermissions = async () => {
requestPermissions, let status = await PushNotifications.checkPermissions()
requestToken,
} from "@app/push/adapters/common" if (["prompt", "prompt-with-rationale"].includes(status.receive)) {
status = await PushNotifications.requestPermissions()
}
return status.receive
}
const requestToken = async () => {
let {token} = pushState.get()
let error = "failed to retrieve token"
if (!token) {
const listeners = [
PushNotifications.addListener("registration", ({value}) => {
token = value
}),
PushNotifications.addListener("registrationError", err => {
error = err.error
}),
]
await Promise.all([
PushNotifications.register(),
poll({
condition: () => Boolean(token),
signal: AbortSignal.timeout(ms(5)),
}),
])
listeners.forEach(p => p.then(listener => listener.remove()))
}
pushState.update(assoc("token", token))
return token ? "granted" : error
}
export class CapacitorNotifications implements IPushAdapter { export class CapacitorNotifications implements IPushAdapter {
_controller = maybe<AbortController>() _controller = maybe<AbortController>()
async request() { async request() {
const status = await requestPermissions() const status = await requestPermissions()
return status === "granted" ? requestToken() : status
if (status !== "granted") {
return status
}
const {token, error = "denied"} = await requestToken()
pushState.update(assoc("token", token))
return token ? "granted" : error
} }
async _syncServer(signal: AbortSignal) { async _syncServer(signal: AbortSignal) {

View file

@ -1,14 +1,6 @@
import {writable} from "svelte/store" import {writable} from "svelte/store"
import type {Subscriber, Unsubscriber} from "svelte/store" import type {Subscriber, Unsubscriber} from "svelte/store"
import { import {assoc, call, ms, now, on, parseJson, poll, spec, throttle, uniq} from "@welshman/lib"
PushNotifications,
type ActionPerformed,
type RegistrationError,
type Token,
} from "@capacitor/push-notifications"
import type {PluginListenerHandle} from "@capacitor/core"
import {navigate} from "@app/modal"
import {assoc, call, now, on, parseJson, poll, spec, throttle, uniq} from "@welshman/lib"
import {LOCAL_RELAY_URL} from "@welshman/net" import {LOCAL_RELAY_URL} from "@welshman/net"
import type {RepositoryUpdate} from "@welshman/net" import type {RepositoryUpdate} from "@welshman/net"
import { import {
@ -24,6 +16,7 @@ import {
} from "@welshman/util" } from "@welshman/util"
import {merged, withGetter} from "@welshman/store" import {merged, withGetter} from "@welshman/store"
import {User} from "@welshman/app" import {User} from "@welshman/app"
import {navigate} from "@app/modal"
import {app, messagingRelayLists, network, roomLists} from "@app/core" import {app, messagingRelayLists, network, roomLists} from "@app/core"
import {DM_KINDS, CONTENT_KINDS, makeCommentFilter} from "@app/content" import {DM_KINDS, CONTENT_KINDS, makeCommentFilter} from "@app/content"
import {getMutedRooms, notificationSettings, shouldNotify, userSettingsValues} from "@app/settings" import {getMutedRooms, notificationSettings, shouldNotify, userSettingsValues} from "@app/settings"
@ -48,11 +41,6 @@ export interface IPushAdapter {
enable: () => Promise<void> enable: () => Promise<void>
} }
export type PushPermissionResult = {
token?: string
error?: string
}
export const onNotification = call(() => { export const onNotification = call(() => {
const allFilters = [ const allFilters = [
{kinds: [MESSAGE, ...CONTENT_KINDS, ...DM_KINDS]}, {kinds: [MESSAGE, ...CONTENT_KINDS, ...DM_KINDS]},
@ -124,7 +112,7 @@ const ingestNotification = async (relay: string, id: string, json?: string) => {
// The local relay eoses immediately, so anything less waits on it alone // The local relay eoses immediately, so anything less waits on it alone
threshold: 1, threshold: 1,
autoClose: true, autoClose: true,
signal: AbortSignal.timeout(5000), signal: AbortSignal.timeout(ms(5)),
}) })
return events[0] return events[0]
@ -135,12 +123,14 @@ const ingestNotification = async (relay: string, id: string, json?: string) => {
const loadNotificationRumor = async (wrap: TrustedEvent) => { const loadNotificationRumor = async (wrap: TrustedEvent) => {
const getRumor = () => app.get().wrapManager.getRumor(wrap.id) const getRumor = () => app.get().wrapManager.getRumor(wrap.id)
await poll({condition: () => Boolean(getRumor()), signal: AbortSignal.timeout(5000)}) await poll({condition: () => Boolean(getRumor()), signal: AbortSignal.timeout(ms(5))})
return getRumor() return getRumor()
} }
export const onPushNotificationAction = async (action: ActionPerformed) => { export const onPushNotificationAction = async (action: {
notification: {data: {relay: string; id: string; event?: string}}
}) => {
const {relay, id, event: json} = action.notification.data const {relay, id, event: json} = action.notification.data
const event = await ingestNotification(relay, id, json) const event = await ingestNotification(relay, id, json)
const target = event?.kind === WRAP ? await loadNotificationRumor(event) : event const target = event?.kind === WRAP ? await loadNotificationRumor(event) : event
@ -156,44 +146,6 @@ export const onPushNotificationAction = async (action: ActionPerformed) => {
} }
} }
export const requestPermissions = async (): Promise<string> => {
let status = await PushNotifications.checkPermissions()
if (["prompt", "prompt-with-rationale"].includes(status.receive)) {
status = await PushNotifications.requestPermissions()
}
return status.receive
}
export const requestToken = async (): Promise<PushPermissionResult> => {
let {token} = pushState.get()
let error = "failed to retrieve token"
if (!token) {
const listeners = [
PushNotifications.addListener("registration", ({value}: Token) => {
token = value
}),
PushNotifications.addListener("registrationError", (err: RegistrationError) => {
error = err.error
}),
]
await Promise.all([
PushNotifications.register(),
poll({
condition: () => Boolean(token),
signal: AbortSignal.timeout(5000),
}),
])
listeners.forEach(p => p.then((listener: PluginListenerHandle) => listener.remove()))
}
return token ? {token} : {error}
}
export const syncRelaySubscriptions = ( export const syncRelaySubscriptions = (
signal: AbortSignal, signal: AbortSignal,
sync: (url: string, key: string, filters: Filter[], ignore: Filter[]) => void, sync: (url: string, key: string, filters: Filter[], ignore: Filter[]) => void,

View file

@ -18,7 +18,10 @@ export class Push {
if (Capacitor.getPlatform() === "electron") { if (Capacitor.getPlatform() === "electron") {
Push._adapter = new ElectronNotifications() Push._adapter = new ElectronNotifications()
} else if (Capacitor.getPlatform() === "android" && useFallback) { } else if (
Capacitor.getPlatform() === "android" &&
(useFallback || !Capacitor.isPluginAvailable("PushNotifications"))
) {
Push._adapter = new AndroidFallbackNotifications() Push._adapter = new AndroidFallbackNotifications()
} else if (Capacitor.isPluginAvailable("PushNotifications")) { } else if (Capacitor.isPluginAvailable("PushNotifications")) {
Push._adapter = new CapacitorNotifications() Push._adapter = new CapacitorNotifications()

View file

@ -80,7 +80,7 @@
const id = url.searchParams.get("id") const id = url.searchParams.get("id")
if (relay && id) { if (relay && id) {
onPushNotificationAction({notification: {data: {relay, id}}} as any) onPushNotificationAction({notification: {data: {relay, id}}})
return return
} }

View file

@ -15,6 +15,7 @@
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
import {PLATFORM_NAME} from "@app/env" import {PLATFORM_NAME} from "@app/env"
import {analyticsAvailable} from "@app/analytics"
import {sendLogs} from "@app/logger" import {sendLogs} from "@app/logger"
import { import {
RelayAuthMode, RelayAuthMode,
@ -110,6 +111,7 @@
<p>These relays won't be used unless explicitly requested.</p> <p>These relays won't be used unless explicitly requested.</p>
{/snippet} {/snippet}
</FieldInline> </FieldInline>
{#if analyticsAvailable}
<FieldInline> <FieldInline>
{#snippet label()} {#snippet label()}
<p>Report usage?</p> <p>Report usage?</p>
@ -121,6 +123,7 @@
<p>Allow {PLATFORM_NAME} to collect anonymous usage data.</p> <p>Allow {PLATFORM_NAME} to collect anonymous usage data.</p>
{/snippet} {/snippet}
</FieldInline> </FieldInline>
{/if}
<FieldInline> <FieldInline>
{#snippet label()} {#snippet label()}
<p>Something went wrong?</p> <p>Something went wrong?</p>

View file

@ -12,7 +12,7 @@
"moduleResolution": "bundler" "moduleResolution": "bundler"
}, },
// TypeScript does not merge includes, so this repeats .svelte-kit/tsconfig.json's list in order // TypeScript does not merge includes, so this repeats .svelte-kit/tsconfig.json's list in order
// to add the e2e harness, which runs in node and is otherwise checked by nothing. // to check the e2e harness and F-Droid overlays alongside the app.
"include": [ "include": [
".svelte-kit/ambient.d.ts", ".svelte-kit/ambient.d.ts",
".svelte-kit/non-ambient.d.ts", ".svelte-kit/non-ambient.d.ts",
@ -24,7 +24,8 @@
"src/**/*.svelte", "src/**/*.svelte",
"test/**/*.ts", "test/**/*.ts",
"tests/**/*.ts", "tests/**/*.ts",
"e2e/**/*.ts" "e2e/**/*.ts",
"fdroid/**/*.ts"
] ]
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
// except $lib which is handled by https://kit.svelte.dev/docs/configuration#files // except $lib which is handled by https://kit.svelte.dev/docs/configuration#files