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/
|
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/
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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 =
|
||||||
|
|
|
||||||
|
|
@ -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",
|
"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
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 {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 =
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue