diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md
index 72b5c7264e..8c87760cd3 100644
--- a/.claude/CLAUDE.md
+++ b/.claude/CLAUDE.md
@@ -1,8 +1,14 @@
-# Amethyst Desktop Fork
+# Amethyst
## Project Overview
-Fork of [Amethyst](https://github.com/vitorpamplona/amethyst) adding Compose Multiplatform Desktop support. Quartz library converted to full KMP for code sharing between Android and Desktop JVM.
+Amethyst is a Nostr Client for Android that was made for Android-only and has been slowly switching
+over to a Kotlin Multiplatform project. This project has 4 main modules: `quartz`, `commons`,
+`amethyst` and `desktopApp`. Quartz should contain implementations of Nostr specifications and
+utilities to help implement them. Commons stores shared code between Amethyst Android (`amethyst`)
+and Amethyst Desktop (`desktopApp`). The Desktop App is designed to be mouse first and so uses a
+completely different screen and navigation architecture while sharing the back end components with
+the android counterpart.
## Architecture
@@ -12,7 +18,8 @@ amethyst/
│ └── src/
│ ├── commonMain/ # Shared Nostr protocol, data models
│ ├── androidMain/ # Android-specific (crypto, storage)
-│ └── jvmMain/ # Desktop JVM-specific
+│ ├── jvmMain/ # Desktop JVM-specific
+│ └── iosMain/ # iOS-specific
├── commons/ # Shared UI components (convert to KMP)
│ └── src/
│ ├── commonMain/ # Shared composables, icons, state
@@ -20,12 +27,12 @@ amethyst/
│ └── jvmMain/ # Desktop-specific UI utilities
├── desktopApp/ # Desktop JVM application (layouts, navigation)
├── amethyst/ # Android app (layouts, navigation)
-└── ammolite/ # Support module
+└── ammolite/ # Support module (unused)
```
**Sharing Philosophy:**
-- `quartz/` = Business logic, protocol, data (no UI)
-- `commons/` = Shared UI components, icons, composables, **ViewModels**
+- `quartz/` = Nostr business logic, protocol, data (no UI)
+- `commons/` = Shared UI components, icons, composables, flows and ViewModels
- `amethyst/` & `desktopApp/` = Platform-native layouts and navigation
## Tech Stack
@@ -241,6 +248,13 @@ actual fun openExternalUrl(url: String) {
}
```
+## Code Formatting
+After completing any task that modifies Kotlin files, always run:
+```
+./gradlew spotlessApply
+```
+Do this before considering the task complete.
+
### Navigation Shell
- **Desktop**: Sidebar + main content area
- **Android**: Bottom navigation
diff --git a/.claude/core-skills-plan.md b/.claude/core-skills-plan.md
index 6b51cdab0a..f85c0d9a18 100644
--- a/.claude/core-skills-plan.md
+++ b/.claude/core-skills-plan.md
@@ -119,7 +119,7 @@ Create 8 hybrid domain skills combining general expertise with AmethystMultiplat
**Focus:** iosMain patterns, Swift/KMP interop, XCFramework generation
**SKILL.md sections:**
-- iOS source sets: iosMain, iosX64Main, iosArm64Main
+- iOS source sets: iosMain, iosArm64Main
- Swift interop: type mapping, nullability
- expect/actual iOS: 10+ examples from quartz/iosMain
- XCFramework setup: baseName = "quartz-kmpKit"
diff --git a/.claude/hooks/session-start.sh b/.claude/hooks/session-start.sh
new file mode 100755
index 0000000000..35cddfb3b4
--- /dev/null
+++ b/.claude/hooks/session-start.sh
@@ -0,0 +1,180 @@
+#!/bin/bash
+# Session start hook: Configure proxy auth, SSL trust, and Android SDK for Claude Code on the web
+set -euo pipefail
+
+# Only run in remote (web) environments
+if [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then
+ exit 0
+fi
+
+# --- Proxy credentials: configure Maven/Gradle if authenticated proxy is set ---
+proxy="${https_proxy:-${HTTPS_PROXY:-}}"
+if [ -n "$proxy" ] && echo "$proxy" | grep -q '@'; then
+ rest="${proxy#*://}"
+ userpass="${rest%@*}"
+ hostport="${rest##*@}"
+ user="${userpass%%:*}"
+ pass="${userpass#*:}"
+ host="${hostport%%:*}"
+ port="${hostport##*:}"
+ port="${port%/}"
+
+ mkdir -p ~/.m2
+ cat > ~/.m2/settings.xml << EOF
+
+
+
+ ccwtruehttps
+ $host$port
+ $user
+
+
+
+
+EOF
+
+ # Force wagon transport for Maven 3.9+ proxy auth compatibility
+ cat > ~/.mavenrc << 'MAVENRC'
+MAVEN_OPTS="$MAVEN_OPTS -Dmaven.resolver.transport=wagon"
+MAVENRC
+
+ mkdir -p ~/.gradle
+ cat > ~/.gradle/gradle.properties << EOF
+systemProp.https.proxyHost=$host
+systemProp.https.proxyPort=$port
+systemProp.https.proxyUser=$user
+systemProp.https.proxyPassword=$pass
+systemProp.http.proxyHost=$host
+systemProp.http.proxyPort=$port
+systemProp.http.proxyUser=$user
+systemProp.http.proxyPassword=$pass
+# Override nonProxyHosts: route all external traffic (incl. *.google.com) through proxy
+systemProp.http.nonProxyHosts=localhost|127.0.0.1
+systemProp.https.nonProxyHosts=localhost|127.0.0.1
+# Use Ubuntu's Java trust store (includes Anthropic TLS inspection CA) for all Gradle JVMs.
+# This is needed because Gradle may download a custom JDK (e.g. JetBrains) whose bundled
+# trust store doesn't include the Anthropic CA, causing TLS inspection failures.
+systemProp.javax.net.ssl.trustStore=/etc/ssl/certs/java/cacerts
+systemProp.javax.net.ssl.trustStoreType=JKS
+systemProp.javax.net.ssl.trustStorePassword=changeit
+systemProp.jdk.http.auth.tunneling.disabledSchemes=
+systemProp.jdk.http.auth.proxying.disabledSchemes=
+EOF
+
+ echo "Configured Maven/Gradle proxy from HTTPS_PROXY" >&2
+fi
+
+# --- SSL trust: import Anthropic TLS inspection CA into JVM trust stores ---
+ANTHROPIC_CA_PEM=$(python3 -c "
+import re, ssl, sys
+try:
+ with open('/etc/ssl/certs/ca-certificates.crt') as f:
+ certs = re.findall(r'-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----', f.read(), re.DOTALL)
+ for cert in certs:
+ der = ssl.PEM_cert_to_DER_cert(cert)
+ if b'Anthropic' in der and b'sandbox-egress-production' in der:
+ print(cert)
+ break
+except Exception as e:
+ sys.stderr.write(f'CA extraction failed: {e}\n')
+" 2>/dev/null)
+
+if [ -n "$ANTHROPIC_CA_PEM" ]; then
+ TMPCA=$(mktemp /tmp/anthropic-ca.XXXXXX.pem)
+ echo "$ANTHROPIC_CA_PEM" > "$TMPCA"
+ for cacerts in \
+ /usr/lib/jvm/java-21-openjdk-amd64/lib/security/cacerts \
+ /root/.gradle/jdks/*/lib/security/cacerts; do
+ [ -f "$cacerts" ] || continue
+ keytool -list -keystore "$cacerts" -storepass changeit \
+ -alias anthropic-egress-production-ca >/dev/null 2>&1 && continue
+ keytool -import \
+ -alias anthropic-egress-production-ca \
+ -file "$TMPCA" \
+ -keystore "$cacerts" \
+ -storepass changeit \
+ -noprompt >/dev/null 2>&1 && \
+ echo "Imported Anthropic CA into $cacerts" >&2
+ done
+ rm -f "$TMPCA"
+fi
+
+ANDROID_SDK_DIR="/root/android-sdk"
+SDK_REPO_BASE="https://dl.google.com/android/repository"
+
+# Install Android SDK packages by downloading directly with curl
+# (sdkmanager cannot reach the SDK repository through the proxy)
+install_sdk_package() {
+ local zip_url="$1"
+ local dest_dir="$2"
+ local inner_dir="$3" # top-level dir inside the zip
+
+ if [ -d "$dest_dir" ]; then
+ return 0
+ fi
+
+ echo "Downloading $zip_url..."
+ local TMP_ZIP
+ TMP_ZIP=$(mktemp /tmp/sdk-pkg.XXXXXX.zip)
+ curl -fsSL "$zip_url" -o "$TMP_ZIP"
+
+ local TMP_DIR
+ TMP_DIR=$(mktemp -d)
+ unzip -q "$TMP_ZIP" -d "$TMP_DIR"
+ rm -f "$TMP_ZIP"
+
+ mkdir -p "$(dirname "$dest_dir")"
+ mv "$TMP_DIR/$inner_dir" "$dest_dir"
+ rm -rf "$TMP_DIR"
+ echo "Installed to $dest_dir"
+}
+
+# Install Android platform 36
+install_sdk_package \
+ "$SDK_REPO_BASE/platform-36_r02.zip" \
+ "$ANDROID_SDK_DIR/platforms/android-36" \
+ "android-36"
+
+# Install build-tools 36.0.0 (zip uses "android-16" as inner dir name)
+install_sdk_package \
+ "$SDK_REPO_BASE/build-tools_r36_linux.zip" \
+ "$ANDROID_SDK_DIR/build-tools/36.0.0" \
+ "android-16"
+
+# Install platform-tools
+install_sdk_package \
+ "$SDK_REPO_BASE/platform-tools_r37.0.0-linux.zip" \
+ "$ANDROID_SDK_DIR/platform-tools" \
+ "platform-tools"
+
+# Accept SDK licenses (create license files manually)
+echo "Writing SDK license files..."
+mkdir -p "$ANDROID_SDK_DIR/licenses"
+# android-sdk-license
+echo -e "\n24333f8a63b6825ea9c5514f83c2829b004d1fee" > "$ANDROID_SDK_DIR/licenses/android-sdk-license"
+echo -e "\n84831b9409646a918e30573bab4c9c91346d8abd" >> "$ANDROID_SDK_DIR/licenses/android-sdk-license"
+# android-sdk-preview-license
+echo -e "\n84831b9409646a918e30573bab4c9c91346d8abd" > "$ANDROID_SDK_DIR/licenses/android-sdk-preview-license"
+echo -e "\n504667f4c0de7af1a06de9f4b1727b84351f2910" >> "$ANDROID_SDK_DIR/licenses/android-sdk-preview-license"
+# intel-android-extra-license
+echo -e "\nd975f751698a77b662f1254ddbeed3901e976f5a" > "$ANDROID_SDK_DIR/licenses/intel-android-extra-license"
+
+# Create local.properties if missing
+REPO_ROOT="$(git -C "$(dirname "$0")" rev-parse --show-toplevel 2>/dev/null || echo "${CLAUDE_PROJECT_DIR:-/home/user/Amber}")"
+LOCAL_PROPS="$REPO_ROOT/local.properties"
+if [ ! -f "$LOCAL_PROPS" ]; then
+ echo "sdk.dir=$ANDROID_SDK_DIR" > "$LOCAL_PROPS"
+ echo "Created local.properties with sdk.dir=$ANDROID_SDK_DIR"
+fi
+
+# Export ANDROID_HOME for the session
+if [ -n "${CLAUDE_ENV_FILE:-}" ]; then
+ echo "export ANDROID_HOME=$ANDROID_SDK_DIR" >> "$CLAUDE_ENV_FILE"
+ echo "export ANDROID_SDK_ROOT=$ANDROID_SDK_DIR" >> "$CLAUDE_ENV_FILE"
+ echo "export PATH=\$PATH:$ANDROID_SDK_DIR/platform-tools" >> "$CLAUDE_ENV_FILE"
+fi
+
+cd "$CLAUDE_PROJECT_DIR"
+./gradlew --version > /dev/null 2>&1
+
+echo "Android SDK setup complete."
\ No newline at end of file
diff --git a/.claude/settings.json b/.claude/settings.json
new file mode 100644
index 0000000000..4ab4d17262
--- /dev/null
+++ b/.claude/settings.json
@@ -0,0 +1,26 @@
+{
+ "hooks": {
+ "SessionStart": [
+ {
+ "hooks": [
+ {
+ "type": "command",
+ "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/session-start.sh"
+ }
+ ]
+ }
+ ],
+ "Stop": [
+ {
+ "matcher": "",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "./gradlew spotlessApply 2>/dev/null || spotless-apply",
+ "timeout": 120
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/.claude/skills/gradle-expert/references/dependency-graph.md b/.claude/skills/gradle-expert/references/dependency-graph.md
index 3f38203989..9417851c41 100644
--- a/.claude/skills/gradle-expert/references/dependency-graph.md
+++ b/.claude/skills/gradle-expert/references/dependency-graph.md
@@ -47,7 +47,7 @@
### :quartz (KMP Nostr Library)
**Type:** Kotlin Multiplatform Library
-**Targets:** JVM, Android, iOS (iosX64, iosArm64, iosSimulatorArm64)
+**Targets:** JVM, Android, iOS (iosArm64, iosSimulatorArm64)
**Dependencies:**
- External: secp256k1, jackson, okhttp, kotlinx.coroutines, kotlinx.collections.immutable
- Source sets: commonMain → jvmAndroid → {androidMain, jvmMain}, iosMain
@@ -127,7 +127,6 @@ commonMain (base)
│ ├─ androidMain (Android platform)
│ └─ jvmMain (Desktop platform)
└─ iosMain (iOS platform)
- ├─ iosX64Main
├─ iosArm64Main
└─ iosSimulatorArm64Main
```
diff --git a/.claude/skills/kotlin-multiplatform/SKILL.md b/.claude/skills/kotlin-multiplatform/SKILL.md
index b0aa2f8d6d..6f8e43f42d 100644
--- a/.claude/skills/kotlin-multiplatform/SKILL.md
+++ b/.claude/skills/kotlin-multiplatform/SKILL.md
@@ -110,8 +110,8 @@ Think of source sets as a dependency graph, not folders.
│ - Jackson │ │ │
│ - OkHttp │ └────┬─────────────┘
└───┬───────────┬───┘ │
- │ │ ├─→ iosX64Main
- ▼ ▼ ├─→ iosArm64Main
+ │ │ │
+ ▼ ▼ ├─→ iosArm64Main
┌─────────┐ ┌──────────┐ └─→ iosSimulatorArm64Main
│android │ │jvmMain │
│Main │ │(Desktop) │
@@ -252,7 +252,7 @@ expect fun currentTimeSeconds(): Long
**iOS (iosMain):**
- Active development, framework configured
-- Architecture targets: iosX64Main, iosArm64Main, iosSimulatorArm64Main
+- Architecture targets: macosArm64Main, iosArm64Main, iosSimulatorArm64Main
- Platform APIs via platform.posix, Security framework
### Web, wasm - Future Targets
diff --git a/.claude/skills/kotlin-multiplatform/references/source-set-hierarchy.md b/.claude/skills/kotlin-multiplatform/references/source-set-hierarchy.md
index ec09e00332..b950c57d5b 100644
--- a/.claude/skills/kotlin-multiplatform/references/source-set-hierarchy.md
+++ b/.claude/skills/kotlin-multiplatform/references/source-set-hierarchy.md
@@ -29,7 +29,7 @@ Visual guide to source set organization with concrete examples from the codebase
│ - Jackson │ │ - Platform libs │
│ - OkHttp │ └───────┬───────────┘
└────┬─────────┬───┘ │
- │ │ ├─→ iosX64Main (simulator Intel)
+ │ │ │
│ │ ├─→ iosArm64Main (device ARM64)
│ │ └─→ iosSimulatorArm64Main (Apple Silicon)
▼ ▼
@@ -238,7 +238,6 @@ iosMain {
}
}
-val iosX64Main by getting { dependsOn(iosMain.get()) }
val iosArm64Main by getting { dependsOn(iosMain.get()) }
val iosSimulatorArm64Main by getting { dependsOn(iosMain.get()) }
```
@@ -249,7 +248,6 @@ val iosSimulatorArm64Main by getting { dependsOn(iosMain.get()) }
- Different from Android/Desktop
**Architecture targets:**
-- iosX64Main: Intel simulator
- iosArm64Main: Device (iPhone, iPad)
- iosSimulatorArm64Main: Apple Silicon simulator
@@ -326,7 +324,7 @@ commonMain
| androidMain | jvmAndroid | Android framework | Activity, ViewModel |
| jvmMain | jvmAndroid | JVM + Compose Desktop | Window, MenuBar |
| iosMain | commonMain | iOS platform | Security framework |
-| iosX64Main | iosMain | Simulator (Intel) | Architecture-specific |
+| iosMain | Simulator (Intel) | Architecture-specific |
| iosArm64Main | iosMain | Device (ARM64) | Architecture-specific |
| jsMain | commonMain | JS/DOM | Web (future) |
| wasmMain | commonMain | wasm APIs | WebAssembly (future) |
diff --git a/.claude/skills/kotlin-multiplatform/references/target-compatibility.md b/.claude/skills/kotlin-multiplatform/references/target-compatibility.md
index 7ccf720fb8..c2a4d71299 100644
--- a/.claude/skills/kotlin-multiplatform/references/target-compatibility.md
+++ b/.claude/skills/kotlin-multiplatform/references/target-compatibility.md
@@ -76,7 +76,6 @@ fun main() = application {
**Source sets:**
- iosMain (common iOS code)
-- iosX64Main (Intel simulator)
- iosArm64Main (device - iPhone/iPad)
- iosSimulatorArm64Main (Apple Silicon simulator)
@@ -110,7 +109,7 @@ actual object Secp256k1Instance {
```kotlin
// quartz/build.gradle.kts
kotlin {
- listOf(iosX64(), iosArm64(), iosSimulatorArm64())
+ listOf(macosArm64(), iosArm64(), iosSimulatorArm64())
.forEach { target ->
target.binaries.framework {
baseName = "quartz-kmpKit"
@@ -310,7 +309,7 @@ fun parseJson(json: String): Event {
- Manual desktop app testing
**iOS:**
-- Unit tests: iosTest (iosX64Test, iosArm64Test, etc.)
+- Unit tests: iosTest (iosArm64Test, etc.)
- Simulator/device testing
**Web (future):**
diff --git a/.git-hooks/pre-push b/.git-hooks/pre-push
index 903d42dc60..2c0dbdc27f 100755
--- a/.git-hooks/pre-push
+++ b/.git-hooks/pre-push
@@ -12,7 +12,7 @@ echo "$JAVA_HOME"
echo "$(java -version)"
echo "Running test... "
-./gradlew test
+./gradlew test --quiet
status=$?
diff --git a/.github/workflows/build-benchmark-apk.yml b/.github/workflows/build-benchmark-apk.yml
new file mode 100644
index 0000000000..0a65ca6c1d
--- /dev/null
+++ b/.github/workflows/build-benchmark-apk.yml
@@ -0,0 +1,69 @@
+name: Build APK For Claude
+
+on:
+ push:
+ branches:
+ - 'claude/**'
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ build-benchmark:
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v6
+
+ - name: Set up JDK 21
+ uses: actions/setup-java@v5
+ with:
+ distribution: 'zulu'
+ java-version: 21
+
+ - name: Cache gradle
+ uses: actions/cache@v5
+ with:
+ path: |
+ ~/.gradle/caches
+ ~/.gradle/wrapper
+ key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
+ restore-keys: |
+ ${{ runner.os }}-gradle-
+
+ - name: Build Benchmark APK
+ run: ./gradlew assemblePlayBenchmark
+
+ - name: Upload Play Benchmark APK
+ id: upload
+ uses: actions/upload-artifact@v6
+ with:
+ name: Play Benchmark APK
+ path: amethyst/build/outputs/apk/play/benchmark/amethyst-play-universal-benchmark.apk
+
+ - name: Comment on PR with APK link
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const artifactId = `${{ steps.upload.outputs.artifact-id }}`;
+ const downloadUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}/artifacts/${artifactId}`;
+ const body = `📦 **Benchmark APK ready!**\n\nDownload: [Play Benchmark APK](${downloadUrl})`;
+
+ const branch = context.ref.replace('refs/heads/', '');
+ const { data: prs } = await github.rest.pulls.list({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ head: `${context.repo.owner}:${branch}`,
+ state: 'open'
+ });
+
+ for (const pr of prs) {
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: pr.number,
+ body
+ });
+ }
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 61e76d85f9..0377b37359 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -1,4 +1,4 @@
-name: Test/Build Android
+name: Test/Build
on:
pull_request:
@@ -6,78 +6,190 @@ on:
push:
branches: [main]
-jobs:
- build:
- runs-on: ubuntu-latest
- timeout-minutes: 30
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+jobs:
+ lint:
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
- name: Set up JDK 21
- uses: actions/setup-java@v4
+ uses: actions/setup-java@v5
with:
distribution: 'zulu'
java-version: 21
- name: Cache gradle
- uses: actions/cache@v4
+ uses: actions/cache@v5
with:
- path: ~/.gradle/caches
- key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}
+ path: |
+ ~/.gradle/caches
+ ~/.gradle/wrapper
+ key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Linter (gradle)
run: ./gradlew spotlessCheck
+ test:
+ needs: lint
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, macos-latest, windows-latest]
+ runs-on: ${{ matrix.os }}
+ timeout-minutes: 30
+ defaults:
+ run:
+ shell: bash
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v6
+
+ - name: Set up JDK 21
+ uses: actions/setup-java@v5
+ with:
+ distribution: 'zulu'
+ java-version: 21
+
+ - name: Cache gradle
+ uses: actions/cache@v5
+ with:
+ path: |
+ ~/.gradle/caches
+ ~/.gradle/wrapper
+ key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
+ restore-keys: |
+ ${{ runner.os }}-gradle-
+
- name: Test (gradle)
run: ./gradlew test --no-daemon
- name: Android Test Report
uses: asadmansr/android-test-report-action@v1.2.0
- if: ${{ always() }} # IMPORTANT: run Android Test Report regardless
+ if: ${{ always() && matrix.os == 'ubuntu-latest' }}
+
+ - name: Upload Test Results
+ uses: actions/upload-artifact@v6
+ if: ${{ always() && matrix.os == 'ubuntu-latest' }}
+ with:
+ name: Test Reports
+ path: amethyst/build/reports
+
+ build-android:
+ needs: test
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v6
+
+ - name: Set up JDK 21
+ uses: actions/setup-java@v5
+ with:
+ distribution: 'zulu'
+ java-version: 21
+
+ - name: Cache gradle
+ uses: actions/cache@v5
+ with:
+ path: |
+ ~/.gradle/caches
+ ~/.gradle/wrapper
+ key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
+ restore-keys: |
+ ${{ runner.os }}-gradle-
- name: Build APK (gradle)
run: ./gradlew assembleDebug
- name: Upload Play APK
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v6
with:
name: Play Debug APK
path: amethyst/build/outputs/apk/play/debug/amethyst-play-universal-debug.apk
- name: Upload FDroid APK
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v6
with:
name: FDroid Debug APK
path: amethyst/build/outputs/apk/fdroid/debug/amethyst-fdroid-universal-debug.apk
- - name: Build APK (gradle)
+ - name: Build Benchmark APK (gradle)
run: ./gradlew assembleBenchmark
- name: Upload Play APK Benchmark
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v6
with:
name: Play Benchmark APK
path: amethyst/build/outputs/apk/play/benchmark/amethyst-play-universal-benchmark.apk
- name: Upload FDroid APK Benchmark
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v6
with:
name: FDroid Benchmark APK
path: amethyst/build/outputs/apk/fdroid/benchmark/amethyst-fdroid-universal-benchmark.apk
- name: Upload Compose Reports
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v6
with:
name: Compose Reports
path: amethyst/build/compose_compiler
- - name: Upload Test Results
- uses: actions/upload-artifact@v4
- with:
- name: Test Reports
- path: amethyst/build/reports
+ build-desktop:
+ needs: test
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - os: ubuntu-latest
+ task: packageDeb
+ artifact-name: Desktop Linux DEB
+ artifact-path: desktopApp/build/compose/binaries/main/deb/*.deb
+ - os: macos-latest
+ task: packageDmg
+ artifact-name: Desktop macOS DMG
+ artifact-path: desktopApp/build/compose/binaries/main/dmg/*.dmg
+ - os: windows-latest
+ task: packageMsi
+ artifact-name: Desktop Windows MSI
+ artifact-path: desktopApp/build/compose/binaries/main/msi/*.msi
+ runs-on: ${{ matrix.os }}
+ timeout-minutes: 30
+ defaults:
+ run:
+ shell: bash
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v6
+ - name: Set up JDK 21
+ uses: actions/setup-java@v5
+ with:
+ distribution: 'zulu'
+ java-version: 21
+
+ - name: Cache gradle
+ uses: actions/cache@v5
+ with:
+ path: |
+ ~/.gradle/caches
+ ~/.gradle/wrapper
+ key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
+ restore-keys: |
+ ${{ runner.os }}-gradle-
+
+ - name: Build Desktop Distribution
+ run: ./gradlew :desktopApp:${{ matrix.task }}
+
+ - name: Upload Desktop Distribution
+ uses: actions/upload-artifact@v6
+ with:
+ name: ${{ matrix.artifact-name }}
+ path: ${{ matrix.artifact-path }}
diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml
index 44f3d56fdc..beb08874b5 100644
--- a/.github/workflows/create-release.yml
+++ b/.github/workflows/create-release.yml
@@ -6,23 +6,42 @@ on:
- 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10
jobs:
- deploy:
+ create-release:
+ runs-on: ubuntu-latest
+ outputs:
+ upload_url: ${{ steps.create_release.outputs.upload_url }}
+ steps:
+ - name: Create Release
+ id: create_release
+ uses: actions/create-release@v1
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ with:
+ tag_name: ${{ github.ref }}
+ release_name: Release ${{ github.ref }}
+ draft: false
+ prerelease: true
+
+ deploy-android:
+ needs: create-release
runs-on: ubuntu-latest
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
- name: Set up JDK 21
- uses: actions/setup-java@v4
+ uses: actions/setup-java@v5
with:
distribution: 'zulu'
java-version: 21
- name: Cache gradle
- uses: actions/cache@v4
+ uses: actions/cache@v5
with:
- path: ~/.gradle/caches
- key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}
+ path: |
+ ~/.gradle/caches
+ ~/.gradle/wrapper
+ key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
@@ -38,7 +57,6 @@ jobs:
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
env:
- # override default build-tools version (29.0.3) -- optional
BUILD_TOOLS_VERSION: "36.0.0"
- name: Sign AAB (F-Droid)
@@ -50,7 +68,6 @@ jobs:
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
env:
- # override default build-tools version (29.0.3) -- optional
BUILD_TOOLS_VERSION: "36.0.0"
- name: Build APK
@@ -65,7 +82,6 @@ jobs:
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
env:
- # override default build-tools version (29.0.3) -- optional
BUILD_TOOLS_VERSION: "36.0.0"
- name: Sign APK (F-Droid)
@@ -77,20 +93,8 @@ jobs:
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
env:
- # override default build-tools version (29.0.3) -- optional
BUILD_TOOLS_VERSION: "36.0.0"
- - name: Create Release
- id: create_release
- uses: actions/create-release@v1
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- with:
- tag_name: ${{ github.ref }}
- release_name: Release ${{ github.ref }}
- draft: false
- prerelease: true
-
# Google Play APK
- name: Upload Play APK Universal Asset
id: upload-release-asset-play-universal-apk
@@ -98,7 +102,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
- upload_url: ${{ steps.create_release.outputs.upload_url }}
+ upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: amethyst/build/outputs/apk/play/release/amethyst-play-universal-release-unsigned-signed.apk
asset_name: amethyst-googleplay-universal-${{ github.ref_name }}.apk
asset_content_type: application/zip
@@ -109,7 +113,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
- upload_url: ${{ steps.create_release.outputs.upload_url }}
+ upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: amethyst/build/outputs/apk/play/release/amethyst-play-x86-release-unsigned-signed.apk
asset_name: amethyst-googleplay-x86-${{ github.ref_name }}.apk
asset_content_type: application/zip
@@ -120,7 +124,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
- upload_url: ${{ steps.create_release.outputs.upload_url }}
+ upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: amethyst/build/outputs/apk/play/release/amethyst-play-x86_64-release-unsigned-signed.apk
asset_name: amethyst-googleplay-x86_64-${{ github.ref_name }}.apk
asset_content_type: application/zip
@@ -131,7 +135,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
- upload_url: ${{ steps.create_release.outputs.upload_url }}
+ upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: amethyst/build/outputs/apk/play/release/amethyst-play-arm64-v8a-release-unsigned-signed.apk
asset_name: amethyst-googleplay-arm64-v8a-${{ github.ref_name }}.apk
asset_content_type: application/zip
@@ -142,7 +146,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
- upload_url: ${{ steps.create_release.outputs.upload_url }}
+ upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: amethyst/build/outputs/apk/play/release/amethyst-play-armeabi-v7a-release-unsigned-signed.apk
asset_name: amethyst-googleplay-armeabi-v7a-${{ github.ref_name }}.apk
asset_content_type: application/zip
@@ -154,7 +158,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
- upload_url: ${{ steps.create_release.outputs.upload_url }}
+ upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-universal-release-unsigned-signed.apk
asset_name: amethyst-fdroid-universal-${{ github.ref_name }}.apk
asset_content_type: application/zip
@@ -165,7 +169,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
- upload_url: ${{ steps.create_release.outputs.upload_url }}
+ upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-x86-release-unsigned-signed.apk
asset_name: amethyst-fdroid-x86-${{ github.ref_name }}.apk
asset_content_type: application/zip
@@ -176,7 +180,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
- upload_url: ${{ steps.create_release.outputs.upload_url }}
+ upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-x86_64-release-unsigned-signed.apk
asset_name: amethyst-fdroid-x86_64-${{ github.ref_name }}.apk
asset_content_type: application/zip
@@ -187,7 +191,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
- upload_url: ${{ steps.create_release.outputs.upload_url }}
+ upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-arm64-v8a-release-unsigned-signed.apk
asset_name: amethyst-fdroid-arm64-v8a-${{ github.ref_name }}.apk
asset_content_type: application/zip
@@ -198,13 +202,11 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
- upload_url: ${{ steps.create_release.outputs.upload_url }}
+ upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-armeabi-v7a-release-unsigned-signed.apk
asset_name: amethyst-fdroid-armeabi-v7a-${{ github.ref_name }}.apk
asset_content_type: application/zip
-
-
# Google Play AAB
- name: Upload Google Play AAB Asset
id: upload-release-asset-play-aab
@@ -212,7 +214,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
- upload_url: ${{ steps.create_release.outputs.upload_url }}
+ upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: amethyst/build/outputs/bundle/playRelease/amethyst-play-release.aab
asset_name: amethyst-googleplay-${{ github.ref_name }}.aab
asset_content_type: application/zip
@@ -224,7 +226,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
- upload_url: ${{ steps.create_release.outputs.upload_url }}
+ upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: amethyst/build/outputs/bundle/fdroidRelease/amethyst-fdroid-release.aab
asset_name: amethyst-fdroid-${{ github.ref_name }}.aab
asset_content_type: application/zip
@@ -236,3 +238,65 @@ jobs:
ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_PASSWORD }}
ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_PRIVATE_KEY }}
ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_PASSWORD }}
+
+ deploy-desktop:
+ needs: create-release
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - os: ubuntu-latest
+ task: packageDeb
+ format: deb
+ platform: linux
+ - os: macos-latest
+ task: packageDmg
+ format: dmg
+ platform: macos
+ - os: windows-latest
+ task: packageMsi
+ format: msi
+ platform: windows
+ runs-on: ${{ matrix.os }}
+ defaults:
+ run:
+ shell: bash
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v6
+
+ - name: Set up JDK 21
+ uses: actions/setup-java@v5
+ with:
+ distribution: 'zulu'
+ java-version: 21
+
+ - name: Cache gradle
+ uses: actions/cache@v5
+ with:
+ path: |
+ ~/.gradle/caches
+ ~/.gradle/wrapper
+ key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
+ restore-keys: |
+ ${{ runner.os }}-gradle-
+
+ - name: Build Desktop Distribution
+ run: ./gradlew :desktopApp:${{ matrix.task }}
+
+ - name: Find distribution file
+ id: find-dist
+ run: |
+ DIST_FILE=$(find desktopApp/build/compose/binaries/main/${{ matrix.format }} -type f \( -name "*.deb" -o -name "*.dmg" -o -name "*.msi" \) | head -1)
+ echo "path=$DIST_FILE" >> $GITHUB_OUTPUT
+ echo "name=$(basename $DIST_FILE)" >> $GITHUB_OUTPUT
+
+ - name: Upload Desktop Distribution to Release
+ uses: actions/upload-release-asset@v1
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ with:
+ upload_url: ${{ needs.create-release.outputs.upload_url }}
+ asset_path: ${{ steps.find-dist.outputs.path }}
+ asset_name: amethyst-desktop-${{ matrix.platform }}-${{ github.ref_name }}.${{ matrix.format }}
+ asset_content_type: application/octet-stream
diff --git a/.github/workflows/crowdin.yml b/.github/workflows/crowdin.yml
index d2707654d0..923e7fa492 100644
--- a/.github/workflows/crowdin.yml
+++ b/.github/workflows/crowdin.yml
@@ -11,7 +11,7 @@ jobs:
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
- name: crowdin action
uses: crowdin/github-action@v2
diff --git a/.gitignore b/.gitignore
index 43404b2597..945f2e02a8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,6 +22,7 @@
/.idea/AndroidProjectSystem.xml
/.idea/deviceManager.xml
/.idea/inspectionProfiles/
+/.idea/migrations.xml
/commons/.idea/gradle.xml
/commons/.idea/misc.xml
/commons/.idea/workspace.xml
@@ -149,3 +150,6 @@ lint/tmp/
# Local task tracking
TASKS.md
+
+# Claude Code local settings
+.claude/settings.local.json
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7a6fc15052..51195f4cba 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,24 +15,30 @@ Redesigns Media Player
- Turn video controller creation into a flow to fix playback lifecycle issues
- Adds support for uploading audio
-Adds support for NIP events (kind 30817)
+Adds support for NIP-47 Wallets
Adds support for NIP-52 Calendar appointments
Adds support for NIP-39 External Identities with kind 10011
-Adds support for NIP-66 Relay Monitor and discovery support to Quartz
+Adds support for NIP-C0 Code Snippets
-Adds support for NIP-C0 Code Snippets to Quartz
+Adds support for NIPs on Nostr (event kind 30817)
Adds support for NIP-A3 Payment targets (PayTo: 10133) by @npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5
+Adds support for BUD-10 "Blossom:" URIs in images, audios, videos, and documents.
+
+Adds support for NIP-40 Expirations in any new post.
+
+Adds support for NIP-66 Relay Monitor and discovery support to Quartz
+
Adds support for Namecoin .bit urls to NIP-05
- Adds choice of ElectrumX server to resolve namecoins.
Adds basic support for Chess with Jester protocol
-Adds NIP-46 support to Quartz and Amethyst Desktop
+Adds NIP-46 Bunker support to Quartz and Amethyst Desktop
Adds a Broadcasting feedback pop-up in the Complete UI mode
@@ -42,11 +48,17 @@ Removes support for NIP-96 and updates Blossom recommendations
Adds support to upload Documents to all new post screens.
+Content warning improvements:
+- Adds optional description field for sensitive content warnings in new posts.
+- Displays additional information on warning composables
+
Redesigns and reorganizes Setting pages
- Consolidate drawer settings into a single Settings hub screen
- Redesigns Zap Amount and NWC setup screens
- Redesigns Custom zap amount screens
-- Add reactions row settings (enable/disable, order, show/hide counters) by @npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5
+- Adds brand new Translation Settings screen
+- Adds blockchain explorer settings page for OTS verification
+- Adds reactions row settings (enable/disable, order, show/hide counters) by @npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5
- Tapping on Zap without any pre-configured amount opens the custom dialog
URL/URI parser rewrite in Kotlin multiplatform (KMP)
@@ -57,14 +69,13 @@ URL/URI parser rewrite in Kotlin multiplatform (KMP)
Minimizes parent thread rendering in quoted notes by @npub1aeh2zw4elewy5682lxc6xnlqzjnxksq303gwu2npfaxd49vmde6qcq4nwx
-Fixes bug on Show More calculations for very long texts without spaces
-
Relay Management:
- Adds relay search tooltip when adding relays
- Adds the list of keys using each relay to the relay information
- Adds active subscriptions and outbox event in the queue to relay information
- Adds a complete list of event kind names to the subscription card to relay information
- Tracks and displays connection success rate on relay settings
+- Add relay settings export functionality
Search fixes
- Breaks the search filter into two subscriptions to prioritize Metadata without punishing content.
@@ -109,9 +120,16 @@ Fixes:
- Fixes crash when getting OpenGraph tags of invalid URLs
- Fixes NIP-44 key mutation in NIP-46 connect
- Location permission watcher moved outside screens to avoid recreation
+- Solves the sorting contract crash on search by precaching all values before sorting users.
+- Fixes lingering relay connections from loading follows outbox's settings.
+- Enhance NIP-38 user status display with emoji support and metadata tags
+- Fixes bug on Show More calculations for very long texts without spaces
+- Fixing IO Dispatchers and coroutine scopes of choice
+- Fixes anySync parallel operation that was returning the first result, not the first positive "any".
AI:
- Add SKILL.md for AI agent customization
+- Add settings and hooks to setup Android Development for the agent
Defaults:
- Switches wss://nostr.band to wss://antiprimal.net, wss://relay.ditto.pub on app defaults
@@ -121,21 +139,23 @@ Defaults:
Adds complete support for iOS to Quartz by @npub1a3tx8wcrt789skl6gg7rqwj4wey0j53eesr4z6asd4h4jwrd62jq0wkq4k
- Provide implementation for Rfc3986 on iOS, using the Swift Rfc3986UriBridge.
-- Provide implementation for LargeCache, using a CacheMap.
+- Provide implementation for LargeCache, using a CacheMap
- Provide implementation for fastFindURLs()
- Provide implementation for makeAbsoluteIfRelativeUrl() in ServerInfoParser.ios.kt
-- Provide implementation for UrlEncoder.
-- Provide implementation for UnicodeNormalizer.
+- Provide implementation for UrlEncoder
+- Provide implementation for UnicodeNormalizer
- Provide implementation for GZip compression/decompression. Some small fixes in URLs.ios.kt
-- Provide implementation for AESCBC.
-- Provide implementation for AESGCM.
-- Provide implementation for DigestInstance.
+- Provide implementation for AESCBC
+- Provide implementation for AESGCM
+- Provide implementation for DigestInstance
+- Provide implementation for LibSodium
- Adds a reqUntilEoseAsFlow extension to the Nostr Client
Amethyst Desktop by @npub12cfje6nl2nuxplcqfvhg7ljt89fmpj0n0fd24zxsukja5qm9wmtqd7y76c
- Adds NIP-46 Bunker Login
- Adds Support for Chess
- Adds Thread Screens
+- Adds advanced search with query engine and filter panel
- Adds encrypted DMs (NIP-04/NIP-17)
- Adds proper empty states with EOSE tracking
- Adds multi-column deck layout
diff --git a/README.md b/README.md
index d1d9dbe7ba..a19f165504 100644
--- a/README.md
+++ b/README.md
@@ -218,6 +218,13 @@ Build and run the Desktop app (requires Java 21+):
```bash
./gradlew :desktopApp:run
```
+Full build (including tests)
+```bash
+./gradlew build
+```
+Requirements:
+- Xcode and iOS simulator
+- libsodium installed (e.g. via brew: `brew install libsodium`
## Testing
```bash
diff --git a/amethyst/build.gradle b/amethyst/build.gradle
index fe6c881a89..70b0379087 100644
--- a/amethyst/build.gradle
+++ b/amethyst/build.gradle
@@ -36,6 +36,17 @@ def generateVersionName(String baseVersion) {
}
}
+// Workaround: stability.analyzer plugin doesn't declare task dependencies properly for Gradle 9.x
+afterEvaluate {
+ def stabilityNames = tasks.names.findAll { it.contains("StabilityCheck") }
+ def compileNames = tasks.names.findAll { it.matches("compile.*UnitTestKotlin") }
+ stabilityNames.each { scName ->
+ compileNames.each { ctName ->
+ tasks.named(scName).configure { mustRunAfter(tasks.named(ctName)) }
+ }
+ }
+}
+
android {
namespace = 'com.vitorpamplona.amethyst'
compileSdk = libs.versions.android.compileSdk.get().toInteger()
@@ -336,9 +347,7 @@ dependencies {
fdroidImplementation libs.unifiedpush
// Charts
- implementation libs.vico.charts.core
implementation libs.vico.charts.compose
- implementation libs.vico.charts.views
implementation libs.vico.charts.m3
// GeoHash
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt
index 5885c1e221..cf9256dc47 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt
@@ -25,12 +25,15 @@ import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import coil3.disk.DiskCache
import coil3.memory.MemoryCache
+import com.vitorpamplona.amethyst.commons.model.NoteState
+import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState
import com.vitorpamplona.amethyst.model.nip03Timestamp.IncomingOtsEventVerifier
import com.vitorpamplona.amethyst.model.nip03Timestamp.TorAwareOkHttpOtsResolverBuilder
import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever
import com.vitorpamplona.amethyst.model.preferences.NamecoinSharedPreferences
+import com.vitorpamplona.amethyst.model.preferences.OtsSharedPreferences
import com.vitorpamplona.amethyst.model.preferences.TorSharedPreferences
import com.vitorpamplona.amethyst.model.preferences.UiSharedPreferences
import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder
@@ -45,6 +48,7 @@ import com.vitorpamplona.amethyst.service.images.ImageLoaderSetup
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.notifications.PokeyReceiver
import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager
+import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManagerForRelays
import com.vitorpamplona.amethyst.service.okhttp.EncryptionKeyCache
import com.vitorpamplona.amethyst.service.okhttp.OkHttpWebSocket
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache
@@ -54,11 +58,15 @@ import com.vitorpamplona.amethyst.service.relayClient.RelayProxyClientConnector
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.AuthCoordinator
import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyCoordinator
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscriptionsCoordinator
+import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState
+import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState
import com.vitorpamplona.amethyst.service.relayClient.speedLogger.RelaySpeedLogger
+import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver
import com.vitorpamplona.amethyst.service.uploads.nip95.Nip95CacheFactory
import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager
import com.vitorpamplona.amethyst.ui.screen.UiSettingsState
import com.vitorpamplona.amethyst.ui.tor.TorManager
+import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayLogger
@@ -73,6 +81,7 @@ import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.DEFAULT_ELECTRUMX_S
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumXClient
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.TOR_ELECTRUMX_SERVERS
+import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
@@ -80,6 +89,10 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.onCompletion
+import kotlinx.coroutines.flow.onStart
+import kotlinx.coroutines.flow.transform
import kotlinx.coroutines.launch
import java.io.File
@@ -111,6 +124,11 @@ class AppModules(
NamecoinSharedPreferences(appContext, applicationIOScope)
}
+ // OTS blockchain explorer preferences (global, like Tor settings)
+ val otsPrefs by lazy {
+ OtsSharedPreferences(appContext, applicationIOScope)
+ }
+
// App services that should be run as soon as there are subscribers to their flows
val locationManager = LocationState(appContext, applicationIOScope)
val connManager = ConnectivityManager(appContext, applicationIOScope)
@@ -137,16 +155,6 @@ class AppModules(
scope = applicationIOScope,
)
- // manages all relay connections
- val okHttpClientForRelays =
- DualHttpClientManager(
- userAgent = appAgent,
- proxyPortProvider = torManager.activePortOrNull,
- isMobileDataProvider = connManager.isMobileOrNull,
- keyCache = keyCache,
- scope = applicationIOScope,
- )
-
// Offers easy methods to know when connections are happening through Tor or not
val roleBasedHttpClientBuilder = RoleBasedHttpClientBuilder(okHttpClients, torPrefs.value)
@@ -179,6 +187,7 @@ class AppModules(
roleBasedHttpClientBuilder::okHttpClientForMoney,
roleBasedHttpClientBuilder::shouldUseTorForMoneyOperations,
otsBlockHeightCache,
+ customExplorerUrl = { otsPrefs.current.normalizedUrl() },
)
// Application-wide ots verification cache
@@ -191,6 +200,15 @@ class AppModules(
applicationIOScope,
)
+ // manages all relay connections
+ val okHttpClientForRelays =
+ DualHttpClientManagerForRelays(
+ userAgent = appAgent,
+ proxyPortProvider = torManager.activePortOrNull,
+ isMobileDataProvider = connManager.isMobileOrNull,
+ scope = applicationIOScope,
+ )
+
// Connects the NostrClient class with okHttp
val websocketBuilder =
OkHttpWebSocket.Builder { url ->
@@ -268,6 +286,44 @@ class AppModules(
scope = applicationIOScope,
)
+ fun subscribedFlow(
+ address: Address,
+ account: Account,
+ ): Flow {
+ val note = cache.getOrCreateAddressableNote(address)
+
+ val userSub = UserFinderQueryState(note.author ?: cache.getOrCreateUser(address.pubKeyHex), account)
+ val noteSub = EventFinderQueryState(note, account)
+
+ return note
+ .flow()
+ .metadata.stateFlow
+ .onStart {
+ sources.userFinder.subscribe(userSub)
+ sources.eventFinder.subscribe(noteSub)
+ }.onCompletion {
+ sources.eventFinder.unsubscribe(noteSub)
+ sources.userFinder.unsubscribe(userSub)
+ }
+ }
+
+ val blossomResolver =
+ BlossomServerResolver(
+ loggedInUsers = { listOfNotNull(sessionManager.loggedInAccount()?.pubKey) },
+ blossomServers = { addressesToSubscribe ->
+ val account = sessionManager.loggedInAccount() ?: return@BlossomServerResolver listOf()
+ addressesToSubscribe.map { address ->
+ subscribedFlow(address, account).transform {
+ val event = it.note.event as? BlossomServersEvent
+ if (event != null) {
+ emit(event)
+ }
+ }
+ }
+ },
+ httpClientBuilder = roleBasedHttpClientBuilder,
+ )
+
// Organizes cache clearing
val trimmingService = MemoryTrimmingService(cache)
@@ -298,7 +354,12 @@ class AppModules(
fun contentResolverFn(): ContentResolver = appContext.contentResolver
fun setImageLoader() {
- ImageLoaderSetup.setup(appContext, { diskCache }, { memoryCache }) { url ->
+ ImageLoaderSetup.setup(
+ app = appContext,
+ diskCache = { diskCache },
+ memoryCache = { memoryCache },
+ blossomServerResolver = blossomResolver,
+ ) { url ->
okHttpClients.getHttpClient(roleBasedHttpClientBuilder.shouldUseTorForImageDownload(url))
}
}
@@ -338,6 +399,11 @@ class AppModules(
delay(3000)
videoCache
}
+
+ applicationIOScope.launch {
+ // Eagerly initialize OtsSharedPreferences off the main thread
+ otsPrefs
+ }
}
fun terminate(appContext: Context) {
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt
index 055cb6ae28..0c5f5c0844 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt
@@ -171,6 +171,7 @@ import com.vitorpamplona.quartz.nip37Drafts.DraftEventCache
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
+import com.vitorpamplona.quartz.nip47WalletConnect.Request
import com.vitorpamplona.quartz.nip47WalletConnect.Response
import com.vitorpamplona.quartz.nip56Reports.ReportType
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
@@ -225,7 +226,6 @@ import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import java.math.BigDecimal
-import java.util.Locale
import kotlin.coroutines.cancellation.CancellationException
@OptIn(DelicateCoroutinesApi::class)
@@ -497,7 +497,17 @@ class Account(
sendNewAppSpecificData()
}
- suspend fun updateTranslateTo(languageCode: Locale) {
+ suspend fun addDontTranslateFrom(languageCode: String) {
+ settings.addDontTranslateFrom(languageCode)
+ sendNewAppSpecificData()
+ }
+
+ suspend fun removeDontTranslateFrom(languageCode: String) {
+ settings.removeDontTranslateFrom(languageCode)
+ sendNewAppSpecificData()
+ }
+
+ suspend fun updateTranslateTo(languageCode: String) {
if (settings.updateTranslateTo(languageCode)) {
sendNewAppSpecificData()
}
@@ -581,6 +591,14 @@ class Account(
suspend fun calculateZappedAmount(zappedNote: Note): BigDecimal = zappedNote.zappedAmountWithNWCPayments(nip47SignerState)
+ suspend fun sendNwcRequest(
+ request: Request,
+ onResponse: (Response?) -> Unit,
+ ) {
+ val (event, relay) = nip47SignerState.sendNwcRequest(request, onResponse)
+ client.send(event, setOf(relay))
+ }
+
suspend fun sendZapPaymentRequestFor(
bolt11: String,
zappedNote: Note?,
@@ -1998,6 +2016,7 @@ class Account(
}
scope.launch(Dispatchers.IO) {
+ @OptIn(kotlinx.coroutines.FlowPreview::class)
settings.saveable.debounce(1000).collect {
if (it.accountSettings != null) {
LocalPreferences.saveToEncryptedStorage(it.accountSettings)
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt
index 57538f5a8e..daa174de48 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt
@@ -63,7 +63,6 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.serialization.Serializable
-import java.util.Locale
val DefaultChannels =
listOf(
@@ -322,9 +321,21 @@ class AccountSettings(
saveAccountSettings()
}
- fun translateToContains(languageCode: Locale) = syncedSettings.languages.translateTo.contains(languageCode.language)
+ fun addDontTranslateFrom(languageCode: String) {
+ syncedSettings.languages.addDontTranslateFrom(languageCode)
+ saveAccountSettings()
+ }
- fun updateTranslateTo(languageCode: Locale): Boolean {
+ fun removeDontTranslateFrom(languageCode: String) {
+ syncedSettings.languages.removeDontTranslateFrom(languageCode)
+ saveAccountSettings()
+ }
+
+ fun translateToContains(languageCode: String) =
+ syncedSettings.languages.translateTo.value
+ .contains(languageCode)
+
+ fun updateTranslateTo(languageCode: String): Boolean {
if (syncedSettings.languages.updateTranslateTo(languageCode)) {
saveAccountSettings()
return true
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt
index eb5006401b..a1ab6665bc 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt
@@ -27,7 +27,6 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
-import java.util.Locale
@Stable
class AccountSyncedSettings(
@@ -45,9 +44,9 @@ class AccountSyncedSettings(
)
val languages =
AccountLanguagePreferences(
- internalSettings.languages.dontTranslateFrom,
- internalSettings.languages.languagePreferences,
- internalSettings.languages.translateTo,
+ MutableStateFlow(internalSettings.languages.dontTranslateFrom),
+ MutableStateFlow(internalSettings.languages.languagePreferences),
+ MutableStateFlow(internalSettings.languages.translateTo),
)
val security =
AccountSecurityPreferences(
@@ -66,9 +65,9 @@ class AccountSyncedSettings(
),
languages =
AccountLanguagePreferencesInternal(
- languages.dontTranslateFrom,
- languages.languagePreferences,
- languages.translateTo,
+ languages.dontTranslateFrom.value,
+ languages.languagePreferences.value,
+ languages.translateTo.value,
),
security =
AccountSecurityPreferencesInternal(
@@ -98,16 +97,16 @@ class AccountSyncedSettings(
zaps.defaultZapType.tryEmit(syncedSettingsInternal.zaps.defaultZapType)
}
- if (languages.dontTranslateFrom != syncedSettingsInternal.languages.dontTranslateFrom) {
- languages.dontTranslateFrom = syncedSettingsInternal.languages.dontTranslateFrom
+ if (languages.dontTranslateFrom.value != syncedSettingsInternal.languages.dontTranslateFrom) {
+ languages.dontTranslateFrom.value = syncedSettingsInternal.languages.dontTranslateFrom
}
- if (languages.languagePreferences != syncedSettingsInternal.languages.languagePreferences) {
- languages.languagePreferences = syncedSettingsInternal.languages.languagePreferences
+ if (languages.languagePreferences.value != syncedSettingsInternal.languages.languagePreferences) {
+ languages.languagePreferences.value = syncedSettingsInternal.languages.languagePreferences
}
- if (languages.translateTo != syncedSettingsInternal.languages.translateTo) {
- languages.translateTo = syncedSettingsInternal.languages.translateTo
+ if (languages.translateTo.value != syncedSettingsInternal.languages.translateTo) {
+ languages.translateTo.value = syncedSettingsInternal.languages.translateTo
}
if (security.showSensitiveContent.value != syncedSettingsInternal.security.showSensitiveContent) {
@@ -123,7 +122,7 @@ class AccountSyncedSettings(
}
}
- fun dontTranslateFromFilteredBySpokenLanguages(): Set = languages.dontTranslateFrom - getLanguagesSpokenByUser()
+ fun dontTranslateFromFilteredBySpokenLanguages(): Set = languages.dontTranslateFrom.value - getLanguagesSpokenByUser()
}
@Stable
@@ -140,27 +139,36 @@ class AccountZapPreferences(
@Stable
class AccountLanguagePreferences(
- var dontTranslateFrom: Set,
- var languagePreferences: Map,
- var translateTo: String,
+ var dontTranslateFrom: MutableStateFlow>,
+ var languagePreferences: MutableStateFlow