mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 00:16:59 +00:00
Merge branch 'main' into claude/event-sync-screen-sYGtN
This commit is contained in:
+20
-6
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Executable
+180
@@ -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."
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
@@ -12,7 +12,7 @@ echo "$JAVA_HOME"
|
||||
echo "$(java -version)"
|
||||
echo "Running test... "
|
||||
|
||||
./gradlew test
|
||||
./gradlew test --quiet
|
||||
|
||||
status=$?
|
||||
|
||||
|
||||
@@ -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
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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 },
|
||||
),
|
||||
)
|
||||
|
||||
+1
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -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://")
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-6
@@ -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(
|
||||
|
||||
+1
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+40
-1
@@ -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.
|
||||
|
||||
+1
-2
@@ -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) {
|
||||
|
||||
+1
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -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) {
|
||||
|
||||
+2
-2
@@ -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)
|
||||
}
|
||||
|
||||
+1
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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)
|
||||
}
|
||||
|
||||
+1
-2
@@ -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) {
|
||||
|
||||
+1
-2
@@ -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) {
|
||||
|
||||
+1
-2
@@ -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) {
|
||||
|
||||
+1
-2
@@ -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) {
|
||||
|
||||
+1
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -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)
|
||||
|
||||
+1
-2
@@ -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) {
|
||||
|
||||
+3
-3
@@ -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,
|
||||
|
||||
+103
@@ -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
|
||||
}
|
||||
}
|
||||
+1
@@ -79,6 +79,7 @@ class MergedFollowListsState(
|
||||
communities = community.mapTo(mutableSetOf()) { it.address.toValue() },
|
||||
)
|
||||
|
||||
@OptIn(kotlinx.coroutines.FlowPreview::class)
|
||||
val flow: StateFlow<AllFollows> =
|
||||
combine(
|
||||
listOf(
|
||||
|
||||
+1
-2
@@ -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))
|
||||
|
||||
+12
-18
@@ -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)
|
||||
}
|
||||
|
||||
+75
@@ -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
|
||||
}
|
||||
}
|
||||
+43
@@ -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
|
||||
}
|
||||
+6
-42
@@ -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()
|
||||
|
||||
|
||||
+116
@@ -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))
|
||||
}
|
||||
+29
-25
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+5
-2
@@ -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
|
||||
|
||||
+67
@@ -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()
|
||||
+1
-15
@@ -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,
|
||||
|
||||
+5
-6
@@ -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
|
||||
}
|
||||
|
||||
+6
-8
@@ -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)
|
||||
|
||||
+5
-3
@@ -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))
|
||||
|
||||
+3
-4
@@ -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)
|
||||
|
||||
+54
-18
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+3
-3
@@ -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 ->
|
||||
|
||||
+11
-3
@@ -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)
|
||||
}
|
||||
|
||||
+150
@@ -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"
|
||||
}
|
||||
}
|
||||
+43
@@ -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)
|
||||
}
|
||||
}
|
||||
+114
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -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(
|
||||
|
||||
+47
@@ -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
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -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) {
|
||||
|
||||
+5
-3
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+108
@@ -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)
|
||||
}
|
||||
+36
-10
@@ -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) }
|
||||
|
||||
+9
@@ -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()
|
||||
|
||||
+6
@@ -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
|
||||
|
||||
|
||||
+58
-8
@@ -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
|
||||
}
|
||||
|
||||
+61
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+192
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
@@ -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
|
||||
}
|
||||
+1
-1
@@ -292,7 +292,7 @@ private fun MyLoadUrlPreviewDirectFillWidth(
|
||||
|
||||
is UrlPreviewState.Loading -> {
|
||||
WaitAndDisplay {
|
||||
DisplayUrlWithLoadingSymbol(url)
|
||||
DisplayUrlWithLoadingSymbol(url, accountViewModel.toastManager::toast)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -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
Reference in New Issue
Block a user