Merge branch 'main' into claude/event-sync-screen-sYGtN

This commit is contained in:
Vitor Pamplona
2026-03-17 11:21:54 -04:00
committed by GitHub
397 changed files with 22846 additions and 1427 deletions
+20 -6
View File
@@ -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
+1 -1
View File
@@ -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"
+180
View File
@@ -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
<settings>
<proxies>
<proxy>
<id>ccw</id><active>true</active><protocol>https</protocol>
<host>$host</host><port>$port</port>
<username>$user</username>
<password><![CDATA[$pass]]></password>
</proxy>
</proxies>
</settings>
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."
+26
View File
@@ -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
}
]
}
]
}
}
@@ -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
```
+3 -3
View File
@@ -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
@@ -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) |
@@ -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):**
+1 -1
View File
@@ -12,7 +12,7 @@ echo "$JAVA_HOME"
echo "$(java -version)"
echo "Running test... "
./gradlew test
./gradlew test --quiet
status=$?
+69
View File
@@ -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
});
}
+134 -22
View File
@@ -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 }}
+99 -35
View File
@@ -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
+1 -1
View File
@@ -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
+4
View File
@@ -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
+33 -13
View File
@@ -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
+7
View File
@@ -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
+11 -2
View File
@@ -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
@@ -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<NoteState> {
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) {
@@ -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)
@@ -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
@@ -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<String> = languages.dontTranslateFrom - getLanguagesSpokenByUser()
fun dontTranslateFromFilteredBySpokenLanguages(): Set<String> = languages.dontTranslateFrom.value - getLanguagesSpokenByUser()
}
@Stable
@@ -140,27 +139,36 @@ class AccountZapPreferences(
@Stable
class AccountLanguagePreferences(
var dontTranslateFrom: Set<String>,
var languagePreferences: Map<String, String>,
var translateTo: String,
var dontTranslateFrom: MutableStateFlow<Set<String>>,
var languagePreferences: MutableStateFlow<Map<String, String>>,
var translateTo: MutableStateFlow<String>,
) {
// ---
// language services
// ---
fun toggleDontTranslateFrom(languageCode: String) {
dontTranslateFrom =
if (!dontTranslateFrom.contains(languageCode)) {
dontTranslateFrom.plus(languageCode)
dontTranslateFrom.update {
if (it.contains(languageCode)) {
it - languageCode
} else {
dontTranslateFrom.minus(languageCode)
it + languageCode
}
}
}
fun translateToContains(languageCode: Locale) = translateTo.contains(languageCode.language)
fun addDontTranslateFrom(languageCode: String) {
dontTranslateFrom.update { it + languageCode }
}
fun updateTranslateTo(languageCode: Locale): Boolean {
if (translateTo != languageCode.language) {
translateTo = languageCode.language
fun removeDontTranslateFrom(languageCode: String) {
dontTranslateFrom.update { it - languageCode }
}
fun translateToContains(languageCode: String) = translateTo.value.contains(languageCode)
fun updateTranslateTo(languageCode: String): Boolean {
if (translateTo.value != languageCode) {
translateTo.tryEmit(languageCode)
return true
}
return false
@@ -172,13 +180,15 @@ class AccountLanguagePreferences(
preference: String,
) {
val key = "$source,$target"
if (key !in languagePreferences) {
languagePreferences = languagePreferences + Pair(key, preference)
} else {
if (languagePreferences.get(key) == preference) {
languagePreferences = languagePreferences.minus(key)
languagePreferences.update {
if (key !in it) {
it + Pair(key, preference)
} else {
languagePreferences = languagePreferences + Pair(key, preference)
if (it.get(key) == preference) {
it.minus(key)
} else {
it + Pair(key, preference)
}
}
}
}
@@ -186,7 +196,7 @@ class AccountLanguagePreferences(
fun preferenceBetween(
source: String,
target: String,
): String? = languagePreferences["$source,$target"]
): String? = languagePreferences.value["$source,$target"]
}
@Stable
@@ -358,19 +358,19 @@ object LocalCache : ILocalCache, ICacheProvider {
fun load(keys: Set<String>): Set<User> = keys.mapNotNullTo(mutableSetOf(), ::checkGetOrCreateUser)
override fun getOrCreateUser(key: HexKey): User {
require(isValidHex(key = key)) { "$key is not a valid hex" }
override fun getOrCreateUser(pubkey: HexKey): User {
require(isValidHex(key = pubkey)) { "$pubkey is not a valid hex" }
return users.getOrCreate(key) {
val nip65RelayListNote = getOrCreateAddressableNoteInternal(AdvertisedRelayListEvent.createAddress(key))
val dmRelayListNote = getOrCreateAddressableNoteInternal(ChatMessageRelayListEvent.createAddress(key))
return users.getOrCreate(pubkey) {
val nip65RelayListNote = getOrCreateAddressableNoteInternal(AdvertisedRelayListEvent.createAddress(pubkey))
val dmRelayListNote = getOrCreateAddressableNoteInternal(ChatMessageRelayListEvent.createAddress(pubkey))
User(it, nip65RelayListNote, dmRelayListNote)
}
}
override fun getUserIfExists(key: String): User? {
if (key.isEmpty()) return null
return users.get(key)
override fun getUserIfExists(pubkey: String): User? {
if (pubkey.isEmpty()) return null
return users.get(pubkey)
}
override fun countUsers(predicate: (String, User) -> Boolean): Int {
@@ -394,7 +394,7 @@ object LocalCache : ILocalCache, ICacheProvider {
fun getAddressableNoteIfExists(address: Address): AddressableNote? = addressables.get(address)
override fun getNoteIfExists(key: String): Note? = if (key.length == 64) notes.get(key) else Address.parse(key)?.let { addressables.get(it) }
override fun getNoteIfExists(hexKey: String): Note? = if (hexKey.length == 64) notes.get(hexKey) else Address.parse(hexKey)?.let { addressables.get(it) }
fun getNoteIfExists(key: ETag): Note? = notes.get(key.eventId)
@@ -2250,6 +2250,7 @@ object LocalCache : ILocalCache, ICacheProvider {
requestNote?.let { request -> zappedNote?.addZapPayment(request, note) }
@OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.IO) {
responseCallback(event)
}
@@ -2298,12 +2299,17 @@ object LocalCache : ILocalCache, ICacheProvider {
}
}
val findsFollowing = finds.associateWith { forAccount?.isFollowing(it) == true }
val anyNameStartsWith = finds.associateWith { it.metadataOrNull()?.anyNameStartsWith(dualCase) == true }
val anyAddressStartsWith = finds.associateWith { it.metadataOrNull()?.anyAddressStartsWith(dualCase) == true }
val displayNames = finds.associateWith { it.toBestDisplayName().lowercase() }
return finds.sortedWith(
compareBy(
{ forAccount?.isFollowing(it) == false },
{ it.metadataOrNull()?.anyNameStartsWith(dualCase) == false },
{ it.metadataOrNull()?.anyAddressStartsWith(dualCase) == false },
{ it.toBestDisplayName().lowercase() },
{ findsFollowing[it] == false },
{ anyNameStartsWith[it] == false },
{ anyAddressStartsWith[it] == false },
{ displayNames[it] },
{ it.pubkeyHex },
),
)
@@ -31,7 +31,6 @@ import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOn
@@ -94,7 +93,7 @@ class PrivateStorageRelayListState(
settings.backupPrivateHomeRelayList?.let { event ->
Log.d("AccountRegisterObservers", "Loading saved private home relay list ${event.toJson()}")
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.IO) {
scope.launch(Dispatchers.IO) {
LocalCache.justConsumeMyOwnEvent(event)
}
}
@@ -29,7 +29,6 @@ import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
class UserMetadataState(
@@ -136,7 +135,7 @@ class UserMetadataState(
Log.d("AccountRegisterObservers", "Loading saved user metadata ${it.toJson()}")
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(it) }
scope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(it) }
}
// saves contact list for the next time.
@@ -0,0 +1,70 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.nip03Timestamp
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.nip03Timestamp.okhttp.OkHttpBitcoinExplorer
import kotlinx.serialization.Serializable
/**
* Immutable data class representing the current OTS blockchain explorer config.
*
* When a custom URL is configured, it is used instead of the automatic
* Tor-aware selection (Mempool when Tor is active, Blockstream otherwise).
* This gives users control over which explorer observes their OTS verifications.
*/
@Serializable
@Stable
data class OtsSettings(
/**
* Custom blockchain explorer base API URL.
* When null/blank, the default Tor-aware selection is used.
* Must be a Mempool-compatible REST API (e.g. https://mempool.space/api/).
*/
val customExplorerUrl: String? = null,
) {
/** True when the user has configured a custom explorer URL. */
val hasCustomExplorer: Boolean get() = !customExplorerUrl.isNullOrBlank()
/**
* Returns the normalized custom URL (trailing slash ensured) or null if not set.
*/
fun normalizedUrl(): String? {
val url = customExplorerUrl?.trim()?.takeIf { it.isNotBlank() } ?: return null
return if (url.endsWith("/")) url else "$url/"
}
companion object {
val DEFAULT = OtsSettings()
val KNOWN_EXPLORERS =
listOf(
OkHttpBitcoinExplorer.MEMPOOL_API_URL to "mempool.space (Tor-friendly)",
OkHttpBitcoinExplorer.BLOCKSTREAM_API_URL to "blockstream.info",
)
fun isValidUrl(url: String): Boolean {
val trimmed = url.trim()
if (trimmed.isBlank()) return false
return trimmed.startsWith("http://") || trimmed.startsWith("https://")
}
}
}
@@ -31,13 +31,15 @@ class TorAwareOkHttpOtsResolverBuilder(
val okHttpClient: (url: String) -> OkHttpClient,
val isTorActive: (url: String) -> Boolean,
val cache: OtsBlockHeightCache,
val customExplorerUrl: () -> String? = { null },
) : OtsResolverBuilder {
fun getAPI(usingTor: Boolean) =
if (usingTor) {
OkHttpBitcoinExplorer.MEMPOOL_API_URL
} else {
OkHttpBitcoinExplorer.BLOCKSTREAM_API_URL
}
fun getAPI(usingTor: Boolean): String =
customExplorerUrl()
?: if (usingTor) {
OkHttpBitcoinExplorer.MEMPOOL_API_URL
} else {
OkHttpBitcoinExplorer.BLOCKSTREAM_API_URL
}
override fun build(): OtsResolver =
OtsResolver(
@@ -31,7 +31,6 @@ import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOn
@@ -91,7 +90,7 @@ class DmRelayListState(
settings.backupDMRelayList?.let {
Log.d("AccountRegisterObservers", "Loading saved DM Relay List ${it.toJson()}")
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.IO) {
scope.launch(Dispatchers.IO) {
LocalCache.justConsumeMyOwnEvent(it)
}
}
@@ -114,7 +114,7 @@ class NwcSignerState(
fun hasWalletConnectSetup(): Boolean = nip47Setup.value != null
override fun isNIP47Author(pubkey: HexKey?): Boolean = nip47Signer.value.pubKey == pubkey
override fun isNIP47Author(pubKey: HexKey?): Boolean = nip47Signer.value.pubKey == pubKey
/**
* Decrypts a NIP-47 payment request using the current signer.
@@ -138,6 +138,45 @@ class NwcSignerState(
return zapPaymentResponseDecryptionCache.value.decryptResponse(event)
}
/**
* Sends a generic NIP-47 request to the connected wallet.
* Subscribes to responses and waits up to 60s for a reply.
*
* @param request the NIP-47 request to send
* @param onResponse callback to handle the response from the wallet
* @return a pair containing the request event and target relay URL
* @throws IllegalArgumentException if no NIP-47 wallet is set up
*/
suspend fun sendNwcRequest(
request: Request,
onResponse: (Response?) -> Unit,
): Pair<LnZapPaymentRequestEvent, NormalizedRelayUrl> {
val walletService = nip47Setup.value ?: throw IllegalArgumentException("No NIP47 setup")
val event = LnZapPaymentRequestEvent.createRequest(request, walletService.pubKeyHex, nip47Signer.value)
val filter =
NWCPaymentQueryState(
fromServiceHex = walletService.pubKeyHex,
toUserHex = event.pubKey,
replyingToHex = event.id,
relay = walletService.relayUri,
)
nwcFilterAssembler.subscribe(filter)
scope.launch(Dispatchers.IO) {
delay(60000)
nwcFilterAssembler.unsubscribe(filter)
}
cache.consume(event, null, true, walletService.relayUri) {
onResponse(decryptResponse(it))
}
return Pair(event, walletService.relayUri)
}
/**
* Sends a zap payment request to a connected Lightning wallet.
* Subscribes to responses and waits up to 60s for a reply.
@@ -32,7 +32,6 @@ import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOn
@@ -96,7 +95,7 @@ class BlockedRelayListState(
settings.backupBlockedRelayList?.let {
Log.d("AccountRegisterObservers", "Loading saved Blocked relay list ${it.toJson()}")
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(it) }
scope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(it) }
}
scope.launch(Dispatchers.IO) {
@@ -31,7 +31,6 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOn
@@ -109,7 +108,7 @@ class GeohashListState(
settings.backupGeohashList?.let { event ->
Log.d("AccountRegisterObservers", "Loading saved Geohash list ${event.toJson()}")
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.IO) {
scope.launch(Dispatchers.IO) {
LocalCache.justConsumeMyOwnEvent(event)
}
}
@@ -31,7 +31,6 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOn
@@ -109,7 +108,7 @@ class HashtagListState(
settings.backupHashtagList?.let { event ->
Log.d("AccountRegisterObservers", "Loading saved Hashtag list ${event.toJson()}")
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.IO) {
scope.launch(Dispatchers.IO) {
LocalCache.justConsumeMyOwnEvent(event)
}
}
@@ -32,7 +32,6 @@ import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOn
@@ -106,7 +105,7 @@ class IndexerRelayListState(
settings.backupIndexRelayList?.let {
Log.d("AccountRegisterObservers", "Loading saved index relay list ${it.toJson()}")
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(it) }
scope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(it) }
}
scope.launch(Dispatchers.IO) {
@@ -36,7 +36,7 @@ import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag
import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.description
import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.image
import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.name
import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.title
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -181,7 +181,7 @@ class LabeledBookmarkListsState(
val template =
listEvent.update {
if (listName != null) name(listName)
if (listName != null) title(listName)
if (listDescription != null) description(listDescription)
if (listImage != null) image(listImage)
}
@@ -33,7 +33,6 @@ import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOn
@@ -144,7 +143,7 @@ class MuteListState(
settings.backupMuteList?.let { event ->
Log.d("AccountRegisterObservers", "Loading saved mute list ${event.toJson()}")
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.IO) {
scope.launch(Dispatchers.IO) {
LocalCache.justConsumeMyOwnEvent(event)
}
}
@@ -38,7 +38,7 @@ import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
import com.vitorpamplona.quartz.nip51Lists.peopleList.description
import com.vitorpamplona.quartz.nip51Lists.peopleList.image
import com.vitorpamplona.quartz.nip51Lists.peopleList.name
import com.vitorpamplona.quartz.nip51Lists.peopleList.title
import com.vitorpamplona.quartz.utils.flattenToSet
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -226,7 +226,7 @@ class PeopleListsState(
val template =
listEvent.update {
if (listName != null) name(listName)
if (listName != null) title(listName)
if (listDescription != null) description(listDescription)
if (listImage != null) image(listImage)
}
@@ -31,7 +31,6 @@ import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOn
@@ -118,7 +117,7 @@ class RelayFeedListState(
settings.backupRelayFeedsList?.let {
Log.d("AccountRegisterObservers", "Loading saved relay feeds list ${it.toJson()}")
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(it) }
scope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(it) }
}
scope.launch(Dispatchers.IO) {
@@ -32,7 +32,6 @@ import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOn
@@ -106,7 +105,7 @@ class SearchRelayListState(
settings.backupSearchRelayList?.let {
Log.d("AccountRegisterObservers", "Loading saved search relay list ${it.toJson()}")
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(it) }
scope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(it) }
}
scope.launch(Dispatchers.IO) {
@@ -31,7 +31,6 @@ import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOn
@@ -93,7 +92,7 @@ class TrustedRelayListState(
settings.backupTrustedRelayList?.let {
Log.d("AccountRegisterObservers", "Loading saved Trusted relay list ${it.toJson()}")
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.IO) { cache.justConsumeMyOwnEvent(it) }
scope.launch(Dispatchers.IO) { cache.justConsumeMyOwnEvent(it) }
}
scope.launch(Dispatchers.IO) {
@@ -33,7 +33,6 @@ import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOn
@@ -147,7 +146,7 @@ class Nip65RelayListState(
settings.backupNIP65RelayList?.let {
Log.d("AccountRegisterObservers", "Loading saved nip65 relay list ${it.toJson()}")
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.IO) { cache.justConsumeMyOwnEvent(it) }
scope.launch(Dispatchers.IO) { cache.justConsumeMyOwnEvent(it) }
}
scope.launch(Dispatchers.IO) {
@@ -34,7 +34,6 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOn
@@ -158,7 +157,7 @@ class CommunityListState(
settings.backupCommunityList?.let { event ->
Log.d("AccountRegisterObservers", "Loading saved Community list ${event.toJson()}")
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.IO) {
scope.launch(Dispatchers.IO) {
LocalCache.justConsumeMyOwnEvent(event)
}
}
@@ -31,7 +31,6 @@ import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
@@ -68,7 +67,7 @@ class AppSpecificState(
settings.backupAppSpecificData?.let { event ->
Log.d("AccountRegisterObservers", "Loading saved app specific data ${event.toJson()}")
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.IO) {
scope.launch(Dispatchers.IO) {
LocalCache.justConsumeMyOwnEvent(event)
try {
val decrypted = signer.decrypt(event.content, event.pubKey)
@@ -29,7 +29,6 @@ import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
@@ -49,7 +48,7 @@ class NipA3PaymentTargetsState(
settings.backupNipA3PaymentTargets?.let {
Log.d("AccountRegisterObservers", "Loading saved nipA3 Payment targets ${it.toJson()}")
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.IO) { cache.justConsumeMyOwnEvent(it) }
scope.launch(Dispatchers.IO) { cache.justConsumeMyOwnEvent(it) }
}
scope.launch(Dispatchers.IO) {
@@ -70,9 +70,9 @@ class BlossomServerListState(
val flow =
getBlossomServersListFlow()
.map { normalizeServers(it.note) }
.onStart { emit(normalizeServers(blossomListNote)) }
.flowOn(Dispatchers.IO)
.map {
normalizeServers(it.note)
}.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
@@ -0,0 +1,103 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.preferences
import android.content.Context
import androidx.compose.runtime.Stable
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.vitorpamplona.amethyst.model.nip03Timestamp.OtsSettings
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlin.coroutines.cancellation.CancellationException
/**
* Persistent storage for [OtsSettings], following the same pattern as
* [NamecoinSharedPreferences].
*
* Uses the app-wide [sharedPreferencesDataStore] so OTS explorer settings
* are global not per-account.
*/
@Stable
class OtsSharedPreferences(
private val context: Context,
private val scope: CoroutineScope,
) {
companion object {
val KEY_CUSTOM_EXPLORER_URL = stringPreferencesKey("ots.customExplorerUrl")
}
/**
* Current settings, loaded synchronously at init to avoid races.
*/
private val _settings =
MutableStateFlow(
runBlocking { loadFromDisk() ?: OtsSettings.DEFAULT },
)
val settings: StateFlow<OtsSettings> = _settings
/** Synchronous snapshot — safe to call from resolver builder lambdas. */
val current: OtsSettings get() = _settings.value
// ── Mutators ───────────────────────────────────────────────────────
suspend fun setCustomExplorerUrl(url: String?) {
val normalized = url?.trim()?.takeIf { it.isNotBlank() }
persist(current.copy(customExplorerUrl = normalized))
}
suspend fun reset() {
persist(OtsSettings.DEFAULT)
}
// ── Internal ───────────────────────────────────────────────────────
private suspend fun persist(settings: OtsSettings) {
_settings.value = settings
try {
context.sharedPreferencesDataStore.edit { prefs ->
if (settings.customExplorerUrl != null) {
prefs[KEY_CUSTOM_EXPLORER_URL] = settings.customExplorerUrl
} else {
prefs.remove(KEY_CUSTOM_EXPLORER_URL)
}
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("OtsPrefs", "Error writing DataStore: ${e.message}")
}
}
private suspend fun loadFromDisk(): OtsSettings? =
try {
val prefs = context.sharedPreferencesDataStore.data.first()
val url = prefs[KEY_CUSTOM_EXPLORER_URL]?.takeIf { it.isNotBlank() }
OtsSettings(customExplorerUrl = url)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("OtsPrefs", "Error reading DataStore: ${e.message}")
null
}
}
@@ -79,6 +79,7 @@ class MergedFollowListsState(
communities = community.mapTo(mutableSetOf()) { it.address.toValue() },
)
@OptIn(kotlinx.coroutines.FlowPreview::class)
val flow: StateFlow<AllFollows> =
combine(
listOf(
@@ -33,7 +33,6 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOn
@@ -117,7 +116,7 @@ class TrustProviderListState(
settings.backupTrustProviderList?.let { event ->
Log.d("AccountRegisterObservers", "Loading saved ephemeral chat list")
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.IO) {
scope.launch(Dispatchers.IO) {
LocalCache.justConsumeMyOwnEvent(event)
}
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.service.cashu.v4
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.cbor.ByteString
@@ -34,6 +35,7 @@ class V4Token(
val t: Array<V4T>?,
)
@OptIn(ExperimentalSerializationApi::class)
@Serializable
class V4T(
// identifier
@@ -42,6 +44,7 @@ class V4T(
val p: Array<V4Proof>,
)
@OptIn(ExperimentalSerializationApi::class)
@Serializable
class V4Proof(
// amount
@@ -57,6 +60,7 @@ class V4Proof(
val w: String? = null,
)
@OptIn(ExperimentalSerializationApi::class)
@Serializable
class V4DleqProof(
@ByteString
@@ -0,0 +1,91 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.images
import androidx.compose.runtime.Stable
import coil3.ImageLoader
import coil3.Uri
import coil3.annotation.ExperimentalCoilApi
import coil3.fetch.FetchResult
import coil3.fetch.Fetcher
import coil3.network.CacheStrategy
import coil3.network.ConcurrentRequestStrategy
import coil3.network.ConnectivityChecker
import coil3.network.NetworkFetcher
import coil3.network.okhttp.asNetworkClient
import coil3.request.Options
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver
import okhttp3.Call
import kotlin.coroutines.cancellation.CancellationException
@Stable
class BlossomFetcher(
private val options: Options,
private val data: Uri,
private val blossomServerResolver: BlossomServerResolver,
private val networkFetcher: (url: String) -> Fetcher,
) : Fetcher {
override suspend fun fetch(): FetchResult? {
println("BlossomFetcher: starting $data")
return try {
val urlResult = blossomServerResolver.findServers(data.toString())
println("BlossomFetcher: finished $data to ${urlResult?.serverUrl}")
networkFetcher(urlResult?.serverUrl ?: data.toString()).fetch()
} catch (e: Exception) {
if (e is CancellationException) throw e
println("BlossomFetcher: cancelled or error: $e $data")
null
}
}
@OptIn(ExperimentalCoilApi::class)
class Factory(
val blossomServerResolver: BlossomServerResolver,
val networkClient: (url: String) -> Call.Factory,
) : Fetcher.Factory<Uri> {
private val connectivityCheckerLazy = singleParameterLazy(::ConnectivityChecker)
override fun create(
data: Uri,
options: Options,
imageLoader: ImageLoader,
): Fetcher? {
println("BlossomFetcher: PreFactory $data")
if (!isApplicable(data)) return null
println("BlossomFetcher: Factory $data")
return BlossomFetcher(options, data, blossomServerResolver) { url ->
NetworkFetcher(
url = url,
options = options,
networkClient = lazy { networkClient(url).asNetworkClient() },
diskCache = lazy { imageLoader.diskCache },
cacheStrategy = lazy { CacheStrategy.DEFAULT },
connectivityChecker = lazy { connectivityCheckerLazy.get(options.context) },
concurrentRequestStrategy = lazy { ConcurrentRequestStrategy.UNCOORDINATED },
)
}
}
private fun isApplicable(data: Uri): Boolean = data.scheme?.lowercase() == "blossom"
}
}
@@ -43,6 +43,7 @@ import coil3.svg.SvgDecoder
import coil3.util.Logger
import coil3.video.VideoFrameDecoder
import com.vitorpamplona.amethyst.isDebug
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver
import com.vitorpamplona.quartz.utils.Log
import okhttp3.Call
@@ -63,6 +64,7 @@ class ImageLoaderSetup {
app: Context,
diskCache: () -> DiskCache,
memoryCache: () -> MemoryCache,
blossomServerResolver: BlossomServerResolver,
callFactory: (url: String) -> Call.Factory,
) {
SingletonImageLoader.setUnsafe(
@@ -78,6 +80,7 @@ class ImageLoaderSetup {
add(VideoFrameDecoder.Factory())
add(Base64Fetcher.Factory)
add(BlurHashFetcher.Factory)
add(BlossomFetcher.Factory(blossomServerResolver, callFactory))
add(Base64Fetcher.BKeyer)
add(BlurHashFetcher.BKeyer)
add(OkHttpFactory(callFactory))
@@ -26,16 +26,12 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import okhttp3.Call
import okhttp3.OkHttpClient
import okhttp3.Request
import java.net.InetSocketAddress
import java.net.Proxy
interface IHttpClientManager {
fun getHttpClient(useProxy: Boolean): OkHttpClient
fun getCurrentProxyPort(useProxy: Boolean): Int?
}
class DualHttpClientManager(
userAgent: String,
proxyPortProvider: StateFlow<Int?>,
@@ -79,18 +75,16 @@ class DualHttpClientManager(
} else {
defaultHttpClientWithoutProxy.value
}
fun getDynamicCallFactory(useProxy: Boolean) = DynamicCallFactory(useProxy, this)
}
object EmptyHttpClientManager : IHttpClientManager {
val rootOkHttpClient by lazy {
OkHttpClient
.Builder()
.followRedirects(true)
.followSslRedirects(true)
.build()
}
override fun getHttpClient(useProxy: Boolean) = rootOkHttpClient
override fun getCurrentProxyPort(useProxy: Boolean) = null
/**
* the okhttp can change on the manager without affecting other systems.
*/
class DynamicCallFactory(
val useProxy: Boolean,
val manager: DualHttpClientManager,
) : Call.Factory {
override fun newCall(request: Request): Call = manager.getHttpClient(useProxy).newCall(request)
}
@@ -0,0 +1,75 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.okhttp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import okhttp3.OkHttpClient
import java.net.InetSocketAddress
import java.net.Proxy
class DualHttpClientManagerForRelays(
userAgent: String,
proxyPortProvider: StateFlow<Int?>,
isMobileDataProvider: StateFlow<Boolean?>,
scope: CoroutineScope,
) : IHttpClientManager {
val factory = OkHttpClientFactoryForRelays(userAgent)
val defaultHttpClient: StateFlow<OkHttpClient> =
combine(proxyPortProvider, isMobileDataProvider) { proxy, mobile ->
factory.buildHttpClient(proxy, mobile)
}.stateIn(
scope,
SharingStarted.WhileSubscribed(1000),
factory.buildHttpClient(proxyPortProvider.value, isMobileDataProvider.value),
)
val defaultHttpClientWithoutProxy: StateFlow<OkHttpClient> =
isMobileDataProvider
.map { mobile ->
factory.buildHttpClient(mobile)
}.stateIn(
scope,
SharingStarted.WhileSubscribed(1000),
factory.buildHttpClient(isMobileDataProvider.value),
)
fun getCurrentProxy(): Proxy? = defaultHttpClient.value.proxy
override fun getCurrentProxyPort(useProxy: Boolean): Int? =
if (useProxy) {
(getCurrentProxy()?.address() as? InetSocketAddress)?.port
} else {
null
}
override fun getHttpClient(useProxy: Boolean): OkHttpClient =
if (useProxy) {
defaultHttpClient.value
} else {
defaultHttpClientWithoutProxy.value
}
}
@@ -0,0 +1,43 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.okhttp
import okhttp3.OkHttpClient
interface IHttpClientManager {
fun getHttpClient(useProxy: Boolean): OkHttpClient
fun getCurrentProxyPort(useProxy: Boolean): Int?
}
object EmptyHttpClientManager : IHttpClientManager {
val rootOkHttpClient by lazy {
OkHttpClient
.Builder()
.followRedirects(true)
.followSslRedirects(true)
.build()
}
override fun getHttpClient(useProxy: Boolean) = rootOkHttpClient
override fun getCurrentProxyPort(useProxy: Boolean) = null
}
@@ -20,9 +20,10 @@
*/
package com.vitorpamplona.amethyst.service.okhttp
import android.os.Build
import com.vitorpamplona.quartz.utils.Log
import okhttp3.Dispatcher
import com.vitorpamplona.amethyst.service.okhttp.OkHttpClientFactoryForRelays.Companion.DEFAULT_IS_MOBILE
import com.vitorpamplona.amethyst.service.okhttp.OkHttpClientFactoryForRelays.Companion.DEFAULT_SOCKS_PORT
import com.vitorpamplona.amethyst.service.okhttp.OkHttpClientFactoryForRelays.Companion.DEFAULT_TIMEOUT_ON_MOBILE_SECS
import com.vitorpamplona.amethyst.service.okhttp.OkHttpClientFactoryForRelays.Companion.DEFAULT_TIMEOUT_ON_WIFI_SECS
import okhttp3.OkHttpClient
import java.net.InetSocketAddress
import java.net.Proxy
@@ -32,53 +33,16 @@ class OkHttpClientFactory(
keyCache: EncryptionKeyCache,
val userAgent: String,
) {
companion object {
// by picking a random proxy port, the connection will fail as it should.
const val DEFAULT_SOCKS_PORT: Int = 9050
const val DEFAULT_IS_MOBILE: Boolean = false
const val DEFAULT_TIMEOUT_ON_WIFI_SECS: Int = 10
const val DEFAULT_TIMEOUT_ON_MOBILE_SECS: Int = 30
private fun isEmulator(): Boolean =
Build.FINGERPRINT.startsWith("generic") ||
Build.FINGERPRINT.lowercase().contains("emulator") ||
Build.MODEL.contains("google_sdk") ||
Build.MODEL.lowercase().contains("droid4x") ||
Build.MODEL.contains("Emulator") ||
Build.MODEL.contains("Android SDK built for x86") ||
Build.MANUFACTURER.contains("Genymotion") ||
(Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic")) ||
"google_sdk" == Build.PRODUCT ||
Build.HARDWARE.contains("goldfish") ||
Build.HARDWARE.contains("ranchu") ||
Build.HARDWARE.contains("vbox86") ||
Build.HARDWARE.contains("nox") ||
Build.HARDWARE.contains("cuttlefish")
}
val logging = LoggingInterceptor()
// val logging = LoggingInterceptor()
val keyDecryptor = EncryptedBlobInterceptor(keyCache)
val myDispatcher =
Dispatcher().apply {
if (!isEmulator()) {
maxRequestsPerHost = 10
maxRequests = 1024
} else {
maxRequestsPerHost = 5
maxRequests = 256
Log.i("OkHttpClientFactory", "Emulator detected, using default maxRequests: 64.")
}
}
private val rootClient =
OkHttpClient
.Builder()
.dispatcher(myDispatcher)
.followRedirects(true)
.followSslRedirects(true)
.addInterceptor(DefaultContentTypeInterceptor(userAgent))
.addNetworkInterceptor(logging)
// .addNetworkInterceptor(logging)
.addNetworkInterceptor(keyDecryptor)
.build()
@@ -0,0 +1,116 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.okhttp
import android.os.Build
import com.vitorpamplona.quartz.utils.Log
import okhttp3.Dispatcher
import okhttp3.OkHttpClient
import java.net.InetSocketAddress
import java.net.Proxy
import java.time.Duration
class OkHttpClientFactoryForRelays(
userAgent: String,
) {
companion object {
// by picking a random proxy port, the connection will fail as it should.
const val DEFAULT_SOCKS_PORT: Int = 9050
const val DEFAULT_IS_MOBILE: Boolean = false
const val DEFAULT_TIMEOUT_ON_WIFI_SECS: Int = 10
const val DEFAULT_TIMEOUT_ON_MOBILE_SECS: Int = 30
private fun isEmulator(): Boolean =
Build.FINGERPRINT.startsWith("generic") ||
Build.FINGERPRINT.lowercase().contains("emulator") ||
Build.MODEL.contains("google_sdk") ||
Build.MODEL.lowercase().contains("droid4x") ||
Build.MODEL.contains("Emulator") ||
Build.MODEL.contains("Android SDK built for x86") ||
Build.MANUFACTURER.contains("Genymotion") ||
(Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic")) ||
"google_sdk" == Build.PRODUCT ||
Build.HARDWARE.contains("goldfish") ||
Build.HARDWARE.contains("ranchu") ||
Build.HARDWARE.contains("vbox86") ||
Build.HARDWARE.contains("nox") ||
Build.HARDWARE.contains("cuttlefish")
}
val myDispatcher =
Dispatcher().apply {
if (!isEmulator()) {
maxRequestsPerHost = 10
maxRequests = 1024
} else {
maxRequestsPerHost = 5
maxRequests = 256
Log.i("OkHttpClientFactory", "Emulator detected, using default maxRequests: 64.")
}
}
private val rootClient =
OkHttpClient
.Builder()
.dispatcher(myDispatcher)
.followRedirects(true)
.followSslRedirects(true)
.addInterceptor(DefaultContentTypeInterceptor(userAgent))
.build()
fun buildHttpClient(
proxy: Proxy?,
timeoutSeconds: Int,
): OkHttpClient {
val seconds = if (proxy != null) timeoutSeconds * 3 else timeoutSeconds
return rootClient
.newBuilder()
.proxy(proxy)
.connectTimeout(Duration.ofSeconds(seconds.toLong()))
.readTimeout(Duration.ofSeconds(seconds.toLong() * 3))
.writeTimeout(Duration.ofSeconds(seconds.toLong() * 3))
.build()
}
fun buildHttpClient(
localSocksProxyPort: Int?,
isMobile: Boolean?,
): OkHttpClient =
buildHttpClient(
buildLocalSocksProxy(localSocksProxyPort),
buildTimeout(isMobile ?: DEFAULT_IS_MOBILE),
)
fun buildHttpClient(isMobile: Boolean?): OkHttpClient =
buildHttpClient(
null,
buildTimeout(isMobile ?: DEFAULT_IS_MOBILE),
)
fun buildTimeout(isMobile: Boolean): Int =
if (isMobile) {
DEFAULT_TIMEOUT_ON_MOBILE_SECS
} else {
DEFAULT_TIMEOUT_ON_WIFI_SECS
}
fun buildLocalSocksProxy(port: Int?) = Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", port ?: DEFAULT_SOCKS_PORT))
}
@@ -25,33 +25,37 @@ import androidx.core.net.toUri
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import com.vitorpamplona.amethyst.commons.compose.GenericBaseCache
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlin.coroutines.cancellation.CancellationException
class MediaItemCache : GenericBaseCache<MediaItemData, LoadedMediaItem>(20) {
override suspend fun compute(key: MediaItemData): LoadedMediaItem =
LoadedMediaItem(
key,
MediaItem
.Builder()
.setMediaId(key.videoUri)
.setUri(key.videoUri)
.setMediaMetadata(
MediaMetadata
.Builder()
.setArtist(key.authorName?.ifBlank { null })
.setTitle(key.title?.ifBlank { null } ?: key.videoUri)
.setExtras(
Bundle().apply {
putString("callbackUri", key.callbackUri)
},
).setArtworkUri(
try {
key.artworkUri?.toUri()
} catch (e: Exception) {
if (e is CancellationException) throw e
null
},
).build(),
).build(),
)
withContext(Dispatchers.IO) {
LoadedMediaItem(
key,
MediaItem
.Builder()
.setMediaId(key.videoUri)
.setUri(key.videoUri)
.setMediaMetadata(
MediaMetadata
.Builder()
.setArtist(key.authorName?.ifBlank { null })
.setTitle(key.title?.ifBlank { null } ?: key.videoUri)
.setExtras(
Bundle().apply {
putString("callbackUri", key.callbackUri)
},
).setArtworkUri(
try {
key.artworkUri?.toUri()
} catch (e: Exception) {
if (e is CancellationException) throw e
null
},
).build(),
).build(),
)
}
}
@@ -44,9 +44,11 @@ import androidx.compose.ui.unit.dp
import androidx.media3.common.Player
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onStart
import kotlin.math.sin
@@ -79,7 +81,7 @@ fun FakeWaveformAnimation(
}
LaunchedEffect(key1 = restartFlow.intValue) {
pollCurrentPosition(mediaControllerState.controller).collect { value ->
mediaControllerState.controller.pollCurrentPositionFlow().collect { value ->
waveformProgress.floatValue = (value % 5000.0f) / 5000.0f
}
}
@@ -93,7 +95,8 @@ fun pollCurrentPosition(controller: Player) =
}
}.onStart {
emit(controller.currentPosition)
}.conflate()
}.flowOn(Dispatchers.IO)
.conflate()
@Preview
@Composable
@@ -0,0 +1,67 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.playback.composable.wavefront
import androidx.media3.common.Player
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.withContext
suspend fun Player.completionRatio() = withContext(Dispatchers.Main) { currentPosition / duration.toFloat() }
suspend fun Player.positionDuration() = withContext(Dispatchers.Main) { PositionDuration(currentPosition, duration) }
class PositionDuration(
val position: Long,
val duration: Long,
) {
fun finished() = position > duration
fun ratio() = position / (duration.toFloat())
}
fun Player.pollCurrentRelativePositionFlow() =
flow {
do {
delay(100)
val ratio = positionDuration()
emit(ratio.ratio())
} while (!ratio.finished())
}.onStart {
emit(completionRatio())
}.flowOn(Dispatchers.IO)
.conflate()
fun Player.pollCurrentPositionFlow() =
flow {
do {
delay(100)
val ratio = positionDuration()
emit(ratio.position)
} while (!ratio.finished())
}.onStart {
emit(withContext(Dispatchers.Main) { currentPosition })
}.flowOn(Dispatchers.IO)
.conflate()
@@ -39,10 +39,6 @@ import com.linc.audiowaveform.infiniteLinearGradient
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
import com.vitorpamplona.amethyst.service.playback.composable.WaveformData
import com.vitorpamplona.amethyst.ui.components.AudioWaveformReadOnly
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.onStart
@Composable
fun Waveform(
@@ -74,20 +70,10 @@ fun Waveform(
}
LaunchedEffect(key1 = restartFlow.intValue) {
pollCurrentRelativePosition(mediaControllerState.controller).collect { value -> waveformProgress.floatValue = value }
mediaControllerState.controller.pollCurrentRelativePositionFlow().collect { value -> waveformProgress.floatValue = value }
}
}
fun pollCurrentRelativePosition(controller: Player) =
flow {
while (controller.currentPosition <= controller.duration) {
emit(controller.currentPosition / controller.duration.toFloat())
delay(100)
}
}.onStart {
emit(controller.currentPosition / controller.duration.toFloat())
}.conflate()
@Composable
fun DrawWaveform(
waveform: WaveformData,
@@ -23,13 +23,12 @@ package com.vitorpamplona.amethyst.service.playback.diskCache
import android.annotation.SuppressLint
import android.content.Context
import androidx.media3.database.StandaloneDatabaseProvider
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
import androidx.media3.datasource.cache.SimpleCache
import androidx.media3.datasource.okhttp.OkHttpDataSource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import java.io.File
@SuppressLint("UnsafeOptInUsageError")
@@ -60,18 +59,18 @@ class VideoCache {
}
// This method should be called when proxy setting changes.
fun renewCacheFactory(client: OkHttpClient) {
fun renewCacheFactory(dataSourceFactory: DataSource.Factory) {
cacheDataSourceFactory =
CacheDataSource
.Factory()
.setCache(simpleCache)
.setUpstreamDataSourceFactory(OkHttpDataSource.Factory(client))
.setUpstreamDataSourceFactory(dataSourceFactory)
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
}
fun get(client: OkHttpClient): CacheDataSource.Factory {
fun get(dataSourceFactory: DataSource.Factory): CacheDataSource.Factory {
// Renews the factory because OkHttpMight have changed.
renewCacheFactory(client)
renewCacheFactory(dataSourceFactory)
return cacheDataSourceFactory
}
@@ -22,28 +22,26 @@ package com.vitorpamplona.amethyst.service.playback.playerPool
import androidx.media3.common.MediaItem
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.datasource.DataSource
import androidx.media3.exoplayer.drm.DrmSessionManagerProvider
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache
import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming
import okhttp3.OkHttpClient
/**
* HLS LiveStreams cannot use cache.
*/
@UnstableApi
class CustomMediaSourceFactory(
okHttpClient: OkHttpClient,
videoCache: VideoCache,
dataSourceFactory: DataSource.Factory,
) : MediaSource.Factory {
private var cachingFactory: MediaSource.Factory =
DefaultMediaSourceFactory(
Amethyst.instance.videoCache.get(okHttpClient),
)
DefaultMediaSourceFactory(videoCache.get(dataSourceFactory))
private var nonCachingFactory: MediaSource.Factory =
DefaultMediaSourceFactory(OkHttpDataSource.Factory(okHttpClient))
DefaultMediaSourceFactory(dataSourceFactory)
override fun setDrmSessionManagerProvider(drmSessionManagerProvider: DrmSessionManagerProvider): MediaSource.Factory {
cachingFactory.setDrmSessionManagerProvider(drmSessionManagerProvider)
@@ -23,23 +23,25 @@ package com.vitorpamplona.amethyst.service.playback.playerPool
import android.content.Context
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DataSource
import androidx.media3.exoplayer.ExoPlayer
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache
import com.vitorpamplona.amethyst.service.playback.playerPool.aspectRatio.AspectRatioCacher
import com.vitorpamplona.amethyst.service.playback.playerPool.positions.CurrentPlayPositionCacher
import com.vitorpamplona.amethyst.service.playback.playerPool.positions.VideoViewedPositionCache
import com.vitorpamplona.amethyst.service.playback.playerPool.wake.KeepVideosPlaying
import okhttp3.OkHttpClient
@OptIn(UnstableApi::class)
class ExoPlayerBuilder(
val okHttp: OkHttpClient,
val videoCache: VideoCache,
val dataSourceFactory: DataSource.Factory,
) {
fun build(context: Context): ExoPlayer =
ExoPlayer
.Builder(context)
.apply {
setMediaSourceFactory(CustomMediaSourceFactory(okHttp))
setMediaSourceFactory(CustomMediaSourceFactory(videoCache, dataSourceFactory))
}.build()
.apply {
addListener(AspectRatioCacher(MediaAspectRatioCache))
@@ -29,8 +29,8 @@ import androidx.core.net.toUri
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DataSourceBitmapLoader
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.MediaSession
import com.google.common.util.concurrent.Futures
@@ -41,7 +41,6 @@ import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
class SessionListener(
val session: MediaSession,
@@ -57,7 +56,7 @@ class SessionListener(
*/
class MediaSessionPool(
val exoPlayerPool: ExoPlayerPool,
val okHttpClient: OkHttpClient,
val dataSourceFactory: DataSource.Factory,
val appContext: Context,
val reset: (MediaSession, Boolean) -> Unit,
) {
@@ -101,7 +100,7 @@ class MediaSessionPool(
DataSourceBitmapLoader
.Builder(context)
.setExecutorService(DataSourceBitmapLoader.DEFAULT_EXECUTOR_SERVICE.get())
.setDataSourceFactory(OkHttpDataSource.Factory(okHttpClient))
.setDataSourceFactory(dataSourceFactory)
.build(),
)
setId(id)
@@ -20,35 +20,69 @@
*/
package com.vitorpamplona.amethyst.service.playback.service
import android.net.Uri
import androidx.annotation.OptIn
import androidx.core.net.toUri
import androidx.media3.common.C
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DataSpec
import androidx.media3.datasource.ResolvingDataSource
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.service.okhttp.DynamicCallFactory
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache
import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
import com.vitorpamplona.amethyst.service.playback.playerPool.ExoPlayerBuilder
import com.vitorpamplona.amethyst.service.playback.playerPool.ExoPlayerPool
import com.vitorpamplona.amethyst.service.playback.playerPool.MediaSessionPool
import com.vitorpamplona.amethyst.service.playback.playerPool.SimultaneousPlaybackCalculator
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver
import com.vitorpamplona.quartz.utils.Log
import okhttp3.OkHttpClient
import kotlinx.coroutines.runBlocking
class PlaybackService : MediaSessionService() {
private var poolNoProxy: MediaSessionPool? = null
private var poolWithProxy: MediaSessionPool? = null
@OptIn(UnstableApi::class)
fun newPool(okHttp: OkHttpClient): MediaSessionPool =
MediaSessionPool(
fun newPool(
videoCache: VideoCache,
okHttpClient: DynamicCallFactory,
blossomServerResolver: BlossomServerResolver,
): MediaSessionPool {
val dataSourceFactory = OkHttpDataSource.Factory(okHttpClient)
val resolvingDataSourceFactory: DataSource.Factory =
ResolvingDataSource.Factory(
dataSourceFactory,
ResolvingDataSource.Resolver { dataSpec: DataSpec ->
val originalUri: Uri = dataSpec.uri
val scheme = originalUri.scheme
if (scheme != null && blossomServerResolver.canResolve(scheme)) {
val serverUrl =
runBlocking {
blossomServerResolver.findServers(originalUri.toString())
}
if (serverUrl != null) {
return@Resolver dataSpec.withUri(serverUrl.serverUrl.toUri())
}
}
dataSpec
},
)
return MediaSessionPool(
exoPlayerPool =
ExoPlayerPool(
ExoPlayerBuilder(okHttp),
ExoPlayerBuilder(videoCache, resolvingDataSourceFactory),
poolSize = SimultaneousPlaybackCalculator.max(applicationContext),
),
okHttpClient = okHttp,
dataSourceFactory = resolvingDataSourceFactory,
appContext = applicationContext,
reset = { session, keepPlaying ->
(session.player as ExoPlayer).apply {
@@ -58,6 +92,7 @@ class PlaybackService : MediaSessionService() {
}
},
)
}
@OptIn(UnstableApi::class)
fun lazyPool(proxyPort: Int): MediaSessionPool {
@@ -65,22 +100,23 @@ class PlaybackService : MediaSessionService() {
// no proxy
poolNoProxy?.let { return it }
// creates new
return newPool(Amethyst.instance.okHttpClients.getHttpClient(false)).also { poolNoProxy = it }
} else {
poolWithProxy?.let { pool ->
// with proxy, check if the port is the same.
val okHttp = Amethyst.instance.okHttpClients.getHttpClient(true)
if (okHttp.proxy != null && okHttp.proxy == pool.exoPlayerPool.builder.okHttp.proxy) {
return pool
}
val okHttpClient = Amethyst.instance.okHttpClients.getDynamicCallFactory(false)
val videoCache = Amethyst.instance.videoCache
val blossomServerResolver = Amethyst.instance.blossomResolver
pool.destroy()
return newPool(okHttp).also { poolWithProxy = it }
}
// creates new
return newPool(videoCache, okHttpClient, blossomServerResolver).also { poolNoProxy = it }
} else {
poolWithProxy?.let { return it }
// creates brand new
return newPool(Amethyst.instance.okHttpClients.getHttpClient(true)).also { poolWithProxy = it }
// proxy port can change without affecting the pool because
// the choice of okhttp is resolved in newCall
val okHttpClient = Amethyst.instance.okHttpClients.getDynamicCallFactory(true)
val videoCache = Amethyst.instance.videoCache
val blossomServerResolver = Amethyst.instance.blossomResolver
return newPool(videoCache, okHttpClient, blossomServerResolver).also { poolWithProxy = it }
}
}
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.service.relayClient
import com.vitorpamplona.amethyst.model.torState.TorRelayEvaluation
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityManager
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityStatus
import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager
import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManagerForRelays
import com.vitorpamplona.amethyst.ui.tor.TorManager
import com.vitorpamplona.amethyst.ui.tor.TorServiceStatus
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
@@ -45,7 +45,7 @@ import okhttp3.OkHttpClient
class RelayProxyClientConnector(
val torEvaluator: StateFlow<TorRelayEvaluation>,
val okHttpClients: DualHttpClientManager,
val okHttpClients: DualHttpClientManagerForRelays,
val connManager: ConnectivityManager,
val torManager: TorManager,
val client: INostrClient,
@@ -51,14 +51,14 @@ class EventWatcherSubAssembler(
}
override fun updateFilter(
key: List<EventFinderQueryState>,
keys: List<EventFinderQueryState>,
since: SincePerRelayMap?,
): List<RelayBasedFilter>? {
if (key.isEmpty()) {
if (keys.isEmpty()) {
return null
}
lastNotesOnFilter = key.map { it.note }
lastNotesOnFilter = keys.map { it.note }
return groupByRelayPresence(lastNotesOnFilter, latestEOSEs)
.map { group ->
@@ -38,7 +38,6 @@ import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
@@ -501,7 +500,15 @@ fun observeUserStatuses(
// Subscribe in the relay for changes in the metadata of this user.
UserFinderFilterAssemblerSubscription(user, accountViewModel)
return user.statusState().statuses.collectAsStateWithLifecycle(persistentListOf())
val flow =
remember(user) {
user.statusState().statuses.onStart {
user.statusState().removeExpired()
}
}
@SuppressLint("StateFlowValueCalledInComposition")
return flow.collectAsStateWithLifecycle(user.statusState().statuses.value)
}
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@@ -520,5 +527,6 @@ fun observeUserRelayIntoList(
.flowOn(Dispatchers.IO)
}
return flow.collectAsStateWithLifecycle(false)
@SuppressLint("StateFlowValueCalledInComposition")
return flow.collectAsStateWithLifecycle(relayUrl in accountViewModel.account.trustedRelays.flow.value)
}
@@ -0,0 +1,150 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.uploads.blossom.bud10
import androidx.collection.LruCache
import com.vitorpamplona.amethyst.commons.richtext.mimeTypeMap
import com.vitorpamplona.amethyst.model.privacyOptions.IRoleBasedHttpClientBuilder
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.isValid
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUri
import com.vitorpamplona.quartz.utils.firstNotNullOrNullAsync
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.withTimeoutOrNull
import okhttp3.OkHttpClient
import kotlin.collections.toTypedArray
import kotlin.let
class BlossomServerResolver(
val loggedInUsers: () -> List<HexKey>,
val blossomServers: (Set<Address>) -> List<Flow<BlossomServersEvent>>,
val httpClientBuilder: IRoleBasedHttpClientBuilder,
) {
val blossomHitCache: ServerHeadCache = ServerHeadCache()
val uriToUrlCache = LruCache<String, BlossomUriServer>(200)
class BlossomUriServer(
val uri: BlossomUri,
val serverUrl: String,
)
fun cachedFindServer(uriStr: String): BlossomUriServer? = uriToUrlCache[uriStr]
suspend fun findServers(uriStr: String): BlossomUriServer? {
uriToUrlCache[uriStr]?.let { return it }
val result =
withTimeoutOrNull(10000) {
findServersInner(uriStr)
}
if (result != null) {
uriToUrlCache.put(uriStr, result)
}
return result
}
@OptIn(ExperimentalCoroutinesApi::class)
suspend fun findServersInner(uriStr: String): BlossomUriServer? {
val uri = BlossomUri.parse(uriStr) ?: return null
val expectedMimeType = mimeTypeMap[uri.extension]
val filename = uri.filename()
if (uri.servers.isNotEmpty()) {
val workingUrl = firstWorkingUrl(uri.servers, filename, expectedMimeType, uri.size)
if (workingUrl != null) {
return BlossomUriServer(uri, workingUrl)
}
}
val blossomServerConfigNeeded = mutableSetOf<Address>()
uri.authors.forEach {
if (it.isValid()) {
blossomServerConfigNeeded.add(BlossomServersEvent.createAddress(it))
}
}
loggedInUsers().forEach {
blossomServerConfigNeeded.add(BlossomServersEvent.createAddress(it))
}
val flows =
blossomServers(blossomServerConfigNeeded)
.map { blossomServerFlow ->
blossomServerFlow.transformLatest {
val servers = it.servers()
if (servers.isNotEmpty()) {
firstWorkingUrl(servers, filename, expectedMimeType, uri.size)?.let { serverUrl ->
emit(serverUrl)
}
}
}
}.toTypedArray()
if (flows.isNotEmpty()) {
val serverResult = merge(*flows).first()
return BlossomUriServer(uri, serverResult)
}
return null
}
private suspend fun firstWorkingUrl(
servers: List<String>,
filename: String,
expectedMimeType: String?,
expectedSize: Long?,
): String? =
firstNotNullOrNullAsync(servers, 10000) {
blossomHitCache.urlIfServerHasFile(it, filename, expectedMimeType, expectedSize) { url ->
client(url, expectedMimeType)
}
}
fun client(
url: String,
mimeType: String?,
): OkHttpClient =
if (mimeType == null) {
httpClientBuilder.okHttpClientForPreview(url)
} else if (mimeType.startsWith("audio/") || mimeType.startsWith("video/")) {
httpClientBuilder.okHttpClientForVideo(url)
} else if (mimeType.startsWith("image/")) {
httpClientBuilder.okHttpClientForImage(url)
} else {
httpClientBuilder.okHttpClientForPreview(url)
}
fun canResolve(scheme: String) = scheme == SCHEME
companion object {
const val SCHEME = "blossom"
}
}
@@ -0,0 +1,43 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.uploads.blossom.bud10
import android.content.Context
import android.content.Intent
import androidx.core.net.toUri
import com.vitorpamplona.amethyst.R
import kotlin.coroutines.cancellation.CancellationException
fun openBlossomUriAsIntent(
context: Context,
blossomUri: String,
onError: (Int, Int) -> Unit,
) {
try {
val intent = Intent(Intent.ACTION_VIEW, blossomUri.toUri())
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
context.startActivity(intent)
} catch (e: Exception) {
if (e is CancellationException) throw e
onError(R.string.no_blossom_apps_found_title, R.string.no_blossom_apps_found_description)
}
}
@@ -0,0 +1,114 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.uploads.blossom.bud10
import androidx.collection.LruCache
import kotlinx.coroutines.CancellationException
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.coroutines.executeAsync
class ServerHeadCache {
val cache = LruCache<String, HasFile>(200)
sealed interface HasFile {
object NoFile : HasFile
class TypeAndSize(
val mimeType: String,
val size: Long,
) : HasFile
}
suspend fun getFileSizeBytes(
url: String,
client: (url: String) -> OkHttpClient,
): HasFile {
cache[url]?.let { return it }
try {
// Build a HEAD request instead of GET
val request =
Request
.Builder()
.url(url)
.head() // Specifies the HEAD method
.build()
client(url).newCall(request).executeAsync().use { response ->
if (!response.isSuccessful) {
cache.put(url, HasFile.NoFile)
return HasFile.NoFile
}
// Retrieve the "Content-Length" header
val contentLength = response.header("Content-Length")?.toLongOrNull()
val mimeType = response.header("Content-Type")?.toMediaType()?.toString()
if (contentLength != null && mimeType != null) {
val result = HasFile.TypeAndSize(mimeType, contentLength)
cache.put(url, result)
return result
} else {
cache.put(url, HasFile.NoFile)
return HasFile.NoFile
}
}
} catch (e: Exception) {
if (e is CancellationException) throw e
cache.put(url, HasFile.NoFile)
return HasFile.NoFile
}
}
suspend fun urlIfServerHasFile(
server: String,
filename: String,
expectedMimeType: String?,
expectedSize: Long?,
client: (url: String) -> OkHttpClient,
): String? {
val url =
if (server.startsWith("http")) {
server.removeSuffix("/") + "/" + filename
} else {
"https://" + server.removeSuffix("/") + "/" + filename
}
val result = getFileSizeBytes(url, client)
if (result is HasFile.TypeAndSize) {
if (expectedSize == null && expectedMimeType == null) {
// any match goes
return url
} else {
if (result.size == expectedSize) {
return url
}
if (expectedSize == null && result.size > 0 && result.mimeType == expectedMimeType) {
return url
}
}
}
return null
}
}
@@ -264,6 +264,7 @@ fun EditPostView(
ImageVideoDescription(
it,
accountViewModel.account.settings.defaultFileServer,
isUploading = postViewModel.mediaUploadTracker.isUploading,
onAdd = { alt, server, sensitiveContent, mediaQuality, _ ->
postViewModel.upload(alt, sensitiveContent, mediaQuality, false, server, accountViewModel.toastManager::toast, context)
accountViewModel.account.settings.changeDefaultFileServer(server)
@@ -372,6 +373,7 @@ private fun BottomRowActions(postViewModel: EditPostViewModel) {
) {
SelectFromGallery(
isUploading = postViewModel.isUploadingImage,
enabled = !postViewModel.isUploadingFile,
tint = MaterialTheme.colorScheme.onBackground,
modifier = Modifier,
) {
@@ -379,7 +381,8 @@ private fun BottomRowActions(postViewModel: EditPostViewModel) {
}
SelectFromFiles(
isUploading = postViewModel.isUploadingImage,
isUploading = postViewModel.isUploadingFile,
enabled = !postViewModel.isUploadingImage,
tint = MaterialTheme.colorScheme.onBackground,
modifier = Modifier,
) {
@@ -39,6 +39,7 @@ import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.uploads.MediaUploadTracker
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
@@ -61,6 +62,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.originalHash
import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent
import com.vitorpamplona.quartz.nip94FileMetadata.size
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Stable
@@ -78,7 +80,9 @@ open class EditPostViewModel : ViewModel() {
var message by mutableStateOf(TextFieldValue(""))
var urlPreview by mutableStateOf<String?>(null)
var isUploadingImage by mutableStateOf(false)
val mediaUploadTracker = MediaUploadTracker()
val isUploadingImage: Boolean get() = mediaUploadTracker.isUploadingImage
val isUploadingFile: Boolean get() = mediaUploadTracker.isUploadingFile
var userSuggestions: UserSuggestionState? = null
var userSuggestionsMainMessage: UserSuggestionAnchor? = null
@@ -175,11 +179,11 @@ open class EditPostViewModel : ViewModel() {
onError: (String, String) -> Unit,
context: Context,
) {
viewModelScope.launch {
viewModelScope.launch(Dispatchers.IO) {
val myAccount = account
val myMultiOrchestrator = multiOrchestrator ?: return@launch
isUploadingImage = true
mediaUploadTracker.startUpload(myMultiOrchestrator.hasNonMedia())
val results =
myMultiOrchestrator.upload(
@@ -242,7 +246,7 @@ open class EditPostViewModel : ViewModel() {
onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n"))
}
isUploadingImage = false
mediaUploadTracker.finishUpload()
}
}
@@ -254,7 +258,7 @@ open class EditPostViewModel : ViewModel() {
multiOrchestrator = null
urlPreview = null
isUploadingImage = false
mediaUploadTracker.finishUpload()
wantsInvoice = false
@@ -295,7 +299,7 @@ open class EditPostViewModel : ViewModel() {
}
}
fun canPost() = message.text.isNotBlank() && !isUploadingImage && !wantsInvoice && multiOrchestrator == null
fun canPost() = message.text.isNotBlank() && !mediaUploadTracker.isUploading && !wantsInvoice && multiOrchestrator == null
fun selectImage(uris: ImmutableList<SelectedMedia>) {
multiOrchestrator = MultiOrchestrator(uris)
@@ -98,7 +98,7 @@ open class NewMediaModel : ViewModel() {
onSucess: () -> Unit,
onError: (String, String) -> Unit,
) {
viewModelScope.launch {
viewModelScope.launch(Dispatchers.IO) {
val myAccount = account ?: return@launch
val serverToUse = selectedServer ?: return@launch
@@ -53,7 +53,7 @@ class BlossomServersViewModel : ViewModel() {
fun refresh() {
isModified = false
_fileServers.update {
val obtainedFileServers = obtainFileServers() ?: emptyList()
val obtainedFileServers = obtainFileServers()
obtainedFileServers.mapNotNull { serverUrl ->
try {
ServerName(
@@ -0,0 +1,47 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.actions.uploads
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
class MediaUploadTracker {
var isUploadingImage by mutableStateOf(false)
private set
var isUploadingFile by mutableStateOf(false)
private set
val isUploading: Boolean get() = isUploadingImage || isUploadingFile
fun startUpload(hasNonMedia: Boolean) {
if (hasNonMedia) {
isUploadingFile = true
} else {
isUploadingImage = true
}
}
fun finishUpload() {
isUploadingImage = false
isUploadingFile = false
}
}
@@ -48,6 +48,7 @@ import java.util.concurrent.atomic.AtomicBoolean
@Composable
fun SelectFromFiles(
isUploading: Boolean,
enabled: Boolean = true,
tint: Color,
modifier: Modifier,
onFilesChosen: (ImmutableList<SelectedMedia>) -> Unit,
@@ -64,19 +65,20 @@ fun SelectFromFiles(
)
}
FileSelectButton(isUploading, tint, modifier) { showFileSelect = true }
FileSelectButton(isUploading, enabled, tint, modifier) { showFileSelect = true }
}
@Composable
private fun FileSelectButton(
isUploading: Boolean,
enabled: Boolean,
tint: Color,
modifier: Modifier,
onClick: () -> Unit,
) {
IconButton(
modifier = modifier,
enabled = !isUploading,
enabled = enabled && !isUploading,
onClick = { onClick() },
) {
if (!isUploading) {
@@ -69,6 +69,7 @@ class SelectedMedia(
@Composable
fun SelectFromGallery(
isUploading: Boolean,
enabled: Boolean = true,
tint: Color,
modifier: Modifier,
onImageChosen: (ImmutableList<SelectedMedia>) -> Unit,
@@ -85,7 +86,7 @@ fun SelectFromGallery(
)
}
GallerySelectButton(isUploading, tint, modifier) { showGallerySelect = true }
GallerySelectButton(isUploading, enabled, tint, modifier) { showGallerySelect = true }
}
@Composable
@@ -107,19 +108,20 @@ fun SelectSingleFromGallery(
)
}
GallerySelectButton(isUploading, tint, modifier) { showGallerySelect = true }
GallerySelectButton(isUploading, true, tint, modifier) { showGallerySelect = true }
}
@Composable
private fun GallerySelectButton(
isUploading: Boolean,
enabled: Boolean,
tint: Color,
modifier: Modifier,
onClick: () -> Unit,
) {
IconButton(
modifier = modifier,
enabled = !isUploading,
enabled = enabled && !isUploading,
onClick = { onClick() },
) {
if (!isUploading) {
@@ -21,24 +21,32 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.style.TextOverflow
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.openBlossomUriAsIntent
@Composable
fun ClickableUrl(
urlText: String,
url: String,
onError: (Int, Int) -> Unit = { _, _ -> },
) {
val uri = LocalUriHandler.current
val context = LocalContext.current
ClickableTextPrimary(
text = urlText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
overflow = TextOverflow.MiddleEllipsis,
onClick = {
runCatching {
val doubleCheckedUrl = if (url.contains("://")) url else "https://$url"
uri.openUri(doubleCheckedUrl)
if (url.startsWith("blossom:")) {
openBlossomUriAsIntent(context, url, onError)
} else {
runCatching {
val doubleCheckedUrl = if (url.contains("://")) url else "https://$url"
uri.openUri(doubleCheckedUrl)
}
}
},
)
@@ -144,7 +144,7 @@ fun ImageGallery(
images.words
.mapNotNull { segment ->
val imageUrl = segment.segmentText
state.imagesForPager[imageUrl] as? MediaUrlImage
state.mediaForPager[imageUrl] as? MediaUrlImage
}.toImmutableList()
Column(modifier = modifier.padding(vertical = Size10dp)) {
@@ -76,7 +76,7 @@ fun LoadUrlPreviewDirect(
is UrlPreviewState.Loading -> {
WaitAndDisplay {
DisplayUrlWithLoadingSymbol(url)
DisplayUrlWithLoadingSymbol(url, accountViewModel.toastManager::toast)
}
}
@@ -82,7 +82,7 @@ fun MyAsyncImage(
LoadingAnimation(Size40dp, Size6dp)
}
} else {
DisplayUrlWithLoadingSymbol(imageUrl)
DisplayUrlWithLoadingSymbol(imageUrl, accountViewModel.toastManager::toast)
}
}
}
@@ -47,6 +47,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFontFamilyResolver
import androidx.compose.ui.platform.LocalLayoutDirection
@@ -54,18 +55,21 @@ import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.compose.produceCachedState
import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.commons.richtext.Base64Segment
import com.vitorpamplona.amethyst.commons.richtext.BechSegment
import com.vitorpamplona.amethyst.commons.richtext.BlossomUriSegment
import com.vitorpamplona.amethyst.commons.richtext.CashuSegment
import com.vitorpamplona.amethyst.commons.richtext.EmailSegment
import com.vitorpamplona.amethyst.commons.richtext.EmojiSegment
@@ -93,6 +97,7 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon
import com.vitorpamplona.amethyst.service.CachedRichTextParser
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.openBlossomUriAsIntent
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.components.markdown.RenderContentAsMarkdown
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
@@ -112,6 +117,7 @@ import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.inlinePlaceholder
import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -170,6 +176,10 @@ fun RenderStrangeNamePreview() {
ClickableRelayUrl(word.segmentText, EmptyNav())
}
is BlossomUriSegment -> {
ClickableRelayUrl(word.segmentText, EmptyNav())
}
is SchemelessUrlSegment -> {
NoProtocolUrlRenderer(word.segmentText)
}
@@ -500,6 +510,8 @@ private fun RenderWordWithoutPreview(
is RelayUrlSegment -> ClickableRelayUrl(word.segmentText, nav)
is BlossomUriSegment -> BlossomUriRendererNoPreview(word.segmentText, accountViewModel)
is SchemelessUrlSegment -> NoProtocolUrlRenderer(word.segmentText)
}
}
@@ -532,19 +544,91 @@ private fun RenderWordWithPreview(
is RegularTextSegment -> Text(word.segmentText)
is Base64Segment -> ZoomableContentView(word.segmentText, state, accountViewModel)
is RelayUrlSegment -> ClickableRelayUrl(word.segmentText, nav)
is BlossomUriSegment -> BlossomUriRenderer(word.segmentText, state, callbackUri, accountViewModel)
is SchemelessUrlSegment -> NoProtocolUrlRenderer(word.segmentText)
}
}
@Composable
fun BlossomUriRenderer(
word: String,
state: RichTextViewerState,
callbackUri: String? = null,
accountViewModel: AccountViewModel,
) {
val isMedia = state.mediaForPager.contains(word)
if (isMedia) {
ZoomableContentView(word, state, accountViewModel)
} else {
val serverResultState =
remember(word) {
mutableStateOf(Amethyst.instance.blossomResolver.cachedFindServer(word))
}
if (serverResultState.value == null) {
LaunchedEffect(word) {
serverResultState.value = Amethyst.instance.blossomResolver.findServers(word)
}
}
val serverResult = serverResultState.value
if (serverResult != null && serverResult.serverUrl.isNotBlank()) {
LoadUrlPreview(serverResult.serverUrl, serverResult.uri.filename(), callbackUri, accountViewModel)
} else {
ClickableBlossomUri(word, accountViewModel)
}
}
}
@Composable
fun ClickableBlossomUri(
blossomUri: String,
accountViewModel: AccountViewModel,
) {
val context = LocalContext.current
ClickableTextPrimary(
text = remember { BlossomUri.parse(blossomUri)?.filename() ?: blossomUri },
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis,
onClick = { openBlossomUriAsIntent(context, blossomUri, accountViewModel.toastManager::toast) },
)
}
@Composable
fun BlossomUriRendererNoPreview(
word: String,
accountViewModel: AccountViewModel,
) {
val serverResultState =
remember(word) {
mutableStateOf(Amethyst.instance.blossomResolver.cachedFindServer(word))
}
if (serverResultState.value == null) {
LaunchedEffect(word) {
serverResultState.value = Amethyst.instance.blossomResolver.findServers(word)
}
}
val serverResult = serverResultState.value
if (serverResult != null && serverResult.serverUrl.isNotBlank()) {
ClickableUrl(serverResult.uri.filename(), serverResult.serverUrl)
} else {
ClickableBlossomUri(word, accountViewModel)
}
}
@Composable
private fun ZoomableContentView(
word: String,
state: RichTextViewerState,
accountViewModel: AccountViewModel,
) {
state.imagesForPager[word]?.let {
state.mediaForPager[word]?.let {
Box(modifier = HalfVertPadding) {
ZoomableContentView(it, state.imageList, roundedCorner = true, contentScale = ContentScale.FillWidth, accountViewModel)
ZoomableContentView(it, state.mediaList, roundedCorner = true, contentScale = ContentScale.FillWidth, accountViewModel)
}
}
}
@@ -119,11 +119,12 @@ object ShareHelper {
bytesRead >= 12 && matchesMagicNumbers(header, 4, MOV_FTYP) -> detectMp4OrMov(header)
// MP4/MOV alternative: moov, mdat, or free at offset 4
bytesRead >= 8 && (
matchesMagicNumbers(header, 4, MOV_MOOV) ||
matchesMagicNumbers(header, 4, MOV_MDAT) ||
matchesMagicNumbers(header, 4, MOV_FREE)
) -> "mp4"
bytesRead >= 8 &&
(
matchesMagicNumbers(header, 4, MOV_MOOV) ||
matchesMagicNumbers(header, 4, MOV_MDAT) ||
matchesMagicNumbers(header, 4, MOV_FREE)
) -> "mp4"
else -> defaultExtension
}
@@ -22,13 +22,16 @@ package com.vitorpamplona.amethyst.ui.components
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@@ -38,10 +41,12 @@ import androidx.compose.material3.SwipeToDismissBoxState
import androidx.compose.material3.SwipeToDismissBoxValue.EndToStart
import androidx.compose.material3.SwipeToDismissBoxValue.Settled
import androidx.compose.material3.SwipeToDismissBoxValue.StartToEnd
import androidx.compose.material3.Text
import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -50,6 +55,7 @@ import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -88,6 +94,40 @@ fun SwipeToDeleteContainer(
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SwipeToDeleteWithConfirmation(
modifier: Modifier = Modifier,
onDelete: () -> Unit,
content: @Composable (RowScope.() -> Unit),
) {
val scope = rememberCoroutineScope()
val dismissState =
rememberSwipeToDismissBoxState(
positionalThreshold = { it * .40f },
)
SwipeToDismissBox(
state = dismissState,
modifier = modifier,
backgroundContent = {
ConfirmDeleteBackground(
dismissState = dismissState,
onConfirmDelete = {
onDelete()
scope.launch { dismissState.reset() }
},
onCancel = {
scope.launch { dismissState.reset() }
},
)
},
enableDismissFromEndToStart = true,
content = content,
)
}
@Composable
fun DismissBackground(dismissState: SwipeToDismissBoxState) {
val color by animateColorAsState(
@@ -127,3 +167,82 @@ fun DismissBackground(dismissState: SwipeToDismissBoxState) {
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ConfirmDeleteBackground(
dismissState: SwipeToDismissBoxState,
onConfirmDelete: () -> Unit,
onCancel: () -> Unit,
) {
val settled = dismissState.currentValue == Settled && dismissState.targetValue == Settled
val color by animateColorAsState(
if (!settled) {
Color(0xFFFF1744)
} else {
MaterialTheme.colorScheme.surfaceVariant
},
label = "ConfirmDeleteBackground",
)
val haptic = LocalHapticFeedback.current
LaunchedEffect(key1 = dismissState.currentValue > dismissState.targetValue) {
if (dismissState.progress > 0 && dismissState.progress < 1) {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
}
}
Row(
modifier =
Modifier
.fillMaxSize()
.background(color)
.padding(20.dp, 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(
modifier =
Modifier
.weight(1f)
.fillMaxHeight()
.clickable(enabled = !settled) { onConfirmDelete() },
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
Icon(
Icons.Default.Delete,
contentDescription = stringRes(id = R.string.request_deletion),
tint = Color.White,
)
Spacer(modifier = Modifier.padding(horizontal = 4.dp))
Text(
text = stringRes(id = R.string.request_deletion),
color = Color.White,
style = MaterialTheme.typography.titleMedium,
)
}
Row(
modifier =
Modifier
.weight(1f)
.fillMaxHeight()
.clickable(enabled = !settled) { onCancel() },
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
Icon(
Icons.Default.Close,
contentDescription = stringRes(id = R.string.cancel),
tint = Color.White,
)
Spacer(modifier = Modifier.padding(horizontal = 4.dp))
Text(
text = stringRes(id = R.string.cancel),
color = Color.White,
style = MaterialTheme.typography.titleMedium,
)
}
}
}
@@ -0,0 +1,108 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.pager.PagerState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
private const val PAGER_ZONE_FRACTION = 0.5f
fun Modifier.zonedDrawerSwipe(
pagerState: PagerState,
openDrawer: () -> Unit,
): Modifier =
composed {
var widthPx by remember { mutableFloatStateOf(1f) }
var gestureStartX by remember { mutableFloatStateOf(0f) }
var gestureStartPage by remember { mutableIntStateOf(0) }
var drawerOpened by remember { mutableStateOf(false) }
val connection =
remember {
object : NestedScrollConnection {
override fun onPreScroll(
available: Offset,
source: NestedScrollSource,
): Offset {
if (source != NestedScrollSource.UserInput) return Offset.Zero
if (drawerOpened) return Offset(available.x, 0f)
// Non-first pages in the drawer zone: intercept before the
// pager consumes the delta to page backwards.
if (available.x > 0f) {
val wasOnFirstPage = gestureStartPage == 0
val isInPagerZone = gestureStartX < widthPx * PAGER_ZONE_FRACTION
if (!wasOnFirstPage && !isInPagerZone) {
drawerOpened = true
openDrawer()
return Offset(available.x, 0f)
}
}
return Offset.Zero
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource,
): Offset {
if (source != NestedScrollSource.UserInput) return Offset.Zero
if (drawerOpened) return Offset(available.x, 0f)
// First page: open drawer only with unconsumed right-swipe
// so child LazyRows can scroll first.
if (available.x > 0f && gestureStartPage == 0) {
drawerOpened = true
openDrawer()
return Offset(available.x, 0f)
}
return Offset.Zero
}
}
}
this
.onSizeChanged { widthPx = it.width.toFloat() }
.pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
gestureStartX = down.position.x
gestureStartPage = pagerState.currentPage
drawerOpened = false
}
}.nestedScroll(connection)
}
@@ -86,6 +86,7 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
import com.vitorpamplona.amethyst.service.images.BlurhashWrapper
import com.vitorpamplona.amethyst.service.playback.composable.VideoView
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.openBlossomUriAsIntent
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.actions.InformationDialog
import com.vitorpamplona.amethyst.ui.note.BlankNote
@@ -286,7 +287,7 @@ fun LocalImageView(
}
} else {
WaitAndDisplay {
DisplayUrlWithLoadingSymbol(content)
DisplayUrlWithLoadingSymbol(content, accountViewModel.toastManager::toast)
}
}
}
@@ -408,7 +409,7 @@ fun UrlImageView(
}
} else {
WaitAndDisplay {
DisplayUrlWithLoadingSymbol(content)
DisplayUrlWithLoadingSymbol(content, accountViewModel.toastManager::toast)
}
}
}
@@ -580,7 +581,10 @@ fun WaitAndDisplay(content: @Composable (AnimatedVisibilityScope.() -> Unit)) {
}
@Composable
fun DisplayUrlWithLoadingSymbol(content: BaseMediaContent) {
fun DisplayUrlWithLoadingSymbol(
content: BaseMediaContent,
onError: (Int, Int) -> Unit = { _, _ -> },
) {
val uri = LocalUriHandler.current
val primary = MaterialTheme.colorScheme.primary
@@ -589,6 +593,8 @@ fun DisplayUrlWithLoadingSymbol(content: BaseMediaContent) {
val regularText = remember { SpanStyle(color = background) }
val clickableTextStyle = remember { SpanStyle(color = primary) }
val context = LocalContext.current
val annotatedTermsString =
remember {
buildAnnotatedString {
@@ -614,7 +620,13 @@ fun DisplayUrlWithLoadingSymbol(content: BaseMediaContent) {
val pressIndicator =
remember {
if (content is MediaUrlContent) {
Modifier.clickable { runCatching { uri.openUri(content.url) } }
Modifier.clickable {
if (content.url.startsWith("blossom:")) {
openBlossomUriAsIntent(context, content.url, onError)
} else {
runCatching { uri.openUri(content.url) }
}
}
} else {
Modifier
}
@@ -628,10 +640,8 @@ fun DisplayUrlWithLoadingSymbol(content: BaseMediaContent) {
) {
Text(
text = annotatedTermsString,
modifier =
pressIndicator
.weight(1f, fill = false),
overflow = TextOverflow.Ellipsis,
modifier = pressIndicator.weight(1f, fill = false),
overflow = TextOverflow.MiddleEllipsis,
maxLines = 1,
)
InlineLoadingIcon()
@@ -639,7 +649,10 @@ fun DisplayUrlWithLoadingSymbol(content: BaseMediaContent) {
}
@Composable
fun DisplayUrlWithLoadingSymbol(url: String) {
fun DisplayUrlWithLoadingSymbol(
url: String,
onError: (Int, Int) -> Unit = { _, _ -> },
) {
val uri = LocalUriHandler.current
val primary = MaterialTheme.colorScheme.primary
@@ -654,7 +667,20 @@ fun DisplayUrlWithLoadingSymbol(url: String) {
}
}
val pressIndicator = remember { Modifier.clickable { runCatching { uri.openUri(url) } } }
val context = LocalContext.current
val pressIndicator =
remember {
Modifier.clickable {
if (url.startsWith("blossom:")) {
openBlossomUriAsIntent(context, url, onError)
} else {
runCatching {
uri.openUri(url)
}
}
}
}
Row(
modifier = Modifier.width(IntrinsicSize.Max),
@@ -37,8 +37,10 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.core.net.toUri
import androidx.core.util.Consumer
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.crashreports.DisplayCrashMessages
@@ -113,6 +115,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.search.SearchScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.AllSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NIP47SetupScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NamecoinSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.OtsSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.ReactionsSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SecurityFiltersScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsScreen
@@ -120,6 +123,10 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UpdateZapAmountScr
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UserSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.ThreadScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.VideoScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletReceiveScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletSendScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletTransactionsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedOff.AddAccountDialog
import com.vitorpamplona.amethyst.ui.uriToRoute
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
@@ -138,7 +145,17 @@ fun AppNavigation(
) {
val nav = rememberNav()
AccountSwitcherAndLeftDrawerLayout(accountViewModel, accountSessionManager, nav) {
val navBackStackEntry by nav.controller.currentBackStackEntryAsState()
val isTabPagerRoute =
navBackStackEntry?.destination?.let { dest ->
dest.hasRoute<Route.Home>() || dest.hasRoute<Route.Message>()
} ?: false
val drawerGesturesEnabled =
!isTabPagerRoute ||
nav.drawerState.isOpen ||
nav.drawerState.targetValue != nav.drawerState.currentValue
AccountSwitcherAndLeftDrawerLayout(accountViewModel, accountSessionManager, nav, drawerGesturesEnabled) {
NavHost(
navController = nav.controller,
startDestination = Route.Home,
@@ -152,6 +169,11 @@ fun AppNavigation(
composable<Route.Notification> { NotificationScreen(accountViewModel, nav) }
composable<Route.Chess> { ChessLobbyScreen(accountViewModel, nav) }
composableFromEnd<Route.Wallet> { WalletScreen(accountViewModel, nav) }
composableFromEnd<Route.WalletSend> { WalletSendScreen(accountViewModel, nav) }
composableFromEnd<Route.WalletReceive> { WalletReceiveScreen(accountViewModel, nav) }
composableFromEnd<Route.WalletTransactions> { WalletTransactionsScreen(accountViewModel, nav) }
composableFromEnd<Route.Lists> { ListOfPeopleListsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.MyPeopleListView> { PeopleListScreen(it.dTag, accountViewModel, nav) }
composableFromEndArgs<Route.MyFollowPackView> { FollowPackScreen(it.dTag, accountViewModel, nav) }
@@ -178,6 +200,7 @@ fun AppNavigation(
composableFromEnd<Route.SecurityFilters> { SecurityFiltersScreen(accountViewModel, nav) }
composableFromEnd<Route.PrivacyOptions> { PrivacyOptionsScreen(Amethyst.instance.torPrefs.value, nav) }
composableFromEnd<Route.NamecoinSettings> { NamecoinSettingsScreen(Amethyst.instance.namecoinPrefs, nav) }
composableFromEnd<Route.OtsSettings> { OtsSettingsScreen(Amethyst.instance.otsPrefs, Amethyst.instance.torPrefs.value, nav) }
composableFromEnd<Route.Bookmarks> { BookmarkListScreen(accountViewModel, nav) }
composableFromEnd<Route.Drafts> { DraftListScreen(accountViewModel, nav) }
composableFromEnd<Route.Settings> { SettingsScreen(accountViewModel, nav) }
@@ -46,6 +46,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.outlined.AccountBalanceWallet
import androidx.compose.material.icons.outlined.CollectionsBookmark
import androidx.compose.material.icons.outlined.Drafts
import androidx.compose.material.icons.outlined.GroupAdd
@@ -464,6 +465,14 @@ fun ListContent(
route = Route.Drafts,
)
NavigationRow(
title = R.string.wallet,
icon = Icons.Outlined.AccountBalanceWallet,
tint = MaterialTheme.colorScheme.onBackground,
nav = nav,
route = Route.Wallet,
)
NavigationRow(
title = R.string.route_chess,
icon = R.drawable.ic_chess,
@@ -43,6 +43,14 @@ sealed class Route {
@Serializable object Chess : Route()
@Serializable object Wallet : Route()
@Serializable object WalletSend : Route()
@Serializable object WalletReceive : Route()
@Serializable object WalletTransactions : Route()
@Serializable object Search : Route()
@Serializable object SecurityFilters : Route()
@@ -51,6 +59,8 @@ sealed class Route {
@Serializable object NamecoinSettings : Route()
@Serializable object OtsSettings : Route()
@Serializable object Bookmarks : Route()
@Serializable object BookmarkGroups : Route()
@@ -43,6 +43,7 @@ fun ActionTopBar(
isActive: () -> Boolean = { true },
onCancel: () -> Unit,
onPost: () -> Unit,
additionalActions: @Composable (() -> Unit)? = null,
) {
ShorterTopAppBar(
title = {
@@ -63,6 +64,9 @@ fun ActionTopBar(
)
},
actions = {
if (additionalActions != null) {
additionalActions()
}
Button(
modifier = HalfHorzPadding,
enabled = isActive(),
@@ -100,12 +104,14 @@ fun SavingTopBar(
isActive: () -> Boolean = { true },
onCancel: () -> Unit,
onPost: () -> Unit,
additionalActions: @Composable (() -> Unit)? = null,
) = ActionTopBar(
titleRes = titleRes,
postRes = R.string.save,
isActive = isActive,
onCancel = onCancel,
onPost = onPost,
additionalActions = additionalActions,
)
@OptIn(ExperimentalMaterial3Api::class)
@@ -62,7 +62,7 @@ fun BadgeCompose(
nav: INav,
) {
val noteState by observeNote(likeSetCard.note, accountViewModel)
val note = noteState?.note
val note = noteState.note
val context = LocalContext.current.applicationContext
@@ -59,11 +59,14 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserStatuses
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.components.ClickableTextPrimary
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.navigation.routes.routeForUser
import com.vitorpamplona.amethyst.ui.painterRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.NIP05IconSize
@@ -77,8 +80,13 @@ import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.tags.aTag.firstTaggedAddress
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.events.firstTaggedEvent
import com.vitorpamplona.quartz.nip01Core.tags.people.firstTaggedUserId
import com.vitorpamplona.quartz.nip30CustomEmoji.taggedEmojis
import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.coroutines.delay
import kotlin.time.Duration.Companion.seconds
@@ -220,12 +228,24 @@ fun DisplayStatus(
accountViewModel: AccountViewModel,
nav: INav,
) {
val emojis =
remember(event) {
val emojiList = event.taggedEmojis()
if (emojiList.isEmpty()) {
persistentMapOf()
} else {
emojiList.associate { it.code to it.url }.toImmutableMap()
}
}
DisplayStatusInner(
event.content,
event.dTag(),
event.firstTaggedUrl()?.ifBlank { null },
event.firstTaggedAddress(),
event.firstTaggedEvent(),
event.firstTaggedUserId(),
emojis,
accountViewModel,
nav,
)
@@ -238,11 +258,13 @@ fun DisplayStatusInner(
url: String?,
nostrATag: Address?,
nostrETag: ETag?,
nostrPTag: String?,
emojis: ImmutableMap<String, String>,
accountViewModel: AccountViewModel,
nav: INav,
) {
when (type) {
"music" -> {
StatusEvent.MUSIC -> {
Icon(
imageVector = CustomHashTagIcons.Tunestr,
null,
@@ -254,13 +276,24 @@ fun DisplayStatusInner(
else -> {}
}
Text(
text = content,
fontSize = Font14SP,
color = MaterialTheme.colorScheme.placeholderText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (emojis.isNotEmpty()) {
CreateTextWithEmoji(
text = content,
emojis = emojis,
color = MaterialTheme.colorScheme.placeholderText,
fontSize = Font14SP,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
} else {
Text(
text = content,
fontSize = Font14SP,
color = MaterialTheme.colorScheme.placeholderText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (url != null) {
val uri = LocalUriHandler.current
@@ -320,6 +353,23 @@ fun DisplayStatusInner(
}
}
}
} else if (nostrPTag != null) {
LoadUser(baseUserHex = nostrPTag, accountViewModel) { user ->
if (user != null) {
Spacer(modifier = StdHorzSpacer)
IconButton(
modifier = Size15Modifier,
onClick = { nav.nav(routeForUser(nostrPTag)) },
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.OpenInNew,
null,
modifier = Size15Modifier,
tint = MaterialTheme.colorScheme.lessImportantLink,
)
}
}
}
}
}
@@ -30,6 +30,11 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Timer
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
@@ -47,6 +52,7 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
@@ -201,6 +207,7 @@ import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent
import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip40Expiration.expiration
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.PinListEvent
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
@@ -1344,6 +1351,27 @@ fun SecondUserInfoRow(
}
}
@Composable
fun DisplayExpiration(expirationDate: Long) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Outlined.Timer,
contentDescription = stringRes(R.string.expiration_date_label),
modifier = Modifier.padding(start = 5.dp).size(15.dp),
tint = MaterialTheme.colorScheme.placeholderText,
)
val context = LocalContext.current
Text(
text = timeAheadNoDot(expirationDate, context),
color = MaterialTheme.colorScheme.placeholderText,
maxLines = 1,
modifier = Modifier.padding(start = 3.dp),
)
}
}
@Composable
fun DisplayOtsIfInOriginal(
note: Note,
@@ -1440,6 +1468,8 @@ fun FirstUserInfoRow(
DisplayDraft()
}
Expiration(baseNote)
TimeAgo(baseNote)
if (moreOptions == null) {
@@ -1450,6 +1480,17 @@ fun FirstUserInfoRow(
}
}
@Composable
fun Expiration(note: Note) {
val event = note.event
if (event != null) {
val expires = remember(event) { event.expiration() }
if (expires != null) {
DisplayExpiration(expires)
}
}
}
@Composable
fun CheckAndDisplayEditStatus(editState: State<GenericLoadable<EditState>>) {
if (editState.value is GenericLoadable.Loaded) {
@@ -30,10 +30,10 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
@@ -49,10 +49,10 @@ fun showAmountInteger(amount: BigDecimal?): String {
if (amount.abs() < BigDecimal(0.01)) return ""
return when {
amount >= OneGiga -> dfG.get().format(amount.div(OneGiga).setScale(0, RoundingMode.HALF_UP))
amount >= OneMega -> dfM.get().format(amount.div(OneMega).setScale(0, RoundingMode.HALF_UP))
amount >= TenKilo -> dfK.get().format(amount.div(OneKilo).setScale(0, RoundingMode.HALF_UP))
else -> dfN.get().format(amount)
amount >= OneGiga -> dfG.get()?.format(amount.div(OneGiga).setScale(0, RoundingMode.HALF_UP)) ?: ""
amount >= OneMega -> dfM.get()?.format(amount.div(OneMega).setScale(0, RoundingMode.HALF_UP)) ?: ""
amount >= TenKilo -> dfK.get()?.format(amount.div(OneKilo).setScale(0, RoundingMode.HALF_UP)) ?: ""
else -> dfN.get()?.format(amount) ?: ""
}
}
@@ -118,13 +118,13 @@ class PollNoteViewModel : ViewModel() {
it.zappedValue.value = zappedValue
it.tally.value = tallyValue.toFloat()
it.consensusThreadhold.value = consensusThreshold != null && tallyValue >= consensusThreshold!!
it.zappedByLoggedIn.value = account?.userProfile()?.let { it1 -> cachedIsPollOptionZappedBy(it.option, it1) } ?: false
it.zappedByLoggedIn.value = account.userProfile().let { it1 -> cachedIsPollOptionZappedBy(it.option, it1) }
}
}
}
fun checkIfCanZap(): Boolean {
val account = account ?: return false
val account = account
val note = pollNote ?: return false
return account.userProfile() != note.author && !wasZappedByLoggedInAccount
}
@@ -0,0 +1,61 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.creators.expiration
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Timer
import androidx.compose.material.icons.outlined.TimerOff
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
@Composable
fun ExpirationDateButton(
isActive: Boolean,
onClick: () -> Unit,
) {
IconButton(
onClick = { onClick() },
) {
if (!isActive) {
Icon(
imageVector = Icons.Outlined.Timer,
contentDescription = stringRes(R.string.add_expiration_date),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onBackground,
)
} else {
Icon(
imageVector = Icons.Outlined.TimerOff,
contentDescription = stringRes(R.string.remove_expiration_date),
modifier = Modifier.size(20.dp),
tint = Color(0xFFFF6600),
)
}
}
}
@@ -0,0 +1,192 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.creators.expiration
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Timer
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.SelectableDates
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TimePicker
import androidx.compose.material3.TimePickerDialog
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.material3.rememberTimePickerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.note.timeAheadNoDot
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.utils.TimeUtils
import java.time.Instant
import java.time.ZoneId
import java.time.ZoneOffset
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ExpirationDatePicker(model: IExpiration) {
var showDatePicker by remember { mutableStateOf(false) }
var showTimePicker by remember { mutableStateOf(false) }
val currentTime = Instant.ofEpochMilli(model.expirationDate * 1000).atZone(ZoneId.systemDefault()).toLocalDateTime()
val datePickerState =
rememberDatePickerState(
initialSelectedDateMillis = model.expirationDate * 1000,
yearRange = currentTime.year..2050,
selectableDates =
object : SelectableDates {
override fun isSelectableDate(utcTimeMillis: Long): Boolean = utcTimeMillis >= System.currentTimeMillis() - 86400000
},
)
val timePickerState =
rememberTimePickerState(
initialHour = currentTime.hour,
initialMinute = currentTime.minute,
is24Hour = false,
)
val context = LocalContext.current
Column(Modifier.fillMaxWidth()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxWidth()
.padding(bottom = 10.dp),
) {
Icon(
imageVector = Icons.Outlined.Timer,
contentDescription = stringRes(R.string.expiration_date_label),
modifier = Modifier.size(20.dp),
tint = Color(0xFFFF6600),
)
Text(
text = stringRes(R.string.expiration_date_label),
fontSize = 20.sp,
fontWeight = FontWeight.W500,
modifier = Modifier.padding(start = 10.dp),
)
}
HorizontalDivider(thickness = DividerThickness)
Text(
text = stringRes(R.string.expiration_date_explainer),
color = MaterialTheme.colorScheme.placeholderText,
modifier = Modifier.padding(vertical = 10.dp),
)
OutlinedCard(
onClick = { showDatePicker = true },
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(Icons.Outlined.Timer, contentDescription = stringResource(R.string.expiration_date_select))
Spacer(Modifier.width(12.dp))
if (model.expirationDate < TimeUtils.oneMinuteFromNow()) {
Text(stringRes(R.string.expiration_date_label) + " " + model.expirationDate, style = MaterialTheme.typography.bodyLarge)
} else {
Text(
text = stringRes(R.string.expiration_expires_in, timeAheadNoDot(model.expirationDate, context)),
style = MaterialTheme.typography.bodyLarge,
)
}
}
}
}
if (showDatePicker) {
DatePickerDialog(
onDismissRequest = { showDatePicker = false },
confirmButton = {
TextButton(onClick = {
showDatePicker = false
showTimePicker = true
}) { Text(stringResource(R.string.next)) }
},
) {
DatePicker(state = datePickerState)
}
}
if (showTimePicker) {
TimePickerDialog(
title = {
Text(stringResource(R.string.expiration_time))
},
onDismissRequest = { showTimePicker = false },
confirmButton = {
TextButton(
onClick = {
val datetimeLocalTimeZone =
datePickerState.selectedDateMillis?.let { localDayAtZeroHourMillis ->
(localDayAtZeroHourMillis / 1000) +
(timePickerState.hour * TimeUtils.ONE_HOUR) +
(timePickerState.minute * TimeUtils.ONE_MINUTE)
} ?: TimeUtils.oneDayAhead()
val offset: ZoneOffset = ZoneId.systemDefault().rules.getOffset(Instant.now())
model.expirationDate = datetimeLocalTimeZone - offset.totalSeconds
showTimePicker = false
},
) { Text(stringResource(R.string.confirm)) }
},
) {
TimePicker(state = timePickerState)
}
}
}
@@ -0,0 +1,28 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.creators.expiration
import androidx.compose.runtime.Stable
@Stable
interface IExpiration {
var expirationDate: Long
}
@@ -292,7 +292,7 @@ private fun MyLoadUrlPreviewDirectFillWidth(
is UrlPreviewState.Loading -> {
WaitAndDisplay {
DisplayUrlWithLoadingSymbol(url)
DisplayUrlWithLoadingSymbol(url, accountViewModel.toastManager::toast)
}
}
@@ -78,6 +78,7 @@ import kotlinx.collections.immutable.toImmutableList
fun ImageVideoDescription(
uris: MultiOrchestrator,
defaultServer: ServerName,
isUploading: Boolean,
onAdd: (String, ServerName, Boolean, Int, Boolean) -> Unit,
onDelete: (SelectedMediaProcessing) -> Unit,
onCancel: () -> Unit,
@@ -319,6 +320,7 @@ fun ImageVideoDescription(
Modifier
.fillMaxWidth()
.padding(vertical = 10.dp),
enabled = !isUploading,
onClick = { onAdd(message, selectedServer, sensitiveContent, mediaQualitySlider, useH265Codec) },
shape = QuoteBorder,
colors =

Some files were not shown because too many files have changed in this diff Show More