Added a source-buildable F-Droid Android distribution (#527)
This commit is contained in:
parent
5d40a9bdc5
commit
2c980caa00
26 changed files with 495 additions and 93 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
package social.flotilla.notifications
|
||||
|
||||
import fr.acinq.secp256k1.Secp256k1
|
||||
|
||||
val fallbackSecp256k1: Secp256k1 = Secp256k1.get()
|
||||
68
fdroid/README.md
Normal file
68
fdroid/README.md
Normal 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
16
fdroid/app.gradle
Normal 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"
|
||||
}
|
||||
20
fdroid/crypto/argon/index.js
Normal file
20
fdroid/crypto/argon/index.js
Normal 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,
|
||||
})
|
||||
}
|
||||
6
fdroid/crypto/argon/package.json
Normal file
6
fdroid/crypto/argon/package.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "@flotilla/fdroid-argon",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": "./index.js"
|
||||
}
|
||||
8
fdroid/crypto/nostr/index.js
Normal file
8
fdroid/crypto/nostr/index.js
Normal 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")
|
||||
},
|
||||
})
|
||||
6
fdroid/crypto/nostr/package.json
Normal file
6
fdroid/crypto/nostr/package.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "@flotilla/fdroid-nostr",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": "./index.js"
|
||||
}
|
||||
70
fdroid/overlay/FallbackSecp256k1Provider.kt
Normal file
70
fdroid/overlay/FallbackSecp256k1Provider.kt
Normal 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()
|
||||
3
fdroid/overlay/analytics.ts
Normal file
3
fdroid/overlay/analytics.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export const analyticsAvailable = false
|
||||
|
||||
export const setupAnalytics = () => () => {}
|
||||
12
fdroid/overlay/capacitor.ts
Normal file
12
fdroid/overlay/capacitor.ts
Normal 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
67
fdroid/prepare.mjs
Normal 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, "")
|
||||
68
fdroid/tests/FallbackSecp256k1Test.kt
Normal file
68
fdroid/tests/FallbackSecp256k1Test.kt
Normal 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()
|
||||
}
|
||||
|
|
@ -18,7 +18,7 @@
|
|||
"bump": "node scripts/bump-version.mjs",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"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:desktop": "playwright test --config e2e/desktop/playwright.config.ts",
|
||||
"test:ui": "playwright test --ui",
|
||||
|
|
|
|||
11
scripts/build-fdroid-assets.sh
Executable file
11
scripts/build-fdroid-assets.sh
Executable 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
|
||||
29
scripts/prepare-fdroid-source.sh
Executable file
29
scripts/prepare-fdroid-source.sh
Executable 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
|
||||
|
|
@ -3,6 +3,8 @@
|
|||
import {page} from "$app/stores"
|
||||
import {getSetting} from "@app/settings"
|
||||
|
||||
export const analyticsAvailable = true
|
||||
|
||||
const w = window as any
|
||||
|
||||
w.plausible =
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import type {Session} from "@welshman/app"
|
|||
import {app, session} from "@app/core"
|
||||
import {pushState} 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 = {
|
||||
relay: string
|
||||
|
|
@ -26,6 +26,7 @@ type AndroidPushFallbackState = {
|
|||
}
|
||||
|
||||
type AndroidPushFallbackPlugin = {
|
||||
requestNotificationPermission: () => Promise<{receive: string}>
|
||||
syncState: (args: {state: AndroidPushFallbackState}) => Promise<void>
|
||||
}
|
||||
|
||||
|
|
@ -37,7 +38,7 @@ export class AndroidFallbackNotifications implements IPushAdapter {
|
|||
_activeSince = now()
|
||||
|
||||
async request() {
|
||||
return requestPermissions()
|
||||
return (await AndroidPushFallback.requestNotificationPermission()).receive
|
||||
}
|
||||
|
||||
async enable() {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import {Capacitor} from "@capacitor/core"
|
||||
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 {Address, DELETE, makeEvent} from "@welshman/util"
|
||||
import {Relays, User} from "@welshman/app"
|
||||
|
|
@ -10,28 +10,54 @@ import {device} from "@app/device"
|
|||
import {PUSH_BRIDGE, PUSH_SERVER} from "@app/env"
|
||||
import {pushState} from "@app/push/adapters/common"
|
||||
import type {IPushAdapter} from "@app/push/adapters/common"
|
||||
import {
|
||||
onPushNotificationAction,
|
||||
syncRelaySubscriptions,
|
||||
requestPermissions,
|
||||
requestToken,
|
||||
} from "@app/push/adapters/common"
|
||||
import {onPushNotificationAction, syncRelaySubscriptions} from "@app/push/adapters/common"
|
||||
|
||||
const requestPermissions = async () => {
|
||||
let status = await PushNotifications.checkPermissions()
|
||||
|
||||
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 {
|
||||
_controller = maybe<AbortController>()
|
||||
|
||||
async request() {
|
||||
const status = await requestPermissions()
|
||||
|
||||
if (status !== "granted") {
|
||||
return status
|
||||
}
|
||||
|
||||
const {token, error = "denied"} = await requestToken()
|
||||
|
||||
pushState.update(assoc("token", token))
|
||||
|
||||
return token ? "granted" : error
|
||||
return status === "granted" ? requestToken() : status
|
||||
}
|
||||
|
||||
async _syncServer(signal: AbortSignal) {
|
||||
|
|
|
|||
|
|
@ -1,14 +1,6 @@
|
|||
import {writable} from "svelte/store"
|
||||
import type {Subscriber, Unsubscriber} from "svelte/store"
|
||||
import {
|
||||
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 {assoc, call, ms, now, on, parseJson, poll, spec, throttle, uniq} from "@welshman/lib"
|
||||
import {LOCAL_RELAY_URL} from "@welshman/net"
|
||||
import type {RepositoryUpdate} from "@welshman/net"
|
||||
import {
|
||||
|
|
@ -24,6 +16,7 @@ import {
|
|||
} from "@welshman/util"
|
||||
import {merged, withGetter} from "@welshman/store"
|
||||
import {User} from "@welshman/app"
|
||||
import {navigate} from "@app/modal"
|
||||
import {app, messagingRelayLists, network, roomLists} from "@app/core"
|
||||
import {DM_KINDS, CONTENT_KINDS, makeCommentFilter} from "@app/content"
|
||||
import {getMutedRooms, notificationSettings, shouldNotify, userSettingsValues} from "@app/settings"
|
||||
|
|
@ -48,11 +41,6 @@ export interface IPushAdapter {
|
|||
enable: () => Promise<void>
|
||||
}
|
||||
|
||||
export type PushPermissionResult = {
|
||||
token?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export const onNotification = call(() => {
|
||||
const allFilters = [
|
||||
{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
|
||||
threshold: 1,
|
||||
autoClose: true,
|
||||
signal: AbortSignal.timeout(5000),
|
||||
signal: AbortSignal.timeout(ms(5)),
|
||||
})
|
||||
|
||||
return events[0]
|
||||
|
|
@ -135,12 +123,14 @@ const ingestNotification = async (relay: string, id: string, json?: string) => {
|
|||
const loadNotificationRumor = async (wrap: TrustedEvent) => {
|
||||
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()
|
||||
}
|
||||
|
||||
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 event = await ingestNotification(relay, id, json)
|
||||
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 = (
|
||||
signal: AbortSignal,
|
||||
sync: (url: string, key: string, filters: Filter[], ignore: Filter[]) => void,
|
||||
|
|
|
|||
|
|
@ -18,7 +18,10 @@ export class Push {
|
|||
|
||||
if (Capacitor.getPlatform() === "electron") {
|
||||
Push._adapter = new ElectronNotifications()
|
||||
} else if (Capacitor.getPlatform() === "android" && useFallback) {
|
||||
} else if (
|
||||
Capacitor.getPlatform() === "android" &&
|
||||
(useFallback || !Capacitor.isPluginAvailable("PushNotifications"))
|
||||
) {
|
||||
Push._adapter = new AndroidFallbackNotifications()
|
||||
} else if (Capacitor.isPluginAvailable("PushNotifications")) {
|
||||
Push._adapter = new CapacitorNotifications()
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@
|
|||
const id = url.searchParams.get("id")
|
||||
|
||||
if (relay && id) {
|
||||
onPushNotificationAction({notification: {data: {relay, id}}} as any)
|
||||
onPushNotificationAction({notification: {data: {relay, id}}})
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
import {pushModal} from "@app/modal"
|
||||
import {pushToast} from "@app/toast"
|
||||
import {PLATFORM_NAME} from "@app/env"
|
||||
import {analyticsAvailable} from "@app/analytics"
|
||||
import {sendLogs} from "@app/logger"
|
||||
import {
|
||||
RelayAuthMode,
|
||||
|
|
@ -110,17 +111,19 @@
|
|||
<p>These relays won't be used unless explicitly requested.</p>
|
||||
{/snippet}
|
||||
</FieldInline>
|
||||
<FieldInline>
|
||||
{#snippet label()}
|
||||
<p>Report usage?</p>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<ToggleInput bind:checked={$settings.report_usage} />
|
||||
{/snippet}
|
||||
{#snippet info()}
|
||||
<p>Allow {PLATFORM_NAME} to collect anonymous usage data.</p>
|
||||
{/snippet}
|
||||
</FieldInline>
|
||||
{#if analyticsAvailable}
|
||||
<FieldInline>
|
||||
{#snippet label()}
|
||||
<p>Report usage?</p>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<ToggleInput bind:checked={$settings.report_usage} />
|
||||
{/snippet}
|
||||
{#snippet info()}
|
||||
<p>Allow {PLATFORM_NAME} to collect anonymous usage data.</p>
|
||||
{/snippet}
|
||||
</FieldInline>
|
||||
{/if}
|
||||
<FieldInline>
|
||||
{#snippet label()}
|
||||
<p>Something went wrong?</p>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
"moduleResolution": "bundler"
|
||||
},
|
||||
// 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": [
|
||||
".svelte-kit/ambient.d.ts",
|
||||
".svelte-kit/non-ambient.d.ts",
|
||||
|
|
@ -24,7 +24,8 @@
|
|||
"src/**/*.svelte",
|
||||
"test/**/*.ts",
|
||||
"tests/**/*.ts",
|
||||
"e2e/**/*.ts"
|
||||
"e2e/**/*.ts",
|
||||
"fdroid/**/*.ts"
|
||||
]
|
||||
// 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
|
||||
|
|
|
|||
Loading…
Reference in a new issue