Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:49 +00:00
commit 9a73aaf629
2067 changed files with 472108 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
# Ignore everything except what the demo Dockerfiles need
*
# Allow neode-ui (frontend + mock backend + docker configs)
!neode-ui/
# Allow demo assets (AIUI pre-built dist)
!demo/
# Allow the Bitcoin UI + ElectrumX UI mock shells (served from /docker/*)
!docker/
docker/*
!docker/bitcoin-ui/
!docker/electrs-ui/
!docker/lnd-ui/
!docker/fedimint-ui/
# Allow backend source for ISO source builds
!core/
!scripts/
!image-recipe/
image-recipe/build/
image-recipe/results/
image-recipe/output/
# Exclude nested node_modules (will npm install in container)
neode-ui/node_modules
neode-ui/dist
+62
View File
@@ -0,0 +1,62 @@
name: Build Archipelago release ISO (gated)
# Resurrected from image-recipe/_archived/.gitea-workflows/build-iso-dev.yml.
# Dispatch-only on purpose: the ISO is cut per release, not per push, and
# the iso-builder runner is a live node — builds are deliberate events.
on:
workflow_dispatch:
jobs:
build-iso:
runs-on: iso-builder
timeout-minutes: 180
steps:
- name: Sync source to workspace
run: |
# Direct fetch + sync (actions/checkout token is broken on this Gitea)
REPO_DIR="$HOME/Projects/archy"
[ -d "$REPO_DIR" ] || REPO_DIR="$HOME/archy"
cd "$REPO_DIR" && git fetch origin main && git reset --hard origin/main
echo "=== Source at commit: $(git log --oneline -1) ==="
- name: Install ISO build dependencies
run: |
if dpkg -s debootstrap squashfs-tools xorriso isolinux syslinux-common mtools \
grub-efi-amd64-bin grub-pc-bin grub-common >/dev/null 2>&1; then
echo "ISO build deps already installed, skipping apt"
else
sudo apt-get update -qq
sudo apt-get install -y -qq \
debootstrap squashfs-tools xorriso \
isolinux syslinux-common mtools \
grub-efi-amd64-bin grub-pc-bin grub-common
fi
- name: Build backend + frontend if stale
run: |
REPO_DIR="$HOME/Projects/archy"
[ -d "$REPO_DIR" ] || REPO_DIR="$HOME/archy"
cd "$REPO_DIR"
. "$HOME/.cargo/env" 2>/dev/null || true
VERSION=$(grep -m1 '^version' core/archipelago/Cargo.toml | sed 's/.*"\(.*\)".*/\1/')
if ! strings core/target/release/archipelago 2>/dev/null | grep -qF "$VERSION"; then
cargo build --release --manifest-path core/Cargo.toml -p archipelago
fi
if ! grep -rqoF "$VERSION" web/dist/neode-ui/assets/*.js 2>/dev/null; then
(cd neode-ui && npm ci && npm run build)
fi
- name: Gated ISO build (gates + build + smoke + qemu)
run: |
REPO_DIR="$HOME/Projects/archy"
[ -d "$REPO_DIR" ] || REPO_DIR="$HOME/archy"
cd "$REPO_DIR"
. "$HOME/.cargo/env" 2>/dev/null || true
bash scripts/build-iso-release.sh
- name: Report artifacts
if: always()
run: |
REPO_DIR="$HOME/Projects/archy"
[ -d "$REPO_DIR" ] || REPO_DIR="$HOME/archy"
ls -lh "$REPO_DIR"/image-recipe/results/*.iso 2>/dev/null | tail -3 || echo "no ISO produced"
+74
View File
@@ -0,0 +1,74 @@
name: Demo images
# Builds and pushes the public-demo images on every change to the UI / mock
# backend, so the separated `archy-demo` Portainer stack auto-tracks the real
# code (see demo-deploy/ and docs/demo-deployment-design.md).
#
# Required repo configuration:
# vars.DEMO_REGISTRY e.g. source.archipelago-foundation.org/lfg2025
# vars.DEMO_REGISTRY_HOST registry host for docker login (no org suffix)
# secrets.DEMO_REGISTRY_USER
# secrets.DEMO_REGISTRY_TOKEN
# Optional:
# secrets.PORTAINER_WEBHOOK redeploy hook called after a successful push
on:
push:
branches: [main]
paths:
- 'neode-ui/**'
- 'docker-compose.demo.yml'
- '.gitea/workflows/demo-images.yml'
workflow_dispatch:
jobs:
build:
name: Build & push demo images
runs-on: ubuntu-latest
# Skip cleanly on forks / before registry config is set.
if: ${{ vars.DEMO_REGISTRY != '' }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
# The demo registry is plain HTTP — teach buildkit to push without TLS
# (the host docker daemon needs it in insecure-registries for login too).
buildkitd-config-inline: |
[registry."${{ vars.DEMO_REGISTRY_HOST || vars.DEMO_REGISTRY }}"]
http = true
- name: Log in to registry
uses: docker/login-action@v3
with:
registry: ${{ vars.DEMO_REGISTRY_HOST || vars.DEMO_REGISTRY }}
username: ${{ secrets.DEMO_REGISTRY_USER }}
password: ${{ secrets.DEMO_REGISTRY_TOKEN }}
- name: Build & push backend
uses: docker/build-push-action@v6
with:
context: .
file: neode-ui/Dockerfile.backend
push: true
tags: |
${{ vars.DEMO_REGISTRY }}/archy-demo-backend:demo
${{ vars.DEMO_REGISTRY }}/archy-demo-backend:${{ github.sha }}
- name: Build & push web
uses: docker/build-push-action@v6
with:
context: .
file: neode-ui/Dockerfile.web
push: true
build-args: |
VITE_DEMO=1
tags: |
${{ vars.DEMO_REGISTRY }}/archy-demo-web:demo
${{ vars.DEMO_REGISTRY }}/archy-demo-web:${{ github.sha }}
- name: Trigger Portainer redeploy
if: ${{ success() && secrets.PORTAINER_WEBHOOK != '' }}
run: curl -fsS -X POST "${{ secrets.PORTAINER_WEBHOOK }}"
+83
View File
@@ -0,0 +1,83 @@
name: Post-Install Tests
on:
workflow_dispatch:
inputs:
target:
description: 'Target node IP or hostname'
required: true
password:
description: 'Node UI password (leave blank to use the NODE_UI_PASSWORD secret)'
required: false
jobs:
post-install-tests:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Install SSH key
env:
SSH_KEY: ${{ secrets.NODE_SSH_KEY }}
run: |
if [ -z "$SSH_KEY" ]; then
echo "ERROR: repository secret NODE_SSH_KEY is not configured."
echo "Post-install tests authenticate by key; password auth is not supported."
exit 1
fi
mkdir -p ~/.ssh && chmod 700 ~/.ssh
printf '%s\n' "$SSH_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
- name: Run post-install tests on target
env:
TARGET: ${{ github.event.inputs.target }}
NODE_PASSWORD: ${{ github.event.inputs.password }}
NODE_UI_PASSWORD: ${{ secrets.NODE_UI_PASSWORD }}
SSH_USER: ${{ vars.NODE_SSH_USER }}
run: |
PASSWORD="${NODE_PASSWORD:-$NODE_UI_PASSWORD}"
if [ -z "$PASSWORD" ]; then
echo "ERROR: no node password supplied (input or NODE_UI_PASSWORD secret)."
exit 1
fi
USER_NAME="${SSH_USER:-archipelago}"
echo "══════════════════════════════════════════"
echo "Running post-install tests on $TARGET"
echo "══════════════════════════════════════════"
scp -o StrictHostKeyChecking=accept-new \
scripts/run-post-install-tests.sh \
"${USER_NAME}@${TARGET}:/tmp/run-post-install-tests.sh"
# Password is passed over stdin, never as an argv the node's process
# list (or this job's log) would expose.
printf '%s' "$PASSWORD" | ssh -o StrictHostKeyChecking=accept-new \
"${USER_NAME}@${TARGET}" \
"sudo bash /tmp/run-post-install-tests.sh --password-stdin"
frontend-tests:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Install dependencies
run: cd neode-ui && npm ci
- name: Type check
run: cd neode-ui && npx vue-tsc -b --noEmit
- name: Run tests
run: cd neode-ui && npx vitest run
- name: Audit dependencies
run: cd neode-ui && npm audit --omit=dev
+78
View File
@@ -0,0 +1,78 @@
name: App Submission
description: Submit an app for the Archipelago marketplace
title: "[App]: "
labels: ["app-submission"]
body:
- type: input
id: app_name
attributes:
label: App Name
placeholder: My Bitcoin App
validations:
required: true
- type: input
id: docker_image
attributes:
label: Container Image
description: Full image reference with tag (no :latest)
placeholder: "ghcr.io/org/app:1.2.3"
validations:
required: true
- type: textarea
id: description
attributes:
label: Description
description: What does this app do?
validations:
required: true
- type: input
id: homepage
attributes:
label: Homepage / Repository
placeholder: "https://github.com/..."
- type: dropdown
id: category
attributes:
label: Category
options:
- Bitcoin
- Lightning
- Privacy
- Storage
- Communication
- Development
- Other
validations:
required: true
- type: checkboxes
id: requirements
attributes:
label: App Requirements Met
options:
- label: Runs as non-root user (UID > 1000)
required: true
- label: No `latest` tag — pinned version
required: true
- label: "Supports x86_64"
required: true
- label: "Supports ARM64"
- label: Tested on Archipelago hardware
required: true
- type: textarea
id: ports
attributes:
label: Required Ports
description: List ports the app needs exposed
placeholder: "8080 (web UI), 9735 (Lightning)"
- type: textarea
id: dependencies
attributes:
label: Dependencies
description: Does this app require other apps (e.g., Bitcoin, LND)?
+81
View File
@@ -0,0 +1,81 @@
name: Bug Report
description: Report a bug in Archipelago
title: "[Bug]: "
labels: ["bug", "triage"]
body:
- type: markdown
attributes:
value: |
Thank you for reporting a bug. Please fill out the sections below.
- type: textarea
id: description
attributes:
label: Description
description: A clear description of the bug.
placeholder: What happened?
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to Reproduce
description: Minimal steps to reproduce the issue.
placeholder: |
1. Go to '...'
2. Click on '...'
3. See error
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected Behavior
description: What should have happened?
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual Behavior
description: What actually happened?
validations:
required: true
- type: input
id: version
attributes:
label: Archipelago Version
description: Check Settings page or run `archipelago --version`
placeholder: "0.1.0"
validations:
required: true
- type: dropdown
id: hardware
attributes:
label: Hardware
options:
- x86_64 (Intel/AMD)
- ARM64 (Raspberry Pi 5)
- ARM64 (Other)
- Other
validations:
required: true
- type: textarea
id: logs
attributes:
label: Relevant Logs
description: |
Run `journalctl -u archipelago --since "1 hour ago"` and paste relevant output.
render: shell
- type: textarea
id: screenshots
attributes:
label: Screenshots
description: If applicable, add screenshots.
+5
View File
@@ -0,0 +1,5 @@
blank_issues_enabled: true
contact_links:
- name: Security Vulnerability
url: mailto:security@archipelago-os.org
about: Do NOT open public issues for security vulnerabilities. Email us directly.
@@ -0,0 +1,44 @@
name: Feature Request
description: Suggest a new feature or improvement
title: "[Feature]: "
labels: ["enhancement"]
body:
- type: textarea
id: problem
attributes:
label: Problem
description: What problem does this solve?
placeholder: I'm always frustrated when...
validations:
required: true
- type: textarea
id: solution
attributes:
label: Proposed Solution
description: How should this work?
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives Considered
description: What other approaches did you consider?
- type: dropdown
id: area
attributes:
label: Area
options:
- Web UI
- Backend / API
- App Management
- Networking
- Security
- Web5 / Identity
- ISO / Installation
- Documentation
- Other
validations:
required: true
+16
View File
@@ -0,0 +1,16 @@
## Summary
<!-- What changed and why? -->
## Verification
<!-- Commands run, devices tested, screenshots, or reason testing was not run. -->
## Checklist
- [ ] Rust formatting/clippy/tests pass when backend code changed.
- [ ] Frontend type-check/build/tests pass when frontend code changed.
- [ ] App manifests validate when app packaging changed.
- [ ] Generated catalogs are updated when manifest-owned catalog fields changed.
- [ ] Docs are updated for user-facing or developer-facing behavior changes.
- [ ] No secrets, generated build outputs, local screenshots, or private host details are included.
+219
View File
@@ -0,0 +1,219 @@
name: macOS Production Build
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
version:
description: 'Version number (e.g., 0.1.0)'
required: true
default: '0.1.0'
env:
RUST_VERSION: stable
NODE_VERSION: 18
jobs:
build-macos:
name: Build macOS App
runs-on: macos-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set version
id: version
run: |
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
VERSION="${{ github.event.inputs.version }}"
else
VERSION="${GITHUB_REF#refs/tags/v}"
fi
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
echo "Building version: $VERSION"
- name: Setup Rust
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: ${{ env.RUST_VERSION }}
components: rustfmt, clippy
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
cache-dependency-path: neode-ui/package-lock.json
- name: Install frontend dependencies
working-directory: neode-ui
run: npm ci
- name: Build Rust backend (Release)
working-directory: core
run: |
cargo build --release --workspace
strip target/release/archipelago
ls -lh target/release/archipelago
- name: Build Vue.js frontend (Production)
working-directory: neode-ui
run: |
npm run build:production
ls -lh dist/
- name: Run production build script
env:
ARCHIPELAGO_VERSION: ${{ steps.version.outputs.VERSION }}
run: |
chmod +x build-macos-production.sh
./build-macos-production.sh
- name: Verify build artifacts
run: |
ls -lh build/macos/
if [ ! -d "build/macos/Archipelago.app" ]; then
echo "❌ App bundle not found!"
exit 1
fi
if [ ! -f "build/macos/Archipelago-${{ steps.version.outputs.VERSION }}-macOS.dmg" ]; then
echo "⚠️ DMG not created (optional)"
fi
- name: Code sign (if credentials available)
if: ${{ secrets.MACOS_CERTIFICATE != '' }}
env:
MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }}
MACOS_CERTIFICATE_PWD: ${{ secrets.MACOS_CERTIFICATE_PWD }}
KEYCHAIN_PWD: ${{ secrets.KEYCHAIN_PWD }}
run: |
# Import certificate
echo "$MACOS_CERTIFICATE" | base64 --decode > certificate.p12
security create-keychain -p "$KEYCHAIN_PWD" build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "$KEYCHAIN_PWD" build.keychain
security import certificate.p12 -k build.keychain -P "$MACOS_CERTIFICATE_PWD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PWD" build.keychain
# Sign the app
codesign --deep --force --verify --verbose \
--sign "Developer ID Application" \
--options runtime \
build/macos/Archipelago.app
# Verify
codesign --verify --verbose build/macos/Archipelago.app
- name: Notarize (if credentials available)
if: ${{ secrets.APPLE_ID != '' }}
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }}
run: |
# Create zip for notarization
ditto -c -k --keepParent build/macos/Archipelago.app Archipelago.zip
# Submit for notarization
xcrun notarytool submit Archipelago.zip \
--apple-id "$APPLE_ID" \
--team-id "$APPLE_TEAM_ID" \
--password "$APPLE_APP_PASSWORD" \
--wait
# Staple
xcrun stapler staple build/macos/Archipelago.app
# Recreate DMG with notarized app
rm -f build/macos/Archipelago-${{ steps.version.outputs.VERSION }}-macOS.dmg
hdiutil create -volname "Archipelago ${{ steps.version.outputs.VERSION }}" \
-srcfolder build/macos/Archipelago.app \
-ov -format UDZO \
build/macos/Archipelago-${{ steps.version.outputs.VERSION }}-macOS.dmg
xcrun stapler staple build/macos/Archipelago-${{ steps.version.outputs.VERSION }}-macOS.dmg
- name: Create checksums
working-directory: build/macos
run: |
if [ -f "Archipelago-${{ steps.version.outputs.VERSION }}-macOS.dmg" ]; then
shasum -a 256 "Archipelago-${{ steps.version.outputs.VERSION }}-macOS.dmg" > checksums.txt
fi
cat checksums.txt || echo "No DMG to checksum"
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: Archipelago-${{ steps.version.outputs.VERSION }}-macOS
path: |
build/macos/Archipelago.app
build/macos/*.dmg
build/macos/checksums.txt
retention-days: 30
- name: Create GitHub Release
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v1
with:
files: |
build/macos/Archipelago-${{ steps.version.outputs.VERSION }}-macOS.dmg
build/macos/checksums.txt
draft: true
generate_release_notes: true
body: |
## Archipelago v${{ steps.version.outputs.VERSION }}
### 🎉 macOS Release
**Download**: `Archipelago-${{ steps.version.outputs.VERSION }}-macOS.dmg`
### Installation
1. Download the DMG file
2. Open and drag Archipelago to Applications
3. Install [Docker Desktop](https://www.docker.com/products/docker-desktop)
4. Launch Archipelago
### What's New
See [CHANGELOG.md](https://github.com/${{ github.repository }}/blob/main/CHANGELOG.md)
### System Requirements
- macOS 10.15 (Catalina) or later
- 8GB RAM minimum (16GB recommended)
- Docker Desktop 23.0+
### Checksums
See `checksums.txt` for SHA-256 verification
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
test-build:
name: Test Build (No Artifacts)
runs-on: macos-latest
if: github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Rust
uses: actions-rust-lang/setup-rust-toolchain@v1
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Test backend build
working-directory: core
run: cargo build --release
- name: Test frontend build
working-directory: neode-ui
run: |
npm ci
npm run build:production
+123
View File
@@ -0,0 +1,123 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
RUST_VERSION: stable
NODE_VERSION: 20
jobs:
rust:
name: Rust
runs-on: ubuntu-latest
defaults:
run:
working-directory: core
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Rust
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: ${{ env.RUST_VERSION }}
components: rustfmt, clippy
- name: Format
run: cargo fmt --all -- --check
# KEY-05 layer (b) is enforced HERE, with no step of its own: core/clippy.toml
# bans the defaulted RNG entry points, and `-D warnings` already turns a
# `disallowed_methods` hit into a build failure. `--all-targets` covers tests
# too, deliberately. See docs/security/KEY-05-ENTROPY-ENFORCEMENT.md
- name: Clippy
run: cargo clippy --all-targets --all-features -- -D warnings
# KEY-05 layer (c) — see core/deny.toml for the policy and its rationale.
#
# The version is pinned deliberately. EmbarkStudios/cargo-deny-action exposes
# no input to pin the cargo-deny version, and an unpinned supply-chain checker
# is a contradiction in terms, so the tool is installed from crates.io — the
# source actually vetted at the 10-06 Task 5 legitimacy checkpoint — rather
# than by adding another unvetted action to this workflow.
#
# `check bans` ONLY: the advisories gate is not enabled (bans-only policy).
- name: Supply chain (cargo-deny)
run: |
cargo install --locked cargo-deny --version 0.20.2
cargo deny check bans
- name: Test
run: cargo test --all-features
frontend:
name: Frontend
runs-on: ubuntu-latest
defaults:
run:
working-directory: neode-ui
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
cache-dependency-path: neode-ui/package-lock.json
- name: Install
run: npm ci
- name: Type check
run: npm run type-check
- name: Test
run: npm test
- name: Build
run: npm run build
manifests:
name: App Manifests
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install YAML parser
run: python3 -m pip install --quiet pyyaml
- name: Validate manifests
run: |
for manifest in apps/*/manifest.yml; do
./scripts/validate-app-manifest.sh --repo-audit "$manifest"
done
# The signed catalog overrides on-disk manifests on every node, so a
# catalog naming a registry host the deployed fleet does not trust breaks
# every install fleet-wide. Blocking, and cheap.
- name: Catalog registry trust floor
run: python3 scripts/check-catalog-registry-trust.py
# A stale image literal on the fallback install path deploys an old
# image after the manifest has moved on — how a withdrawn, vulnerable
# release gets installed post-fix. Blocking.
- name: Installer image pins
run: python3 scripts/check-installer-image-pins.py
# Advisory: shows where the release catalog has fallen behind the
# manifests in this repo. Not blocking, because the catalog can only be
# updated through the signing ceremony, so drift is expected between a
# manifest landing and the next signed release.
- name: Catalog drift (advisory)
continue-on-error: true
run: python3 scripts/check-app-catalog-drift.py --catalog releases/app-catalog.json --release
+74
View File
@@ -0,0 +1,74 @@
name: Demo images
# Builds and pushes the public-demo images on every change to the UI / mock
# backend, so the separated `archy-demo` Portainer stack auto-tracks the real
# code (see demo-deploy/ and docs/demo-deployment-design.md).
#
# Required repo configuration:
# vars.DEMO_REGISTRY e.g. source.archipelago-foundation.org/lfg2025
# vars.DEMO_REGISTRY_HOST registry host for docker login (no org suffix)
# secrets.DEMO_REGISTRY_USER
# secrets.DEMO_REGISTRY_TOKEN
# Optional:
# secrets.PORTAINER_WEBHOOK redeploy hook called after a successful push
on:
push:
branches: [main]
paths:
- 'neode-ui/**'
- 'docker-compose.demo.yml'
- '.github/workflows/demo-images.yml'
workflow_dispatch:
jobs:
build:
name: Build & push demo images
runs-on: ubuntu-latest
# Skip cleanly on forks / before registry config is set.
if: ${{ vars.DEMO_REGISTRY != '' }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
# The demo registry is plain HTTP — teach buildkit to push without TLS
# (the host docker daemon needs it in insecure-registries for login too).
buildkitd-config-inline: |
[registry."${{ vars.DEMO_REGISTRY_HOST || vars.DEMO_REGISTRY }}"]
http = true
- name: Log in to registry
uses: docker/login-action@v3
with:
registry: ${{ vars.DEMO_REGISTRY_HOST || vars.DEMO_REGISTRY }}
username: ${{ secrets.DEMO_REGISTRY_USER }}
password: ${{ secrets.DEMO_REGISTRY_TOKEN }}
- name: Build & push backend
uses: docker/build-push-action@v6
with:
context: .
file: neode-ui/Dockerfile.backend
push: true
tags: |
${{ vars.DEMO_REGISTRY }}/archy-demo-backend:demo
${{ vars.DEMO_REGISTRY }}/archy-demo-backend:${{ github.sha }}
- name: Build & push web
uses: docker/build-push-action@v6
with:
context: .
file: neode-ui/Dockerfile.web
push: true
build-args: |
VITE_DEMO=1
tags: |
${{ vars.DEMO_REGISTRY }}/archy-demo-web:demo
${{ vars.DEMO_REGISTRY }}/archy-demo-web:${{ github.sha }}
- name: Trigger Portainer redeploy
if: ${{ success() && secrets.PORTAINER_WEBHOOK != '' }}
run: curl -fsS -X POST "${{ secrets.PORTAINER_WEBHOOK }}"
+164
View File
@@ -0,0 +1,164 @@
# SSH keys and sandbox copies
.ssh/
# Rust build output
target/
**/target/
# Node.js
node_modules/
**/node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Build outputs
dist/
dist-ssr/
build/
*.local
# Vite build cache
neode-ui/.vite/
# IDE / editor
.idea/
.vscode/
*.swp
*.swo
*~
.DS_Store
._*
Thumbs.db
# Environment and local overrides
.env
.env.local
.env.*.local
.env.production
core/.env.production
scripts/deploy-config.sh
# Logs
logs/
*.log
# Testing
coverage/
.nyc_output/
# Image / release artifacts
*.iso
*.img
*.dmg
*.app
*.apk
*.keystore
*.s9pk
*.tar.gz
# Release artifacts live in release attachments, not Git history.
releases/**
!releases/
!releases/manifest.json
# The signed app catalog and the registry trust floor are source, not build
# output: nodes fetch the catalog from this path on main, and the floor is what
# scripts/check-catalog-registry-trust.py checks it against. Both were being
# swallowed by the rule above — app-catalog.json only stayed tracked because it
# predates it.
!releases/app-catalog.json
!releases/registry-trust-floor.json
# Image recipe output
image-recipe/output/
image-recipe/*.iso
image-recipe/*.img
# Loop tool artifacts
*/loop/
loop/loop/
loop/loop.log.bak
# Separate repos nested in tree
web/
# Resilience harness reports contain session cookies.
scripts/resilience/reports/
# Codex / pnpm / python caches / editor backups
.codex
.codex-target-*/
.codex-tmp/
.claude/
.pnpm-store/
# Key material and local databases — belt-and-braces so a stray key or a
# copied node database can never be committed. Open-source readiness plan,
# Phase 1 item 5: `.claude/settings.local.json` was previously only caught by
# a machine-global ignore rule, which protects one machine and no contributor.
*.key
*.pem
id_rsa*
*.sqlite
*.sqlite3
*.db
# ...except the throwaway TLS fixtures the appgate tests compile in via
# include_bytes!. They are documented non-identity material (see that
# directory's README) and are already tracked; the negation stops the rule
# above from silently dropping them if they are ever regenerated.
!core/archipelago/src/appgate/testdata/*.key
**/__pycache__/
*.bak
# Local evidence screenshots; intentional UI screenshots should live under an
# app/docs asset path with a descriptive filename.
Screenshot *.png
uploads/
# ── Local-only material ─────────────────────────────────────────────────────
# Present on disk, never tracked: everything describing Archipelago's own
# infrastructure or internal development process. The repo is source code and
# guidelines only. Inventory: .local-only/manifest.txt — wipe: .local-only/wipe.sh
/.local-only/
/.planning/
/loop/
/docs/operations-runbook.md
/docs/hotfix-process.md
/docs/PRODUCTION-MASTER-PLAN.md
/docs/UNIFIED-TASK-TRACKER.md
/docs/FIPS-UPTIME-AND-UI-STATE-PLAN.md
/docs/HANDOFF-2026-07-20-fips-peer-files.md
/docs/HANDOFF-2026-07-23-companion-apk-deploy.md
/docs/qr-scanner-snappiness-handover.md
/docs/RETICULUM-TRANSPORT-PROGRESS.md
/docs/combined-test-plan-2026-07-22.md
/docs/pine-voice-release-test-plan.md
/docs/OPEN-SOURCE-READINESS-PLAN.md
/docs/archive/HANDOVER-2026-07-02-iso-feedback.md
/docs/archive/SESSION-1.8.0-OTA-PROGRESS.md
/docs/security/KEY-02-FLEET-ROTATION.md
/docs/security/KEY-03-SIGNING-POSTURE.md
/tests/production-quality/TRACKER.md
/scripts/deploy-config-defaults.sh
/scripts/deploy-tailscale.sh
/scripts/deploy-to-target.sh
/scripts/setup-target-dev.sh
/scripts/setup-aiui-server.sh
/scripts/setup-https-dev.sh
/scripts/debug-frontend.sh
/scripts/node-profile.sh
/scripts/fleet-fips-pair.sh
/scripts/fleet-fips-unpair.sh
/image-recipe/sync-from-live.sh
/docs/security/PHASE-10-VERIFICATION-GUIDE.md
/docs/security/KEY-01-ON-NODE-VERIFICATION.md
/docs/security/KEY-02-ROOTFS-EVIDENCE.md
/docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md
/image-recipe/INTEGRATION-GUIDE.md
/docs/multinode-testing-plan.md
/docs/bitcoin-version-bulletproof-rollout.md
# Generated PWA dev output (vite-plugin-pwa) — never a source artifact
neode-ui/dev-dist/
+25
View File
@@ -0,0 +1,25 @@
*.iml
.gradle
/local.properties
/.idea
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties
/app/build
/app/release
*.apk
*.aab
*.jks
*.keystore
# Exception: the repo-dedicated *debug* keystore is committed on purpose so every
# machine (and the published companion download) signs debug builds identically —
# updates then install over the top without an uninstall. Debug keys are not
# secret (well-known password "android"); never commit a real release keystore.
!/app/debug.keystore
# Rust build outputs (archy-fips-core → jniLibs via buildRustArm64)
/rust/archy-fips-core/target
/app/src/main/jniLibs
+101
View File
@@ -0,0 +1,101 @@
# Companion App — Build, Ship & "App Not Installed" Runbook
Canonical procedure for releasing the Archipelago Companion Android app and for
debugging install failures. Read this before touching the companion release flow.
Hard lessons from 2026-06-26 are baked in below — don't relearn them.
## Ship the companion (the only sanctioned way)
```bash
./Android/ship-companion.sh
```
This calls `scripts/publish-companion-apk.sh` (the single source of truth, also
used by the `.githooks/pre-push` hook), which:
1. **Removes/rejects resource dirs whose names contain spaces.** Empty stray
`mipmap-* NNN` dirs (left by icon-export tools) break a *clean* build with
`Invalid resource directory name`. Incremental builds hide them — clean builds
don't.
2. **Always does a CLEAN build** (`:app:clean :app:assembleDebug`).
3. **Forces v1 + v2 + v3 signing** via `zipalign` + `apksigner`.
4. **Verifies all three schemes** (`apksigner verify --min-sdk-version 21`) and
**aborts** if any is missing.
5. Stages the signed APK at `neode-ui/public/packages/archipelago-companion.apk`,
commits, and pushes with `SHIP_COMPANION=1` (the sanctioned pre-push bypass).
6. The first-launch companion modal and Android "Share this app" QR point at
`http://146.59.87.168:2100/packages/archipelago-companion.apk`. After the
repo artifact is built, mirror that exact APK to the VPS2-served path before
calling the release done.
**Never** hand-roll `gradlew assembleDebug` + `cp` to the served path. That path
skips the clean build and the signature enforcement and is exactly how a broken
APK shipped.
### Bump the version first
Edit `Android/app/build.gradle.kts``versionCode` (must strictly increase) and
`versionName`. The committed value can drift AHEAD of what's actually built into
the served APK, so verify the served APK's real version after shipping:
`aapt2 dump badging neode-ui/public/packages/archipelago-companion.apk | grep version`.
## Signing facts (important)
- Debug builds are signed with the **committed** `Android/app/debug.keystore`
(store/key pass `android`, alias `androiddebugkey`) so every machine and the
served download share ONE signing key. Cert SHA-256: `D6:22:E0:7E:…:66:4D`.
- **AGP silently ignores `enableV1Signing = true` for `minSdk ≥ 24`**, so a plain
gradle build produces a **v2-only** APK. The `apksigner` step in the publish
script is what actually guarantees v1+v2+v3 — do not remove it.
- **Changing the signing key forces every existing install to be uninstalled
once.** Android blocks in-place upgrades across different signatures. Treat the
keystore as permanent; never regenerate it casually.
## Debugging "App Not Installed" — DIAGNOSE FIRST
Do **not** theorize about signing schemes / OEM quirks. Get the real reason:
```bash
adb install ~/Desktop/archipelago-companion-<ver>.apk
# -> Failure [INSTALL_FAILED_<REASON>: ...]
```
Map the reason:
| `INSTALL_FAILED_*` | Cause | Fix |
|---|---|---|
| `UPDATE_INCOMPATIBLE … signatures do not match` | Old install signed with a **different key** (e.g. pre-shared-keystore per-machine key `58:31:12…`). | Uninstall the old package, then install. **One-time** per device after a key change. |
| `INVALID_APK` / parse error | Corrupt/incomplete download or bad signing. | Re-download; re-run the publish script. |
| `INSUFFICIENT_STORAGE` | Storage. | Free space. |
| `OLDER_SDK` | Device below `minSdk` (26 = Android 8.0). | Unsupported device. |
> A manual uninstall on the phone may NOT clear `UPDATE_INCOMPATIBLE` if the
> package is registered under another user/profile — `pm path <pkg>` under user 0
> can show nothing while the conflict persists. `adb uninstall <pkg>` clears it
> across all users.
## Phone / adb safety (non-negotiable)
When acting on the user's physical phone, be surgical — the user once had all
home-screen app layouts wiped by an over-broad action.
- Default to **read-only** adb (`devices`, `getprop`, `pm path/list`, `dumpsys`).
- Mutations (`adb install`, `adb uninstall com.archipelago.app.debug`) only with
explicit go-ahead and **scoped to our exact package** — echo it first.
- **Never** run launcher/system resets: no `pm clear` on launchers, no
`reset-permissions`, no factory wipe, no uninstalling apps you didn't build.
## Verify the published download after shipping
The checked-in artifact is Gitea raw-on-main. The QR/App Store download served
to users is the VPS2 `:2100` URL. Confirm both live byte streams match what you
built and signed:
```bash
SERVED=neode-ui/public/packages/archipelago-companion.apk
GITEA_URL=https://source.archipelago-foundation.org/lfg2025/archy/raw/branch/main/$SERVED
QR_URL=http://146.59.87.168:2100/packages/archipelago-companion.apk
curl -sS -o /tmp/live-gitea.apk "$GITEA_URL"
curl -sS -o /tmp/live-qr.apk "$QR_URL"
shasum -a 256 "$SERVED" /tmp/live-gitea.apk /tmp/live-qr.apk # all must match
apksigner verify -v --min-sdk-version 21 /tmp/live-qr.apk | grep -i "scheme" # v1/v2/v3 = true
```
+93
View File
@@ -0,0 +1,93 @@
Copyright 2011 The Montserrat Project Authors (https://github.com/JulietaUla/Montserrat)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+165
View File
@@ -0,0 +1,165 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.archipelago.app"
compileSdk = 35
defaultConfig {
applicationId = "com.archipelago.app"
minSdk = 26
targetSdk = 35
versionCode = 47
versionName = "0.5.27"
vectorDrawables {
useSupportLibrary = true
}
// The embedded FIPS mesh (libarchy_fips_core.so) is built arm64-only,
// matching real handsets. FipsNative.available gates every call, so
// the app still runs as a plain companion elsewhere (e.g. x86 emu).
ndk { abiFilters += "arm64-v8a" }
}
signingConfigs {
// Repo-dedicated debug keystore (committed at app/debug.keystore) so every
// machine — and the published companion download — signs debug builds with
// the SAME key. Without this, Gradle falls back to each machine's
// ~/.android/debug.keystore, so a build from a different machine has a
// different signature and the phone rejects the update ("App not installed").
getByName("debug") {
storeFile = file("debug.keystore")
storePassword = "android"
keyAlias = "androiddebugkey"
keyPassword = "android"
// Force both legacy JAR (v1) and APK Signature Scheme v2. AGP drops v1
// for minSdk>=24, but some OEM package installers (e.g. Samsung) reject
// a v2-only sideload with "App not installed" — keep v1 for max compat.
enableV1Signing = true
enableV2Signing = true
}
}
buildTypes {
debug {
// Separate app ID so a debug/test build installs alongside the
// release app instead of colliding on signature.
applicationIdSuffix = ".debug"
versionNameSuffix = "-debug"
signingConfig = signingConfigs.getByName("debug")
}
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.14"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
// ---------------------------------------------------------------------------
// Embedded FIPS mesh: cross-compile Android/rust/archy-fips-core via cargo-ndk
// into jniLibs before the native-libs merge, so a plain `gradlew assembleDebug`
// builds the Rust too. Requires rustup target aarch64-linux-android, cargo-ndk,
// and an NDK (ANDROID_NDK_HOME or the SDK's ndk/ dir).
// ---------------------------------------------------------------------------
val rustCrateDir = layout.projectDirectory.dir("../rust/archy-fips-core")
val jniLibsDir = layout.projectDirectory.dir("src/main/jniLibs")
tasks.register<Exec>("buildRustArm64") {
workingDir = rustCrateDir.asFile
inputs.dir(rustCrateDir.dir("src"))
inputs.file(rustCrateDir.file("Cargo.toml"))
outputs.dir(jniLibsDir)
// cargo/cargo-ndk live in ~/.cargo/bin, which Gradle's env may not have.
val home = System.getProperty("user.home")
environment("PATH", "$home/.cargo/bin:${System.getenv("PATH")}")
if (System.getenv("ANDROID_NDK_HOME") == null) {
val sdkNdk = file("$home/Library/Android/sdk/ndk")
.listFiles()?.maxByOrNull { it.name }
if (sdkNdk != null) environment("ANDROID_NDK_HOME", sdkNdk.absolutePath)
}
commandLine(
"cargo", "ndk",
"-t", "arm64-v8a",
"--platform", "26",
"-o", jniLibsDir.asFile.absolutePath,
"build", "--release",
)
}
tasks.matching {
it.name in listOf(
"mergeDebugNativeLibs", "mergeReleaseNativeLibs",
"mergeDebugJniLibFolders", "mergeReleaseJniLibFolders",
)
}.configureEach { dependsOn("buildRustArm64") }
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.05.00")
implementation(composeBom)
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.2")
implementation("androidx.activity:activity-compose:1.9.0")
// Compose
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.compose.animation:animation")
// Navigation
implementation("androidx.navigation:navigation-compose:2.7.7")
// DataStore for preferences
implementation("androidx.datastore:datastore-preferences:1.1.1")
// WebView
implementation("androidx.webkit:webkit:1.11.0")
// Splash screen
implementation("androidx.core:core-splashscreen:1.0.1")
// OkHttp for WebSocket (remote input)
implementation("com.squareup.okhttp3:okhttp:4.12.0")
// CameraX + ZXing (Apache-2.0, on-device, no telemetry) for pairing-QR scanning
implementation("androidx.camera:camera-camera2:1.3.4")
implementation("androidx.camera:camera-lifecycle:1.3.4")
implementation("androidx.camera:camera-view:1.3.4")
implementation("com.google.zxing:core:3.5.3")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
}
+7
View File
@@ -0,0 +1,7 @@
# Keep WebView JavaScript interface
-keepclassmembers class com.archipelago.app.ui.screens.WebViewScreen$* {
public *;
}
# Keep Compose
-dontwarn androidx.compose.**
+75
View File
@@ -0,0 +1,75 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Pairing-QR scanner. Camera is optional: manual entry still works without one. -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
<!-- Embedded FIPS mesh tunnel (ArchyVpnService) runs as a foreground service. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:name=".ArchipelagoApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.Archipelago"
android:usesCleartextTraffic="true"
tools:targetApi="35">
<!-- Party-screen "Share this app": exposes the copied APK from
cache/share/ to the system share sheet, nothing else. -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:theme="@style/Theme.Archipelago.Splash"
android:windowSoftInputMode="adjustResize"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Pairing deep link from the web UI's Companion popup:
archipelago://pair?v=1&url=...[&pw=...] (docs/companion-pairing-qr.md) -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="archipelago" android:host="pair" />
</intent-filter>
</activity>
<!-- Embedded FIPS mesh node: split-tunnel VpnService (fd00::/8 only),
configured entirely by scanning the node's pairing QR. -->
<service
android:name=".fips.ArchyVpnService"
android:exported="false"
android:foregroundServiceType="specialUse"
android:permission="android.permission.BIND_VPN_SERVICE">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="mesh-vpn-tunnel" />
<intent-filter>
<action android:name="android.net.VpnService" />
</intent-filter>
</service>
</application>
</manifest>
+492
View File
@@ -0,0 +1,492 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>Archipelago</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: #000;
color: #f5f5f5;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px;
padding-top: calc(24px + env(safe-area-inset-top, 0px));
overflow: hidden;
-webkit-tap-highlight-color: transparent;
}
/* --- Intro Screen --- */
#intro {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 20px;
animation: fadeIn 0.6s ease;
}
#intro.hidden, #connect.hidden, #connecting.hidden { display: none; }
.logo-container {
width: 88px;
height: 88px;
border-radius: 20px;
overflow: hidden;
border: 2px solid rgba(255,255,255,0.12);
background: #030202;
}
.logo-container svg { width: 100%; height: 100%; }
.logo-square {
opacity: 0;
animation: squareIn 3s ease-out infinite;
}
@keyframes squareIn {
0% { opacity: 0; }
15% { opacity: 1; }
100% { opacity: 1; }
}
.brand-name {
font-size: 12px;
font-weight: 600;
letter-spacing: 6px;
color: #F7931A;
text-transform: uppercase;
}
h1 {
font-size: 28px;
font-weight: 600;
line-height: 1.3;
color: #f5f5f5;
margin-top: 16px;
}
.subtitle {
font-size: 16px;
color: #666;
line-height: 1.6;
max-width: 320px;
}
/* --- Glass Button --- */
.glass-button {
width: 100%;
max-width: 340px;
padding: 16px 24px;
border-radius: 12px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
border: none;
transition: all 0.15s ease;
-webkit-tap-highlight-color: transparent;
}
.glass-button:active { transform: scale(0.97); }
.glass-button-primary {
background: #F7931A;
color: #000;
}
.glass-button-primary:disabled {
background: rgba(247,147,26,0.3);
color: rgba(0,0,0,0.5);
}
.glass-button-outline {
background: rgba(255,255,255,0.06);
color: #f5f5f5;
border: 1px solid rgba(255,255,255,0.12);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
/* --- Connect Screen --- */
#connect {
width: 100%;
max-width: 400px;
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
animation: fadeIn 0.4s ease;
}
.glass-card {
width: 100%;
padding: 20px;
border-radius: 16px;
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.08);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
.form-group { margin-bottom: 16px; }
.form-group:last-child { margin-bottom: 0; }
label {
display: block;
font-size: 13px;
font-weight: 500;
color: rgba(255,255,255,0.5);
margin-bottom: 8px;
}
input[type="text"] {
width: 100%;
padding: 14px 16px;
border-radius: 12px;
border: 1px solid rgba(255,255,255,0.12);
background: transparent;
color: #f5f5f5;
font-size: 16px;
font-family: inherit;
outline: none;
transition: border-color 0.2s;
}
input[type="text"]:focus {
border-color: #F7931A;
}
input[type="text"]::placeholder {
color: rgba(255,255,255,0.25);
}
.port-row {
display: flex;
gap: 12px;
align-items: flex-end;
}
.port-input { width: 120px; }
.toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 0;
}
.toggle-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: rgba(255,255,255,0.7);
}
.toggle-label svg { width: 18px; height: 18px; opacity: 0.5; }
/* Toggle switch */
.toggle {
position: relative;
width: 48px;
height: 28px;
-webkit-appearance: none;
appearance: none;
background: rgba(255,255,255,0.12);
border-radius: 14px;
border: none;
cursor: pointer;
transition: background 0.2s;
}
.toggle:checked { background: #F7931A; }
.toggle::before {
content: '';
position: absolute;
top: 3px;
left: 3px;
width: 22px;
height: 22px;
border-radius: 50%;
background: #fff;
transition: transform 0.2s;
}
.toggle:checked::before { transform: translateX(20px); }
/* Error */
.error-msg {
width: 100%;
padding: 12px 16px;
border-radius: 12px;
background: rgba(239,68,68,0.12);
border: 1px solid rgba(239,68,68,0.25);
color: #ef4444;
font-size: 14px;
display: none;
}
.error-msg.visible { display: block; }
/* Saved servers */
.saved-title {
font-size: 11px;
font-weight: 600;
letter-spacing: 1px;
color: rgba(255,255,255,0.3);
text-transform: uppercase;
}
.saved-item {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-radius: 12px;
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.08);
cursor: pointer;
transition: background 0.15s;
}
.saved-item:active { background: rgba(255,255,255,0.08); }
.saved-addr {
font-size: 14px;
color: #f5f5f5;
}
.saved-remove {
background: none;
border: none;
color: rgba(255,255,255,0.3);
font-size: 18px;
cursor: pointer;
padding: 4px 8px;
}
/* Connecting overlay */
#connecting {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
animation: fadeIn 0.3s ease;
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid rgba(247,147,26,0.2);
border-top-color: #F7931A;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
/* Hide scrollbar */
::-webkit-scrollbar { display: none; }
</style>
</head>
<body>
<!-- Intro -->
<div id="intro">
<div class="logo-container">
<svg viewBox="0 0 1024 1024" fill="none">
<rect width="1024" height="1024" fill="#030202"/>
<rect x="357.614" y="318" width="71.007" height="70.936" fill="white" class="logo-square" style="animation-delay:0ms"/>
<rect x="436.152" y="318" width="72.082" height="70.936" fill="white" class="logo-square" style="animation-delay:100ms"/>
<rect x="515.766" y="318" width="72.082" height="70.936" fill="white" class="logo-square" style="animation-delay:200ms"/>
<rect x="595.379" y="318" width="71.007" height="70.936" fill="white" class="logo-square" style="animation-delay:300ms"/>
<rect x="595.379" y="396.46" width="71.007" height="72.011" fill="white" class="logo-square" style="animation-delay:400ms"/>
<rect x="673.917" y="396.46" width="72.083" height="72.011" fill="white" class="logo-square" style="animation-delay:500ms"/>
<rect x="278" y="475.994" width="72.083" height="72.012" fill="white" class="logo-square" style="animation-delay:600ms"/>
<rect x="357.614" y="475.994" width="71.007" height="72.012" fill="white" class="logo-square" style="animation-delay:700ms"/>
<rect x="436.152" y="475.994" width="72.082" height="72.012" fill="white" class="logo-square" style="animation-delay:800ms"/>
<rect x="515.766" y="475.994" width="72.082" height="72.012" fill="white" class="logo-square" style="animation-delay:900ms"/>
<rect x="595.379" y="475.994" width="71.007" height="72.012" fill="white" class="logo-square" style="animation-delay:1000ms"/>
<rect x="673.917" y="475.994" width="72.083" height="72.012" fill="white" class="logo-square" style="animation-delay:1100ms"/>
<rect x="278" y="555.529" width="72.083" height="70.936" fill="white" class="logo-square" style="animation-delay:1200ms"/>
<rect x="357.614" y="555.529" width="71.007" height="70.936" fill="white" class="logo-square" style="animation-delay:1300ms"/>
<rect x="595.379" y="555.529" width="71.007" height="70.936" fill="white" class="logo-square" style="animation-delay:1400ms"/>
<rect x="673.917" y="555.529" width="72.083" height="70.936" fill="white" class="logo-square" style="animation-delay:1500ms"/>
<rect x="357.614" y="633.989" width="71.007" height="72.011" fill="white" class="logo-square" style="animation-delay:1600ms"/>
<rect x="436.152" y="633.989" width="72.082" height="72.011" fill="white" class="logo-square" style="animation-delay:1700ms"/>
<rect x="515.766" y="633.989" width="72.082" height="72.011" fill="white" class="logo-square" style="animation-delay:1800ms"/>
<rect x="595.379" y="633.989" width="71.007" height="72.011" fill="white" class="logo-square" style="animation-delay:1900ms"/>
</svg>
</div>
<span class="brand-name">Archipelago</span>
<h1>Your Sovereign<br>Personal Server</h1>
<p class="subtitle">Bitcoin node, app platform, and private cloud — all in one box you control.</p>
<button class="glass-button glass-button-primary" onclick="showConnect()" style="margin-top:16px">Get Started</button>
</div>
<!-- Connect -->
<div id="connect" class="hidden">
<div class="logo-container" style="width:56px;height:56px;border-radius:14px">
<svg viewBox="0 0 1024 1024" fill="none">
<rect width="1024" height="1024" fill="#030202"/>
<rect x="357.614" y="318" width="71.007" height="70.936" fill="white"/>
<rect x="436.152" y="318" width="72.082" height="70.936" fill="white"/>
<rect x="515.766" y="318" width="72.082" height="70.936" fill="white"/>
<rect x="595.379" y="318" width="71.007" height="70.936" fill="white"/>
<rect x="595.379" y="396.46" width="71.007" height="72.011" fill="white"/>
<rect x="673.917" y="396.46" width="72.083" height="72.011" fill="white"/>
<rect x="278" y="475.994" width="72.083" height="72.012" fill="white"/>
<rect x="357.614" y="475.994" width="71.007" height="72.012" fill="white"/>
<rect x="436.152" y="475.994" width="72.082" height="72.012" fill="white"/>
<rect x="515.766" y="475.994" width="72.082" height="72.012" fill="white"/>
<rect x="595.379" y="475.994" width="71.007" height="72.012" fill="white"/>
<rect x="673.917" y="475.994" width="72.083" height="72.012" fill="white"/>
<rect x="278" y="555.529" width="72.083" height="70.936" fill="white"/>
<rect x="357.614" y="555.529" width="71.007" height="70.936" fill="white"/>
<rect x="595.379" y="555.529" width="71.007" height="70.936" fill="white"/>
<rect x="673.917" y="555.529" width="72.083" height="70.936" fill="white"/>
<rect x="357.614" y="633.989" width="71.007" height="72.011" fill="white"/>
<rect x="436.152" y="633.989" width="72.082" height="72.011" fill="white"/>
<rect x="515.766" y="633.989" width="72.082" height="72.011" fill="white"/>
<rect x="595.379" y="633.989" width="71.007" height="72.011" fill="white"/>
</svg>
</div>
<h1 style="font-size:22px">Connect to Server</h1>
<p class="subtitle" style="font-size:14px">Enter your Archipelago server IP or hostname</p>
<div class="glass-card">
<div class="form-group">
<label>Server Address</label>
<input type="text" id="address" placeholder="192.168.1.100" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false">
</div>
<div class="form-group">
<div class="port-row">
<div class="port-input">
<label>Port (optional)</label>
<input type="text" id="port" placeholder="80" inputmode="numeric" pattern="[0-9]*">
</div>
</div>
</div>
<div class="form-group">
<div class="toggle-row">
<span class="toggle-label">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>
Use HTTPS
</span>
<input type="checkbox" id="https" class="toggle">
</div>
</div>
</div>
<div id="error" class="error-msg"></div>
<button class="glass-button glass-button-primary" id="connectBtn" onclick="doConnect()" disabled>Connect</button>
<div id="savedServers"></div>
</div>
<!-- Connecting -->
<div id="connecting" class="hidden">
<div class="spinner"></div>
<p style="color:rgba(255,255,255,0.6);font-size:14px">Connecting…</p>
</div>
<script>
var STORAGE_KEY = 'archipelago_servers';
var ACTIVE_KEY = 'archipelago_active';
function showConnect() {
document.getElementById('intro').classList.add('hidden');
document.getElementById('connect').classList.remove('hidden');
document.getElementById('address').focus();
renderSaved();
}
// Enable button when address has content
document.getElementById('address').addEventListener('input', function() {
document.getElementById('connectBtn').disabled = !this.value.trim();
});
// Enter to connect
document.getElementById('address').addEventListener('keydown', function(e) {
if (e.key === 'Enter' && this.value.trim()) doConnect();
});
document.getElementById('port').addEventListener('keydown', function(e) {
if (e.key === 'Enter') doConnect();
});
function buildUrl() {
var addr = document.getElementById('address').value.trim();
var port = document.getElementById('port').value.trim();
var https = document.getElementById('https').checked;
var scheme = https ? 'https' : 'http';
var portSuffix = port ? ':' + port : '';
return scheme + '://' + addr + portSuffix;
}
function doConnect() {
var addr = document.getElementById('address').value.trim();
if (!addr) return;
var url = buildUrl();
document.getElementById('connect').classList.add('hidden');
document.getElementById('connecting').classList.remove('hidden');
document.getElementById('error').classList.remove('visible');
// Save and navigate directly — no XHR test needed,
// the WebView error handler catches failures
saveServer(url);
localStorage.setItem(ACTIVE_KEY, url);
AndroidBridge.onConnected(url);
}
function saveServer(url) {
var saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
if (saved.indexOf(url) === -1) saved.push(url);
localStorage.setItem(STORAGE_KEY, JSON.stringify(saved));
}
function removeServer(url) {
var saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
saved = saved.filter(function(s) { return s !== url; });
localStorage.setItem(STORAGE_KEY, JSON.stringify(saved));
renderSaved();
}
function connectSaved(url) {
document.getElementById('intro').classList.add('hidden');
document.getElementById('connect').classList.add('hidden');
document.getElementById('connecting').classList.remove('hidden');
localStorage.setItem(ACTIVE_KEY, url);
AndroidBridge.onConnected(url);
}
function renderSaved() {
var saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
var container = document.getElementById('savedServers');
if (!saved.length) { container.innerHTML = ''; return; }
var html = '<p class="saved-title" style="margin-top:8px;margin-bottom:8px">Saved Servers</p>';
saved.forEach(function(url) {
html += '<div class="saved-item" onclick="connectSaved(\'' + url + '\')">' +
'<span class="saved-addr">' + url.replace(/^https?:\/\//, '') + '</span>' +
'<button class="saved-remove" onclick="event.stopPropagation();removeServer(\'' + url + '\')">&times;</button>' +
'</div>';
});
container.innerHTML = html;
}
// On load: check if already connected
(function() {
var active = localStorage.getItem(ACTIVE_KEY);
if (active) {
connectSaved(active);
}
})();
</script>
</body>
</html>
@@ -0,0 +1,40 @@
package com.archipelago.app
import android.app.Application
import android.os.Looper
import android.webkit.WebView
import com.archipelago.app.data.ServerPreferences
import com.archipelago.app.fips.FipsNative
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
class ArchipelagoApp : Application() {
private val warmupScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onCreate() {
super.onCreate()
// Warmups that otherwise land inside the first frame:
// - FipsNative.available dlopens the 7 MB Rust core; referenced from
// composition (NESMenu, mesh auto-start), it blocked the UI thread.
// - The first DataStore read gates the nav graph's start destination;
// parsing it here means the launch gate resolves in the first
// emission instead of waiting on cold disk IO.
warmupScope.launch {
FipsNative.available
runCatching { ServerPreferences(this@ArchipelagoApp).launchState.first() }
}
// First WebView construction pays Chromium provider load (~150-400 ms
// cold). Absorb it while the main thread is idle before the kiosk
// needs it, instead of serially after the connection probe.
Looper.getMainLooper().queue.addIdleHandler {
runCatching { WebView(this).destroy() }
false // one-shot
}
}
}
@@ -0,0 +1,59 @@
package com.archipelago.app
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import com.archipelago.app.ui.navigation.AppNavHost
import com.archipelago.app.ui.screens.releaseKioskWebView
import com.archipelago.app.ui.theme.ArchipelagoTheme
import kotlinx.coroutines.flow.MutableStateFlow
class MainActivity : ComponentActivity() {
// Pairing deep link (archipelago://pair?...) from the launch intent or a
// later one (launchMode=singleTask). Consumed by AppNavHost.
private val pendingPairUri = MutableStateFlow<String?>(null)
override fun onCreate(savedInstanceState: Bundle?) {
// Hold the branded system splash until the nav graph has its launch
// state — without this the splash dropped at the first composed frame,
// which was EMPTY (the DataStore read hadn't landed): splash → black
// flash → UI on every launch.
var navReady = false
val splash = installSplashScreen()
splash.setKeepOnScreenCondition { !navReady }
enableEdgeToEdge()
super.onCreate(savedInstanceState)
pendingPairUri.value = intent?.dataString
setContent {
ArchipelagoTheme {
val pairUri by pendingPairUri.collectAsState()
AppNavHost(
pairUri = pairUri,
onPairUriConsumed = { pendingPairUri.value = null },
onReady = { navReady = true },
)
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
pendingPairUri.value = intent.dataString
}
override fun onDestroy() {
super.onDestroy()
// Swiped out of recents (or otherwise finished) — let go of the
// retained kiosk WebView so the next launch starts clean. Without
// this the FIPS service keeps the process (and the static WebView)
// alive, and "close the app" no longer restarted it. isFinishing
// keeps config changes (rotation) on the fast reattach path.
if (isFinishing) releaseKioskWebView()
}
}
@@ -0,0 +1,287 @@
package com.archipelago.app.data
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "server_prefs")
data class ServerEntry(
val address: String,
val useHttps: Boolean,
val port: String = "",
val password: String = "",
val name: String = "",
/** Node's FIPS mesh ULA (IPv6) — reachable from anywhere once meshed. */
val meshIp: String = "",
/** Node's FIPS npub — the durable identity. When present it, not the
* address, is what identifies the entry: FIPS peers on npubs, IPs are
* only dial hints (docs/companion-pairing-qr.md, npub-first contract). */
val npub: String = "",
) {
/** Label to show in lists — the user-given name, or the address if unnamed. */
fun displayName(): String = name.ifBlank { address }
/**
* Is this node reachable over the Archipelago FIPS mesh?
*
* A node that advertised either identity (npub) or a mesh address (ULA)
* came from a FIPS-capable pairing QR. Anything else — a hand-entered LAN
* box, someone else's server behind their own VPN — is a plain HTTP
* target, and the companion must NOT raise its own tunnel for it: Android
* allows exactly one VPN at a time, so doing so would silently take the
* tunnel away from whatever the user actually uses to reach that node.
*/
fun isFipsNode(): Boolean = npub.isNotBlank() || meshIp.isNotBlank()
/** Bracket bare IPv6 literals (the mesh ULA) so they form valid URLs. */
private fun urlHost(host: String): String =
if (host.contains(":") && !host.startsWith("[")) "[$host]" else host
fun toUrl(): String {
val scheme = if (useHttps) "https" else "http"
val portSuffix = if (port.isNotBlank()) ":$port" else ""
return "$scheme://${urlHost(address)}$portSuffix"
}
fun toWsUrl(): String {
val scheme = if (useHttps) "wss" else "ws"
val portSuffix = if (port.isNotBlank()) ":$port" else ""
return "$scheme://${urlHost(address)}$portSuffix"
}
/** Mesh-address UI URL, or null when the node never advertised one. */
fun toMeshUrl(): String? =
meshIp.takeIf { it.isNotBlank() }?.let { "http://${urlHost(it)}" }
// name/meshIp/npub are trailing fields so entries saved before they
// existed (4/5/6 fields) still deserialize, defaulting to "".
fun serialize(): String = "$address|$useHttps|$port|$password|$name|$meshIp|$npub"
/** Same node as [other]? npub identity wins; address/port/scheme is the
* fallback for LAN-only entries that never advertised FIPS. */
fun sameNode(other: ServerEntry): Boolean =
(npub.isNotBlank() && npub == other.npub) ||
(address == other.address && port == other.port && useHttps == other.useHttps)
companion object {
fun deserialize(raw: String): ServerEntry? {
val parts = raw.split("|")
if (parts.size < 2) return null
return ServerEntry(
address = parts[0],
useHttps = parts[1].toBooleanStrictOrNull() ?: false,
port = parts.getOrElse(2) { "" },
password = parts.getOrElse(3) { "" },
name = parts.getOrElse(4) { "" },
meshIp = parts.getOrElse(5) { "" },
npub = parts.getOrElse(6) { "" },
)
}
}
}
class ServerPreferences(private val context: Context) {
private val activeAddressKey = stringPreferencesKey("active_address")
private val activeHttpsKey = booleanPreferencesKey("active_https")
private val activePortKey = stringPreferencesKey("active_port")
private val activePasswordKey = stringPreferencesKey("active_password")
private val activeNameKey = stringPreferencesKey("active_name")
private val activeMeshIpKey = stringPreferencesKey("active_mesh_ip")
private val activeNpubKey = stringPreferencesKey("active_npub")
private val savedServersKey = stringSetPreferencesKey("saved_servers")
private val introSeenKey = booleanPreferencesKey("intro_seen")
private val gestureHintSeenKey = booleanPreferencesKey("gesture_hint_seen")
private fun activeServerFrom(prefs: Preferences): ServerEntry? {
val address = prefs[activeAddressKey] ?: return null
return ServerEntry(
address = address,
useHttps = prefs[activeHttpsKey] ?: false,
port = prefs[activePortKey] ?: "",
password = prefs[activePasswordKey] ?: "",
name = prefs[activeNameKey] ?: "",
meshIp = prefs[activeMeshIpKey] ?: "",
npub = prefs[activeNpubKey] ?: "",
)
}
// distinctUntilChanged on every flow: DataStore emits on EVERY write to the
// file regardless of key, and each spurious emission recomposed whatever
// screen collected it (the kiosk recomposed on gesture-hint writes).
val activeServer: Flow<ServerEntry?> = context.dataStore.data
.map { prefs -> activeServerFrom(prefs) }
.distinctUntilChanged()
val savedServers: Flow<List<ServerEntry>> = context.dataStore.data.map { prefs ->
val raw = prefs[savedServersKey] ?: emptySet()
// Sorted so set-iteration order can't produce a structurally different
// list for the same servers (which defeats distinctUntilChanged).
raw.mapNotNull { ServerEntry.deserialize(it) }.sortedBy { it.displayName() }
}.distinctUntilChanged()
val introSeen: Flow<Boolean> = context.dataStore.data.map { prefs ->
prefs[introSeenKey] ?: false
}.distinctUntilChanged()
/** One-shot flag for the three-finger-hold teaching overlay. */
val gestureHintSeen: Flow<Boolean> = context.dataStore.data.map { prefs ->
prefs[gestureHintSeenKey] ?: false
}.distinctUntilChanged()
/** Everything the nav graph needs to pick a start destination, derived
* from ONE DataStore emission. Collecting introSeen and activeServer as
* two separate flows let them land in different frames — the intro flag
* could resolve first and flash the Connect screen at a paired user
* before the active server arrived. */
data class LaunchState(
val introSeen: Boolean,
val activeServer: ServerEntry?,
/** Every saved node — the launch gate needs the COUNT to decide
* whether to ask which one to connect to. */
val savedServers: List<ServerEntry>,
)
val launchState: Flow<LaunchState> = context.dataStore.data.map { prefs ->
LaunchState(
introSeen = prefs[introSeenKey] ?: false,
activeServer = activeServerFrom(prefs),
savedServers = (prefs[savedServersKey] ?: emptySet())
.mapNotNull { ServerEntry.deserialize(it) }
.sortedBy { it.displayName() },
)
}.distinctUntilChanged()
suspend fun setActiveServer(server: ServerEntry) {
context.dataStore.edit { prefs ->
prefs[activeAddressKey] = server.address
prefs[activeHttpsKey] = server.useHttps
prefs[activePortKey] = server.port
prefs[activePasswordKey] = server.password
prefs[activeNameKey] = server.name
prefs[activeMeshIpKey] = server.meshIp
prefs[activeNpubKey] = server.npub
}
addSavedServer(server)
}
suspend fun clearActiveServer() {
context.dataStore.edit { prefs ->
prefs.remove(activeAddressKey)
prefs.remove(activeHttpsKey)
prefs.remove(activePortKey)
prefs.remove(activePasswordKey)
prefs.remove(activeNameKey)
prefs.remove(activeMeshIpKey)
prefs.remove(activeNpubKey)
}
}
suspend fun addSavedServer(server: ServerEntry) {
context.dataStore.edit { prefs ->
val current = prefs[savedServersKey] ?: emptySet()
prefs[savedServersKey] = current + server.serialize()
}
}
/**
* Replace a saved server in place. Matches the existing entry by node
* identity — npub first, address/port/scheme as the LAN-only fallback
* (ServerEntry.sameNode) — so edits that change the name, password or even
* every address still update the right record. An edit form that doesn't
* carry the npub keeps the stored one. If the edited server is also the
* active one, the active record is kept in sync.
*/
suspend fun updateSavedServer(original: ServerEntry, updated: ServerEntry) {
val toStore = updated.copy(npub = updated.npub.ifBlank { original.npub })
context.dataStore.edit { prefs ->
val current = prefs[savedServersKey] ?: emptySet()
val filtered = current.filterNot { raw ->
ServerEntry.deserialize(raw)?.sameNode(original) == true
}.toSet()
prefs[savedServersKey] = filtered + toStore.serialize()
val activeNpub = prefs[activeNpubKey] ?: ""
val isActive = (activeNpub.isNotBlank() && activeNpub == original.npub) ||
(
prefs[activeAddressKey] == original.address &&
(prefs[activePortKey] ?: "") == original.port &&
(prefs[activeHttpsKey] ?: false) == original.useHttps
)
if (isActive) {
prefs[activeAddressKey] = toStore.address
prefs[activeHttpsKey] = toStore.useHttps
prefs[activePortKey] = toStore.port
prefs[activePasswordKey] = toStore.password
prefs[activeNameKey] = toStore.name
prefs[activeMeshIpKey] = toStore.meshIp
prefs[activeNpubKey] = toStore.npub
}
}
}
/**
* Add a server, or update the entry for the same node — npub first,
* address/port/scheme as the LAN-only fallback (ServerEntry.sameNode) —
* used by QR pairing so re-scanning a node never duplicates it, even after
* the LAN renumbered and every address changed (npub-first contract in
* docs/companion-pairing-qr.md). A blank incoming password/name keeps the
* stored value (a real node's QR never carries the password). Returns the
* merged entry.
*/
suspend fun upsertServer(server: ServerEntry): ServerEntry {
var merged = server
context.dataStore.edit { prefs ->
val current = prefs[savedServersKey] ?: emptySet()
val existing = current.mapNotNull { ServerEntry.deserialize(it) }
.firstOrNull { it.sameNode(server) }
if (existing != null) {
merged = server.copy(
password = server.password.ifBlank { existing.password },
name = server.name.ifBlank { existing.name },
meshIp = server.meshIp.ifBlank { existing.meshIp },
npub = server.npub.ifBlank { existing.npub },
)
}
val filtered = current.filterNot { raw ->
ServerEntry.deserialize(raw)?.sameNode(merged) == true
}.toSet()
prefs[savedServersKey] = filtered + merged.serialize()
}
return merged
}
suspend fun removeSavedServer(server: ServerEntry) {
context.dataStore.edit { prefs ->
val current = prefs[savedServersKey] ?: emptySet()
// Match by node identity (npub, else address/port/scheme) rather
// than the exact serialized string, so a rename — or a legacy
// short-format entry — still removes the right record.
prefs[savedServersKey] = current.filterNot { raw ->
ServerEntry.deserialize(raw)?.sameNode(server) == true
}.toSet()
}
}
suspend fun markIntroSeen() {
context.dataStore.edit { prefs ->
prefs[introSeenKey] = true
}
}
suspend fun markGestureHintSeen() {
context.dataStore.edit { prefs ->
prefs[gestureHintSeenKey] = true
}
}
}
@@ -0,0 +1,128 @@
package com.archipelago.app.data
import android.net.Uri
import com.archipelago.app.fips.AnchorPeer
import com.archipelago.app.fips.FipsPairInfo
/**
* Result of parsing a pairing QR / deep link.
*
* UnsupportedVersion means the payload is structurally a pairing URI but its
* major version is newer than this app understands — the UI should tell the
* user to update the app rather than call the code invalid.
*/
sealed class PairResult {
data class Success(
val server: ServerEntry,
/** Mesh info when the node advertises FIPS (fnpub present). */
val fips: FipsPairInfo? = null,
) : PairResult()
object UnsupportedVersion : PairResult()
object Invalid : PairResult()
}
/**
* Parser for the companion pairing QR / OS deep link. Contract:
* docs/companion-pairing-qr.md (repo root).
*
* archipelago://pair?v=1&url=<origin>&name=…[&tok=…][&pw=…][&fnpub=…&fip=…&fhost=…&fudp=…&ftcp=…]
*
* - `url` is a full origin including scheme (http for LAN/mDNS nodes, https
* for the public demo); trailing slashes are normalized away.
* - `tok` is a device token minted by the node; it goes through the password
* field on purpose — the whole password auto-login path (WebSocket auth +
* WebView form injection) then works unchanged, and the backend accepts
* device tokens wherever it accepts the password. `pw` (demo only) wins if
* both are ever present.
* - `fnpub`/`fip`/`fhost`/`fudp`/`ftcp` describe the node's FIPS mesh; their
* absence just means LAN-only pairing (older node, or FIPS not provisioned).
* - Unknown extra query params are tolerated (forward compat under v=1).
*/
object ServerQrParser {
private const val SUPPORTED_MAJOR = 1
fun parse(raw: String): PairResult {
val uri = try {
Uri.parse(raw.trim())
} catch (_: Exception) {
return PairResult.Invalid
}
if (!"archipelago".equals(uri.scheme, ignoreCase = true)) return PairResult.Invalid
if (uri.isOpaque || !"pair".equals(uri.host, ignoreCase = true)) return PairResult.Invalid
val major = uri.getQueryParameter("v")
?.trim()
?.takeWhile { it.isDigit() }
?.toIntOrNull()
?: return PairResult.Invalid
if (major != SUPPORTED_MAJOR) return PairResult.UnsupportedVersion
val serverUrl = uri.getQueryParameter("url")?.trim()?.trimEnd('/')
if (serverUrl.isNullOrBlank()) return PairResult.Invalid
val server = Uri.parse(serverUrl)
val scheme = server.scheme?.lowercase()
if (scheme != "http" && scheme != "https") return PairResult.Invalid
val host = server.host
if (host.isNullOrBlank()) return PairResult.Invalid
val fips = parseFips(uri)
val credential = uri.getQueryParameter("pw")?.takeIf { it.isNotBlank() }
?: uri.getQueryParameter("tok")
?: ""
return PairResult.Success(
server = ServerEntry(
address = host,
useHttps = scheme == "https",
port = if (server.port != -1) server.port.toString() else "",
password = credential,
name = uri.getQueryParameter("name") ?: "",
meshIp = fips?.ula ?: "",
// npub is the durable identity — saved-server upserts match on
// it, so re-scanning after a LAN renumber updates in place.
npub = fips?.npub ?: "",
),
fips = fips,
)
}
private fun parseFips(uri: Uri): FipsPairInfo? {
val npub = uri.getQueryParameter("fnpub")?.trim()
var host = uri.getQueryParameter("fhost")?.trim()
if (npub.isNullOrBlank() || host.isNullOrBlank()) return null
// A .fips fhost is a dead dial hint: Android's system DNS can't
// resolve .fips, so every handshake send fails and first connect
// falls back to slow anchor discovery. If the QR's `url` host is a
// real address, dial that instead (QRs minted while the node UI was
// browsed over the mesh carry npub….fips here).
if (host.endsWith(".fips")) {
val urlHost = uri.getQueryParameter("url")
?.let { runCatching { Uri.parse(it).host }.getOrNull() }
if (!urlHost.isNullOrBlank() && !urlHost.endsWith(".fips")) host = urlHost
}
return FipsPairInfo(
npub = npub,
ula = uri.getQueryParameter("fip")?.trim().orEmpty(),
host = host,
udpPort = uri.getQueryParameter("fudp")?.toIntOrNull() ?: 2121,
tcpPort = uri.getQueryParameter("ftcp")?.toIntOrNull() ?: 8443,
anchors = parseAnchors(uri.getQueryParameter("fanchors")),
)
}
/** `fanchors` = comma-joined `npub@host:port/transport`; bad items skipped. */
private fun parseAnchors(raw: String?): List<AnchorPeer> {
if (raw.isNullOrBlank()) return emptyList()
return raw.split(",").mapNotNull { item ->
val at = item.indexOf('@')
val slash = item.lastIndexOf('/')
if (at <= 0 || slash <= at) return@mapNotNull null
val npub = item.substring(0, at).trim()
val addr = item.substring(at + 1, slash).trim()
val transport = item.substring(slash + 1).trim().lowercase()
if (npub.isBlank() || !addr.contains(":")) return@mapNotNull null
if (transport != "udp" && transport != "tcp") return@mapNotNull null
AnchorPeer(npub = npub, addr = addr, transport = transport)
}
}
}
@@ -0,0 +1,336 @@
package com.archipelago.app.fips
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Intent
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.net.VpnService
import android.os.Build
import android.util.Log
import com.archipelago.app.MainActivity
import com.archipelago.app.R
import com.archipelago.app.data.ServerPreferences
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
/**
* VpnService hosting the embedded FIPS mesh node.
*
* Split tunnel: only fd00::/8 (the FIPS ULA space) routes into the TUN, so
* normal phone traffic is untouched — this is mesh reachability, not a
* default-route VPN. The established fd is detached and handed to the Rust
* node (Node::start_with_tun_fd); the node owns it until stop.
*/
class ArchyVpnService : VpnService() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var warmerJob: Job? = null
private var handoffKickJob: Job? = null
// Seamless transport handoff (Wi-Fi ⇄ 5G ⇄ future BLE). Without this the
// tunnel's underlying network stays pinned to the interface that was
// default when the VPN came up; when the phone leaves Wi-Fi for 5G the
// mesh sockets ride a dead network and sessions never recover until the
// app is restarted (user-reported 2026-07-27). The callback (a) re-pins
// the tunnel to the new default network via setUnderlyingNetworks and
// (b) forces an immediate mesh re-home so discovery + sessions rebuild
// on the new path within seconds instead of waiting out dead-link
// timeouts.
private var connectivityManager: ConnectivityManager? = null
private var networkCallback: ConnectivityManager.NetworkCallback? = null
@Volatile
private var currentUnderlying: Network? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_STOP) {
shutdown()
return START_NOT_STICKY
}
startForeground(NOTIFICATION_ID, buildNotification())
scope.launch { startMesh() }
return START_STICKY
}
private suspend fun startMesh() {
if (!FipsNative.available) {
shutdown()
return
}
val prefs = FipsPreferences(this)
val identity = FipsManager.ensureIdentity(prefs)
val peersJson = prefs.combinedPeersJson()
if (identity == null || peersJson == "[]") {
Log.w(TAG, "mesh not configured — stopping")
shutdown()
return
}
// Party mode: fixed inbound UDP bind so a nearby phone can dial us
// directly over a shared LAN/hotspot (no internet required).
val listenPort = if (prefs.partyListen()) PartyQr.PARTY_UDP_PORT else 0
// A fresh app open re-triggers the service. Tearing a HEALTHY mesh
// down to rebuild it costs ~8s of anchor+session bring-up on every
// launch (observed live: stop 00:34:38 → session back 00:34:49) and
// is what made "freshly loading the app" slow. Keep a running node;
// restart only when it's dead or a pairing changed the peer set.
if (FipsNative.isRunning() && !FipsManager.peersDirty) {
Log.i(TAG, "mesh already running — keeping warm sessions")
startSessionWarmer()
return
}
FipsManager.peersDirty = false
// Re-establishing while running would strand the old fd; restart clean.
if (FipsNative.isRunning()) FipsNative.stop()
val pfd = try {
Builder()
.setSession("Archipelago Mesh")
.setMtu(1280)
.addAddress(identity.address, 128)
.addRoute("fd00::", 8)
// The TUN is IPv6-only. Android blocks every address family
// the VPN has no address for — without this, bringing the
// mesh up cut ALL of the phone's IPv4 internet.
.allowFamily(android.system.OsConstants.AF_INET)
// And let apps that bind their own network skip the TUN
// entirely — this is mesh reachability, not a privacy VPN.
.allowBypass()
.apply {
// Android 10+ treats VPN networks as METERED by default,
// which flips the whole phone into data-saver behaviour
// (background sync off, "metered" warnings) while the
// mesh is up. It inherits the underlying network's real
// metered state instead.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) setMetered(false)
}
.establish()
} catch (e: Exception) {
Log.e(TAG, "VPN establish failed", e)
null
}
if (pfd == null) {
shutdown()
return
}
val fd = pfd.detachFd()
val result = FipsNative.start(identity.secret, peersJson, fd, listenPort)
Log.i(TAG, "mesh start: $result (listen=$listenPort)")
if (result.contains("\"error\"")) {
shutdown()
} else {
startSessionWarmer()
registerNetworkHandoff()
// Phone-to-phone chat/beam + the phone's own mesh-served page.
FlareServer.start(this, identity.address, identity.npub, prefs.partyName())
// Mutual pairing: when a phone that scanned OUR QR announces
// itself, store it as a party peer and re-run the mesh config so
// this side gets the peer + chat entry without scanning back.
FlareServer.onHello = { peer ->
scope.launch {
prefs.upsertPartyPeer(peer)
FipsManager.requestMeshRestart(this@ArchyVpnService)
}
}
}
}
/**
* Pre-warm + keep-warm mesh sessions to every known node ULA.
*
* Discovery + first session through the public tree can take 15s+
* (from node diagnosis) — paying that cost here, the
* moment the tunnel is up, means the connect probe and WebView hit an
* established session instead of timing out on a cold one. The periodic
* touch afterwards keeps the session from idling out. Failed connects
* are expected and cheap; the attempt itself is what drives discovery.
*/
private fun startSessionWarmer() {
warmerJob?.cancel()
warmerJob = scope.launch {
val prefs = ServerPreferences(this@ArchyVpnService)
val fipsPrefs = FipsPreferences(this@ArchyVpnService)
var round = 0
while (isActive && FipsNative.isRunning()) {
val targets = try {
prefs.savedServers.first()
.mapNotNull { it.meshIp.ifBlank { null } }
.map { it to 80 } +
// Party phones answer on the flare port, not :80.
fipsPrefs.partyPeers().map { it.ula to PartyQr.FLARE_PORT }
} catch (_: Exception) {
emptyList()
}.distinct()
if (round == 0) Log.i(TAG, "session warmer: ${targets.map { it.first }}")
// Probe all targets CONCURRENTLY with a short timeout — the
// old sequential 20s-per-target loop let one cold node starve
// every other target for the whole aggressive window.
targets.map { (ula, port) ->
launch {
try {
java.net.Socket().use { s ->
s.connect(
java.net.InetSocketAddress(
java.net.InetAddress.getByName(ula),
port,
),
5_000,
)
}
} catch (_: Exception) {
// Cold path / node away — the attempt still drove
// session establishment; try again next round.
}
}
}.forEach { it.join() }
round++
// Aggressive for the first ~minute (session bring-up), then a
// slow keep-warm tick that costs nearly nothing.
delay(if (round < 12) 5_000 else 60_000)
}
}
}
/**
* Track the phone's default network and hand the mesh over to it as the
* phone roams (Wi-Fi ⇄ 5G). Two actions per change:
* 1. setUnderlyingNetworks(new) — the tunnel's packets follow the live
* network instead of dying on the one it launched with.
* 2. re-home the mesh — kick the session warmer so discovery + sessions
* rebuild on the new path; the node's own fast-reconnect (1s) redials
* peers over the new route.
* onAvailable also fires for the FIRST network, which is how the initial
* underlying network gets set.
*
* requestNetwork, NOT registerDefaultNetworkCallback: this app is routed
* through its own TUN, so its "default network" IS the VPN — a default
* callback fires once with our own tunnel and never again on Wi-Fi ⇄ 5G.
* A NetworkRequest's default capabilities include NOT_VPN, so requestNetwork
* tracks the best real transport underneath instead.
*/
private fun registerNetworkHandoff() {
if (networkCallback != null) return
val cm = getSystemService(ConnectivityManager::class.java) ?: return
connectivityManager = cm
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
val cb = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
handoffTo(network)
}
override fun onLost(network: Network) {
// The lost network was our underlying one — clear the pin so
// the system falls back to whatever default remains; the next
// onAvailable re-pins explicitly.
if (network == currentUnderlying) {
currentUnderlying = null
runCatching { setUnderlyingNetworks(null) }
}
}
}
networkCallback = cb
runCatching { cm.requestNetwork(request, cb) }
}
private fun handoffTo(network: Network) {
val changed = network != currentUnderlying
currentUnderlying = network
// Always re-assert; cheap and covers capability changes on the same
// Network object.
runCatching { setUnderlyingNetworks(arrayOf(network)) }
if (changed && FipsNative.isRunning()) {
Log.i(TAG, "network handoff → re-homing mesh on new default network")
// Coalesced, not immediate: marginal Wi-Fi flaps the default
// Wi-Fi ⇄ cell in bursts, and an aggressive warmer pass per flip
// meant near-constant session churn — the "reconnects a lot"
// report. The re-pin above still happens on every change; only
// the rediscovery kick waits for the network to hold still.
handoffKickJob?.cancel()
handoffKickJob = scope.launch {
delay(2_000)
if (FipsNative.isRunning()) startSessionWarmer()
}
}
}
private fun unregisterNetworkHandoff() {
handoffKickJob?.cancel()
handoffKickJob = null
val cm = connectivityManager
val cb = networkCallback
if (cm != null && cb != null) {
runCatching { cm.unregisterNetworkCallback(cb) }
}
networkCallback = null
connectivityManager = null
currentUnderlying = null
}
private fun shutdown() {
warmerJob?.cancel()
unregisterNetworkHandoff()
FlareServer.stop()
FipsNative.stop()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
override fun onDestroy() {
unregisterNetworkHandoff()
FipsNative.stop()
scope.cancel()
super.onDestroy()
}
override fun onRevoke() {
// User pulled VPN permission from system settings.
shutdown()
}
private fun buildNotification(): Notification {
val manager = getSystemService(NotificationManager::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
manager.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
"Mesh connection",
NotificationManager.IMPORTANCE_MIN,
).apply { description = "Keeps the node reachable from anywhere" }
)
}
val tapIntent = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE,
)
return Notification.Builder(this, CHANNEL_ID)
.setContentTitle("Connected to your Archipelago")
.setContentText("Secure mesh link active")
.setSmallIcon(R.mipmap.ic_launcher)
.setContentIntent(tapIntent)
.setOngoing(true)
.build()
}
companion object {
const val ACTION_STOP = "com.archipelago.app.fips.STOP"
private const val CHANNEL_ID = "archy_mesh"
private const val NOTIFICATION_ID = 4841
private const val TAG = "ArchyVpnService"
}
}
@@ -0,0 +1,118 @@
package com.archipelago.app.fips
import android.content.Context
import android.content.Intent
import android.net.VpnService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.withContext
/**
* Glue between pairing and the mesh: persists the node peer from a scanned
* QR and asks the UI to bring the tunnel up. There is deliberately no
* settings surface — scanning a node's QR is the entire configuration.
*
* The one unavoidable interaction is Android's VPN consent dialog
* (VpnService.prepare), which only an Activity can launch; [consentNeeded]
* signals AppNavHost to run it, once, on first pairing.
*/
object FipsManager {
/** Set when a pairing registered mesh info and the VPN needs starting. */
private val _consentNeeded = MutableStateFlow(false)
val consentNeeded: StateFlow<Boolean> = _consentNeeded
/** True after a pairing changed the peer set while the node was running —
* tells the service a restart is genuinely needed (the ONLY case; a
* routine app open must keep the warm mesh, not rebuild it). */
@Volatile
var peersDirty: Boolean = false
fun consentHandled() {
_consentNeeded.value = false
}
/**
* Persist mesh info from a pairing scan and request tunnel start.
* No-op on devices without the native lib (non-arm64).
*/
suspend fun registerNode(context: Context, info: FipsPairInfo?, alias: String) {
if (info == null) return
// Every caller reaches this from a Compose scope — i.e. the MAIN
// thread — the instant a pairing QR decodes. Everything below is
// main-hostile: touching FipsNative dlopens the 7 MB mesh core,
// ensureIdentity runs native ed25519 keygen, and VpnService.prepare
// is a binder round-trip. Left on the UI thread it froze the frame
// right after the camera got the code, which reads as "the scanner
// is slow" when the scan itself already succeeded.
val consent = withContext(Dispatchers.IO) {
if (!FipsNative.available) return@withContext null
val prefs = FipsPreferences(context)
ensureIdentity(prefs)
prefs.upsertNodePeer(info, alias)
peersDirty = true
// Restart the mesh with the new peer RIGHT NOW when consent already
// exists — relying on the consentNeeded collector left a running
// mesh on the OLD peer list whenever the collector wasn't active
// (fresh pairings looked dead until a full app restart).
VpnService.prepare(context) == null
} ?: return
if (consent) startService(context) else _consentNeeded.value = true
}
/** Generate-once mesh identity. Returns null only if the RNG/native fails. */
suspend fun ensureIdentity(prefs: FipsPreferences): FipsNative.Identity? {
prefs.identity()?.let { return it }
val generated = FipsNative.parseIdentity(FipsNative.generateIdentity()) ?: return null
prefs.saveIdentity(generated)
return generated
}
/**
* Start the mesh service if this device is paired and the user has already
* consented to the VPN (prepare() == null). Called on app start so the
* tunnel comes back without any interaction; first-time consent goes
* through AppNavHost instead.
*/
suspend fun autoStartIfReady(context: Context) {
// Self-dispatching for the same reason as registerNode: callers reach
// this from Compose scopes, and dlopen + binder must not ride the UI
// thread (the connect path calls it while the scanner is still up).
val ready = withContext(Dispatchers.IO) {
if (!FipsNative.available) return@withContext false
val prefs = FipsPreferences(context)
if (prefs.identity() == null || !prefs.hasPeers()) return@withContext false
// consent missing — don't prompt here
VpnService.prepare(context) == null
}
if (ready) startService(context)
}
fun startService(context: Context) {
val intent = Intent(context, ArchyVpnService::class.java)
context.startForegroundService(intent)
}
/**
* Re-run the mesh with current prefs (party listen toggled, peer added).
* Marks the peer set dirty so startMesh genuinely restarts the node —
* otherwise the keep-warm fast path would skip the new config.
* First-timers go through the consent flow.
*/
fun requestMeshRestart(context: Context) {
if (!FipsNative.available) return
peersDirty = true
if (VpnService.prepare(context) == null) {
startService(context)
} else {
_consentNeeded.value = true
}
}
fun stopService(context: Context) {
val intent = Intent(context, ArchyVpnService::class.java)
.setAction(ArchyVpnService.ACTION_STOP)
context.startService(intent)
}
}
@@ -0,0 +1,49 @@
package com.archipelago.app.fips
import org.json.JSONObject
/**
* JNI binding to the embedded FIPS mesh node (Android/rust/archy-fips-core,
* built into libarchy_fips_core.so by the buildRustArm64 gradle task).
*
* All calls return JSON strings; failures come back as {"error": "…"} rather
* than exceptions. [available] is false on ABIs the .so isn't built for
* (anything but arm64) — every caller must gate on it so the app still runs
* as a plain companion there.
*/
object FipsNative {
val available: Boolean = try {
System.loadLibrary("archy_fips_core")
true
} catch (_: Throwable) {
false
}
external fun generateIdentity(): String
external fun deriveIdentity(secret: String): String
/**
* [listenPort] 0 = outbound-only (default posture). Non-zero binds UDP on
* that port so a nearby phone can dial us directly (party mode); the node
* stays leaf-only either way.
*/
external fun start(secret: String, peersJson: String, tunFd: Int, listenPort: Int): String
external fun stop()
external fun isRunning(): Boolean
external fun statusJson(): String
data class Identity(val secret: String, val npub: String, val address: String)
/** Parse an identity JSON reply; null on {"error": …} or malformed. */
fun parseIdentity(json: String): Identity? = try {
val obj = JSONObject(json)
if (obj.has("error")) null
else Identity(
secret = obj.getString("secret"),
npub = obj.getString("npub"),
address = obj.getString("address"),
)
} catch (_: Exception) {
null
}
}
@@ -0,0 +1,29 @@
package com.archipelago.app.fips
/**
* Node mesh parameters carried by the pairing QR (fnpub/fip/fhost/fudp/ftcp —
* docs/companion-pairing-qr.md). The phone's embedded FIPS node dials
* host:udpPort / host:tcpPort claiming nothing; the mesh accepts inbound peers
* without registration, so possession of these params is all pairing takes.
*/
data class FipsPairInfo(
val npub: String,
/** Node's fips0 ULA — where its UI stays reachable once meshed. May be empty. */
val ula: String,
val host: String,
val udpPort: Int,
val tcpPort: Int,
/**
* Public rendezvous anchors (the node's seed-anchor list). The phone
* peers with these too, so it can route to the node via the mesh when
* the node's LAN endpoint isn't directly dialable.
*/
val anchors: List<AnchorPeer> = emptyList(),
)
/** One rendezvous anchor from the QR's `fanchors` param (npub@addr/transport). */
data class AnchorPeer(
val npub: String,
val addr: String,
val transport: String,
)
@@ -0,0 +1,341 @@
package com.archipelago.app.fips
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import org.json.JSONArray
import org.json.JSONObject
import androidx.datastore.preferences.preferencesDataStore
private val Context.fipsDataStore: DataStore<Preferences> by preferencesDataStore(name = "fips_prefs")
// Archipelago-operated public anchor (vps2). Baked in so EVERY pairing yields
// both paths — direct LAN p2p to the node AND a public rendezvous for
// away-from-home — even when the scanned node is old enough that its QR
// carries no fanchors. Keep in lockstep with
// core/archipelago/src/fips/anchors.rs (ARCHY_ANCHOR_*).
internal const val ARCHY_ANCHOR_NPUB =
"npub1dptaktwxv0mm245g2lqjykwm5ll0jpc6m3r4242ydfa9z7qe6urs3jvrak"
internal const val ARCHY_ANCHOR_ADDR = "146.59.87.168:8444"
internal const val ARCHY_ANCHOR_TRANSPORT = "tcp"
/**
* Public FIPS network anchors (join.fips.network — the dual-transport TCP
* pair; keep in lockstep with core/archipelago/src/fips/anchors.rs
* fips_network_anchors()). Baked into every pairing at trailing priority so
* a degraded/unreachable vps2 anchor can never strand the phone: the mesh
* still joins the public tree and routes to the node through it.
*/
internal val PUBLIC_FIPS_ANCHORS = listOf(
Triple(
"npub10yffd020a4ag8zcy75f9pruq3rnghvvhd5hphl9s62zgp35s560qrksp9u",
"23.182.128.74:443",
"tcp",
),
Triple(
"npub1qmc3cvfz0yu2hx96nq3gp55zdan2qclealn7xshgr448d3nh6lks7zel98",
"217.77.8.91:443",
"tcp",
),
)
/**
* Mesh identity + known node peers. Follows the same plaintext-DataStore
* storage model as ServerPreferences (the server password lives there the
* same way); the mesh secret only grants mesh membership, not node login.
*/
class FipsPreferences(private val context: Context) {
private val secretKey = stringPreferencesKey("fips_secret")
private val npubKey = stringPreferencesKey("fips_npub")
private val addressKey = stringPreferencesKey("fips_address")
/** JSON array of node peers in fips PeerConfig shape (see NodePeer). */
private val peersKey = stringPreferencesKey("fips_node_peers")
/** JSON array of phone party peers (PartyPeer shape, NOT PeerConfig). */
private val partyPeersKey = stringPreferencesKey("fips_party_peers")
/** Party mode: accept a direct inbound mesh link (UDP 2121). */
private val partyListenKey = booleanPreferencesKey("fips_party_listen")
/** Name shown in this phone's party QR and outgoing flares. */
private val partyNameKey = stringPreferencesKey("fips_party_name")
suspend fun identity(): FipsNative.Identity? {
val prefs = context.fipsDataStore.data.first()
val secret = prefs[secretKey] ?: return null
return FipsNative.Identity(
secret = secret,
npub = prefs[npubKey] ?: "",
address = prefs[addressKey] ?: "",
)
}
suspend fun saveIdentity(identity: FipsNative.Identity) {
context.fipsDataStore.edit { prefs ->
prefs[secretKey] = identity.secret
prefs[npubKey] = identity.npub
prefs[addressKey] = identity.address
}
}
suspend fun peersJson(): String {
val prefs = context.fipsDataStore.data.first()
return prefs[peersKey] ?: "[]"
}
suspend fun hasPeers(): Boolean = JSONArray(peersJson()).length() > 0
// ── Mesh Party (phone↔phone) ────────────────────────────────────────────
suspend fun partyListen(): Boolean =
context.fipsDataStore.data.first()[partyListenKey] ?: false
val partyListenFlow: Flow<Boolean>
get() = context.fipsDataStore.data.map { it[partyListenKey] ?: false }
suspend fun setPartyListen(enabled: Boolean) {
context.fipsDataStore.edit { it[partyListenKey] = enabled }
}
suspend fun partyName(): String =
context.fipsDataStore.data.first()[partyNameKey]
?: android.os.Build.MODEL.orEmpty().ifBlank { "Phone" }
suspend fun setPartyName(name: String) {
context.fipsDataStore.edit { it[partyNameKey] = name.trim() }
}
val partyPeersFlow: Flow<List<PartyPeer>>
get() = context.fipsDataStore.data.map { parsePartyPeers(it[partyPeersKey] ?: "[]") }
suspend fun partyPeers(): List<PartyPeer> =
parsePartyPeers(context.fipsDataStore.data.first()[partyPeersKey] ?: "[]")
/** Matched by npub, so re-scanning updates the direct-dial address in place. */
suspend fun upsertPartyPeer(peer: PartyPeer) {
context.fipsDataStore.edit { prefs ->
val kept = parsePartyPeers(prefs[partyPeersKey] ?: "[]").filter { it.npub != peer.npub }
prefs[partyPeersKey] = toJson(kept + peer)
}
}
suspend fun removePartyPeer(npub: String) {
context.fipsDataStore.edit { prefs ->
val kept = parsePartyPeers(prefs[partyPeersKey] ?: "[]").filter { it.npub != npub }
prefs[partyPeersKey] = toJson(kept)
}
}
/**
* Node peers + direct-dial party peers, in the fips PeerConfig JSON the
* Rust node deserializes. Party peers get the best priority: on a shared
* LAN/hotspot the direct link beats every anchor path, and while off-LAN
* the failed dial is cheap (auto-reconnect keeps retrying, which is
* exactly what makes the link snap up the moment both phones share WiFi).
* Party peers without an underlay address are mesh-routed and need no
* entry here at all.
*/
suspend fun combinedPeersJson(): String {
val merged = JSONArray(peersJson())
val party = partyPeers()
for (peer in party) {
if (peer.ip.isBlank() || peer.port <= 0) continue
merged.put(JSONObject().apply {
put("npub", peer.npub)
put("alias", hostSafeAlias(peer.name.ifBlank { "party-phone" }))
put("addresses", JSONArray().put(JSONObject().apply {
put("transport", "udp")
put("addr", "${peer.ip}:${peer.port}")
put("priority", 5)
}))
})
}
// A party-only phone (never paired with a node) still needs a public
// rendezvous to reach its peers ACROSS the internet — without it, two
// bare phones would be hotspot/LAN-only. Node pairing normally bakes
// this anchor in; do the same when there are party peers.
if (party.isNotEmpty() &&
(0 until merged.length()).none {
merged.optJSONObject(it)?.optString("npub") == ARCHY_ANCHOR_NPUB
}
) {
merged.put(JSONObject().apply {
put("npub", ARCHY_ANCHOR_NPUB)
put("alias", "archipelago-anchor")
put("addresses", JSONArray().put(JSONObject().apply {
put("transport", ARCHY_ANCHOR_TRANSPORT)
put("addr", ARCHY_ANCHOR_ADDR)
put("priority", 40)
}))
})
}
// Backfill anchor peers for entries paired before newer QR/app
// releases added them. `upsertNodePeer` persists these on re-scan, but
// startup must also self-heal old DataStore state so updating the APK is
// enough to get off-LAN redundancy.
if (merged.length() > 0) {
addAnchorIfMissing(merged, ARCHY_ANCHOR_NPUB, "archipelago-anchor", ARCHY_ANCHOR_ADDR, ARCHY_ANCHOR_TRANSPORT, 40)
for ((i, anchor) in PUBLIC_FIPS_ANCHORS.withIndex()) {
val (npub, addr, transport) = anchor
addAnchorIfMissing(merged, npub, "fips-network-anchor-${i + 1}", addr, transport, 50 + i * 10)
}
}
return merged.toString()
}
private fun addAnchorIfMissing(
peers: JSONArray,
npub: String,
alias: String,
addr: String,
transport: String,
priority: Int,
) {
if ((0 until peers.length()).any { peers.optJSONObject(it)?.optString("npub") == npub }) return
peers.put(JSONObject().apply {
put("npub", npub)
put("alias", alias)
put("addresses", JSONArray().put(JSONObject().apply {
put("transport", transport)
put("addr", addr)
put("priority", priority)
}))
})
}
private fun parsePartyPeers(json: String): List<PartyPeer> = try {
val arr = JSONArray(json)
(0 until arr.length()).mapNotNull { i ->
val o = arr.optJSONObject(i) ?: return@mapNotNull null
val npub = o.optString("npub")
val ula = o.optString("ula")
if (npub.isBlank() || ula.isBlank()) return@mapNotNull null
PartyPeer(
npub = npub,
ula = ula,
name = o.optString("name").ifBlank { "Phone" },
ip = o.optString("ip"),
port = o.optInt("port"),
)
}
} catch (_: Exception) {
emptyList()
}
private fun toJson(peers: List<PartyPeer>): String {
val arr = JSONArray()
for (p in peers) {
arr.put(JSONObject().apply {
put("npub", p.npub)
put("ula", p.ula)
put("name", p.name)
put("ip", p.ip)
put("port", p.port)
})
}
return arr.toString()
}
/**
* Add or update the node peer plus its rendezvous anchors (each matched
* by npub, so re-pairing updates addresses instead of duplicating).
* Stored directly in the fips PeerConfig JSON shape the Rust side
* deserializes. The node's direct addresses get the best priorities;
* anchors trail so the mesh prefers the direct path when it works.
*/
suspend fun upsertNodePeer(info: FipsPairInfo, alias: String) {
val incoming = mutableListOf<JSONObject>()
incoming += JSONObject().apply {
put("npub", info.npub)
put("alias", hostSafeAlias(alias.ifBlank { "Archipelago" }))
val addresses = JSONArray()
// .fips hosts are unresolvable on Android (no system .fips DNS):
// storing one gives the mesh a dial target that fails every
// handshake and stalls first connect on anchor discovery.
if (info.udpPort > 0 && !info.host.endsWith(".fips")) {
addresses.put(JSONObject().apply {
put("transport", "udp")
put("addr", "${info.host}:${info.udpPort}")
put("priority", 10)
})
}
if (info.tcpPort > 0 && !info.host.endsWith(".fips")) {
addresses.put(JSONObject().apply {
put("transport", "tcp")
put("addr", "${info.host}:${info.tcpPort}")
put("priority", 20)
})
}
put("addresses", addresses)
}
for (anchor in info.anchors) {
if (anchor.npub == info.npub) continue
if (anchor.addr.substringBeforeLast(":").endsWith(".fips")) continue
incoming += JSONObject().apply {
put("npub", anchor.npub)
put("alias", "mesh-anchor")
put("addresses", JSONArray().put(JSONObject().apply {
put("transport", anchor.transport)
put("addr", anchor.addr)
put("priority", 30)
}))
}
}
// Guarantee the public anchor: without it, a QR from an older node
// leaves the phone LAN-only and pairing/connecting dies off-LAN.
if (info.npub != ARCHY_ANCHOR_NPUB &&
incoming.none { it.optString("npub") == ARCHY_ANCHOR_NPUB }
) {
incoming += JSONObject().apply {
put("npub", ARCHY_ANCHOR_NPUB)
put("alias", "archipelago-anchor")
put("addresses", JSONArray().put(JSONObject().apply {
put("transport", ARCHY_ANCHOR_TRANSPORT)
put("addr", ARCHY_ANCHOR_ADDR)
put("priority", 40)
}))
}
}
// And the public FIPS network anchors at trailing priority, so one
// degraded rendezvous (vps2, 2026-07-24) can never strand the phone.
for ((i, anchor) in PUBLIC_FIPS_ANCHORS.withIndex()) {
val (npub, addr, transport) = anchor
if (info.npub == npub || incoming.any { it.optString("npub") == npub }) continue
incoming += JSONObject().apply {
put("npub", npub)
put("alias", "fips-network-anchor-${i + 1}")
put("addresses", JSONArray().put(JSONObject().apply {
put("transport", transport)
put("addr", addr)
put("priority", 50 + i * 10)
}))
}
}
val incomingNpubs = incoming.map { it.optString("npub") }.toSet()
context.fipsDataStore.edit { prefs ->
val current = JSONArray(prefs[peersKey] ?: "[]")
val merged = JSONArray()
for (i in 0 until current.length()) {
val existing = current.optJSONObject(i) ?: continue
if (existing.optString("npub") !in incomingNpubs) merged.put(existing)
}
incoming.forEach { merged.put(it) }
prefs[peersKey] = merged.toString()
}
}
}
/**
* Peer aliases feed the fips host map as `<alias>.fips` hostnames; anything
* that isn't a valid DNS label ("Test Node" — the space) gets rejected and
* silently drops the peer from name resolution. Slug it instead of losing it.
*/
internal fun hostSafeAlias(alias: String): String =
alias.lowercase()
.replace(Regex("[^a-z0-9.-]+"), "-")
.trim('-', '.')
.ifBlank { "archipelago" }
@@ -0,0 +1,398 @@
package com.archipelago.app.fips
import android.content.Context
import android.util.Log
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import java.io.BufferedInputStream
import java.io.File
import java.io.InputStream
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.ServerSocket
import java.net.Socket
import java.util.UUID
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
/** One chat/photo message in a party conversation, keyed by the peer's npub. */
data class FlareMessage(
val id: String,
val peerNpub: String,
val fromMe: Boolean,
val name: String,
val text: String = "",
val photoPath: String = "",
val ts: Long,
val status: Status = Status.RECEIVED,
) {
enum class Status { SENDING, SENT, FAILED, RECEIVED }
}
/** In-memory conversation store (demo scope — nothing persists across restarts). */
object FlareStore {
private val _messages = MutableStateFlow<List<FlareMessage>>(emptyList())
val messages: StateFlow<List<FlareMessage>> = _messages
fun add(message: FlareMessage) {
_messages.value = _messages.value + message
}
fun setStatus(id: String, status: FlareMessage.Status) {
_messages.value = _messages.value.map { if (it.id == id) it.copy(status = status) else it }
}
}
/**
* Minimal HTTP listener bound ONLY on this phone's mesh ULA — plain HTTP is
* fine there because FIPS is the encryption + peer-identity layer (same
* stance as the node's ULA-only peer listener). This is what makes the phone
* a *server* on the mesh: another phone (or `curl -6` from any mesh node)
* reaches it by npub-derived address with no port forwarding, DNS, or CA.
*
* FIPS authenticates the node, not the request (project doctrine), so inputs
* are still validated at this boundary: size caps, JSON shape, no
* client-controlled paths.
*/
object FlareServer {
private const val TAG = "FlareServer"
private const val MAX_PHOTO_BYTES = 8 * 1024 * 1024
private const val MAX_TEXT_CHARS = 4_000
private const val MAX_HEADER_BYTES = 16 * 1024
private var socket: ServerSocket? = null
private var pool: ExecutorService? = null
@Volatile private var identityName = "Phone"
@Volatile private var identityNpub = ""
@Volatile private var photoDir: File? = null
/** Invoked when a peer announces itself (POST /hello) — pairing used to
* be one-way: only the SCANNING phone learned the other side, so the
* scanned phone had no peer, no chat entry, no way in. The VPN service
* wires this to upsert the peer + restart the mesh config. */
@Volatile var onHello: ((PartyPeer) -> Unit)? = null
@Synchronized
fun start(context: Context, ula: String, myNpub: String, myName: String) {
stop()
identityNpub = myNpub
identityName = myName
photoDir = File(context.cacheDir, "flare").apply { mkdirs() }
val pool = Executors.newCachedThreadPool().also { this.pool = it }
pool.execute {
try {
val server = ServerSocket().apply {
reuseAddress = true
bind(InetSocketAddress(InetAddress.getByName(ula), PartyQr.FLARE_PORT))
}
socket = server
Log.i(TAG, "flare listening on [$ula]:${PartyQr.FLARE_PORT}")
while (!server.isClosed) {
val client = try {
server.accept()
} catch (_: Exception) {
break
}
pool.execute { handle(client) }
}
} catch (e: Exception) {
Log.e(TAG, "flare server died: $e")
}
}
}
@Synchronized
fun stop() {
try {
socket?.close()
} catch (_: Exception) {
}
socket = null
pool?.shutdownNow()
pool = null
}
private fun handle(client: Socket) {
client.use { sock ->
sock.soTimeout = 30_000
try {
val input = BufferedInputStream(sock.getInputStream())
val requestLine = readLine(input) ?: return
val parts = requestLine.trim().split(" ")
if (parts.size < 2) return respond(sock, 400, json("bad_request"))
val (method, path) = parts[0] to parts[1]
var contentLength = 0
var from = ""
var fromName = ""
var headerBytes = requestLine.length
while (true) {
val line = readLine(input) ?: return
if (line.isEmpty()) break
headerBytes += line.length
if (headerBytes > MAX_HEADER_BYTES) return respond(sock, 431, json("headers_too_large"))
val idx = line.indexOf(':')
if (idx <= 0) continue
val key = line.substring(0, idx).trim().lowercase()
val value = line.substring(idx + 1).trim()
when (key) {
"content-length" -> contentLength = value.toIntOrNull() ?: 0
"x-from" -> from = value.take(80)
"x-name" -> fromName = value.take(80)
}
}
when {
method == "GET" && (path == "/" || path.startsWith("/?")) ->
respondHtml(sock, profilePage())
method == "POST" && path == "/hello" -> {
if (contentLength !in 1..MAX_HEADER_BYTES) return respond(sock, 413, json("too_large"))
val body = readExactly(input, contentLength) ?: return
receiveHello(String(body, Charsets.UTF_8))
respond(sock, 200, """{"ok":true}""")
}
method == "POST" && path == "/flare" -> {
if (contentLength !in 1..MAX_HEADER_BYTES) return respond(sock, 413, json("too_large"))
val body = readExactly(input, contentLength) ?: return
receiveFlare(String(body, Charsets.UTF_8))
respond(sock, 200, """{"ok":true}""")
}
method == "POST" && path == "/photo" -> {
if (contentLength !in 1..MAX_PHOTO_BYTES) return respond(sock, 413, json("too_large"))
if (!from.startsWith("npub1")) return respond(sock, 400, json("bad_request"))
val body = readExactly(input, contentLength) ?: return
receivePhoto(from, fromName, body)
respond(sock, 200, """{"ok":true}""")
}
else -> respond(sock, 404, json("not_found"))
}
} catch (e: Exception) {
Log.w(TAG, "request failed: $e")
}
}
}
/** A peer that scanned OUR QR announces itself — mutual pairing. */
private fun receiveHello(body: String) {
val o = try {
JSONObject(body)
} catch (_: Exception) {
return
}
val from = o.optString("from")
val ula = o.optString("ula")
if (!from.startsWith("npub1") || !ula.startsWith("fd")) return
if (from == identityNpub) return
val peer = PartyPeer(
npub = from.take(80),
ula = ula.take(64),
name = o.optString("name").take(24).ifBlank { "Phone" },
ip = o.optString("ip").take(40),
port = o.optInt("port", 0),
)
onHello?.invoke(peer)
// Seed the conversation so the chat has a visible entry on this side.
FlareStore.add(
FlareMessage(
id = UUID.randomUUID().toString(),
peerNpub = peer.npub,
fromMe = false,
name = peer.name,
text = "👋 ${peer.name} joined the party",
ts = System.currentTimeMillis(),
)
)
}
private fun receiveFlare(body: String) {
val o = try {
JSONObject(body)
} catch (_: Exception) {
return
}
val from = o.optString("from")
if (!from.startsWith("npub1")) return
val text = o.optString("text").take(MAX_TEXT_CHARS)
if (text.isBlank()) return
FlareStore.add(
FlareMessage(
id = UUID.randomUUID().toString(),
peerNpub = from,
fromMe = false,
name = o.optString("name").take(80).ifBlank { "Phone" },
text = text,
ts = System.currentTimeMillis(),
)
)
}
private fun receivePhoto(from: String, fromName: String, bytes: ByteArray) {
// Server-generated filename — the sender never controls the path.
val dir = photoDir ?: return
val file = File(dir, "${UUID.randomUUID()}.jpg")
file.writeBytes(bytes)
FlareStore.add(
FlareMessage(
id = UUID.randomUUID().toString(),
peerNpub = from,
fromMe = false,
name = fromName.ifBlank { "Phone" },
photoPath = file.absolutePath,
ts = System.currentTimeMillis(),
)
)
}
private fun profilePage(): String {
val npub = identityNpub
val name = identityName
return """
<!doctype html><html><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>$name — on the mesh</title>
<style>
body{background:#0a0a0a;color:#eee;font-family:monospace;
display:flex;min-height:100vh;align-items:center;justify-content:center;margin:0}
.card{border:1px solid rgba(255,255,255,.12);border-radius:20px;padding:32px;
max-width:560px;background:rgba(255,255,255,.04)}
h1{color:#f7931a;margin:0 0 8px;font-size:22px}
.npub{word-break:break-all;color:#888;font-size:12px;margin:12px 0}
p{line-height:1.5}
</style></head><body><div class="card">
<h1>⚡ $name</h1>
<div class="npub">$npub</div>
<p>This page is being served <b>by a phone</b>, addressed by its
cryptographic identity over the FIPS mesh.</p>
<p>No port forwarding. No DNS. No certificate authority. No cloud.
The key <i>is</i> the address — and the transport underneath can be
5G, WiFi, or a hotspot with no internet at all.</p>
</div></body></html>
""".trimIndent()
}
// ── tiny HTTP plumbing ──────────────────────────────────────────────────
/** Read one CRLF-terminated header line as ISO-8859-1; null on EOF. */
private fun readLine(input: InputStream): String? {
val sb = StringBuilder()
while (true) {
val b = input.read()
if (b == -1) return if (sb.isEmpty()) null else sb.toString()
if (b == '\n'.code) return sb.toString().trimEnd('\r')
sb.append(b.toChar())
if (sb.length > MAX_HEADER_BYTES) return null
}
}
private fun readExactly(input: InputStream, length: Int): ByteArray? {
val buf = ByteArray(length)
var off = 0
while (off < length) {
val n = input.read(buf, off, length - off)
if (n == -1) return null
off += n
}
return buf
}
private fun json(code: String) = """{"error":{"code":"$code","message":"request rejected"}}"""
private fun respond(sock: Socket, status: Int, body: String) =
writeResponse(sock, status, "application/json", body.toByteArray(Charsets.UTF_8))
private fun respondHtml(sock: Socket, body: String) =
writeResponse(sock, 200, "text/html; charset=utf-8", body.toByteArray(Charsets.UTF_8))
private fun writeResponse(sock: Socket, status: Int, contentType: String, body: ByteArray) {
val reason = when (status) {
200 -> "OK"; 400 -> "Bad Request"; 404 -> "Not Found"
413 -> "Payload Too Large"; 431 -> "Headers Too Large"
else -> "Error"
}
val head = "HTTP/1.1 $status $reason\r\n" +
"Content-Type: $contentType\r\n" +
"Content-Length: ${body.size}\r\n" +
"Connection: close\r\n\r\n"
sock.getOutputStream().apply {
write(head.toByteArray(Charsets.ISO_8859_1))
write(body)
flush()
}
}
}
/** Outbound flares: plain HTTP to the peer's ULA — FIPS encrypts underneath. */
object FlareClient {
// Connect timeout must outlive cold mesh-session establishment (~15s via
// the public tree); the attempt itself drives
// session setup, same trick as the VPN service's session warmer.
private val http = OkHttpClient.Builder()
.connectTimeout(25, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
.build()
private fun base(peer: PartyPeer) = "http://[${peer.ula}]:${PartyQr.FLARE_PORT}"
/** Announce myself to a freshly scanned peer so pairing becomes MUTUAL —
* their phone gets me as a peer + a chat entry without scanning back.
* Blocking — call from Dispatchers.IO. */
fun sendHello(
peer: PartyPeer,
myNpub: String,
myName: String,
myUla: String,
myIp: String?,
myPort: Int,
): Boolean = try {
val body = JSONObject()
.put("from", myNpub)
.put("name", myName)
.put("ula", myUla)
.put("ip", myIp ?: "")
.put("port", if (myIp != null) myPort else 0)
.toString()
.toRequestBody("application/json".toMediaType())
http.newCall(
Request.Builder().url("${base(peer)}/hello").post(body).build()
).execute().use { it.isSuccessful }
} catch (_: Exception) {
false
}
/** Blocking — call from Dispatchers.IO. */
fun sendText(peer: PartyPeer, myNpub: String, myName: String, text: String): Boolean = try {
val body = JSONObject()
.put("from", myNpub)
.put("name", myName)
.put("text", text)
.put("ts", System.currentTimeMillis())
.toString()
.toRequestBody("application/json".toMediaType())
http.newCall(
Request.Builder().url("${base(peer)}/flare").post(body).build()
).execute().use { it.isSuccessful }
} catch (_: Exception) {
false
}
/** Blocking — call from Dispatchers.IO. */
fun sendPhoto(peer: PartyPeer, myNpub: String, myName: String, jpeg: ByteArray): Boolean = try {
http.newCall(
Request.Builder()
.url("${base(peer)}/photo")
.header("X-From", myNpub)
.header("X-Name", myName)
.post(jpeg.toRequestBody("image/jpeg".toMediaType()))
.build()
).execute().use { it.isSuccessful }
} catch (_: Exception) {
false
}
}
@@ -0,0 +1,102 @@
package com.archipelago.app.fips
import android.net.Uri
import java.net.Inet4Address
import java.net.NetworkInterface
/**
* Phone↔phone mesh pairing ("Mesh Party") QR contract:
*
* archipelago://party?v=1&npub=<npub>&ula=<fd..>&name=<name>[&ip=<v4>&port=<udp>]
*
* npub + ula alone are enough to chat *through* the mesh (anchors route by
* node address, no underlay info needed). ip/port are present only while the
* showing phone has its inbound UDP listener up (party mode) — the scanner
* then also gets a direct-dial link that works on a shared LAN/hotspot with
* no internet at all. Same versioning stance as the node pairing QR
* (docs/companion-pairing-qr.md): unknown params tolerated under v=1.
*/
data class PartyPeer(
val npub: String,
val ula: String,
val name: String,
/** Direct-dial underlay endpoint; empty when the peer wasn't listening. */
val ip: String = "",
val port: Int = 0,
)
object PartyQr {
const val SCHEME_HOST = "party"
private const val SUPPORTED_MAJOR = 1
/** UDP port a party-mode phone listens on (matches the node's mesh port). */
const val PARTY_UDP_PORT = 2121
/** Application-layer chat/beam port, bound only on the mesh ULA. */
const val FLARE_PORT = 5680
fun build(npub: String, ula: String, name: String, ip: String?, port: Int): String {
val b = Uri.Builder()
.scheme("archipelago")
.authority(SCHEME_HOST)
.appendQueryParameter("v", "1")
.appendQueryParameter("npub", npub)
.appendQueryParameter("ula", ula)
.appendQueryParameter("name", name)
if (!ip.isNullOrBlank() && port > 0) {
b.appendQueryParameter("ip", ip)
b.appendQueryParameter("port", port.toString())
}
return b.build().toString()
}
/** Null when [raw] is not a valid party QR (foreign codes just keep scanning). */
fun parse(raw: String): PartyPeer? {
val uri = try {
Uri.parse(raw.trim())
} catch (_: Exception) {
return null
}
if (!"archipelago".equals(uri.scheme, ignoreCase = true)) return null
if (uri.isOpaque || !SCHEME_HOST.equals(uri.host, ignoreCase = true)) return null
val major = uri.getQueryParameter("v")?.takeWhile { it.isDigit() }?.toIntOrNull() ?: return null
if (major != SUPPORTED_MAJOR) return null
val npub = uri.getQueryParameter("npub")?.trim().orEmpty()
val ula = uri.getQueryParameter("ula")?.trim().orEmpty()
if (!npub.startsWith("npub1") || !ula.startsWith("fd")) return null
return PartyPeer(
npub = npub,
ula = ula,
name = uri.getQueryParameter("name")?.trim().orEmpty().ifBlank { "Phone" },
ip = uri.getQueryParameter("ip")?.trim().orEmpty(),
port = uri.getQueryParameter("port")?.toIntOrNull() ?: 0,
)
}
/**
* This phone's private IPv4 on WiFi or its own hotspot, for the QR's
* direct-dial hint. Hotspot interfaces (ap/swlan/softap) win over wlan so
* the hotspot-host phone advertises the address its guests can reach.
*/
fun localWifiIpv4(): String? {
val candidates = mutableListOf<Pair<String, String>>() // ifname → addr
try {
for (nif in NetworkInterface.getNetworkInterfaces()) {
if (!nif.isUp || nif.isLoopback) continue
for (addr in nif.inetAddresses) {
if (addr is Inet4Address && addr.isSiteLocalAddress) {
candidates += nif.name to addr.hostAddress.orEmpty()
}
}
}
} catch (_: Exception) {
return null
}
val hotspot = candidates.firstOrNull {
it.first.startsWith("ap") || it.first.startsWith("swlan") || it.first.startsWith("softap")
}
return (hotspot ?: candidates.firstOrNull { it.first.startsWith("wlan") } ?: candidates.firstOrNull())
?.second
}
}
@@ -0,0 +1,203 @@
package com.archipelago.app.network
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import java.security.cert.X509Certificate
import java.util.concurrent.TimeUnit
import javax.net.ssl.SSLContext
import javax.net.ssl.X509TrustManager
enum class ConnectionState { DISCONNECTED, CONNECTING, CONNECTED, AUTH_FAILED, ERROR }
class InputWebSocket(
private val scope: CoroutineScope,
) {
private var ws: WebSocket? = null
private var reconnectJob: Job? = null
private var reconnectAttempt = 0
private var serverUrl: String = ""
private var password: String = ""
private var sessionCookie: String? = null
/** Player ID for arcade mode (0 = broadcast, 1 = P1, 2 = P2) */
var playerId: Int = 0
/**
* Invoked when the kiosk asks us to open a URL in the phone's default
* browser ({"t":"o","url":"…"}). "Open in external browser" apps can't be
* usefully opened on the kiosk, so the kiosk forwards them here.
*/
var onExternalOpen: ((String) -> Unit)? = null
private val _state = MutableStateFlow(ConnectionState.DISCONNECTED)
val state: StateFlow<ConnectionState> = _state
private val trustManager = object : X509TrustManager {
override fun checkClientTrusted(chain: Array<X509Certificate>?, authType: String?) {}
override fun checkServerTrusted(chain: Array<X509Certificate>?, authType: String?) {}
override fun getAcceptedIssuers(): Array<X509Certificate> = arrayOf()
}
private val client: OkHttpClient by lazy {
val sc = SSLContext.getInstance("TLS")
sc.init(null, arrayOf(trustManager), java.security.SecureRandom())
OkHttpClient.Builder()
.sslSocketFactory(sc.socketFactory, trustManager)
.hostnameVerifier { _, _ -> true }
.pingInterval(30, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.MILLISECONDS)
.connectTimeout(10, TimeUnit.SECONDS)
.build()
}
fun connect(httpUrl: String, pwd: String = "") {
disconnect()
serverUrl = httpUrl
password = pwd
sessionCookie = null
reconnectAttempt = 0
scope.launch(Dispatchers.IO) { doAuth() }
}
private suspend fun doAuth() {
_state.value = ConnectionState.CONNECTING
if (password.isBlank()) {
doConnect()
return
}
try {
val body = """{"method":"auth.login","params":{"password":"$password"}}"""
.toRequestBody("application/json".toMediaType())
val req = Request.Builder()
.url("$serverUrl/rpc/v1")
.post(body)
.build()
val response = withContext(Dispatchers.IO) { client.newCall(req).execute() }
if (response.isSuccessful) {
sessionCookie = response.headers("Set-Cookie")
.mapNotNull { cookie ->
cookie.split(";")
.firstOrNull()
?.trim()
?.takeIf { it.startsWith("session=") }
?.removePrefix("session=")
}
.firstOrNull()
response.close()
if (sessionCookie != null) {
doConnect()
} else {
_state.value = ConnectionState.AUTH_FAILED
}
} else {
response.close()
_state.value = ConnectionState.AUTH_FAILED
}
} catch (_: Exception) {
_state.value = ConnectionState.ERROR
scheduleReconnect()
}
}
private fun doConnect() {
val basePath = "/ws/remote-input" + if (playerId > 0) "?p=$playerId" else ""
val wsUrl = serverUrl
.replace("https://", "wss://")
.replace("http://", "ws://")
.trimEnd('/') + basePath
val reqBuilder = Request.Builder().url(wsUrl)
sessionCookie?.let { reqBuilder.header("Cookie", "session=$it") }
ws = client.newWebSocket(reqBuilder.build(), object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
_state.value = ConnectionState.CONNECTED
reconnectAttempt = 0
}
override fun onMessage(webSocket: WebSocket, text: String) {
// The only inbound message we act on is an external-open request
// forwarded from the kiosk: {"t":"o","url":"https://…"}.
try {
val obj = org.json.JSONObject(text)
if (obj.optString("t") == "o") {
val url = obj.optString("url")
if (url.startsWith("http://") || url.startsWith("https://")) {
onExternalOpen?.invoke(url)
}
}
} catch (_: Exception) {}
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
_state.value = ConnectionState.ERROR
scheduleReconnect()
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
webSocket.close(1000, null)
_state.value = ConnectionState.DISCONNECTED
if (code != 1000) scheduleReconnect()
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
_state.value = ConnectionState.DISCONNECTED
}
})
}
private fun scheduleReconnect() {
reconnectJob?.cancel()
reconnectJob = scope.launch(Dispatchers.IO) {
val delayMs = minOf(1000L * (1 shl minOf(reconnectAttempt, 5)), 30_000L)
reconnectAttempt++
delay(delayMs)
doAuth()
}
}
fun disconnect() {
reconnectJob?.cancel()
ws?.close(1000, "bye")
ws = null
_state.value = ConnectionState.DISCONNECTED
}
// ─── Input senders ──────────────────────────────────────────
fun sendKey(key: String) {
val pField = if (playerId > 0) ""","p":$playerId""" else ""
ws?.send("""{"t":"k","k":"$key"$pField}""")
}
fun sendMouseMove(dx: Int, dy: Int) {
ws?.send("""{"t":"m","x":$dx,"y":$dy}""")
}
fun sendClick(button: Int = 1) {
ws?.send("""{"t":"c","b":$button}""")
}
fun sendScroll(dy: Int) {
ws?.send("""{"t":"s","y":$dy}""")
}
}
@@ -0,0 +1,56 @@
package com.archipelago.app.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
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.draw.clip
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.Neo
import com.archipelago.app.ui.theme.neoInset
import com.archipelago.app.ui.theme.neoRaised
private val R = 14.dp
@Composable
fun ActionButtons(
onEscape: () -> Unit,
onEnter: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(10.dp), horizontalAlignment = Alignment.CenterHorizontally) {
NeoBtn("ESC", Neo.textSecondary(), Modifier.fillMaxWidth().weight(1f), onEscape)
NeoBtn("ENTER", BitcoinOrange.copy(alpha = 0.7f), Modifier.fillMaxWidth().weight(1f), onEnter)
}
}
@Composable
private fun NeoBtn(label: String, color: androidx.compose.ui.graphics.Color, modifier: Modifier, onClick: () -> Unit) {
var p by remember { mutableStateOf(false) }
val l = Neo.shadowLight(); val d = Neo.shadowDark()
Box(
modifier = modifier
.then(if (p) Modifier.neoInset(l, d, R, 1.dp, 2.dp) else Modifier.neoRaised(l, d, R, 2.dp, 4.dp))
.clip(RoundedCornerShape(R))
.background(Neo.surfaceRaised())
.pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) },
contentAlignment = Alignment.Center,
) {
Text(label, color = if (p) color else color.copy(alpha = 0.7f), fontSize = 12.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.5.sp)
}
}
@@ -0,0 +1,121 @@
package com.archipelago.app.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.Neo
import com.archipelago.app.ui.theme.neoInset
import com.archipelago.app.ui.theme.neoRaised
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
private val BTN = 50.dp
private val BTN_R = 12.dp
private val GAP = 8.dp
private val NOB = 24.dp
@Composable
fun DPad(
onDirection: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val surface = Neo.surface()
val raised = Neo.surfaceRaised()
val l = Neo.shadowLight()
val d = Neo.shadowDark()
// Recessed well
Box(
modifier = modifier
.neoInset(l, d, 20.dp, 2.dp, 4.dp)
.clip(RoundedCornerShape(20.dp))
.background(surface)
.padding(14.dp),
contentAlignment = Alignment.Center,
) {
// Cross layout with explicit spacing
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Btn(Icons.Default.KeyboardArrowUp, "Up", onDirection)
Box(modifier = Modifier.size(height = GAP, width = BTN)) // spacer
Row(verticalAlignment = Alignment.CenterVertically) {
Btn(Icons.AutoMirrored.Filled.KeyboardArrowLeft, "Left", onDirection)
Box(modifier = Modifier.size(width = GAP, height = BTN)) // spacer
// Center nob
Box(
modifier = Modifier
.size(NOB)
.neoRaised(l, d, NOB / 2, 1.dp, 2.dp)
.clip(CircleShape)
.background(raised),
contentAlignment = Alignment.Center,
) {
Box(Modifier.size(8.dp).clip(CircleShape).background(BitcoinOrange.copy(alpha = 0.15f)))
}
Box(modifier = Modifier.size(width = GAP, height = BTN)) // spacer
Btn(Icons.AutoMirrored.Filled.KeyboardArrowRight, "Right", onDirection)
}
Box(modifier = Modifier.size(height = GAP, width = BTN)) // spacer
Btn(Icons.Default.KeyboardArrowDown, "Down", onDirection)
}
}
}
@Composable
private fun Btn(icon: ImageVector, key: String, onDir: (String) -> Unit) {
val scope = rememberCoroutineScope()
var job by remember { mutableStateOf<Job?>(null) }
var p by remember { mutableStateOf(false) }
val bg = Neo.surfaceRaised()
val l = Neo.shadowLight()
val d = Neo.shadowDark()
val tint = Neo.textPrimary()
DisposableEffect(Unit) { onDispose { job?.cancel() } }
Box(
modifier = Modifier
.size(BTN)
.then(if (p) Modifier.neoInset(l, d, BTN_R, 1.dp, 2.dp) else Modifier.neoRaised(l, d, BTN_R, 2.dp, 4.dp))
.clip(RoundedCornerShape(BTN_R))
.background(bg)
.pointerInput(key) {
detectTapGestures(onPress = {
p = true; onDir(key)
// 500ms initial delay so a normal tap sends one key, not two
// (a touch tap often exceeds 350ms → doubled nav sound).
job = scope.launch { delay(500); while (true) { onDir(key); delay(100) } }
tryAwaitRelease(); p = false; job?.cancel()
})
},
contentAlignment = Alignment.Center,
) {
Icon(icon, key, Modifier.fillMaxSize(0.48f), tint = if (p) tint.copy(alpha = 0.9f) else tint.copy(alpha = 0.5f))
}
}
@@ -0,0 +1,134 @@
package com.archipelago.app.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
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.draw.clip
import androidx.compose.ui.input.pointer.changedToUp
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.Neo
import com.archipelago.app.ui.theme.neoInset
import com.archipelago.app.ui.theme.neoRaised
@Composable
fun GamepadLayout(
onKey: (String) -> Unit,
onThreeFingerHold: () -> Unit,
modifier: Modifier = Modifier,
) {
val surface = Neo.surface()
Box(
modifier = modifier
.fillMaxSize()
.background(surface)
.pointerInput(Unit) {
awaitEachGesture {
awaitFirstDown(requireUnconsumed = false)
var t = 0L; var fired = false
do {
val ev = awaitPointerEvent()
val a = ev.changes.filter { !it.changedToUp() }
if (a.size >= 3 && t == 0L) t = System.currentTimeMillis()
if (a.size >= 3 && !fired && t > 0 && System.currentTimeMillis() - t > 500) { fired = true; onThreeFingerHold() }
if (a.size < 3) t = 0L
} while (ev.changes.any { it.pressed })
}
}
.padding(horizontal = 24.dp, vertical = 16.dp),
) {
// D-pad — centered left
DPad(
onDirection = onKey,
modifier = Modifier.align(Alignment.CenterStart).size(200.dp),
)
// Face buttons — centered right (diamond)
Column(
modifier = Modifier.align(Alignment.CenterEnd),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
FaceBtn("esc", 64.dp) { onKey("Escape") }
Row(horizontalArrangement = Arrangement.spacedBy(28.dp)) {
FaceBtn("tab", 64.dp) { onKey("Tab") }
FaceBtn("enter", 64.dp, accent = true) { onKey("Return") }
}
FaceBtn("bksp", 64.dp) { onKey("BackSpace") }
}
// Bottom: L, SELECT, START, R
Row(
modifier = Modifier.align(Alignment.BottomCenter),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
PillBtn("L", 56.dp) { onKey("Prior") }
PillBtn("SELECT", 80.dp) { onKey("Escape") }
PillBtn("START", 80.dp) { onKey("Return") }
PillBtn("R", 56.dp) { onKey("Next") }
}
}
}
@Composable
private fun FaceBtn(label: String, size: Dp, accent: Boolean = false, onClick: () -> Unit) {
var p by remember { mutableStateOf(false) }
val l = Neo.shadowLight(); val d = Neo.shadowDark()
val tc = if (accent) BitcoinOrange.copy(alpha = 0.7f) else Neo.textSecondary()
Box(
modifier = Modifier
.size(size)
.then(if (p) Modifier.neoInset(l, d, size / 2, 1.dp, 3.dp) else Modifier.neoRaised(l, d, size / 2, 2.dp, 4.dp))
.clip(CircleShape)
.background(Neo.surfaceRaised())
.pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) },
contentAlignment = Alignment.Center,
) {
Text(label, color = if (p) tc.copy(alpha = 1f) else tc, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.5.sp)
}
}
@Composable
private fun PillBtn(label: String, w: Dp, onClick: () -> Unit) {
var p by remember { mutableStateOf(false) }
val l = Neo.shadowLight(); val d = Neo.shadowDark()
Box(
modifier = Modifier
.width(w).height(34.dp)
.then(if (p) Modifier.neoInset(l, d, 8.dp, 1.dp, 2.dp) else Modifier.neoRaised(l, d, 8.dp, 2.dp, 4.dp))
.clip(RoundedCornerShape(8.dp))
.background(Neo.surfaceRaised())
.pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) },
contentAlignment = Alignment.Center,
) {
Text(label, color = Neo.textMuted(), fontSize = 9.sp, fontWeight = FontWeight.Medium, letterSpacing = 1.sp)
}
}
@@ -0,0 +1,147 @@
package com.archipelago.app.ui.components
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.archipelago.app.R
import com.archipelago.app.ui.theme.BitcoinOrange
import kotlinx.coroutines.delay
/**
* First-launch teaching overlay for the three-finger hold gesture. Three
* fingertip dots pulse in a "press" rhythm with an expanding ring while a
* short caption explains what the gesture opens. Dismissed by tapping
* anywhere (or automatically after a few seconds) — shown once, ever.
*/
@Composable
fun GestureHintOverlay(onDismiss: () -> Unit) {
// Auto-dismiss so a user who taps nothing is never stuck behind the scrim.
LaunchedEffect(Unit) {
delay(6500)
onDismiss()
}
val transition = rememberInfiniteTransition(label = "gesture-hint")
// Fingertips press down together…
val press by transition.animateFloat(
initialValue = 1f,
targetValue = 0.86f,
animationSpec = infiniteRepeatable(tween(650), RepeatMode.Reverse),
label = "press",
)
// …while a ring ripples outward on each press cycle.
val ripple by transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(tween(1300, easing = LinearEasing)),
label = "ripple",
)
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.72f))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onDismiss,
),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
// Hand: three fingertip dots in a natural arc + ripple ring.
Box(Modifier.size(160.dp), contentAlignment = Alignment.Center) {
Box(
Modifier
.size(150.dp)
.scale(0.4f + ripple * 0.6f)
.border(
2.dp,
BitcoinOrange.copy(alpha = (1f - ripple) * 0.8f),
CircleShape,
),
)
FingerDot(x = (-44).dp, y = 14.dp, scale = press)
FingerDot(x = 0.dp, y = (-12).dp, scale = press)
FingerDot(x = 44.dp, y = 8.dp, scale = press)
}
Spacer(Modifier.height(28.dp))
Text(
text = stringResource(R.string.gesture_hint_title),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = Color.White,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(10.dp))
Text(
text = stringResource(R.string.gesture_hint_body),
style = MaterialTheme.typography.bodyMedium,
color = Color.White.copy(alpha = 0.7f),
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 48.dp),
)
Spacer(Modifier.height(32.dp))
Box(
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.background(Color.White.copy(alpha = 0.12f))
.clickable(onClick = onDismiss)
.padding(horizontal = 28.dp, vertical = 12.dp),
) {
Text(
text = stringResource(R.string.gesture_hint_got_it),
style = MaterialTheme.typography.labelLarge,
color = Color.White,
)
}
}
}
}
@Composable
private fun FingerDot(x: androidx.compose.ui.unit.Dp, y: androidx.compose.ui.unit.Dp, scale: Float) {
Box(
Modifier
.offset(x = x, y = y)
.size(26.dp)
.scale(scale)
.background(Color.White.copy(alpha = 0.92f), CircleShape),
)
}
@@ -0,0 +1,86 @@
package com.archipelago.app.ui.components
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.archipelago.app.R
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.SurfaceBlack
import com.archipelago.app.ui.theme.TextMuted
import com.archipelago.app.ui.theme.TextPrimary
/**
* Full-screen loader shown while the app is dialing a node.
*
* Two faces, because they are two different promises:
* - [mesh] `true` — a FIPS node: the branded "F*CK IPs" screen, because what
* is loading really is a connection to a cryptographic identity, not an IP.
* - [mesh] `false` — a plain node reached over the network like anything
* else. No mesh branding at all: claiming the mesh is carrying a connection
* it isn't is worse than an anonymous spinner.
*/
@Composable
fun MeshLoadingScreen(
mesh: Boolean = true,
nodeName: String = "",
done: Boolean = false,
) {
Box(
Modifier
.fillMaxSize()
.background(SurfaceBlack),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
// The app's own badge — the same ringed mark as the launcher icon
// and the system splash, so launch → splash → this screen is one
// continuous identity.
Image(
painter = painterResource(id = R.drawable.ic_logo),
contentDescription = null,
modifier = Modifier.size(112.dp),
)
Spacer(Modifier.height(24.dp))
Text(
text = if (mesh) "F*CK IPS MESH" else "CONNECTING",
color = BitcoinOrange,
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 4.sp,
)
Spacer(Modifier.height(10.dp))
Text(
text = when {
mesh -> "Dialing your node by its key — no IPs harmed"
nodeName.isNotBlank() -> "Reaching $nodeName"
else -> "Reaching your node"
},
color = if (done) TextPrimary else TextMuted,
fontSize = 13.sp,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 32.dp),
)
Spacer(Modifier.height(28.dp))
SlidingLoader(
modifier = Modifier.width(220.dp),
done = done,
)
}
}
}
@@ -0,0 +1,488 @@
package com.archipelago.app.ui.components
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.input.pointer.changedToUp
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.archipelago.app.R
import com.archipelago.app.ui.theme.ControllerStyle
import com.archipelago.app.ui.theme.NES
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.math.abs
// ═══════════════════════════════════════════════════════════
// Palettes
// ═══════════════════════════════════════════════════════════
data class NESPalette(
val body: Color, val face: Color, val ridge: Color,
val label: Color, val labelMuted: Color,
val dpad: Color, val dpadHi: Color,
val btn: Color, val btnPress: Color,
val capsule: Color, val capsulePress: Color,
val inlayBg: Color, val inlayBorder: Color,
)
val ClassicPalette = NESPalette(
body = NES.ClassicBody, face = NES.ClassicFace, ridge = NES.ClassicRidge,
label = NES.ClassicLabel, labelMuted = NES.ClassicLabelMuted,
dpad = Color(0xFF0C0C0C), dpadHi = Color(0xFF1A1A1A),
btn = NES.ClassicButtonRed, btnPress = NES.ClassicButtonRedPress,
capsule = Color(0xFF1C1C1C), capsulePress = Color(0xFF0E0E0E),
inlayBg = Color(0xFF080808), inlayBorder = Color(0xFF999999),
)
// Glassmorphism-black (OS design): translucent dark surfaces so the backdrop
// shows through the controller, subtle white-alpha borders, translucent-white
// buttons. Accents come from each button's ring.
val DarkPalette = NESPalette(
body = Color(0xA6121216), face = Color(0x8C0E0E12), ridge = Color(0x14FFFFFF),
label = Color(0xFF9A9A9A), labelMuted = Color(0xFF777777),
dpad = Color(0xFF202024), dpadHi = Color(0xFF33333A),
btn = Color(0x14FFFFFF), btnPress = Color(0x0AFFFFFF),
capsule = Color(0x12FFFFFF), capsulePress = Color(0x08FFFFFF),
inlayBg = Color(0x990A0A0A), inlayBorder = Color(0x1FFFFFFF),
)
fun paletteFor(style: ControllerStyle) = if (style == ControllerStyle.CLASSIC) ClassicPalette else DarkPalette
// ═══════════════════════════════════════════════════════════
// Landscape NES Controller
// ═══════════════════════════════════════════════════════════
@Composable
fun NESController(
style: ControllerStyle = ControllerStyle.CLASSIC,
playerId: Int = 0,
onKey: (String) -> Unit,
onMenu: () -> Unit,
onPlayerToggle: () -> Unit = {},
onToggleStyle: (() -> Unit)? = null,
modifier: Modifier = Modifier,
) {
val c = paletteFor(style)
val isClassic = style == ControllerStyle.CLASSIC
Box(
modifier = modifier
.fillMaxSize()
.threeFingerHold(onMenu)
.padding(horizontal = 40.dp, vertical = 24.dp),
contentAlignment = Alignment.Center,
) {
// Controller body
Box(
Modifier
.fillMaxWidth(0.86f)
.aspectRatio(2.3f)
.shadow(32.dp, RoundedCornerShape(16.dp), ambientColor = Color(0xFF000000), spotColor = Color(0xFF000000))
.clip(RoundedCornerShape(16.dp))
.background(
Brush.verticalGradient(listOf(c.body, c.body))
)
.border(1.dp, Color.White.copy(alpha = if (isClassic) 0.08f else 0.04f), RoundedCornerShape(16.dp)),
) {
// Top highlight edge
Box(
Modifier.fillMaxWidth().height(1.dp).align(Alignment.TopCenter)
.background(Color.White.copy(alpha = if (isClassic) 0.12f else 0.05f))
)
// Face plate
Box(
Modifier
.fillMaxSize()
.padding(14.dp)
.clip(RoundedCornerShape(10.dp))
.background(c.face)
.border(0.5.dp, Color.White.copy(alpha = 0.03f), RoundedCornerShape(10.dp)),
) {
// Ridges
Ridges(c.ridge, Modifier.align(Alignment.CenterStart).width(7.dp).fillMaxHeight().padding(vertical = 12.dp))
Ridges(c.ridge, Modifier.align(Alignment.CenterEnd).width(7.dp).fillMaxHeight().padding(vertical = 12.dp))
// D-Pad in inlay (more left margin)
Inlay(c, Modifier.align(Alignment.CenterStart).padding(start = 48.dp).size(140.dp)) {
OnePointDPad(c, 120.dp, onKey)
}
// Center: Logo + START/SELECT
Column(
Modifier.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Image(
painter = painterResource(id = R.drawable.ic_logo_wide),
contentDescription = "Archipelago",
modifier = Modifier.width(180.dp),
colorFilter = ColorFilter.tint(if (isClassic) NES.ClassicLabel else c.label),
)
Spacer(Modifier.height(10.dp))
Inlay(c, Modifier.padding(horizontal = 4.dp)) {
Row(
Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
CapsuleBtn("SELECT", c, 64.dp, 28.dp) { onKey("Escape") }
CapsuleBtn("START", c, 64.dp, 28.dp) { onKey("Return") }
}
}
}
// A/B/C Buttons in inlay — triangle: C top, B+A bottom
Inlay(c, Modifier.align(Alignment.CenterEnd).padding(end = 48.dp).size(140.dp)) {
Column(
Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
// C on top
GlassFaceBtn("C", Color(0xFFBBBBBB), 44.dp) { onKey("c") }
Spacer(Modifier.height(6.dp))
// B + A on bottom row
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
GlassFaceBtn("B", Color(0xFF60A5FA), 44.dp) { onKey("b") }
GlassFaceBtn("A", Color(0xFFF7931A), 44.dp) { onKey("a") }
}
}
}
// Player toggle + settings (bottom center)
Row(
Modifier.align(Alignment.BottomCenter).padding(bottom = 4.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
PlayerPill(c, playerId, onPlayerToggle)
SettingsBtn(c, Modifier, onMenu)
onToggleStyle?.let { StyleBtn(c, Modifier, it) }
}
}
}
}
}
// ═══════════════════════════════════════════════════════════
// Shared sub-components
// ═══════════════════════════════════════════════════════════
/** Inlay well — dark recessed area with border */
@Composable
fun Inlay(c: NESPalette, modifier: Modifier = Modifier, content: @Composable () -> Unit) {
Box(
modifier = modifier
.clip(RoundedCornerShape(10.dp))
.background(c.inlayBg)
.border(3.dp, c.inlayBorder, RoundedCornerShape(10.dp))
.padding(4.dp),
contentAlignment = Alignment.Center,
) { content() }
}
/** One-piece D-pad — single cross shape, touch detects direction */
@Composable
fun OnePointDPad(c: NESPalette, size: Dp, onDir: (String) -> Unit) {
val scope = rememberCoroutineScope()
var job by remember { mutableStateOf<Job?>(null) }
var activeDir by remember { mutableStateOf<String?>(null) }
DisposableEffect(Unit) { onDispose { job?.cancel() } }
Canvas(
modifier = Modifier
.size(size)
.pointerInput(Unit) {
detectTapGestures(
onPress = { offset ->
val cx = this@pointerInput.size.width / 2f
val cy = this@pointerInput.size.height / 2f
val dx = offset.x - cx
val dy = offset.y - cy
val dead = cx * 0.24f
if (abs(dx) < dead && abs(dy) < dead) {
tryAwaitRelease(); return@detectTapGestures
}
val dir = if (abs(dx) > abs(dy)) {
if (dx > 0) "Right" else "Left"
} else {
if (dy > 0) "Down" else "Up"
}
activeDir = dir; onDir(dir)
job?.cancel()
// 500ms initial delay so a normal tap sends one key, not
// two (a touch tap often exceeds 300ms → doubled nav sound).
job = scope.launch { delay(500); while (true) { onDir(dir); delay(90) } }
tryAwaitRelease()
job?.cancel(); activeDir = null
},
)
},
) {
val w = size.toPx()
val arm = w * 0.33f // arm width = 1/3 of total
val offset = (w - arm) / 2f
// Cross shape
val crossColor = c.dpad
// Vertical bar
drawRoundRect(
color = crossColor,
topLeft = Offset(offset, 0f),
size = Size(arm, w),
cornerRadius = CornerRadius(4.dp.toPx()),
)
// Horizontal bar
drawRoundRect(
color = crossColor,
topLeft = Offset(0f, offset),
size = Size(w, arm),
cornerRadius = CornerRadius(4.dp.toPx()),
)
// Top-edge lighting
drawRoundRect(
brush = Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.06f), Color.Transparent)),
topLeft = Offset(offset, 0f),
size = Size(arm, w * 0.15f),
cornerRadius = CornerRadius(4.dp.toPx()),
)
drawRoundRect(
brush = Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.06f), Color.Transparent)),
topLeft = Offset(0f, offset),
size = Size(w, arm * 0.3f),
cornerRadius = CornerRadius(4.dp.toPx()),
)
// Active direction highlight
activeDir?.let { dir ->
val hi = c.dpadHi
when (dir) {
"Up" -> drawRoundRect(hi, Offset(offset, 0f), Size(arm, arm), CornerRadius(4.dp.toPx()))
"Down" -> drawRoundRect(hi, Offset(offset, w - arm), Size(arm, arm), CornerRadius(4.dp.toPx()))
"Left" -> drawRoundRect(hi, Offset(0f, offset), Size(arm, arm), CornerRadius(4.dp.toPx()))
"Right" -> drawRoundRect(hi, Offset(w - arm, offset), Size(arm, arm), CornerRadius(4.dp.toPx()))
}
}
// Center circle
drawCircle(c.dpadHi, radius = w * 0.06f, center = Offset(w / 2f, w / 2f))
}
}
@Composable
fun Ridges(color: Color, modifier: Modifier) {
Canvas(modifier = modifier) {
val h = 1.5.dp.toPx(); val gap = 3.dp.toPx(); var y = 0f
while (y < size.height) { drawRect(color, Offset(0f, y), Size(size.width, h)); y += h + gap }
}
}
/** A/B round button with lighting */
@Composable
fun RoundBtn(c: NESPalette, sz: Dp = 52.dp, onClick: () -> Unit) {
var p by remember { mutableStateOf(false) }
Box(
Modifier
.size(sz)
.shadow(if (p) 1.dp else 4.dp, CircleShape)
.clip(CircleShape)
.background(Brush.verticalGradient(
if (p) listOf(c.btnPress, c.btn.copy(alpha = 0.85f))
else listOf(c.btn, c.btn.copy(alpha = 0.8f))
))
.pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) },
contentAlignment = Alignment.Center,
) {
if (!p) Box(Modifier.fillMaxSize().clip(CircleShape).background(
Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.18f), Color.Transparent))
))
}
}
/** Colored round button — custom color instead of palette */
@Composable
fun ColorBtn(color: Color, pressColor: Color, sz: Dp = 48.dp, onClick: () -> Unit) {
var p by remember { mutableStateOf(false) }
Box(
Modifier
.size(sz)
.shadow(if (p) 1.dp else 4.dp, CircleShape)
.clip(CircleShape)
.background(Brush.verticalGradient(
if (p) listOf(pressColor, color.copy(alpha = 0.85f))
else listOf(color, color.copy(alpha = 0.8f))
))
.pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) },
contentAlignment = Alignment.Center,
) {
if (!p) Box(Modifier.fillMaxSize().clip(CircleShape).background(
Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.18f), Color.Transparent))
))
}
}
/** Glass face button — dark translucent fill, colored ring + letter (OS style) */
@Composable
fun GlassFaceBtn(label: String, accent: Color, sz: Dp = 44.dp, onClick: () -> Unit) {
var p by remember { mutableStateOf(false) }
Box(
Modifier
.size(sz)
.clip(CircleShape)
.background(
Brush.verticalGradient(
if (p) listOf(Color.White.copy(alpha = 0.05f), Color.White.copy(alpha = 0.02f))
else listOf(Color.White.copy(alpha = 0.10f), Color.White.copy(alpha = 0.03f))
)
)
.border(1.5.dp, accent.copy(alpha = if (p) 0.95f else 0.55f), CircleShape)
.pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) },
contentAlignment = Alignment.Center,
) {
Text(label, color = accent.copy(alpha = if (p) 1f else 0.85f), fontSize = 16.sp, fontWeight = FontWeight.Bold)
}
}
/** START/SELECT capsule */
@Composable
fun CapsuleBtn(label: String, c: NESPalette, w: Dp = 64.dp, h: Dp = 28.dp, onClick: () -> Unit) {
var p by remember { mutableStateOf(false) }
Box(
Modifier
.width(w).height(h)
.shadow(if (p) 0.dp else 2.dp, RoundedCornerShape(4.dp))
.clip(RoundedCornerShape(4.dp))
.background(Brush.verticalGradient(
if (p) listOf(c.capsulePress, c.capsule)
else listOf(c.capsule, c.capsule.copy(alpha = 0.85f))
))
.pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) },
contentAlignment = Alignment.Center,
) {
if (!p) Box(Modifier.fillMaxSize().clip(RoundedCornerShape(4.dp)).background(
Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.05f), Color.Transparent))
))
Text(label, color = c.labelMuted, fontSize = 8.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp)
}
}
/** Settings gear button (48dp — large enough for easy tap on TV) */
@Composable
fun SettingsBtn(c: NESPalette, modifier: Modifier = Modifier, onClick: () -> Unit) {
var p by remember { mutableStateOf(false) }
Box(
modifier = modifier
.size(48.dp)
.clip(CircleShape)
.background(if (p) c.capsulePress else c.capsule)
.pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) },
contentAlignment = Alignment.Center,
) {
Icon(Icons.Default.Settings, "Settings", Modifier.size(28.dp), tint = c.labelMuted)
}
}
/** Dark/Classic style toggle — lives next to the settings gear (the menu hub
* no longer carries it). */
@Composable
fun StyleBtn(c: NESPalette, modifier: Modifier = Modifier, onClick: () -> Unit) {
var p by remember { mutableStateOf(false) }
Box(
modifier = modifier
.size(48.dp)
.clip(CircleShape)
.background(if (p) c.capsulePress else c.capsule)
.pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) },
contentAlignment = Alignment.Center,
) {
Icon(Icons.Default.Palette, "Controller style", Modifier.size(26.dp), tint = c.labelMuted)
}
}
/** Player ID toggle pill (P1/P2/ALL) */
@Composable
fun PlayerPill(c: NESPalette, playerId: Int, onToggle: () -> Unit) {
val label = when (playerId) { 1 -> "P1"; 2 -> "P2"; else -> "ALL" }
val accent = when (playerId) { 1 -> Color(0xFF00F0FF); 2 -> Color(0xFFFF0080); else -> c.labelMuted }
var p by remember { mutableStateOf(false) }
Box(
modifier = Modifier
.height(28.dp)
.width(44.dp)
.clip(RoundedCornerShape(6.dp))
.background(if (p) c.capsulePress else c.capsule)
.border(1.dp, accent.copy(alpha = 0.5f), RoundedCornerShape(6.dp))
.pointerInput(Unit) { detectTapGestures(onPress = { p = true; onToggle(); tryAwaitRelease(); p = false }) },
contentAlignment = Alignment.Center,
) {
Text(label, color = accent, fontSize = 10.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp)
}
}
/** Three-finger hold gesture modifier (two fingers stay free for scrolling) */
fun Modifier.threeFingerHold(onHold: () -> Unit) = this.pointerInput(Unit) {
awaitEachGesture {
awaitFirstDown(requireUnconsumed = false)
var t = 0L; var fired = false
do {
val ev = awaitPointerEvent()
val a = ev.changes.filter { !it.changedToUp() }
if (a.size >= 3 && t == 0L) t = System.currentTimeMillis()
if (a.size >= 3 && !fired && t > 0 && System.currentTimeMillis() - t > 500) { fired = true; onHold() }
if (a.size < 3) t = 0L
} while (ev.changes.any { it.pressed })
}
}
@@ -0,0 +1,211 @@
package com.archipelago.app.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.archipelago.app.ui.theme.ControllerStyle
import com.archipelago.app.ui.theme.NES
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
private enum class NKLayer { ALPHA, NUM, SYM }
private val KEY_H = 42.dp
private val GAP = 4.dp
@Composable
fun NESKeyboard(
style: ControllerStyle = ControllerStyle.CLASSIC,
onKey: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val c = paletteFor(style)
val isClassic = style == ControllerStyle.CLASSIC
val keyBg = c.dpad
val keyBgP = c.dpadHi
val keyTxt = c.labelMuted
val accent = if (isClassic) NES.ClassicLabel else c.labelMuted
var layer by remember { mutableStateOf(NKLayer.ALPHA) }
var shifted by remember { mutableStateOf(false) }
var capsLock by remember { mutableStateOf(false) }
var ctrlHeld by remember { mutableStateOf(false) }
val up = shifted || capsLock
fun emit(k: String) {
val key = if (ctrlHeld) "ctrl+$k" else k
onKey(key)
if (shifted && !capsLock) shifted = false
if (ctrlHeld) ctrlHeld = false
}
fun ch(cc: String) { emit(if (up && layer == NKLayer.ALPHA) "shift+$cc" else cc) }
// NES body wrapping keyboard
Column(
modifier = modifier
.clip(RoundedCornerShape(14.dp))
.background(c.body)
.padding(8.dp)
.clip(RoundedCornerShape(8.dp))
.background(c.face)
.padding(horizontal = 8.dp, vertical = 6.dp),
verticalArrangement = Arrangement.spacedBy(GAP),
) {
when (layer) {
NKLayer.ALPHA -> {
KeyRow("q w e r t y u i o p".split(" "), up, keyBg, keyBgP, keyTxt, ::ch)
KeyRow("a s d f g h j k l".split(" "), up, keyBg, keyBgP, keyTxt, ::ch, inset = 16.dp)
Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) {
NKey(if (capsLock) "\u21EA" else "\u21E7", Modifier.weight(1.4f), keyBg, keyBgP, if (up) accent else keyTxt) {
if (capsLock) { capsLock = false; shifted = false } else if (shifted) capsLock = true else shifted = true
}
"z x c v b n m".split(" ").forEach { k ->
NKey(if (up) k.uppercase() else k, Modifier.weight(1f), keyBg, keyBgP, keyTxt, 17) { ch(k) }
}
NRepKey("\u232B", Modifier.weight(1.4f), keyBg, keyBgP, keyTxt) { emit("BackSpace") }
}
}
NKLayer.NUM -> {
KeyRow("1 2 3 4 5 6 7 8 9 0".split(" "), false, keyBg, keyBgP, keyTxt, ::emit)
KeyRow("- / : ; ( ) \$ & @ \"".split(" "), false, keyBg, keyBgP, keyTxt, ::emit)
Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) {
NKey("#+=", Modifier.weight(1.4f), keyBg, keyBgP, keyTxt) { layer = NKLayer.SYM }
". , ? ! '".split(" ").forEach { k ->
NKey(k, Modifier.weight(1f), keyBg, keyBgP, keyTxt) { emit(k) }
}
NRepKey("\u232B", Modifier.weight(1.4f), keyBg, keyBgP, keyTxt) { emit("BackSpace") }
}
}
NKLayer.SYM -> {
KeyRow("[ ] { } # % ^ * + =".split(" "), false, keyBg, keyBgP, keyTxt, ::emit)
KeyRow("_ \\ | ~ < > ` @ !".split(" "), false, keyBg, keyBgP, keyTxt, ::emit)
Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) {
NKey("123", Modifier.weight(1.4f), keyBg, keyBgP, keyTxt) { layer = NKLayer.NUM }
". , ? ! '".split(" ").forEach { k ->
NKey(k, Modifier.weight(1f), keyBg, keyBgP, keyTxt) { emit(k) }
}
NRepKey("\u232B", Modifier.weight(1.4f), keyBg, keyBgP, keyTxt) { emit("BackSpace") }
}
}
}
// Bottom row
Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) {
NKey(if (layer == NKLayer.ALPHA) "123" else "ABC", Modifier.weight(1.4f), keyBg, keyBgP, keyTxt) {
layer = if (layer == NKLayer.ALPHA) NKLayer.NUM else NKLayer.ALPHA; shifted = false; capsLock = false
}
NKey("Ctrl", Modifier.weight(1.2f), keyBg, keyBgP, if (ctrlHeld) accent else keyTxt, 11) {
ctrlHeld = !ctrlHeld
}
NKey(",", Modifier.weight(0.8f), keyBg, keyBgP, keyTxt) { emit("comma") }
NKey("space", Modifier.weight(4f), keyBg, keyBgP, keyTxt, 12) { emit("space") }
NKey(".", Modifier.weight(0.8f), keyBg, keyBgP, keyTxt) { emit("period") }
NKey("\u23CE", Modifier.weight(1.4f), keyBg, keyBgP, accent, 15) { emit("Return") }
}
}
}
/** Key row — each key gets equal weight */
@Composable
private fun KeyRow(
keys: List<String>, up: Boolean,
bg: Color, bgP: Color, txt: Color,
onKey: (String) -> Unit, inset: Dp = 0.dp,
) {
Row(
Modifier.fillMaxWidth().height(KEY_H).padding(horizontal = inset),
Arrangement.spacedBy(GAP),
) {
keys.forEach { k ->
NKey(
label = if (up) k.uppercase() else k,
modifier = Modifier.weight(1f),
bg = bg, bgP = bgP, txt = txt,
fontSize = 17,
onTap = { onKey(k) },
)
}
}
}
/** Single NES key — D-pad style flat dark button */
@Composable
private fun NKey(
label: String, modifier: Modifier = Modifier,
bg: Color, bgP: Color, txt: Color,
fontSize: Int = 13, onTap: () -> Unit,
) {
var p by remember { mutableStateOf(false) }
Box(
modifier = modifier
.height(KEY_H)
.clip(RoundedCornerShape(4.dp))
.background(Brush.verticalGradient(if (p) listOf(bgP, bg) else listOf(bg, bg.copy(alpha = 0.9f))))
.then(
if (!p) Modifier.border(0.5.dp,
Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.06f), Color.Transparent)),
RoundedCornerShape(4.dp))
else Modifier
)
.pointerInput(label) {
detectTapGestures(onPress = { p = true; onTap(); tryAwaitRelease(); p = false })
},
contentAlignment = Alignment.Center,
) {
Text(label, color = txt, fontSize = fontSize.sp, textAlign = TextAlign.Center, maxLines = 1)
}
}
/** Repeatable NES key (backspace) */
@Composable
private fun NRepKey(
label: String, modifier: Modifier,
bg: Color, bgP: Color, txt: Color, onTap: () -> Unit,
) {
var p by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
var job by remember { mutableStateOf<Job?>(null) }
DisposableEffect(Unit) { onDispose { job?.cancel() } }
Box(
modifier = modifier
.height(KEY_H)
.clip(RoundedCornerShape(4.dp))
.background(Brush.verticalGradient(if (p) listOf(bgP, bg) else listOf(bg, bg.copy(alpha = 0.9f))))
.pointerInput(Unit) {
detectTapGestures(onPress = {
p = true; onTap()
job = scope.launch { delay(400); while (true) { onTap(); delay(55) } }
tryAwaitRelease(); job?.cancel(); p = false
})
},
contentAlignment = Alignment.Center,
) {
Text(label, color = txt, fontSize = 16.sp)
}
}
@@ -0,0 +1,656 @@
package com.archipelago.app.ui.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.Dashboard
import androidx.compose.material.icons.filled.Dns
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.Keyboard
import androidx.compose.material.icons.filled.RestartAlt
import androidx.compose.material.icons.filled.SportsEsports
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.QrCodeScanner
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import com.archipelago.app.fips.FipsManager
import com.archipelago.app.fips.FipsNative
import com.archipelago.app.fips.FipsPreferences
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.archipelago.app.R
import com.archipelago.app.data.ServerEntry
import com.archipelago.app.ui.screens.restartCompanionApp
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.SurfaceDark
import com.archipelago.app.ui.theme.TextMuted
import com.archipelago.app.ui.theme.TextPrimary
// Glassmorphism palette (OS design): near-black surfaces, subtle white borders,
// Bitcoin-orange accent.
private val PanelBg = SurfaceDark // #0A0A0A
private val PanelBorder = Color.White.copy(alpha = 0.12f)
private val RowBg = Color.White.copy(alpha = 0.05f)
private val RowBorder = Color.White.copy(alpha = 0.08f)
private val FieldBg = Color.White.copy(alpha = 0.04f)
private val PANEL_R = 20.dp
private val ROW_R = 14.dp
private val ROW_H = 54.dp
private val FIELD_H = 58.dp
/** Glassmorphism modal menu — #0A0A0A surface, subtle white borders. */
@Composable
fun NESMenu(
visible: Boolean,
servers: List<ServerEntry>,
activeServer: ServerEntry?,
onDismiss: () -> Unit,
onSelectServer: (ServerEntry) -> Unit,
onAddServer: (ServerEntry) -> Unit,
onScanQr: (() -> Unit)? = null,
onEditServer: (ServerEntry, ServerEntry) -> Unit,
onRemoveServer: (ServerEntry) -> Unit,
onRemote: () -> Unit,
onKeyboard: () -> Unit,
onBackToWebView: (() -> Unit)? = null,
onMeshParty: (() -> Unit)? = null,
) {
AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) {
// Contained hub overlay: a centred glass panel (not full-screen) that
// holds the card page and its sub-pages (Nodes, FIPS) and scrolls
// inside its own bounds when content is tall. Tapping the dimmed
// backdrop dismisses.
Box(
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.7f))
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onDismiss() },
contentAlignment = Alignment.Center,
) {
AnimatedVisibility(visible = visible, enter = fadeIn() + scaleIn(initialScale = 0.95f), exit = fadeOut() + scaleOut(targetScale = 0.95f)) {
MenuPanel(servers, activeServer, onDismiss, onSelectServer, onAddServer, onScanQr, onEditServer, onRemoveServer, onRemote, onKeyboard, onBackToWebView, onMeshParty)
}
}
}
}
@Composable
private fun MenuPanel(
servers: List<ServerEntry>,
activeServer: ServerEntry?,
onDismiss: () -> Unit,
onSelectServer: (ServerEntry) -> Unit,
onAddServer: (ServerEntry) -> Unit,
onScanQr: (() -> Unit)?,
onEditServer: (ServerEntry, ServerEntry) -> Unit,
onRemoveServer: (ServerEntry) -> Unit,
onRemote: () -> Unit,
onKeyboard: () -> Unit,
onBackToWebView: (() -> Unit)?,
onMeshParty: (() -> Unit)?,
) {
var showAdd by remember { mutableStateOf(false) }
// The saved server being edited, or null when adding a new one.
var editing by remember { mutableStateOf<ServerEntry?>(null) }
var nm by remember { mutableStateOf("") }
var addr by remember { mutableStateOf("") }
var pwd by remember { mutableStateOf("") }
var https by remember { mutableStateOf(false) }
fun resetForm() {
nm = ""; addr = ""; pwd = ""; https = false; showAdd = false; editing = null
}
fun startEdit(server: ServerEntry) {
editing = server
nm = server.name; addr = server.address; pwd = server.password; https = server.useHttps
showAdd = false
}
fun submit() {
if (addr.isBlank()) return
val orig = editing
if (orig != null) {
// Preserve port (compact form doesn't expose it); scheme is now editable.
onEditServer(orig, orig.copy(address = addr, useHttps = https, password = pwd, name = nm))
} else {
onAddServer(ServerEntry(addr, https, password = pwd, name = nm))
}
resetForm()
}
var page by remember { mutableStateOf(HubPage.HUB) }
Column(
modifier = Modifier
.widthIn(max = 420.dp)
.fillMaxWidth()
.padding(horizontal = 20.dp)
// Cap height just short of the full screen; the panel wraps short
// content and only scrolls in the rare case it outgrows this.
.heightIn(max = (LocalConfiguration.current.screenHeightDp * 0.92f).dp)
.clip(RoundedCornerShape(PANEL_R))
.background(PanelBg.copy(alpha = 0.86f))
.border(1.dp, PanelBorder, RoundedCornerShape(PANEL_R))
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {}
.verticalScroll(rememberScrollState())
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
// Header: back (on sub-pages) or title, and a close on the hub.
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
if (page == HubPage.HUB) {
Text("Menu", color = TextPrimary, fontSize = 20.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 2.sp)
IconRound(Icons.Default.Close, "Close") { onDismiss() }
} else {
Row(verticalAlignment = Alignment.CenterVertically) {
IconRound(Icons.AutoMirrored.Filled.ArrowBack, "Back") { resetForm(); page = HubPage.HUB }
Spacer(Modifier.width(12.dp))
Text(
if (page == HubPage.NODES) "Nodes" else "FIPS Mesh",
color = TextPrimary, fontSize = 20.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.sp,
)
}
IconRound(Icons.Default.Close, "Close") { onDismiss() }
}
}
Spacer(Modifier.height(4.dp))
when (page) {
HubPage.HUB -> {
// Card page — one card per destination. Dashboard first: it's a
// peer of the others so three-finger → hub → Dashboard returns
// to the node UI, same shape as every other option.
if (onBackToWebView != null) {
HubCard(Icons.Default.Dashboard, "Dashboard", "The node's web interface") { onBackToWebView() }
}
HubCard(Icons.Default.SportsEsports, "Remote", "Game controller for the node") { onRemote() }
HubCard(Icons.Default.Keyboard, "Keyboard", "Type into the node") { onKeyboard() }
HubCard(Icons.Default.Dns, "Nodes", activeServer?.displayName() ?: "Add or switch servers") {
page = HubPage.NODES
}
// Mesh oversight only when this session is actually on the
// mesh. Offering "FIPS Mesh" while connected to a plain node
// (whose traffic is going nowhere near the tunnel) advertises
// a connection the user doesn't have.
if (FipsNative.available && activeServer?.isFipsNode() == true) {
HubCard(Icons.Default.Bolt, "FIPS Mesh", "Mesh identity & status") { page = HubPage.FIPS }
}
if (onMeshParty != null) {
HubCard(Icons.Default.Groups, "Mesh Party", "Phone-to-phone chat & beam") { onMeshParty() }
}
// Dark/Classic style lives on the remote/keyboard screen next to
// the settings button — not here.
// Small version chip at the hub's foot — the one place a
// connected user can always check what build they're on.
val hubContext = LocalContext.current
// Restart: the dashboard WebView is retained across
// remote ⇄ dashboard (that's the point), which also means a
// wedged page can't be cleared by leaving the screen. This
// throws the page away and relaunches the app clean — the mesh
// service keeps running.
HubCard(Icons.Default.RestartAlt, "Restart", "Reload the app from scratch") {
onDismiss()
restartCompanionApp(hubContext)
}
val versionLabel = remember {
runCatching {
hubContext.packageManager
.getPackageInfo(hubContext.packageName, 0).versionName
}.getOrNull()?.let { "Companion v$it" } ?: ""
}
if (versionLabel.isNotEmpty()) {
Text(
versionLabel,
color = TextMuted.copy(alpha = 0.6f),
fontSize = 11.sp,
letterSpacing = 1.sp,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
)
}
}
HubPage.NODES -> {
servers.forEach { server ->
val active = server.serialize() == activeServer?.serialize()
MenuItem(
label = server.displayName(),
selected = active,
onClick = { onSelectServer(server) },
onEdit = { startEdit(server) },
onRemove = { onRemoveServer(server) },
)
}
if (servers.isEmpty()) {
Text("No servers", color = TextMuted, fontSize = 14.sp, modifier = Modifier.padding(vertical = 4.dp))
}
if (showAdd || editing != null) {
Column(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(ROW_R))
.background(FieldBg)
.border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
if (editing != null) "Edit Server" else "Add Server",
color = TextMuted, fontSize = 13.sp, letterSpacing = 1.sp, fontWeight = FontWeight.Medium,
)
Text(
"Cancel", color = TextMuted, fontSize = 13.sp,
modifier = Modifier.clickable { resetForm() }.padding(start = 8.dp),
)
}
GlassField(
value = nm, onValueChange = { nm = it },
placeholder = "Name (optional)",
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Next),
)
GlassField(
value = addr, onValueChange = { addr = it.trim() },
placeholder = "192.168.1.100",
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
GlassField(
value = pwd, onValueChange = { pwd = it },
placeholder = "Password",
modifier = Modifier.weight(1f),
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),
keyboardActions = KeyboardActions(onGo = { submit() }),
)
Box(
Modifier.size(FIELD_H).clip(RoundedCornerShape(12.dp)).background(BitcoinOrange.copy(alpha = 0.15f))
.border(1.dp, BitcoinOrange.copy(alpha = 0.4f), RoundedCornerShape(12.dp))
.clickable { submit() },
contentAlignment = Alignment.Center,
) { Text("OK", color = BitcoinOrange, fontSize = 14.sp, fontWeight = FontWeight.Bold) }
}
// HTTPS scheme toggle (available on both add and edit).
Row(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(ROW_R))
.clickable { https = !https }
.padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text("Use HTTPS", color = TextMuted, fontSize = 13.sp)
Box(
Modifier
.width(46.dp).height(26.dp)
.clip(RoundedCornerShape(13.dp))
.background(if (https) BitcoinOrange.copy(alpha = 0.9f) else RowBg)
.border(1.dp, if (https) BitcoinOrange else RowBorder, RoundedCornerShape(13.dp)),
contentAlignment = if (https) Alignment.CenterEnd else Alignment.CenterStart,
) {
Box(
Modifier
.padding(horizontal = 3.dp)
.size(20.dp)
.clip(RoundedCornerShape(10.dp))
.background(if (https) Color.White else TextMuted),
)
}
}
}
} else {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Box(Modifier.weight(1f)) {
MenuItem(label = "Add Server", labelColor = BitcoinOrange, onClick = { showAdd = true })
}
if (onScanQr != null) {
Box(
Modifier
.size(ROW_H)
.clip(RoundedCornerShape(ROW_R))
.background(RowBg)
.border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
.clickable { onScanQr() },
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Default.QrCodeScanner,
contentDescription = stringResource(R.string.add_server_qr),
tint = BitcoinOrange,
modifier = Modifier.size(24.dp),
)
}
}
}
}
}
HubPage.FIPS -> {
FipsSection(embedded = true)
}
}
}
}
private enum class HubPage { HUB, NODES, FIPS }
/** Big tappable destination card for the hub page: icon + title + subtitle. */
@Composable
private fun HubCard(
icon: androidx.compose.ui.graphics.vector.ImageVector,
title: String,
subtitle: String,
onClick: () -> Unit,
) {
Row(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(ROW_R))
.background(RowBg)
.border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
.clickable { onClick() }
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
Box(
Modifier.size(40.dp).clip(RoundedCornerShape(12.dp)).background(BitcoinOrange.copy(alpha = 0.14f)),
contentAlignment = Alignment.Center,
) {
Icon(icon, contentDescription = title, tint = BitcoinOrange, modifier = Modifier.size(22.dp))
}
Column(Modifier.weight(1f)) {
Text(title, color = TextPrimary, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
Text(subtitle, color = TextMuted, fontSize = 12.sp, maxLines = 1)
}
}
}
/** Small circular icon button used in the hub header. */
@Composable
private fun IconRound(
icon: androidx.compose.ui.graphics.vector.ImageVector,
desc: String,
onClick: () -> Unit,
) {
Box(
Modifier
.size(40.dp)
.clip(RoundedCornerShape(20.dp))
.background(RowBg)
.border(1.dp, RowBorder, RoundedCornerShape(20.dp))
.clickable { onClick() },
contentAlignment = Alignment.Center,
) {
Icon(icon, contentDescription = desc, tint = TextPrimary, modifier = Modifier.size(20.dp))
}
}
/** Snapshot of the phone's mesh identity + state for the FIPS menu section. */
private data class FipsInfo(
val available: Boolean,
val running: Boolean,
val npub: String,
val meshAddress: String,
val peerCount: Int,
)
/**
* FIPS mesh oversight: shows what the phone's embedded mesh node is doing —
* running state, its mesh identity (npub), its mesh address (fd…ULA), how
* many peers/anchors it's configured with — and a one-tap Reconnect that
* re-homes the mesh (also the manual fix if a network handoff ever misses).
* Collapsed by default so the menu stays compact.
*/
@Composable
private fun FipsSection(embedded: Boolean = false) {
if (!FipsNative.available) return
val context = LocalContext.current
val clipboard = LocalClipboardManager.current
var expanded by remember { mutableStateOf(embedded) }
var info by remember { mutableStateOf<FipsInfo?>(null) }
// Load identity/state when the section opens (cheap DataStore + JSON read).
LaunchedEffect(expanded) {
if (expanded && info == null) {
val prefs = FipsPreferences(context)
val id = prefs.identity()
val peers = runCatching {
org.json.JSONArray(prefs.peersJson()).length()
}.getOrDefault(0)
info = FipsInfo(
available = true,
running = FipsNative.isRunning(),
npub = id?.npub.orEmpty(),
meshAddress = id?.address.orEmpty(),
peerCount = peers,
)
}
}
Column(Modifier.fillMaxWidth()) {
// Embedded in the hub's FIPS sub-page the header row would duplicate
// the page title, so only the standalone (collapsible) form shows it.
if (!embedded) MenuItem(
label = "FIPS Mesh",
labelColor = BitcoinOrange,
onClick = { expanded = !expanded },
)
if (expanded) {
val i = info
Column(
Modifier
.fillMaxWidth()
.padding(top = 6.dp)
.clip(RoundedCornerShape(ROW_R))
.background(FieldBg)
.border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
if (i == null) {
Text("Loading…", color = TextMuted, fontSize = 13.sp)
} else {
FipsRow("Status", if (i.running) "Connected" else "Stopped",
valueColor = if (i.running) BitcoinOrange else TextMuted)
if (i.meshAddress.isNotBlank()) {
FipsRow("Mesh address", i.meshAddress, mono = true,
onCopy = { clipboard.setText(AnnotatedString(i.meshAddress)) })
}
if (i.npub.isNotBlank()) {
FipsRow("Identity (npub)", i.npub, mono = true,
onCopy = { clipboard.setText(AnnotatedString(i.npub)) })
}
FipsRow("Peers & anchors", i.peerCount.toString())
Text(
"Your node reaches this phone over the mesh by its npub — no ports opened to the internet.",
color = TextMuted, fontSize = 11.sp,
)
MenuItem(
label = "Reconnect mesh",
labelColor = BitcoinOrange,
onClick = {
FipsManager.requestMeshRestart(context)
info = null
if (!embedded) expanded = false
},
)
}
}
}
}
}
@Composable
private fun FipsRow(
label: String,
value: String,
valueColor: Color = TextPrimary,
mono: Boolean = false,
onCopy: (() -> Unit)? = null,
) {
Row(
Modifier
.fillMaxWidth()
.then(if (onCopy != null) Modifier.clickable { onCopy() } else Modifier),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(label, color = TextMuted, fontSize = 12.sp, modifier = Modifier.width(120.dp))
Text(
value,
color = valueColor,
fontSize = if (mono) 11.sp else 13.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.weight(1f),
textAlign = TextAlign.End,
)
if (onCopy != null) {
Text("", color = TextMuted, fontSize = 13.sp, modifier = Modifier.padding(start = 8.dp))
}
}
}
@Composable
private fun MenuItem(
label: String,
selected: Boolean = false,
labelColor: Color = TextPrimary,
onClick: () -> Unit,
onEdit: (() -> Unit)? = null,
onRemove: (() -> Unit)? = null,
) {
Row(
Modifier
.fillMaxWidth()
.height(ROW_H)
.clip(RoundedCornerShape(ROW_R))
.background(if (selected) BitcoinOrange.copy(alpha = 0.12f) else RowBg)
.border(1.dp, if (selected) BitcoinOrange.copy(alpha = 0.4f) else RowBorder, RoundedCornerShape(ROW_R))
.clickable { onClick() }
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
label,
color = if (selected) BitcoinOrange else labelColor,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.weight(1f),
)
if (onEdit != null) {
Text(
"",
color = TextMuted,
fontSize = 16.sp,
modifier = Modifier.clickable { onEdit() }.padding(horizontal = 8.dp),
)
}
if (onRemove != null) {
Text(
"",
color = TextMuted,
fontSize = 16.sp,
modifier = Modifier.clickable { onRemove() }.padding(horizontal = 8.dp),
)
}
}
}
/** Glass text field with centered input text. */
@Composable
private fun GlassField(
value: String,
onValueChange: (String) -> Unit,
placeholder: String,
modifier: Modifier = Modifier,
visualTransformation: androidx.compose.ui.text.input.VisualTransformation = androidx.compose.ui.text.input.VisualTransformation.None,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default,
) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
placeholder = {
Text(placeholder, color = TextMuted, fontSize = 15.sp, modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center)
},
modifier = modifier.fillMaxWidth().height(FIELD_H),
singleLine = true,
visualTransformation = visualTransformation,
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
textStyle = TextStyle(color = TextPrimary, fontSize = 16.sp, textAlign = TextAlign.Center),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.White.copy(alpha = 0.3f),
unfocusedBorderColor = Color.White.copy(alpha = 0.12f),
cursorColor = BitcoinOrange,
focusedTextColor = TextPrimary,
unfocusedTextColor = TextPrimary,
),
shape = RoundedCornerShape(12.dp),
)
}
@@ -0,0 +1,163 @@
package com.archipelago.app.ui.components
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.archipelago.app.R
import com.archipelago.app.ui.theme.ControllerStyle
import com.archipelago.app.ui.theme.NES
/**
* Portrait gamepad — vertical remote shape like Apple TV but NES-styled.
* Large trackpad top, D-pad middle, A/B + START/SELECT bottom.
*/
@Composable
fun NESPortraitController(
style: ControllerStyle = ControllerStyle.CLASSIC,
playerId: Int = 0,
onKey: (String) -> Unit,
onMouseMove: (Int, Int) -> Unit = { _, _ -> },
onMouseClick: (Int) -> Unit = { _ -> },
onMouseScroll: (Int) -> Unit = { _ -> },
onMenu: () -> Unit,
onPlayerToggle: () -> Unit = {},
onToggleStyle: (() -> Unit)? = null,
) {
val c = paletteFor(style)
val isClassic = style == ControllerStyle.CLASSIC
Box(
Modifier
.fillMaxSize()
.threeFingerHold(onMenu)
.padding(horizontal = 40.dp, vertical = 24.dp),
contentAlignment = Alignment.Center,
) {
// Remote body — tall vertical shape
Box(
Modifier
.fillMaxWidth(0.75f)
.fillMaxSize()
.shadow(28.dp, RoundedCornerShape(20.dp), ambientColor = Color.Black, spotColor = Color.Black)
.clip(RoundedCornerShape(20.dp))
.background(Brush.verticalGradient(listOf(c.body, c.body)))
.border(1.dp, Color.White.copy(alpha = if (isClassic) 0.08f else 0.04f), RoundedCornerShape(20.dp)),
) {
// Top highlight
Box(
Modifier.fillMaxWidth().height(1.dp).align(Alignment.TopCenter)
.background(Color.White.copy(alpha = if (isClassic) 0.12f else 0.05f))
)
// Face plate
Column(
Modifier
.fillMaxSize()
.padding(14.dp)
.clip(RoundedCornerShape(14.dp))
.background(c.face)
.border(0.5.dp, Color.White.copy(alpha = 0.03f), RoundedCornerShape(14.dp))
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceBetween,
) {
// Trackpad area (touch surface for mouse)
Trackpad(
onMove = { dx, dy -> onMouseMove(dx, dy) },
onClick = { onMouseClick(it) },
onScroll = { dy -> onMouseScroll(dy) },
onThreeFingerHold = onMenu,
modifier = Modifier
.fillMaxWidth()
.weight(1f),
)
Spacer(Modifier.height(12.dp))
// D-Pad
Inlay(c, Modifier.size(150.dp)) {
OnePointDPad(c, 130.dp, onKey)
}
Spacer(Modifier.height(12.dp))
// Logo
Image(
painter = painterResource(id = R.drawable.ic_logo_wide),
contentDescription = "Archipelago",
modifier = Modifier.width(140.dp),
colorFilter = ColorFilter.tint(if (isClassic) NES.ClassicLabel else c.label),
)
Spacer(Modifier.height(12.dp))
// A/B/C Buttons — triangle: C top, B+A bottom
Inlay(c, Modifier.fillMaxWidth()) {
Column(
Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
GlassFaceBtn("C", Color(0xFFBBBBBB), 46.dp) { onKey("c") }
Spacer(Modifier.height(6.dp))
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
GlassFaceBtn("B", Color(0xFF60A5FA), 46.dp) { onKey("b") }
GlassFaceBtn("A", Color(0xFFF7931A), 46.dp) { onKey("a") }
}
}
}
Spacer(Modifier.height(10.dp))
// START / SELECT
Inlay(c, Modifier) {
Row(
Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
CapsuleBtn("SELECT", c, 64.dp, 28.dp) { onKey("Escape") }
CapsuleBtn("START", c, 64.dp, 28.dp) { onKey("Return") }
}
}
Spacer(Modifier.height(6.dp))
// Player toggle + Settings
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
PlayerPill(c, playerId, onPlayerToggle)
Spacer(Modifier.width(10.dp))
SettingsBtn(c, Modifier, onMenu)
onToggleStyle?.let {
Spacer(Modifier.width(10.dp))
StyleBtn(c, Modifier, it)
}
}
}
}
}
}
@@ -0,0 +1,776 @@
package com.archipelago.app.ui.components
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.hardware.camera2.CameraCharacteristics
import android.hardware.camera2.CameraManager
import android.hardware.camera2.CameraMetadata
import android.hardware.camera2.CaptureRequest
import android.os.Process
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.camera.camera2.interop.Camera2Interop
import androidx.camera.camera2.interop.ExperimentalCamera2Interop
import androidx.camera.core.CameraSelector
import androidx.camera.core.FocusMeteringAction
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
import androidx.camera.core.Preview
import androidx.camera.core.SurfaceOrientedMeteringPointFactory
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.FlashOff
import androidx.compose.material.icons.filled.FlashOn
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import com.archipelago.app.R
import com.archipelago.app.data.PairResult
import com.archipelago.app.data.ServerQrParser
import com.archipelago.app.ui.screens.GlassButton
import com.archipelago.app.ui.theme.BitcoinOrange
import com.google.zxing.BarcodeFormat
import com.google.zxing.BinaryBitmap
import com.google.zxing.DecodeHintType
import com.google.zxing.MultiFormatReader
import com.google.zxing.PlanarYUVLuminanceSource
import com.google.zxing.common.GlobalHistogramBinarizer
import com.google.zxing.common.HybridBinarizer
import com.google.zxing.qrcode.QRCodeReader
import kotlinx.coroutines.delay
import java.util.concurrent.Executors
/**
* Scans the node pairing QR (docs/companion-pairing-qr.md) and reports the
* decoded server entry. Handles the camera permission itself; foreign/invalid
* codes show a hint in the status strip and scanning continues.
*
* Visually this is the SAME glass modal the web wallet uses (neode-ui's
* WalletScanModal) — scrim, glass card, square preview, orange viewfinder,
* status strip — so pairing from the app and scanning from the web UI look
* like one product rather than two different scanners.
*/
@Composable
fun QrScannerOverlay(
visible: Boolean,
onDismiss: () -> Unit,
onServerScanned: (PairResult.Success) -> Unit,
) {
val haptics = LocalHapticFeedback.current
var hintRes by remember { mutableStateOf<Int?>(null) }
var handled by remember { mutableStateOf(false) }
LaunchedEffect(visible) {
if (visible) {
handled = false
hintRes = null
}
}
// Foreign-code hint fades after a moment so scanning feels live again.
LaunchedEffect(hintRes) {
if (hintRes != null) {
delay(2500)
hintRes = null
}
}
QrGlassModal(
visible = visible,
title = stringResource(R.string.scan_node_qr),
status = hintRes?.let { stringResource(it) to true },
idleHint = stringResource(R.string.scan_qr_hint),
permissionRationale = stringResource(R.string.camera_permission_needed),
onDismiss = onDismiss,
onDecoded = { text ->
if (!handled) {
when (val result = ServerQrParser.parse(text)) {
is PairResult.Success -> {
handled = true
// Confirm the hit in the hand — the eye is still on the
// code, not on the screen.
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
onServerScanned(result)
}
is PairResult.UnsupportedVersion -> hintRes = R.string.update_app_for_qr
is PairResult.Invalid -> hintRes = R.string.invalid_pairing_qr
}
}
},
)
}
/**
* The shared native scanner shell — one visual contract for every camera the
* app opens (pairing, wallet), mirroring neode-ui's WalletScanModal so the
* native and web scanners are indistinguishable:
* - black/60 scrim, dismiss on tap-outside
* - glass card (rounded 24, white/10 hairline) capped at 420dp
* - square preview with the 62% orange viewfinder and a darkened surround
* - a status strip that carries hints and errors
* - an optional footer (the wallet's "Upload image")
*/
@Composable
internal fun QrGlassModal(
visible: Boolean,
title: String,
// message + isError; null falls back to [idleHint].
status: Pair<String, Boolean>?,
idleHint: String,
permissionRationale: String,
onDismiss: () -> Unit,
onDecoded: (String) -> Unit,
footer: @Composable (() -> Unit)? = null,
) {
val context = LocalContext.current
var hasPermission by remember {
mutableStateOf(
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED
)
}
var torchOn by remember { mutableStateOf(false) }
var hasTorch by remember { mutableStateOf(false) }
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted -> hasPermission = granted }
LaunchedEffect(visible) {
if (visible) {
val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED
hasPermission = granted
if (!granted) permissionLauncher.launch(Manifest.permission.CAMERA)
} else {
torchOn = false
}
}
AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) {
BackHandler { onDismiss() }
Box(
Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.6f))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onDismiss,
),
contentAlignment = Alignment.Center,
) {
Column(
Modifier
.padding(16.dp)
.widthIn(max = 420.dp)
.fillMaxWidth()
.clip(RoundedCornerShape(24.dp))
.background(Color(0xF212151C))
.border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(24.dp))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = {}, // swallow — only the scrim dismisses
)
.padding(24.dp),
) {
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
color = Color.White,
)
IconButton(onClick = onDismiss) {
Icon(
Icons.Default.Close,
stringResource(R.string.close),
tint = Color.White.copy(alpha = 0.7f),
)
}
}
Spacer(Modifier.height(8.dp))
Box(
Modifier
.fillMaxWidth()
.aspectRatio(1f)
.clip(RoundedCornerShape(12.dp))
.background(Color.Black.copy(alpha = 0.4f))
.border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(12.dp)),
contentAlignment = Alignment.Center,
) {
if (hasPermission) {
CameraQrPreview(
onDecoded = onDecoded,
torchOn = torchOn,
onTorchAvailable = { hasTorch = it },
)
// Viewfinder — 62% of the preview, matching the web
// modal's .scan-viewfinder, and matching the ROI the
// decoder actually reads (QR_ROI_FRACTION).
Box(
Modifier
.fillMaxSize(QR_ROI_FRACTION)
.border(
2.dp,
BitcoinOrange.copy(alpha = 0.85f),
RoundedCornerShape(16.dp),
),
)
if (hasTorch) {
IconButton(
onClick = { torchOn = !torchOn },
modifier = Modifier
.align(Alignment.TopEnd)
.padding(6.dp)
.clip(RoundedCornerShape(50))
.background(Color.Black.copy(alpha = 0.45f)),
) {
Icon(
if (torchOn) Icons.Default.FlashOn else Icons.Default.FlashOff,
stringResource(
if (torchOn) R.string.torch_off else R.string.torch_on,
),
tint = if (torchOn) BitcoinOrange else Color.White.copy(alpha = 0.85f),
)
}
}
} else {
Column(
Modifier.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = permissionRationale,
color = Color.White.copy(alpha = 0.7f),
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
)
GlassButton(
text = stringResource(R.string.grant_camera_access),
onClick = { permissionLauncher.launch(Manifest.permission.CAMERA) },
modifier = Modifier.fillMaxWidth().height(48.dp),
)
}
}
}
Spacer(Modifier.height(16.dp))
Box(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(Color.White.copy(alpha = 0.05f))
.padding(12.dp)
.defaultMinSize(minHeight = 24.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = status?.first?.takeIf { it.isNotBlank() } ?: idleHint,
style = MaterialTheme.typography.bodySmall,
color = if (status?.second == true) {
Color(0xFFF87171)
} else {
Color.White.copy(alpha = 0.6f)
},
textAlign = TextAlign.Center,
)
}
if (footer != null) {
Spacer(Modifier.height(16.dp))
footer()
}
}
}
}
}
/**
* Warm the CameraX provider and the ZXing decode path before the user ever
* asks for a scan, so opening the scanner doesn't pay provider init + class
* loading on the critical path. Does NOT open the camera: no permission is
* needed, no LED lights up, nothing is recorded — [ProcessCameraProvider]
* init is process-wide and cached, and the synthetic decode below just walks
* a blank 32x32 frame to class-load the binarizer/detector.
*
* Called once per process from the kiosk WebView (first page load) and by the
* page via `ArchipelagoQr.prewarm()`.
*/
internal fun prewarmQrScanner(context: Context) {
if (!qrPrewarmed.compareAndSet(false, true)) return
val app = context.applicationContext
runCatching { ProcessCameraProvider.getInstance(app) }
// Off the UI thread: the first decode attempt loads a dozen ZXing classes.
Executors.newSingleThreadExecutor().let { exec ->
exec.execute {
runCatching {
val blank = ByteArray(32 * 32)
val reader = MultiFormatReader().apply {
setHints(mapOf(DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE)))
}
val source = PlanarYUVLuminanceSource(blank, 32, 32, 0, 0, 32, 32, false)
reader.decodeWithState(BinaryBitmap(HybridBinarizer(source)))
}
}
exec.shutdown()
}
}
private val qrPrewarmed = java.util.concurrent.atomic.AtomicBoolean(false)
/**
* Fraction of the preview's shorter edge that both the on-screen viewfinder
* and the decoder's region of interest use. Keeping them identical is the
* point: the user aims at the box, and the box is exactly what gets decoded.
*/
internal const val QR_ROI_FRACTION = 0.62f
/**
* Shared by the pairing scanner and the wallet scan modal.
*
* [torchOn] drives the flash; [onTorchAvailable] reports whether this camera
* has one at all (the caller only draws its toggle when it does).
*
* ## Why this looks the way it does
*
* The previous version hunted: a scheduled tick alternated the optical zoom
* between 1x and 1.5x and re-fired `startFocusAndMetering(...disableAutoCancel())`
* every 2 seconds. Both are camera-hostile:
*
* - Every zoom step restarts AE/AF convergence, so the sensor spends the
* seconds right after it delivering soft frames — precisely the frames the
* decoder needs to be sharp. The visible symptom is the "zooms in and out
* and takes ages" report.
* - `disableAutoCancel()` leaves AF **locked** at whatever it converged on
* instead of handing the lens back to continuous AF, so a re-aim never
* refocused on its own; the next timer tick then kicked off another full
* sweep from a locked position — a lens that hunts forever.
*
* A stock camera app does neither. It leaves CameraX's continuous AF alone,
* refocuses on tap, and never touches zoom. This does the same, with one
* concession to the "hand-held QR is a static scene" case: if nothing has
* decoded for a few seconds, ONE auto-cancelling focus nudge is issued (and
* then not again for a while), which re-arms continuous AF instead of
* fighting it.
*/
@Composable
internal fun CameraQrPreview(
onDecoded: (String) -> Unit,
torchOn: Boolean = false,
onTorchAvailable: (Boolean) -> Unit = {},
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val currentOnDecoded by rememberUpdatedState(onDecoded)
val currentOnTorchAvailable by rememberUpdatedState(onTorchAvailable)
var camera by remember { mutableStateOf<androidx.camera.core.Camera?>(null) }
val previewView = remember {
PreviewView(context).apply {
scaleType = PreviewView.ScaleType.FILL_CENTER
// TextureView, not the SurfaceView default: SurfaceView punches a
// hole in the window, which black-flashes inside Compose fades and
// ignores rounded-corner clipping (the glass modal).
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
}
}
// Set by the analyzer on every decode; the focus nudge below reads it to
// tell "nothing in view" from "reading fine, leave the camera alone".
val lastDecodeAt = remember { java.util.concurrent.atomic.AtomicLong(0L) }
// A tap-to-focus wins over the periodic centre AF for a few seconds.
val lastTapFocusAt = remember { java.util.concurrent.atomic.AtomicLong(0L) }
DisposableEffect(Unit) {
// Analysis runs at display priority: the decode thread competes with
// the FIPS mesh service's native workers in this same process, and a
// background-priority analyzer is exactly how a sharp, well-framed
// code still takes seconds to land.
val analysisExecutor = Executors.newSingleThreadExecutor { r ->
Thread {
Process.setThreadPriority(Process.THREAD_PRIORITY_DISPLAY)
r.run()
}.apply { name = "qr-analyzer" }
}
val mainExecutor = ContextCompat.getMainExecutor(context)
val providerFuture = ProcessCameraProvider.getInstance(context)
var provider: ProcessCameraProvider? = null
val focusScheduler = Executors.newSingleThreadScheduledExecutor()
providerFuture.addListener({
val p = providerFuture.get()
provider = p
val previewBuilder = Preview.Builder()
tuneForBarcodes(previewBuilder, context)
val preview = previewBuilder.build().also {
it.setSurfaceProvider(previewView.surfaceProvider)
}
// Dense Lightning-invoice QRs need BOTH enough pixels per module and
// sharp focus. 1280x720 left dense invoices undecodable while sparse
// address QRs still read — the "scanner doesn't pick up invoices"
// report. 1920x1080 roughly doubles module resolution. The analyzer
// never binarizes the full 2 MP: it reads the centre ROI at this
// resolution (for dense codes) and the whole frame at half of it
// (for coverage), so the big frame costs little.
@Suppress("DEPRECATION")
val analysis = ImageAnalysis.Builder()
.setTargetResolution(android.util.Size(1920, 1080))
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
.also {
it.setAnalyzer(
analysisExecutor,
QrCodeAnalyzer { text ->
lastDecodeAt.set(System.currentTimeMillis())
mainExecutor.execute { currentOnDecoded(text) }
},
)
}
try {
p.unbindAll()
val cam = p.bindToLifecycle(lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis)
camera = cam
currentOnTorchAvailable(cam.cameraInfo.hasFlashUnit())
// Start the clock at bind time so the nudge below waits for the
// user to actually aim before it does anything.
lastDecodeAt.set(System.currentTimeMillis())
// Centre point, normalized — valid before the view is measured.
val point = SurfaceOrientedMeteringPointFactory(1f, 1f).createPoint(0.5f, 0.5f)
// A one-shot AF action puts the lens in AUTO — i.e. LOCKED —
// until it auto-cancels. The default 5s lock is far too long
// here: it spans exactly the window where the user is swinging
// the phone towards the code, and a locked lens cannot follow
// them. Hand control back after 1s so CONTINUOUS_PICTURE (set
// explicitly in tuneForBarcodes) does the real work, which is
// what actually tracks a moving aim.
val focusAction = FocusMeteringAction.Builder(point, FocusMeteringAction.FLAG_AF)
.setAutoCancelDuration(1, java.util.concurrent.TimeUnit.SECONDS)
.build()
var lastNudgeAt = 0L
focusScheduler.scheduleWithFixedDelay({
val now = System.currentTimeMillis()
// The nudge only exists for the one case continuous AF
// genuinely misses: the phone held perfectly still on a
// code while the lens sits at its resting focus, with no
// scene change to trigger a sweep.
if (now - lastDecodeAt.get() > 2_000 &&
now - lastNudgeAt > 3_000 &&
now - lastTapFocusAt.get() > 3_000
) {
lastNudgeAt = now
runCatching { cam.cameraControl.startFocusAndMetering(focusAction) }
}
}, 1, 1, java.util.concurrent.TimeUnit.SECONDS)
} catch (_: Exception) {
// Camera unavailable — the user can dismiss and enter details manually.
}
}, mainExecutor)
onDispose {
focusScheduler.shutdownNow()
runCatching { camera?.cameraControl?.enableTorch(false) }
camera = null
provider?.unbindAll()
analysisExecutor.shutdown()
}
}
// Torch follows the caller's state (and switches off when the view goes).
LaunchedEffect(camera, torchOn) {
runCatching { camera?.cameraControl?.enableTorch(torchOn) }
}
AndroidView(
factory = { previewView },
modifier = Modifier
.fillMaxSize()
// Tap-to-focus: the ROI assumes the code is centred; a tap lets the
// user point at one that isn't, or re-trigger AF the instant
// they've framed it.
.pointerInput(camera) {
detectTapGestures { offset ->
val cam = camera ?: return@detectTapGestures
val factory = previewView.meteringPointFactory
val action = FocusMeteringAction.Builder(
factory.createPoint(offset.x, offset.y),
FocusMeteringAction.FLAG_AF or FocusMeteringAction.FLAG_AE,
).build()
lastTapFocusAt.set(System.currentTimeMillis())
runCatching { cam.cameraControl.startFocusAndMetering(action) }
}
},
)
}
/**
* Configure the capture session the way a dedicated barcode scanner does,
* rather than the way a photo app does.
*
* The single most valuable knob is **CONTROL_AE_TARGET_FPS_RANGE**. Left
* alone, auto-exposure indoors happily drops the sensor to 1015 fps and
* takes 60100 ms exposures — every hand-held frame is then motion-blurred,
* and a blurred QR is not a slow decode, it is *no* decode. The user waves
* the phone about waiting for a lock that cannot happen. Pinning the lower
* bound of the AE range as high as the device allows caps exposure time
* (~33 ms at 30 fps), so frames come out sharp; AE compensates with gain
* instead, and ZXing tolerates noise far better than it tolerates blur.
* (Dark rooms get grainier as a result — that is what the torch button is
* for, and grainy-but-sharp still decodes where smooth-but-smeared never
* does.)
*
* CONTINUOUS_PICTURE is set explicitly so that when a tap-to-focus action
* expires, CameraX restores continuous AF rather than whatever the device
* defaults to; FAST noise/edge processing shaves ISP latency per frame.
*
* All of it is best-effort — an OEM that rejects a key just keeps its default.
*/
@androidx.annotation.OptIn(ExperimentalCamera2Interop::class)
private fun tuneForBarcodes(builder: Preview.Builder, context: Context) {
runCatching {
val ext = Camera2Interop.Extender(builder)
ext.setCaptureRequestOption(
CaptureRequest.CONTROL_AF_MODE,
CameraMetadata.CONTROL_AF_MODE_CONTINUOUS_PICTURE,
)
ext.setCaptureRequestOption(
CaptureRequest.NOISE_REDUCTION_MODE,
CameraMetadata.NOISE_REDUCTION_MODE_FAST,
)
ext.setCaptureRequestOption(
CaptureRequest.EDGE_MODE,
CameraMetadata.EDGE_MODE_FAST,
)
highestSteadyFpsRange(context)?.let {
ext.setCaptureRequestOption(CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE, it)
}
}
}
/**
* The back camera's AE range with the highest floor, ignoring anything that
* runs past 30 fps (those are the high-speed/slow-motion modes, which cost
* light for frames we do not need).
*/
private fun highestSteadyFpsRange(context: Context): android.util.Range<Int>? = runCatching {
val manager = context.getSystemService(CameraManager::class.java) ?: return@runCatching null
val backId = manager.cameraIdList.firstOrNull { id ->
manager.getCameraCharacteristics(id)
.get(CameraCharacteristics.LENS_FACING) == CameraCharacteristics.LENS_FACING_BACK
} ?: return@runCatching null
manager.getCameraCharacteristics(backId)
.get(CameraCharacteristics.CONTROL_AE_AVAILABLE_TARGET_FPS_RANGES)
?.filter { it.upper <= 30 }
?.maxWithOrNull(compareBy({ it.lower }, { it.upper }))
}.getOrNull()
/**
* ZXing decoder over the camera's Y (luminance) plane.
*
* ## The rule this class exists to obey
*
* **Every frame costs the same, and every frame sees the whole scene.**
*
* That sounds obvious; the previous version violated both halves and produced
* a scanner with a very specific failure: it locked on instantly if the code
* was already in view when the camera opened, but crawled if you opened it
* and then moved to the code. The cause was an escalation ladder — each frame
* that failed to decode unlocked progressively more expensive searches, up to
* a TRY_HARDER pass over the full 2 MP frame plus an inverted retry, easily
* 150300 ms of work.
*
* So the moment the user began hunting for the code, the analyzer dropped from
* ~30 attempts per second to ~4, each one on a motion-blurred frame. By the
* time they framed the code and held still, the pipeline was busy grinding
* through an exhaustive search of an old, blurry frame. Escalating on failure
* is exactly backwards: failure means the user is still aiming, which is when
* the scanner must be at its *fastest*, not its most thorough.
*
* ## What runs now, on every single frame
*
* 1. **Centre ROI at full resolution** ([QR_ROI_FRACTION], ~0.45 MP). Full
* sensor detail, so dense Lightning invoices keep their pixels-per-module.
* 2. **The whole frame at half resolution** (~0.5 MP). This is what fixes the
* "move to the code" case: coverage is no longer limited to the viewfinder
* box on the fast path, so a code that is merely *near* the middle decodes
* immediately instead of waiting for a slow tier to come around. A code
* big enough to be off-centre is big enough to survive the 2x downscale.
* 3. **One alternating second binarizer** — GlobalHistogram over the ROI on
* even frames, over the half-frame on odd ones. Hybrid is tuned for
* shadowed paper; most codes this app scans are on a *screen* (the node's
* pairing popup, another phone's wallet) where a global threshold is both
* cheaper and more reliable. Alternating keeps the per-frame budget flat.
*
* Two rare extras, both bounded so they can never dent the loop above: an
* inverted ROI pass every 8th frame (light-on-dark codes), and one TRY_HARDER
* pass over the half-frame at most once a second (skewed/damaged codes).
*
* Steady-state that is ~35 ms per frame — around 27 attempts per second, and
* it does not degrade the longer the user hunts.
*
* Buffers are allocated once and reused: the original path allocated a fresh
* ~2 MB array per frame, 60 MB/s of garbage at 30 fps, with GC pauses landing
* mid-decode.
*/
private class QrCodeAnalyzer(private val onDecoded: (String) -> Unit) : ImageAnalysis.Analyzer {
// QRCodeReader directly rather than MultiFormatReader: with a single
// format in play the dispatch and per-call state reset are pure overhead.
private val reader = QRCodeReader()
private val plainHints = mapOf<DecodeHintType, Any>(
DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE),
)
private val hardHints = mapOf<DecodeHintType, Any>(
DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE),
DecodeHintType.TRY_HARDER to true,
)
private var roiBuffer = ByteArray(0)
private var halfBuffer = ByteArray(0)
private var frame = 0L
private var lastHardAt = 0L
private fun read(
source: PlanarYUVLuminanceSource,
global: Boolean = false,
hard: Boolean = false,
inverted: Boolean = false,
): String? {
val src = if (inverted) source.invert() else source
val bitmap = BinaryBitmap(
if (global) GlobalHistogramBinarizer(src) else HybridBinarizer(src),
)
return runCatching {
reader.decode(bitmap, if (hard) hardHints else plainHints).text
}.getOrNull().also { reader.reset() }
}
override fun analyze(image: ImageProxy) {
try {
val plane = image.planes[0]
val buffer = plane.buffer
val stride = plane.rowStride
// YUV_420_888 permits an interleaved Y plane. Rare, but a device
// that does it would otherwise hand the decoder pure noise.
val pixelStride = plane.pixelStride
val width = image.width
val height = image.height
frame++
buffer.rewind()
val available = buffer.remaining()
// ── 1. Centre ROI, full resolution ──────────────────────────────
val side = (minOf(width, height) * QR_ROI_FRACTION).toInt().coerceAtLeast(1)
val left = (width - side) / 2
val top = (height - side) / 2
if (roiBuffer.size != side * side) roiBuffer = ByteArray(side * side)
for (row in 0 until side) {
val srcPos = (top + row) * stride + left * pixelStride
if (srcPos + side * pixelStride > available) break
if (pixelStride == 1) {
buffer.position(srcPos)
buffer.get(roiBuffer, row * side, side)
} else {
val dst = row * side
for (col in 0 until side) {
roiBuffer[dst + col] = buffer.get(srcPos + col * pixelStride)
}
}
}
val roi = PlanarYUVLuminanceSource(roiBuffer, side, side, 0, 0, side, side, false)
read(roi)?.let { onDecoded(it); return }
// ── 2. Whole frame, half resolution ─────────────────────────────
val hw = width / 2
val hh = height / 2
if (halfBuffer.size != hw * hh) halfBuffer = ByteArray(hw * hh)
var truncated = false
for (row in 0 until hh) {
val srcRow = row * 2 * stride
val dst = row * hw
for (col in 0 until hw) {
val srcPos = srcRow + col * 2 * pixelStride
if (srcPos >= available) { truncated = true; break }
halfBuffer[dst + col] = buffer.get(srcPos)
}
if (truncated) break
}
val half = PlanarYUVLuminanceSource(halfBuffer, hw, hh, 0, 0, hw, hh, false)
read(half)?.let { onDecoded(it); return }
// ── 3. Alternating second binarizer ─────────────────────────────
val second = if (frame % 2 == 0L) roi else half
read(second, global = true)?.let { onDecoded(it); return }
// ── Bounded extras ──────────────────────────────────────────────
if (frame % 8 == 0L) {
read(roi, inverted = true)?.let { onDecoded(it); return }
}
val now = System.currentTimeMillis()
if (now - lastHardAt >= 1_000) {
lastHardAt = now
read(half, hard = true)?.let { onDecoded(it); return }
}
} catch (_: Exception) {
// Malformed frame; skip it.
} finally {
image.close()
}
}
}
@@ -0,0 +1,263 @@
package com.archipelago.app.ui.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Gamepad
import androidx.compose.material.icons.filled.Keyboard
import androidx.compose.material.icons.filled.RadioButtonChecked
import androidx.compose.material.icons.filled.RadioButtonUnchecked
import androidx.compose.material.icons.filled.Web
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
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.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import com.archipelago.app.data.ServerEntry
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.Neo
import com.archipelago.app.ui.theme.TextMuted
import com.archipelago.app.ui.theme.TextPrimary
import com.archipelago.app.ui.theme.neoRaised
private val ROW_H = 48.dp
private val ROW_R = 12.dp
@Composable
fun ServerModal(
visible: Boolean,
servers: List<ServerEntry>,
activeServer: ServerEntry?,
isGamepadMode: Boolean,
onDismiss: () -> Unit,
onSelectServer: (ServerEntry) -> Unit,
onAddServer: (ServerEntry) -> Unit,
onRemoveServer: (ServerEntry) -> Unit,
onToggleGamepadMode: () -> Unit,
onBackToWebView: (() -> Unit)? = null,
) {
AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.55f))
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() },
) { onDismiss() },
contentAlignment = Alignment.Center,
) {
AnimatedVisibility(visible = visible, enter = fadeIn() + scaleIn(initialScale = 0.95f), exit = fadeOut() + scaleOut(targetScale = 0.95f)) {
ModalBody(servers, activeServer, isGamepadMode, onDismiss, onSelectServer, onAddServer, onRemoveServer, onToggleGamepadMode, onBackToWebView)
}
}
}
}
@Composable
private fun ModalBody(
servers: List<ServerEntry>,
activeServer: ServerEntry?,
isGamepadMode: Boolean,
onDismiss: () -> Unit,
onSelectServer: (ServerEntry) -> Unit,
onAddServer: (ServerEntry) -> Unit,
onRemoveServer: (ServerEntry) -> Unit,
onToggleGamepadMode: () -> Unit,
onBackToWebView: (() -> Unit)?,
) {
val surface = Neo.surfaceRaised()
val light = Neo.shadowLight()
val dark = Neo.shadowDark()
var showAddForm by remember { mutableStateOf(false) }
var newAddress by remember { mutableStateOf("") }
var newPassword by remember { mutableStateOf("") }
Column(
modifier = Modifier
.widthIn(max = 380.dp)
.neoRaised(light, dark, 24.dp, 6.dp, 12.dp)
.clip(RoundedCornerShape(24.dp))
.background(surface)
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {}
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
// Header
Row(Modifier.fillMaxWidth(), Arrangement.SpaceBetween, Alignment.CenterVertically) {
Text("Servers", style = MaterialTheme.typography.titleMedium, color = Neo.textPrimary())
IconButton(onClick = onDismiss, modifier = Modifier.size(32.dp)) {
Icon(Icons.Default.Close, "Close", Modifier.size(16.dp), tint = Neo.textMuted())
}
}
// Server rows
servers.forEach { server ->
val isActive = server.serialize() == activeServer?.serialize()
ModalRow(
icon = if (isActive) Icons.Default.RadioButtonChecked else Icons.Default.RadioButtonUnchecked,
iconTint = if (isActive) BitcoinOrange else Neo.textMuted(),
label = server.address + if (server.port.isNotBlank()) ":${server.port}" else "",
onClick = { onSelectServer(server) },
trailing = {
IconButton(onClick = { onRemoveServer(server) }, modifier = Modifier.size(28.dp)) {
Icon(Icons.Default.Close, "Remove", Modifier.size(14.dp), tint = Neo.textMuted())
}
},
)
}
if (servers.isEmpty()) {
Text("No servers", style = MaterialTheme.typography.bodyMedium, color = Neo.textMuted(), modifier = Modifier.padding(vertical = 4.dp))
}
// Add server
if (showAddForm) {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(ROW_R))
.background(Neo.surface())
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedTextField(
value = newAddress, onValueChange = { newAddress = it.trim() },
placeholder = { Text("192.168.1.100") },
modifier = Modifier.fillMaxWidth(), singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
colors = neoFieldColors(),
shape = RoundedCornerShape(10.dp),
textStyle = MaterialTheme.typography.bodyMedium,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = newPassword, onValueChange = { newPassword = it },
placeholder = { Text("Password") },
modifier = Modifier.weight(1f), singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),
keyboardActions = KeyboardActions(onGo = {
if (newAddress.isNotBlank()) {
onAddServer(ServerEntry(newAddress, false, password = newPassword))
newAddress = ""; newPassword = ""; showAddForm = false
}
}),
colors = neoFieldColors(),
shape = RoundedCornerShape(10.dp),
textStyle = MaterialTheme.typography.bodyMedium,
)
Box(
modifier = Modifier.size(36.dp).clip(CircleShape).background(BitcoinOrange.copy(alpha = 0.15f))
.clickable {
if (newAddress.isNotBlank()) {
onAddServer(ServerEntry(newAddress, false, password = newPassword))
newAddress = ""; newPassword = ""; showAddForm = false
}
},
contentAlignment = Alignment.Center,
) { Icon(Icons.Default.Add, "Add", Modifier.size(16.dp), tint = BitcoinOrange) }
}
}
} else {
ModalRow(icon = Icons.Default.Add, iconTint = BitcoinOrange, label = "Add Server", labelColor = BitcoinOrange, onClick = { showAddForm = true })
}
HorizontalDivider(color = Neo.border(), modifier = Modifier.padding(vertical = 4.dp))
// Gamepad toggle — label says what you switch TO
ModalRow(
icon = if (isGamepadMode) Icons.Default.Keyboard else Icons.Default.Gamepad,
iconTint = Neo.textSecondary(),
label = if (isGamepadMode) "Switch to Keyboard" else "Switch to Gamepad",
onClick = onToggleGamepadMode,
)
// Back to dashboard
if (onBackToWebView != null) {
ModalRow(icon = Icons.Default.Web, iconTint = Neo.textSecondary(), label = "Back to Dashboard", onClick = onBackToWebView)
}
}
}
/** Uniform-height row used for all modal actions */
@Composable
private fun ModalRow(
icon: ImageVector,
iconTint: Color,
label: String,
onClick: () -> Unit,
labelColor: Color = Neo.textPrimary(),
trailing: (@Composable () -> Unit)? = null,
) {
val bg = Neo.surface()
val light = Neo.shadowLight()
val dark = Neo.shadowDark()
Row(
modifier = Modifier
.fillMaxWidth()
.height(ROW_H)
.neoRaised(light, dark, ROW_R, 2.dp, 5.dp)
.clip(RoundedCornerShape(ROW_R))
.background(bg)
.clickable { onClick() }
.padding(horizontal = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(icon, null, Modifier.size(18.dp), tint = iconTint)
Spacer(Modifier.width(12.dp))
Text(label, style = MaterialTheme.typography.bodyMedium, color = labelColor, modifier = Modifier.weight(1f))
if (trailing != null) trailing()
}
}
@Composable
private fun neoFieldColors() = OutlinedTextFieldDefaults.colors(
focusedBorderColor = BitcoinOrange.copy(alpha = 0.4f),
unfocusedBorderColor = Neo.border(),
cursorColor = BitcoinOrange,
focusedTextColor = Neo.textPrimary(),
unfocusedTextColor = Neo.textPrimary(),
)
@@ -0,0 +1,119 @@
package com.archipelago.app.ui.components
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.keyframes
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.archipelago.app.ui.theme.BitcoinOrange
/** green-400 — the same "done" colour the web install overlay lands on. */
private val DoneGreen = Color(0xFF4ADE80)
/**
* The Archipelago loading bar: a stripe that runs side to side inside a dim
* track and lands as a solid green bar when the work completes.
*
* This is a direct port of the platform's install-progress overlay
* (neode-ui SystemUpdate.vue `.install-overlay-bar-anim`): a third-width
* orange stripe on a white/10 track, 1.8s ease-in-out, going full green on
* success. Using the same loader natively is what makes the companion feel
* like the same product as the node UI rather than a stock Android app.
*
* @param done finished successfully — the bar fills solid green.
* @param stalled waiting on the user / something external — the bar parks
* half-full in a dimmed orange instead of animating, so it
* reads as "this needs you", not "still working".
*/
@Composable
fun SlidingLoader(
modifier: Modifier = Modifier,
done: Boolean = false,
stalled: Boolean = false,
height: Dp = 8.dp,
) {
val doneProgress by animateFloatAsState(
targetValue = if (done) 1f else 0f,
animationSpec = tween(320),
label = "loaderDone",
)
BoxWithConstraints(
modifier
.fillMaxWidth()
.height(height)
.clip(RoundedCornerShape(percent = 50))
.background(Color.White.copy(alpha = 0.10f)),
) {
val trackWidth = maxWidth
val stripeWidth = trackWidth / 3
val stripePx = with(LocalDensity.current) { stripeWidth.toPx() }
if (doneProgress < 1f) {
if (stalled) {
Box(
Modifier
.fillMaxWidth(0.5f)
.fillMaxHeight()
.clip(RoundedCornerShape(percent = 50))
.background(BitcoinOrange.copy(alpha = 0.6f)),
)
} else {
// Keyframes copied from the web overlay: -100% → 120% → 300%
// of the STRIPE's own width, which is what gives the bar its
// fast sweep out and lazy re-entry.
val transition = rememberInfiniteTransition(label = "loaderSlide")
val offset by transition.animateFloat(
initialValue = -1f,
targetValue = 3f,
animationSpec = infiniteRepeatable(
animation = keyframes {
durationMillis = 1800
(-1f) at 0
1.2f at 900
3f at 1800
},
repeatMode = RepeatMode.Restart,
),
label = "loaderOffset",
)
Box(
Modifier
.fillMaxWidth(1f / 3f)
.fillMaxHeight()
.graphicsLayer { translationX = offset * stripePx }
.clip(RoundedCornerShape(percent = 50))
.background(BitcoinOrange),
)
}
}
if (doneProgress > 0f) {
Box(
Modifier
.fillMaxWidth()
.fillMaxHeight()
.graphicsLayer { alpha = doneProgress }
.background(DoneGreen),
)
}
}
}
@@ -0,0 +1,116 @@
package com.archipelago.app.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.changedToUp
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.unit.dp
import com.archipelago.app.ui.theme.Neo
import com.archipelago.app.ui.theme.neoInset
private const val TAP_THRESHOLD = 12f
private const val TAP_TIMEOUT = 250L
@Composable
fun Trackpad(
onMove: (dx: Int, dy: Int) -> Unit,
onClick: (button: Int) -> Unit,
onScroll: (dy: Int) -> Unit,
onThreeFingerHold: () -> Unit,
modifier: Modifier = Modifier,
) {
var fingers by remember { mutableIntStateOf(0) }
val surface = Neo.surface()
val light = Neo.shadowLight()
val dark = Neo.shadowDark()
val muted = Neo.textMuted()
Box(
modifier = modifier
.neoInset(light, dark, 20.dp, 3.dp, 6.dp)
.clip(RoundedCornerShape(20.dp))
.background(surface)
.pointerInput(Unit) {
awaitEachGesture {
val first = awaitFirstDown(requireUnconsumed = false)
var total = Offset.Zero
val t0 = System.currentTimeMillis()
var maxPtrs = 1
var holdFired = false
var threeStart = 0L
var scrollAcc = 0f
fingers = 1
do {
val ev = awaitPointerEvent()
val active = ev.changes.filter { !it.changedToUp() }
maxPtrs = maxOf(maxPtrs, active.size)
fingers = active.size
when {
// Three fingers = hold for menu; two = scroll. Kept
// on separate counts so a long two-finger scroll can
// never fire the menu mid-gesture.
active.size >= 3 -> {
if (threeStart == 0L) threeStart = System.currentTimeMillis()
if (!holdFired && System.currentTimeMillis() - threeStart > 500) {
holdFired = true
onThreeFingerHold()
}
ev.changes.forEach { it.consume() }
}
active.size == 2 -> {
threeStart = 0L
val dy = active.map { it.positionChange().y }.average().toFloat()
scrollAcc += dy
if (kotlin.math.abs(scrollAcc) > 12f) {
onScroll(if (scrollAcc > 0) 1 else -1)
scrollAcc = 0f
}
ev.changes.forEach { it.consume() }
}
active.size == 1 && maxPtrs == 1 -> {
val d = active.first().positionChange()
total += d
if (d != Offset.Zero) onMove(d.x.toInt(), d.y.toInt())
active.first().consume()
}
}
} while (ev.changes.any { it.pressed })
fingers = 0
val elapsed = System.currentTimeMillis() - t0
if (maxPtrs == 1 && elapsed < TAP_TIMEOUT && total.getDistance() < TAP_THRESHOLD) {
onClick(1)
}
}
},
contentAlignment = Alignment.Center,
) {
Text(
text = when {
fingers >= 3 -> "hold for menu"
fingers == 2 -> "scroll"
else -> ""
},
style = MaterialTheme.typography.labelSmall,
color = muted.copy(alpha = 0.4f),
)
}
}
@@ -0,0 +1,173 @@
package com.archipelago.app.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.Neo
import com.archipelago.app.ui.theme.neoInset
import com.archipelago.app.ui.theme.neoRaised
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
private enum class Layer { ALPHA, NUM, SYM }
private val KEY_H = 46.dp
private val KEY_R = 10.dp
private val GAP = 5.dp
@Composable
fun VirtualKeyboard(onKey: (String) -> Unit, modifier: Modifier = Modifier) {
var layer by remember { mutableStateOf(Layer.ALPHA) }
var shifted by remember { mutableStateOf(false) }
var capsLock by remember { mutableStateOf(false) }
val up = shifted || capsLock
fun emit(k: String) { onKey(k); if (shifted && !capsLock) shifted = false }
fun ch(c: String) { emit(if (up && layer == Layer.ALPHA) "shift+$c" else c) }
Column(
modifier = modifier.background(Neo.surface()).padding(horizontal = 6.dp, vertical = 6.dp),
verticalArrangement = Arrangement.spacedBy(GAP),
) {
when (layer) {
Layer.ALPHA -> {
CRow("q w e r t y u i o p".split(" "), up, ::ch)
CRow("a s d f g h j k l".split(" "), up, ::ch, inset = 18.dp)
Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) {
SKey(if (capsLock) "\u21EA" else "\u21E7", Modifier.weight(1.4f), active = up) {
if (capsLock) { capsLock = false; shifted = false } else if (shifted) capsLock = true else shifted = true
}
"z x c v b n m".split(" ").forEach { c -> CKey(if (up) c.uppercase() else c, Modifier.weight(1f)) { ch(c) } }
RKey("\u232B", Modifier.weight(1.4f)) { emit("BackSpace") }
}
}
Layer.NUM -> {
SRow("1 2 3 4 5 6 7 8 9 0".split(" "), ::emit)
SRow("- / : ; ( ) \$ & @ \"".split(" "), ::emit)
Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) {
SKey("#+=", Modifier.weight(1.4f)) { layer = Layer.SYM }
". , ? ! '".split(" ").forEach { c -> CKey(c, Modifier.weight(1f)) { emit(c) } }
RKey("\u232B", Modifier.weight(1.4f)) { emit("BackSpace") }
}
}
Layer.SYM -> {
SRow("[ ] { } # % ^ * + =".split(" "), ::emit)
SRow("_ \\ | ~ < > ` @ !".split(" "), ::emit)
Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) {
SKey("123", Modifier.weight(1.4f)) { layer = Layer.NUM }
". , ? ! '".split(" ").forEach { c -> CKey(c, Modifier.weight(1f)) { emit(c) } }
RKey("\u232B", Modifier.weight(1.4f)) { emit("BackSpace") }
}
}
}
Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) {
SKey(if (layer == Layer.ALPHA) "123" else "ABC", Modifier.weight(1.4f)) {
layer = if (layer == Layer.ALPHA) Layer.NUM else Layer.ALPHA; shifted = false; capsLock = false
}
CKey(",", Modifier.weight(1f)) { emit("comma") }
CKey("space", Modifier.weight(5f), fontSize = 13) { emit("space") }
CKey(".", Modifier.weight(1f)) { emit("period") }
AKey("\u23CE", Modifier.weight(1.4f)) { emit("Return") }
}
}
}
@Composable
private fun CRow(keys: List<String>, up: Boolean, onKey: (String) -> Unit, inset: Dp = 0.dp) {
Row(Modifier.fillMaxWidth().height(KEY_H).padding(horizontal = inset), Arrangement.spacedBy(GAP)) {
keys.forEach { c -> CKey(if (up) c.uppercase() else c, Modifier.weight(1f)) { onKey(c) } }
}
}
@Composable
private fun SRow(keys: List<String>, onKey: (String) -> Unit) {
Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) {
keys.forEach { c -> CKey(c, Modifier.weight(1f)) { onKey(c) } }
}
}
/** Character key */
@Composable
private fun CKey(label: String, modifier: Modifier = Modifier, fontSize: Int = 19, onTap: () -> Unit) {
var p by remember { mutableStateOf(false) }
val bg = Neo.surfaceRaised(); val l = Neo.shadowLight(); val d = Neo.shadowDark(); val t = Neo.textPrimary()
Box(
modifier = modifier.height(KEY_H)
.then(if (p) Modifier.neoInset(l, d, KEY_R) else Modifier.neoRaised(l, d, KEY_R))
.clip(RoundedCornerShape(KEY_R)).background(bg)
.pointerInput(label) { detectTapGestures(onPress = { p = true; onTap(); tryAwaitRelease(); p = false }) },
contentAlignment = Alignment.Center,
) { Text(label, color = t.copy(alpha = if (p) 0.9f else 0.7f), fontSize = fontSize.sp, textAlign = TextAlign.Center, maxLines = 1) }
}
/** Special key */
@Composable
private fun SKey(label: String, modifier: Modifier = Modifier, active: Boolean = false, onTap: () -> Unit) {
var p by remember { mutableStateOf(false) }
val bg = Neo.surfaceRaised(); val l = Neo.shadowLight(); val d = Neo.shadowDark()
val tc = if (active) BitcoinOrange.copy(alpha = 0.8f) else Neo.textSecondary()
Box(
modifier = modifier.height(KEY_H)
.then(if (p) Modifier.neoInset(l, d, KEY_R) else Modifier.neoRaised(l, d, KEY_R))
.clip(RoundedCornerShape(KEY_R)).background(bg)
.pointerInput(label) { detectTapGestures(onPress = { p = true; onTap(); tryAwaitRelease(); p = false }) },
contentAlignment = Alignment.Center,
) { Text(label, color = tc, fontSize = 14.sp, fontWeight = FontWeight.Medium, textAlign = TextAlign.Center) }
}
/** Accent key (return) */
@Composable
private fun AKey(label: String, modifier: Modifier = Modifier, onTap: () -> Unit) {
var p by remember { mutableStateOf(false) }
val l = Neo.shadowLight(); val d = Neo.shadowDark()
Box(
modifier = modifier.height(KEY_H)
.then(if (p) Modifier.neoInset(l, d, KEY_R) else Modifier.neoRaised(l, d, KEY_R))
.clip(RoundedCornerShape(KEY_R)).background(Neo.surfaceRaised())
.pointerInput(Unit) { detectTapGestures(onPress = { p = true; onTap(); tryAwaitRelease(); p = false }) },
contentAlignment = Alignment.Center,
) { Text(label, color = BitcoinOrange.copy(alpha = 0.7f), fontSize = 17.sp, fontWeight = FontWeight.Bold) }
}
/** Repeatable key (backspace) */
@Composable
private fun RKey(label: String, modifier: Modifier = Modifier, onTap: () -> Unit) {
var p by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope(); var job by remember { mutableStateOf<Job?>(null) }
val l = Neo.shadowLight(); val d = Neo.shadowDark()
DisposableEffect(Unit) { onDispose { job?.cancel() } }
Box(
modifier = modifier.height(KEY_H)
.then(if (p) Modifier.neoInset(l, d, KEY_R) else Modifier.neoRaised(l, d, KEY_R))
.clip(RoundedCornerShape(KEY_R)).background(Neo.surfaceRaised())
.pointerInput(Unit) { detectTapGestures(onPress = {
p = true; onTap(); job = scope.launch { delay(400); while (true) { onTap(); delay(55) } }
tryAwaitRelease(); job?.cancel(); p = false
}) },
contentAlignment = Alignment.Center,
) { Text(label, color = Neo.textSecondary(), fontSize = 17.sp) }
}
@@ -0,0 +1,149 @@
package com.archipelago.app.ui.components
import android.content.Context
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.archipelago.app.R
import com.archipelago.app.ui.screens.GlassButton
import com.google.zxing.BarcodeFormat
import com.google.zxing.BinaryBitmap
import com.google.zxing.DecodeHintType
import com.google.zxing.MultiFormatReader
import com.google.zxing.NotFoundException
import com.google.zxing.RGBLuminanceSource
import com.google.zxing.common.HybridBinarizer
/**
* Native replacement for the web wallet's scan pane — the shared [QrGlassModal]
* shell (same visual design as neode-ui's WalletScanModal) with the camera and
* decoding running natively, so the preview doesn't lag the way getUserMedia
* does inside a WebView.
*
* Decoded text is handed back to the page ([onDecoded]) which does all the
* detection/spend logic; the page in turn streams status lines (animated-QR
* progress, "not recognised" errors) back in via [status] and closes the
* modal through the JS bridge once it accepts a code.
*/
@Composable
fun WalletQrScannerModal(
visible: Boolean,
status: Pair<String, Boolean>?, // message from the web page + isError
onDecoded: (String) -> Unit,
onDismiss: () -> Unit,
) {
val context = LocalContext.current
val haptics = LocalHapticFeedback.current
// Local error from a failed image upload; a fresh web status replaces it.
var uploadError by remember { mutableStateOf<String?>(null) }
val noQrMessage = stringResource(R.string.no_qr_in_image)
val imagePicker = rememberLauncherForActivityResult(
ActivityResultContracts.GetContent()
) { uri ->
if (uri != null) {
val decoded = decodeQrFromUri(context, uri)
if (decoded != null) {
uploadError = null
onDecoded(decoded)
} else {
uploadError = noQrMessage
}
}
}
LaunchedEffect(visible) { if (visible) uploadError = null }
LaunchedEffect(status) { if (status != null) uploadError = null }
// Throttle repeat frames: a static QR decodes many times a second but the
// page only needs one; animated QRs still stream because each frame's
// text differs.
var lastText by remember { mutableStateOf("") }
var lastSentAt by remember { mutableStateOf(0L) }
LaunchedEffect(visible) {
if (visible) {
lastText = ""
lastSentAt = 0L
}
}
QrGlassModal(
visible = visible,
title = stringResource(R.string.scan_to_send),
status = uploadError?.let { it to true } ?: status,
idleHint = stringResource(R.string.scan_wallet_hint),
permissionRationale = stringResource(R.string.camera_permission_needed),
onDismiss = onDismiss,
onDecoded = { text ->
val now = System.currentTimeMillis()
if (text != lastText || now - lastSentAt > 250) {
// Buzz on the FIRST hit only: an animated QR streams a new
// frame every few ms, and one buzz each would be a drill in
// the hand.
if (lastText.isEmpty()) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
}
lastText = text
lastSentAt = now
onDecoded(text)
}
},
footer = {
GlassButton(
text = stringResource(R.string.upload_qr_image),
onClick = { imagePicker.launch("image/*") },
modifier = Modifier.fillMaxWidth().height(48.dp),
)
},
)
}
/** Decode a QR from a picked image, downsampled so huge photos stay cheap. */
private fun decodeQrFromUri(context: Context, uri: Uri): String? {
return try {
val resolver = context.contentResolver
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
resolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, bounds) }
var sample = 1
val maxDim = maxOf(bounds.outWidth, bounds.outHeight)
while (maxDim / (sample * 2) >= 1600) sample *= 2
val opts = BitmapFactory.Options().apply { inSampleSize = sample }
val bmp = resolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, opts) }
?: return null
val pixels = IntArray(bmp.width * bmp.height)
bmp.getPixels(pixels, 0, bmp.width, 0, 0, bmp.width, bmp.height)
val source = RGBLuminanceSource(bmp.width, bmp.height, pixels)
val reader = MultiFormatReader().apply {
setHints(
mapOf(
DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE),
DecodeHintType.TRY_HARDER to true,
)
)
}
try {
reader.decodeWithState(BinaryBitmap(HybridBinarizer(source))).text
} catch (_: NotFoundException) {
// Light-on-dark QRs (dark-themed wallets) decode inverted.
reader.reset()
reader.decodeWithState(BinaryBitmap(HybridBinarizer(source.invert()))).text
}
} catch (_: Exception) {
null
}
}
@@ -0,0 +1,299 @@
package com.archipelago.app.ui.navigation
import android.net.VpnService
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.archipelago.app.data.PairResult
import com.archipelago.app.data.ServerEntry
import com.archipelago.app.data.ServerPreferences
import com.archipelago.app.data.ServerQrParser
import com.archipelago.app.fips.FipsManager
import com.archipelago.app.ui.screens.FlareScreen
import com.archipelago.app.ui.screens.IntroScreen
import com.archipelago.app.ui.screens.NodePickerScreen
import com.archipelago.app.ui.screens.PartyScreen
import com.archipelago.app.ui.screens.RemoteInputScreen
import com.archipelago.app.ui.screens.ServerConnectScreen
import com.archipelago.app.ui.screens.WebViewScreen
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
object Routes {
const val INTRO = "intro"
const val NODE_PICKER = "node_picker"
const val SERVER_CONNECT = "server_connect"
const val WEB_VIEW = "web_view"
const val REMOTE_INPUT = "remote_input"
const val MESH_PARTY = "mesh_party"
const val FLARE = "flare"
}
/**
* Process-scoped "have we already asked which node?" flag.
*
* The picker is a COLD-START question: opening the app fresh (or after the
* mesh service and its process were killed) is exactly when the user may want
* a different node than last time. An Activity recreation inside a live
* process — rotation, theme change — must not re-ask, and neither must a
* simple return from the background, so the flag lives with the process
* rather than in saved state.
*/
private object LaunchGate {
@Volatile
var nodeChoiceMade: Boolean = false
}
@Composable
fun AppNavHost(
pairUri: String? = null,
onPairUriConsumed: () -> Unit = {},
onReady: () -> Unit = {},
) {
val context = LocalContext.current
val prefs = remember { ServerPreferences(context) }
val navController = rememberNavController()
val scope = rememberCoroutineScope()
// One combined emission — introSeen and activeServer resolving in separate
// frames used to flash the Connect screen at paired users on launch.
val launchState by prefs.launchState.collectAsState(initial = null)
val introSeen = launchState?.introSeen
val activeServer = launchState?.activeServer
val savedServers = launchState?.savedServers ?: emptyList()
// Pairing entry from a deep link that carried no password — prefills the
// connect form so the user lands on the password prompt for that server.
var pairPrefill by remember { mutableStateOf<ServerEntry?>(null) }
// Mesh tunnel: Android's VPN consent dialog is the single unavoidable
// interaction — it can only be launched from an Activity, so pairing
// paths raise FipsManager.consentNeeded and it is handled here, once.
val consentNeeded by FipsManager.consentNeeded.collectAsState()
val vpnConsentLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
FipsManager.consentHandled()
if (result.resultCode == android.app.Activity.RESULT_OK) {
FipsManager.startService(context)
}
}
LaunchedEffect(consentNeeded) {
if (!consentNeeded) return@LaunchedEffect
val consentIntent = VpnService.prepare(context)
if (consentIntent == null) {
FipsManager.consentHandled()
FipsManager.startService(context)
} else {
vpnConsentLauncher.launch(consentIntent)
}
}
if (introSeen == null) return
// Ask which node when the user keeps more than one and this is a cold
// start. Anything else (single node, mid-process Activity recreation,
// a pairing deep link) goes straight through as before.
val needsNodeChoice = introSeen == true &&
!LaunchGate.nodeChoiceMade &&
savedServers.size > 1
// Paired + previously consented → the mesh comes back silently on launch,
// but ONLY once the session's node is known to be a FIPS node. Bringing
// the tunnel up before that took Android's single VPN slot away from
// whatever the user uses to reach a non-mesh node. Off the main
// dispatcher: this path dlopens the 7 MB fips core and does a binder
// round-trip (VpnService.prepare).
LaunchedEffect(needsNodeChoice, activeServer?.npub, activeServer?.meshIp) {
if (needsNodeChoice) return@LaunchedEffect
if (activeServer?.isFipsNode() != true) return@LaunchedEffect
withContext(Dispatchers.IO) { FipsManager.autoStartIfReady(context) }
}
// Launch state resolved — MainActivity holds the system splash until now,
// so the first visible frame is the real UI, never a black gap.
LaunchedEffect(Unit) { onReady() }
// Declared after the introSeen gate so it can't fire before the NavHost
// below has set the nav graph; pairUri stays pending until consumed here.
LaunchedEffect(pairUri) {
val raw = pairUri ?: return@LaunchedEffect
onPairUriConsumed()
when (val result = ServerQrParser.parse(raw)) {
is PairResult.Success -> {
// Pairing implies the app is installed and in use — skip the intro.
prefs.markIntroSeen()
val merged = prefs.upsertServer(result.server)
FipsManager.registerNode(context, result.fips, merged.displayName())
if (merged.password.isNotBlank()) {
// Demo flow: password came with the link — connect in one step.
prefs.setActiveServer(merged)
navController.navigate(Routes.WEB_VIEW) {
popUpTo(0) { inclusive = true }
}
} else {
pairPrefill = merged
navController.navigate(Routes.SERVER_CONNECT) {
popUpTo(0) { inclusive = true }
}
}
}
else -> {
// Invalid or too-new pairing link — ignore; normal startup continues.
}
}
}
val startDestination = when {
introSeen == false -> Routes.INTRO
needsNodeChoice -> Routes.NODE_PICKER
activeServer != null -> Routes.WEB_VIEW
else -> Routes.SERVER_CONNECT
}
NavHost(
navController = navController,
startDestination = startDestination,
) {
composable(Routes.NODE_PICKER) {
NodePickerScreen(
servers = savedServers,
lastActive = activeServer,
onPick = { server ->
LaunchGate.nodeChoiceMade = true
scope.launch {
prefs.setActiveServer(server)
// The mesh follows the choice, and ONLY the choice.
// A non-mesh node gets the tunnel taken down: Android
// hands out one VPN slot, and holding it hostage is
// what broke reaching nodes behind a different VPN.
withContext(Dispatchers.IO) {
if (server.isFipsNode()) {
FipsManager.autoStartIfReady(context)
} else {
FipsManager.stopService(context)
}
}
navController.navigate(Routes.WEB_VIEW) {
popUpTo(0) { inclusive = true }
}
}
},
onAddNode = {
LaunchGate.nodeChoiceMade = true
navController.navigate(Routes.SERVER_CONNECT)
},
)
}
composable(Routes.INTRO) {
IntroScreen(
onMeshParty = {
navController.navigate(Routes.MESH_PARTY)
},
onContinue = {
scope.launch {
prefs.markIntroSeen()
navController.navigate(Routes.SERVER_CONNECT) {
popUpTo(Routes.INTRO) { inclusive = true }
}
}
},
)
}
composable(Routes.SERVER_CONNECT) {
ServerConnectScreen(
onConnected = { _ ->
navController.navigate(Routes.WEB_VIEW) {
popUpTo(Routes.SERVER_CONNECT) { inclusive = true }
}
},
initialServer = pairPrefill,
)
}
composable(Routes.WEB_VIEW) {
val server = activeServer
if (server == null) {
ServerConnectScreen(
onConnected = { _ ->
navController.navigate(Routes.WEB_VIEW) {
popUpTo(0) { inclusive = true }
}
},
)
} else {
WebViewScreen(
serverUrl = server.toUrl(),
serverPassword = server.password,
meshFallbackUrl = server.toMeshUrl(),
onDisconnect = {
scope.launch {
prefs.clearActiveServer()
navController.navigate(Routes.SERVER_CONNECT) {
popUpTo(0) { inclusive = true }
}
}
},
onRemoteInput = {
navController.navigate(Routes.REMOTE_INPUT)
},
onRemoteKeyboard = {
navController.navigate("${Routes.REMOTE_INPUT}?keyboard=true")
},
onMeshParty = {
navController.navigate(Routes.MESH_PARTY)
},
)
}
}
composable(
"${Routes.REMOTE_INPUT}?keyboard={keyboard}",
arguments = listOf(
navArgument("keyboard") {
type = NavType.BoolType
defaultValue = false
},
),
) { entry ->
RemoteInputScreen(
onBack = {
navController.popBackStack()
},
onMeshParty = {
navController.navigate(Routes.MESH_PARTY)
},
startInKeyboard = entry.arguments?.getBoolean("keyboard") == true,
)
}
composable(Routes.MESH_PARTY) {
PartyScreen(
onBack = { navController.popBackStack() },
onOpenChat = { navController.navigate(Routes.FLARE) },
)
}
composable(Routes.FLARE) {
FlareScreen(
onBack = { navController.popBackStack() },
)
}
}
}
@@ -0,0 +1,382 @@
package com.archipelago.app.ui.screens
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Image
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.archipelago.app.fips.FipsManager
import com.archipelago.app.fips.FipsNative
import com.archipelago.app.fips.FipsPreferences
import com.archipelago.app.fips.FlareClient
import com.archipelago.app.fips.FlareMessage
import com.archipelago.app.fips.FlareStore
import com.archipelago.app.fips.PartyPeer
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.SurfaceDark
import com.archipelago.app.ui.theme.TextMuted
import com.archipelago.app.ui.theme.TextPrimary
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.ByteArrayOutputStream
import java.io.File
import java.util.UUID
private val BubbleTheirs = Color.White.copy(alpha = 0.07f)
private val BubbleBorder = Color.White.copy(alpha = 0.08f)
/**
* Flare — phone↔phone chat and photo beam over the FIPS mesh. Every byte is
* E2E encrypted by the mesh layer and addressed by npub; whether it travels
* via a public anchor (5G) or a direct hotspot link is invisible up here —
* which is the entire point.
*/
@Composable
fun FlareScreen(onBack: () -> Unit) {
val context = LocalContext.current
val prefs = remember { FipsPreferences(context) }
val scope = rememberCoroutineScope()
var identity by remember { mutableStateOf<FipsNative.Identity?>(null) }
var myName by remember { mutableStateOf("Phone") }
val peers by prefs.partyPeersFlow.collectAsState(initial = emptyList())
var selectedNpub by remember { mutableStateOf<String?>(null) }
val allMessages by FlareStore.messages.collectAsState()
var draft by remember { mutableStateOf("") }
LaunchedEffect(Unit) {
identity = FipsManager.ensureIdentity(prefs)
myName = prefs.partyName()
}
LaunchedEffect(peers) {
if (selectedNpub == null || peers.none { it.npub == selectedNpub }) {
selectedNpub = peers.firstOrNull()?.npub
}
}
val peer = peers.firstOrNull { it.npub == selectedNpub }
// derivedStateOf: filtering inline re-ran over the whole store on every
// recomposition — including one per keystroke in the composer.
val messages by remember(selectedNpub) {
androidx.compose.runtime.derivedStateOf { allMessages.filter { it.peerNpub == selectedNpub } }
}
val listState = rememberLazyListState()
LaunchedEffect(messages.size) {
if (messages.isNotEmpty()) listState.animateScrollToItem(messages.size - 1)
}
fun sendText() {
val target = peer ?: return
val me = identity ?: return
val text = draft.trim()
if (text.isEmpty()) return
draft = ""
val msg = FlareMessage(
id = UUID.randomUUID().toString(),
peerNpub = target.npub,
fromMe = true,
name = myName,
text = text,
ts = System.currentTimeMillis(),
status = FlareMessage.Status.SENDING,
)
FlareStore.add(msg)
scope.launch(Dispatchers.IO) {
val ok = FlareClient.sendText(target, me.npub, myName, text)
FlareStore.setStatus(msg.id, if (ok) FlareMessage.Status.SENT else FlareMessage.Status.FAILED)
}
}
fun sendPhoto(uri: Uri) {
val target = peer ?: return
val me = identity ?: return
scope.launch(Dispatchers.IO) {
val jpeg = compressPhoto(context, uri) ?: return@launch
// Local copy so our own bubble renders the sent photo.
val dir = File(context.cacheDir, "flare").apply { mkdirs() }
val local = File(dir, "${UUID.randomUUID()}.jpg").apply { writeBytes(jpeg) }
val msg = FlareMessage(
id = UUID.randomUUID().toString(),
peerNpub = target.npub,
fromMe = true,
name = myName,
photoPath = local.absolutePath,
ts = System.currentTimeMillis(),
status = FlareMessage.Status.SENDING,
)
FlareStore.add(msg)
val ok = FlareClient.sendPhoto(target, me.npub, myName, jpeg)
FlareStore.setStatus(msg.id, if (ok) FlareMessage.Status.SENT else FlareMessage.Status.FAILED)
}
}
val photoPicker = rememberLauncherForActivityResult(
ActivityResultContracts.GetContent()
) { uri -> uri?.let { sendPhoto(it) } }
BackHandler { onBack() }
Column(
Modifier
.fillMaxSize()
.background(SurfaceDark)
.statusBarsPadding()
.navigationBarsPadding()
.imePadding(),
) {
// Header
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(" Back", color = TextMuted, fontSize = 15.sp, modifier = Modifier.clickable { onBack() }.padding(6.dp))
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("FLARE", color = TextPrimary, fontSize = 16.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 3.sp)
peer?.let {
Text(
it.name + if (it.ip.isNotBlank()) " · direct+mesh" else " · mesh",
color = BitcoinOrange,
fontSize = 11.sp,
)
}
}
Spacer(Modifier.size(48.dp))
}
// Peer tabs when chatting with more than one phone
if (peers.size > 1) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
peers.forEach { p ->
val active = p.npub == selectedNpub
Text(
p.name,
color = if (active) BitcoinOrange else TextMuted,
fontSize = 13.sp,
modifier = Modifier
.clip(RoundedCornerShape(10.dp))
.background(if (active) BitcoinOrange.copy(alpha = 0.12f) else Color.Transparent)
.border(
1.dp,
if (active) BitcoinOrange.copy(alpha = 0.4f) else BubbleBorder,
RoundedCornerShape(10.dp),
)
.clickable { selectedNpub = p.npub }
.padding(horizontal = 12.dp, vertical = 6.dp),
)
}
}
}
if (peer == null) {
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
Text("No party peers yet — scan a phone first", color = TextMuted, fontSize = 14.sp)
}
} else {
LazyColumn(
state = listState,
modifier = Modifier.weight(1f).fillMaxWidth(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(messages, key = { it.id }) { msg ->
MessageBubble(msg)
}
}
// Composer
Row(
Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(
Modifier
.size(48.dp)
.clip(RoundedCornerShape(12.dp))
.background(BubbleTheirs)
.border(1.dp, BubbleBorder, RoundedCornerShape(12.dp))
.clickable { photoPicker.launch("image/*") },
contentAlignment = Alignment.Center,
) {
Icon(Icons.Default.Image, "Beam a photo", tint = BitcoinOrange, modifier = Modifier.size(22.dp))
}
OutlinedTextField(
value = draft,
onValueChange = { draft = it },
placeholder = { Text("Send a flare…", color = TextMuted, fontSize = 14.sp) },
modifier = Modifier.weight(1f),
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = { sendText() }),
textStyle = TextStyle(color = TextPrimary, fontSize = 15.sp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.White.copy(alpha = 0.3f),
unfocusedBorderColor = Color.White.copy(alpha = 0.12f),
cursorColor = BitcoinOrange,
focusedTextColor = TextPrimary,
unfocusedTextColor = TextPrimary,
),
shape = RoundedCornerShape(12.dp),
)
Box(
Modifier
.size(48.dp)
.clip(RoundedCornerShape(12.dp))
.background(BitcoinOrange.copy(alpha = 0.15f))
.border(1.dp, BitcoinOrange.copy(alpha = 0.4f), RoundedCornerShape(12.dp))
.clickable { sendText() },
contentAlignment = Alignment.Center,
) {
Text("", color = BitcoinOrange, fontSize = 18.sp)
}
}
}
}
}
@Composable
private fun MessageBubble(msg: FlareMessage) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = if (msg.fromMe) Arrangement.End else Arrangement.Start,
) {
Column(
Modifier
.widthIn(max = 300.dp)
.clip(RoundedCornerShape(16.dp))
.background(if (msg.fromMe) BitcoinOrange.copy(alpha = 0.14f) else BubbleTheirs)
.border(
1.dp,
if (msg.fromMe) BitcoinOrange.copy(alpha = 0.35f) else BubbleBorder,
RoundedCornerShape(16.dp),
)
.padding(horizontal = 12.dp, vertical = 8.dp),
) {
if (msg.photoPath.isNotBlank()) {
// Decoded off-main and downsampled to the bubble width —
// full-size decode in remember{} ran on the UI thread mid-
// scroll and held ~8 MB per visible photo (OOM territory).
var bmp by remember(msg.photoPath) { mutableStateOf<android.graphics.Bitmap?>(null) }
LaunchedEffect(msg.photoPath) {
bmp = withContext(Dispatchers.IO) { decodeSampledPhoto(msg.photoPath, 600) }
}
bmp?.let {
Image(
bitmap = it.asImageBitmap(),
contentDescription = "Beamed photo",
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp)),
contentScale = ContentScale.FillWidth,
)
}
}
if (msg.text.isNotBlank()) {
Text(msg.text, color = TextPrimary, fontSize = 15.sp)
}
Text(
when (msg.status) {
FlareMessage.Status.SENDING -> "sending…"
FlareMessage.Status.SENT -> "sent · E2E via mesh"
FlareMessage.Status.FAILED -> "failed — tap to retry later"
FlareMessage.Status.RECEIVED -> msg.name
},
color = if (msg.status == FlareMessage.Status.FAILED) BitcoinOrange else TextMuted,
fontSize = 10.sp,
modifier = Modifier.padding(top = 2.dp),
)
}
}
}
/** Decode, downscale (≤1600px) and JPEG-compress a picked photo off-main. */
/** Decode a stored beamed photo at roughly [maxPx] on the long edge — the
* bubble renders at ~300 dp, so the stored 1600 px original is 25× the
* pixels needed. Blocking — call on IO. */
private fun decodeSampledPhoto(path: String, maxPx: Int): android.graphics.Bitmap? = try {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(path, bounds)
var sample = 1
while (maxOf(bounds.outWidth, bounds.outHeight) / (sample * 2) >= maxPx) sample *= 2
BitmapFactory.decodeFile(path, BitmapFactory.Options().apply { inSampleSize = sample })
} catch (_: Exception) {
null
}
private suspend fun compressPhoto(context: android.content.Context, uri: Uri): ByteArray? =
withContext(Dispatchers.IO) {
try {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
context.contentResolver.openInputStream(uri)?.use {
BitmapFactory.decodeStream(it, null, bounds)
}
var sample = 1
while (maxOf(bounds.outWidth, bounds.outHeight) / sample > 1600) sample *= 2
val opts = BitmapFactory.Options().apply { inSampleSize = sample }
val bitmap = context.contentResolver.openInputStream(uri)?.use {
BitmapFactory.decodeStream(it, null, opts)
} ?: return@withContext null
val out = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.JPEG, 80, out)
bitmap.recycle()
out.toByteArray()
} catch (_: Exception) {
null
}
}
@@ -0,0 +1,260 @@
package com.archipelago.app.ui.screens
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.slideInVertically
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.draw.alpha
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.archipelago.app.R
import com.archipelago.app.ui.theme.SurfaceBlack
import com.archipelago.app.ui.theme.TextMuted
import com.archipelago.app.ui.theme.TextPrimary
import kotlinx.coroutines.delay
@Composable
fun IntroScreen(
onContinue: () -> Unit,
// Mesh Party works with no node at all (phone↔phone) — offered right on
// the first screen so a friend who just got the app can join a party.
onMeshParty: () -> Unit = {},
) {
val logoAlpha = remember { Animatable(0f) }
var showContent by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
// Content fades in WITH the logo, not after it — the serial
// 800ms + 300ms sequence held "Get Started" off-screen for 1.1s.
showContent = true
logoAlpha.animateTo(1f, animationSpec = tween(450))
}
Box(
modifier = Modifier
.fillMaxSize()
.background(SurfaceBlack),
) {
// Reddish synthwave backdrop
Image(
painter = painterResource(id = R.drawable.bg_synthwave),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
// Dark scrim so the title/buttons stay legible over the art
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(
Color.Black.copy(alpha = 0.55f),
Color.Black.copy(alpha = 0.35f),
Color.Black.copy(alpha = 0.75f),
),
)
),
)
Column(
modifier = Modifier
.align(Alignment.Center)
.fillMaxWidth()
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(horizontal = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
// Circular badge logo
Image(
painter = painterResource(id = R.drawable.ic_logo),
contentDescription = "Archipelago",
modifier = Modifier
.size(160.dp)
// graphicsLayer defers the alpha read to the draw phase —
// .alpha(value) recomposed the whole screen per frame.
.graphicsLayer { alpha = logoAlpha.value },
)
Spacer(modifier = Modifier.height(48.dp))
AnimatedVisibility(
visible = showContent,
enter = fadeIn(tween(600)) + slideInVertically(
initialOffsetY = { it / 4 },
animationSpec = tween(600),
),
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = stringResource(R.string.welcome_title),
style = MaterialTheme.typography.headlineLarge,
color = Color(0xFFFAFAFA),
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.welcome_subtitle),
style = MaterialTheme.typography.bodyLarge,
color = Color(0xFFFAFAFA),
textAlign = TextAlign.Center,
lineHeight = 26.sp,
)
Spacer(modifier = Modifier.height(48.dp))
GlassButton(
text = stringResource(R.string.get_started),
onClick = onContinue,
modifier = Modifier.fillMaxWidth().height(56.dp),
)
Spacer(modifier = Modifier.height(12.dp))
GlassButton(
text = stringResource(R.string.mesh_party),
onClick = onMeshParty,
modifier = Modifier.fillMaxWidth().height(48.dp),
)
}
}
}
}
}
/** The pixel-art "A" from AnimatedLogo.vue — 20 white squares */
@Composable
fun PixelArtLogo(modifier: Modifier = Modifier) {
Canvas(modifier = modifier) {
val s = size.width / 1024f
val rects = listOf(
floatArrayOf(357.614f, 318f, 71.007f, 70.936f),
floatArrayOf(436.152f, 318f, 72.082f, 70.936f),
floatArrayOf(515.766f, 318f, 72.082f, 70.936f),
floatArrayOf(595.379f, 318f, 71.007f, 70.936f),
floatArrayOf(595.379f, 396.46f, 71.007f, 72.011f),
floatArrayOf(673.917f, 396.46f, 72.083f, 72.011f),
floatArrayOf(278f, 475.994f, 72.083f, 72.012f),
floatArrayOf(357.614f, 475.994f, 71.007f, 72.012f),
floatArrayOf(436.152f, 475.994f, 72.082f, 72.012f),
floatArrayOf(515.766f, 475.994f, 72.082f, 72.012f),
floatArrayOf(595.379f, 475.994f, 71.007f, 72.012f),
floatArrayOf(673.917f, 475.994f, 72.083f, 72.012f),
floatArrayOf(278f, 555.529f, 72.083f, 70.936f),
floatArrayOf(357.614f, 555.529f, 71.007f, 70.936f),
floatArrayOf(595.379f, 555.529f, 71.007f, 70.936f),
floatArrayOf(673.917f, 555.529f, 72.083f, 70.936f),
floatArrayOf(357.614f, 633.989f, 71.007f, 72.011f),
floatArrayOf(436.152f, 633.989f, 72.082f, 72.011f),
floatArrayOf(515.766f, 633.989f, 72.082f, 72.011f),
floatArrayOf(595.379f, 633.989f, 71.007f, 72.011f),
)
for (r in rects) {
drawRect(
color = Color.White,
topLeft = Offset(r[0] * s, r[1] * s),
size = Size(r[2] * s, r[3] * s),
)
}
}
}
/**
* Glass-style button matching Archipelago's .glass-button.
* Custom press state (subtle brighten) instead of Material ripple.
*/
@Composable
fun GlassButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
val pressAlpha by animateFloatAsState(
targetValue = if (isPressed) 1f else 0f,
animationSpec = tween(if (isPressed) 0 else 150),
label = "press",
)
// Lerp between rest and pressed states
val bgTop = 0.12f + pressAlpha * 0.08f // 0.12 → 0.20
val bgBottom = 0.04f + pressAlpha * 0.06f // 0.04 → 0.10
val borderA = 0.15f + pressAlpha * 0.10f // 0.15 → 0.25
val textAlpha = 1f - pressAlpha * 0.2f // 1.0 → 0.8
Box(
modifier = modifier
.clip(RoundedCornerShape(12.dp))
.background(
Brush.verticalGradient(
colors = listOf(
Color.White.copy(alpha = bgTop),
Color.White.copy(alpha = bgBottom),
),
)
)
.border(
width = 1.dp,
color = Color.White.copy(alpha = borderA),
shape = RoundedCornerShape(12.dp),
)
.clickable(
interactionSource = interactionSource,
indication = null,
onClick = onClick,
),
contentAlignment = Alignment.Center,
) {
Text(
text = text,
color = Color.White.copy(alpha = textAlpha),
style = MaterialTheme.typography.labelLarge,
fontSize = 16.sp,
)
}
}
@@ -0,0 +1,226 @@
package com.archipelago.app.ui.screens
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.LockOpen
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.archipelago.app.R
import com.archipelago.app.data.ServerEntry
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.SuccessGreen
import com.archipelago.app.ui.theme.SurfaceBlack
import com.archipelago.app.ui.theme.TextMuted
import com.archipelago.app.ui.theme.TextPrimary
/**
* "Which node?" — shown at launch when more than one node is saved.
*
* The companion used to dive straight back into whichever node was last
* active, which is wrong the moment a user keeps more than one: they arrive
* somewhere they didn't choose, and (worse) the FIPS tunnel came up before
* anyone said which network this session belongs to. Picking first makes the
* choice explicit and lets the mesh stay down for nodes that aren't on it.
*
* [onPick] carries the entry; the caller decides what the mesh does about it.
*/
@Composable
fun NodePickerScreen(
servers: List<ServerEntry>,
lastActive: ServerEntry?,
onPick: (ServerEntry) -> Unit,
onAddNode: () -> Unit,
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(SurfaceBlack),
) {
Image(
painter = painterResource(id = R.drawable.bg_synthwave),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(
Color.Black.copy(alpha = 0.65f),
Color.Black.copy(alpha = 0.5f),
Color.Black.copy(alpha = 0.85f),
),
)
),
)
Column(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing)
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp)
.padding(top = 48.dp, bottom = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically),
) {
Image(
painter = painterResource(id = R.drawable.ic_logo),
contentDescription = "Archipelago",
modifier = Modifier.size(88.dp),
)
Spacer(Modifier.height(4.dp))
Text(
text = stringResource(R.string.pick_node_title),
style = MaterialTheme.typography.headlineMedium,
color = TextPrimary,
textAlign = TextAlign.Center,
)
Text(
text = stringResource(R.string.pick_node_hint),
style = MaterialTheme.typography.bodyMedium,
color = TextMuted,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(8.dp))
servers.forEach { server ->
NodeCard(
server = server,
isLast = lastActive?.sameNode(server) == true,
onClick = { onPick(server) },
)
}
Spacer(Modifier.height(8.dp))
GlassButton(
text = stringResource(R.string.pick_node_add),
onClick = onAddNode,
modifier = Modifier.fillMaxWidth().height(52.dp),
)
}
}
}
@Composable
private fun NodeCard(
server: ServerEntry,
isLast: Boolean,
onClick: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.background(Color.Black.copy(alpha = 0.6f))
.background(
Brush.verticalGradient(
colors = listOf(
Color.White.copy(alpha = 0.08f),
Color.White.copy(alpha = 0.02f),
),
)
)
.border(
1.dp,
if (isLast) BitcoinOrange.copy(alpha = 0.35f) else Color.White.copy(alpha = 0.1f),
RoundedCornerShape(14.dp),
)
.clickable { onClick() }
.padding(horizontal = 16.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = if (server.useHttps) Icons.Default.Lock else Icons.Default.LockOpen,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = if (server.useHttps) SuccessGreen else BitcoinOrange,
)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(
text = server.displayName(),
style = MaterialTheme.typography.titleMedium,
color = TextPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
val secondary = buildString {
if (server.name.isNotBlank()) append(server.address)
if (server.port.isNotBlank()) {
if (isNotEmpty()) append(":${server.port}") else append("Port ${server.port}")
}
}
if (secondary.isNotBlank()) {
Text(
text = secondary,
style = MaterialTheme.typography.labelMedium,
color = TextMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
// The one thing that actually changes behaviour on this screen: a mesh
// node brings the FIPS tunnel up, a plain one deliberately does not.
if (server.isFipsNode()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = BitcoinOrange,
)
Spacer(Modifier.width(4.dp))
Text(
text = "FIPS",
color = BitcoinOrange,
fontSize = 11.sp,
letterSpacing = 1.sp,
style = MaterialTheme.typography.labelMedium,
)
}
}
}
}
@@ -0,0 +1,544 @@
package com.archipelago.app.ui.screens
import android.graphics.Bitmap
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.archipelago.app.fips.FipsManager
import com.archipelago.app.fips.FipsNative
import com.archipelago.app.fips.FipsPreferences
import com.archipelago.app.fips.FlareClient
import com.archipelago.app.fips.PartyPeer
import com.archipelago.app.fips.PartyQr
import com.archipelago.app.ui.components.CameraQrPreview
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.SurfaceDark
import com.archipelago.app.ui.theme.TextMuted
import com.archipelago.app.ui.theme.TextPrimary
import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType
import com.google.zxing.qrcode.QRCodeWriter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
private val CardBg = Color.White.copy(alpha = 0.05f)
private val CardBorder = Color.White.copy(alpha = 0.08f)
/** Public companion download (vps2 demo host — serves the same APK as the
* nodes' QR link). Rendered as the "Share this app" QR. */
private const val APP_DOWNLOAD_URL =
"http://146.59.87.168:2100/packages/archipelago-companion.apk"
/**
* Mesh Party — phone↔phone FIPS pairing. Show your QR, scan theirs, and the
* two embedded mesh nodes link up: through anchors when there's internet,
* directly over any shared WiFi/hotspot when there isn't.
*/
@Composable
fun PartyScreen(
onBack: () -> Unit,
onOpenChat: () -> Unit,
) {
val context = LocalContext.current
val prefs = remember { FipsPreferences(context) }
val scope = rememberCoroutineScope()
var identity by remember { mutableStateOf<FipsNative.Identity?>(null) }
var name by remember { mutableStateOf("") }
var localIp by remember { mutableStateOf<String?>(null) }
var showScanner by remember { mutableStateOf(false) }
var showShareQr by remember { mutableStateOf(false) }
var scanHint by remember { mutableStateOf<String?>(null) }
// Camera permission — fresh installs (and reinstalls: uninstall wipes
// grants) land here with no CAMERA grant, and the raw preview just showed
// black. Ask the moment the scanner opens.
var hasCamera by remember {
mutableStateOf(
androidx.core.content.ContextCompat.checkSelfPermission(
context, android.Manifest.permission.CAMERA,
) == android.content.pm.PackageManager.PERMISSION_GRANTED
)
}
val cameraPermLauncher = androidx.activity.compose.rememberLauncherForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.RequestPermission()
) { hasCamera = it }
LaunchedEffect(showScanner) {
if (showScanner && !hasCamera) {
cameraPermLauncher.launch(android.Manifest.permission.CAMERA)
}
}
val listenOn by prefs.partyListenFlow.collectAsState(initial = false)
val peers by prefs.partyPeersFlow.collectAsState(initial = emptyList())
LaunchedEffect(Unit) {
identity = FipsManager.ensureIdentity(prefs)
name = prefs.partyName()
// The hotspot/WiFi address can change while this screen is open
// (e.g. the user flips the hotspot on mid-demo) — keep it fresh.
// Tight only at first (the hotspot-flip window); interface walks
// allocate, so back off once the screen has been open a while.
var round = 0
while (true) {
localIp = withContext(Dispatchers.IO) { PartyQr.localWifiIpv4() }
delay(if (round++ < 10) 3_000 else 30_000)
}
}
val qrPayload = identity?.let { id ->
PartyQr.build(
npub = id.npub,
ula = id.address,
name = name.ifBlank { "Phone" },
ip = if (listenOn) localIp else null,
port = PartyQr.PARTY_UDP_PORT,
)
}
// QR encode + bitmap fill off the composition: done in remember{} it ran
// on the UI thread PER KEYSTROKE of the name field (the payload embeds the
// name) — a ZXing encode plus a megabyte-plus allocation per character.
// The 250 ms delay is a free debounce via coroutine cancellation.
var qrBitmap by remember { mutableStateOf<android.graphics.Bitmap?>(null) }
LaunchedEffect(qrPayload) {
if (qrPayload == null) { qrBitmap = null; return@LaunchedEffect }
if (qrBitmap != null) delay(250)
qrBitmap = withContext(Dispatchers.Default) { renderQr(qrPayload) }
}
BackHandler {
when {
showScanner -> showScanner = false
showShareQr -> showShareQr = false
else -> onBack()
}
}
Box(Modifier.fillMaxSize().background(SurfaceDark)) {
Column(
Modifier
.fillMaxSize()
.statusBarsPadding()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(" Back", color = TextMuted, fontSize = 15.sp, modifier = Modifier.clickable { onBack() }.padding(8.dp))
Text("MESH PARTY", color = TextPrimary, fontSize = 17.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 3.sp)
Spacer(Modifier.size(56.dp))
}
// My QR card
Column(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(20.dp))
.background(CardBg)
.border(1.dp, CardBorder, RoundedCornerShape(20.dp))
.padding(18.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
qrBitmap?.let { bmp ->
Box(
Modifier
.clip(RoundedCornerShape(16.dp))
.background(Color.White)
.padding(12.dp),
) {
Image(
bitmap = bmp.asImageBitmap(),
contentDescription = "My mesh party QR",
modifier = Modifier.size(220.dp),
)
}
} ?: Text("Mesh identity unavailable on this device", color = TextMuted, fontSize = 14.sp)
identity?.let {
Text(
it.npub.take(16) + "" + it.npub.takeLast(6),
color = TextMuted,
fontSize = 12.sp,
textAlign = TextAlign.Center,
)
}
OutlinedTextField(
value = name,
onValueChange = {
name = it.take(24)
scope.launch { prefs.setPartyName(name) }
},
placeholder = { Text("Your name", color = TextMuted, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth()) },
modifier = Modifier.fillMaxWidth().height(56.dp),
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
textStyle = TextStyle(color = TextPrimary, fontSize = 15.sp, textAlign = TextAlign.Center),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.White.copy(alpha = 0.3f),
unfocusedBorderColor = Color.White.copy(alpha = 0.12f),
cursorColor = BitcoinOrange,
focusedTextColor = TextPrimary,
unfocusedTextColor = TextPrimary,
),
shape = RoundedCornerShape(12.dp),
)
// Direct-link toggle (hotspot mode)
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(Modifier.padding(end = 12.dp)) {
Text("Accept direct links", color = TextPrimary, fontSize = 15.sp, fontWeight = FontWeight.Medium)
Text(
when {
listenOn && localIp != null -> "Dialable at $localIp:${PartyQr.PARTY_UDP_PORT} — no internet needed"
listenOn -> "Waiting for a WiFi/hotspot address…"
else -> "Off — mesh routes via anchors only"
},
color = if (listenOn) BitcoinOrange else TextMuted,
fontSize = 12.sp,
)
}
Switch(
checked = listenOn,
onCheckedChange = { on ->
scope.launch {
prefs.setPartyListen(on)
FipsManager.requestMeshRestart(context)
}
},
colors = SwitchDefaults.colors(
checkedTrackColor = BitcoinOrange,
checkedThumbColor = Color.White,
),
)
}
}
GlassButton(
text = "Scan a Phone",
onClick = { showScanner = true },
modifier = Modifier.fillMaxWidth().height(56.dp),
)
if (peers.isNotEmpty()) {
Text("PARTY PEERS", color = TextMuted, fontSize = 12.sp, letterSpacing = 2.sp)
peers.forEach { peer ->
Row(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.background(CardBg)
.border(1.dp, CardBorder, RoundedCornerShape(14.dp))
.clickable { onOpenChat() }
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(Modifier.padding(end = 8.dp)) {
Text(peer.name, color = TextPrimary, fontSize = 15.sp, fontWeight = FontWeight.Medium)
Text(
peer.npub.take(14) + "" + if (peer.ip.isNotBlank()) " · direct ${peer.ip}" else " · via mesh",
color = TextMuted,
fontSize = 11.sp,
)
}
Text(
"",
color = TextMuted,
fontSize = 16.sp,
modifier = Modifier.clickable {
scope.launch {
prefs.removePartyPeer(peer.npub)
FipsManager.requestMeshRestart(context)
}
}.padding(8.dp),
)
}
}
GlassButton(
text = "Open Flare Chat",
onClick = onOpenChat,
modifier = Modifier.fillMaxWidth().height(56.dp),
)
} else {
Text(
"Scan another phone's party QR (or let them scan yours) to link your mesh nodes — works over 5G via anchors, or over any shared WiFi/hotspot with zero internet.",
color = TextMuted,
fontSize = 13.sp,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
)
}
Spacer(Modifier.height(12.dp))
// Hand the app itself to a friend: shares this install's own APK
// through the system sheet (Quick Share/Bluetooth), so a nearby
// phone gets the companion with zero internet — the whole party
// premise.
GlassButton(
text = "Share this app",
onClick = { showShareQr = true },
modifier = Modifier.fillMaxWidth().height(48.dp),
)
Spacer(Modifier.height(12.dp))
}
// "Share this app" — a QR of the public download link, so the other
// phone scans it with its normal camera and installs over any
// internet. (The vps2 demo host serves the same APK the nodes do.)
AnimatedVisibility(visible = showShareQr, enter = fadeIn(), exit = fadeOut()) {
Box(
Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.94f))
.clickable { showShareQr = false },
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
// Encoded off-main; done in remember{} it dropped the
// overlay's first fade-in frame.
var dlQr by remember { mutableStateOf<android.graphics.Bitmap?>(null) }
LaunchedEffect(Unit) {
dlQr = withContext(Dispatchers.Default) { renderQr(APP_DOWNLOAD_URL) }
}
dlQr?.let { bmp ->
Box(
Modifier
.clip(RoundedCornerShape(16.dp))
.background(Color.White)
.padding(14.dp),
) {
Image(
bitmap = bmp.asImageBitmap(),
contentDescription = "Companion download QR",
modifier = Modifier.size(240.dp),
)
}
}
Spacer(Modifier.height(18.dp))
Text(
"Scan with any camera to download\nthe Archipelago Companion",
color = TextPrimary,
fontSize = 15.sp,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(10.dp))
Text(
"…or send the APK file directly",
color = BitcoinOrange,
fontSize = 13.sp,
modifier = Modifier.clickable { scope.launch { shareCompanionApk(context) } }.padding(8.dp),
)
Spacer(Modifier.height(6.dp))
Text("Close", color = TextMuted, fontSize = 14.sp, modifier = Modifier.clickable { showShareQr = false }.padding(8.dp))
}
}
}
// Party QR scanner overlay
AnimatedVisibility(visible = showScanner, enter = fadeIn(), exit = fadeOut()) {
Box(Modifier.fillMaxSize().background(Color.Black)) {
if (!hasCamera) {
Column(
Modifier.align(Alignment.Center).padding(horizontal = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Text(
"Camera access is needed to scan a party QR.",
color = TextPrimary,
fontSize = 15.sp,
textAlign = TextAlign.Center,
)
GlassButton(
text = "Grant camera access",
onClick = { cameraPermLauncher.launch(android.Manifest.permission.CAMERA) },
modifier = Modifier.fillMaxWidth().height(48.dp),
)
}
} else CameraQrPreview(onDecoded = { text ->
val peer = PartyQr.parse(text)
when {
peer == null -> scanHint = "Not a mesh party QR"
peer.npub == identity?.npub -> scanHint = "That's your own QR"
else -> {
showScanner = false
scanHint = null
scope.launch {
prefs.upsertPartyPeer(peer)
// Pick up the direct-dial PeerConfig (and the
// listener, if ours is on) immediately.
FipsManager.requestMeshRestart(context)
// Pairing must be MUTUAL: announce ourselves so
// the scanned phone gets us as a peer + a chat
// entry without scanning back. Retried while
// the fresh link/session comes up.
val me = identity
if (me != null) {
launch(Dispatchers.IO) {
for (attempt in 0 until 6) {
val ok = FlareClient.sendHello(
peer = peer,
myNpub = me.npub,
myName = name.ifBlank { "Phone" },
myUla = me.address,
myIp = if (listenOn) localIp else null,
myPort = PartyQr.PARTY_UDP_PORT,
)
if (ok) break
delay(3_000)
}
}
}
onOpenChat()
}
}
}
})
Box(
Modifier
.align(Alignment.Center)
.size(260.dp)
.border(2.dp, BitcoinOrange.copy(alpha = 0.85f), RoundedCornerShape(20.dp)),
)
Text(
"Close",
color = TextPrimary,
fontSize = 16.sp,
modifier = Modifier
.align(Alignment.TopEnd)
.statusBarsPadding()
.clickable { showScanner = false }
.padding(20.dp),
)
scanHint?.let {
Text(
it,
color = BitcoinOrange,
fontSize = 14.sp,
textAlign = TextAlign.Center,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 48.dp)
.fillMaxWidth(),
)
}
}
}
}
// Scan hints fade so the camera feels live again.
LaunchedEffect(scanHint) {
if (scanHint != null) {
delay(2500)
scanHint = null
}
}
}
/** Render a QR payload as a bitmap (dark modules on white). 512 px covers the
* 240.dp display size at any density; 640 was a third more pixels for nothing. */
private fun renderQr(payload: String, size: Int = 512): Bitmap? = try {
val matrix = QRCodeWriter().encode(
payload,
BarcodeFormat.QR_CODE,
size,
size,
mapOf(EncodeHintType.MARGIN to 1),
)
val pixels = IntArray(size * size)
for (y in 0 until size) {
for (x in 0 until size) {
pixels[y * size + x] = if (matrix[x, y]) 0xFF0A0A0A.toInt() else 0xFFFFFFFF.toInt()
}
}
Bitmap.createBitmap(pixels, size, size, Bitmap.Config.ARGB_8888)
} catch (_: Exception) {
null
}
/** Share this install's own APK via the system share sheet — a nearby friend
* gets the companion with no internet at all (Quick Share / Bluetooth).
* The ~27 MB copy runs on IO — inline in the click handler it froze the UI
* for seconds (ANR territory on slow flash). Copied once per install; the
* cached file is reused while its size still matches the source. */
private suspend fun shareCompanionApk(context: android.content.Context) {
try {
val uri = withContext(Dispatchers.IO) {
val src = java.io.File(context.applicationInfo.sourceDir)
val dir = java.io.File(context.cacheDir, "share").apply { mkdirs() }
val out = java.io.File(dir, "archipelago-companion.apk")
if (!out.exists() || out.length() != src.length()) {
src.copyTo(out, overwrite = true)
}
androidx.core.content.FileProvider.getUriForFile(
context, "${context.packageName}.fileprovider", out,
)
}
val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
type = "application/vnd.android.package-archive"
putExtra(android.content.Intent.EXTRA_STREAM, uri)
addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(
android.content.Intent.createChooser(send, "Share Archipelago Companion")
.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK),
)
} catch (_: Exception) {
// No share targets / copy failed — nothing sensible to do here.
}
}
@@ -0,0 +1,287 @@
package com.archipelago.app.ui.screens
import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.archipelago.app.R
import com.archipelago.app.data.ServerPreferences
import com.archipelago.app.fips.FipsManager
import com.archipelago.app.network.ConnectionState
import com.archipelago.app.network.InputWebSocket
import com.archipelago.app.ui.components.NESController
import com.archipelago.app.ui.components.NESKeyboard
import com.archipelago.app.ui.components.NESMenu
import com.archipelago.app.ui.components.NESPortraitController
import com.archipelago.app.ui.components.QrScannerOverlay
import com.archipelago.app.ui.components.Trackpad
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.ControllerStyle
import com.archipelago.app.ui.theme.ErrorRed
import com.archipelago.app.ui.theme.SuccessGreen
import com.archipelago.app.ui.theme.TextMuted
import kotlinx.coroutines.launch
@Composable
fun RemoteInputScreen(
onBack: () -> Unit,
onMeshParty: (() -> Unit)? = null,
// Land on the keyboard instead of the gamepad (hub menu's Keyboard card).
startInKeyboard: Boolean = false,
) {
val context = LocalContext.current
val prefs = remember { ServerPreferences(context) }
val scope = rememberCoroutineScope()
val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
val savedServers by prefs.savedServers.collectAsState(initial = emptyList())
val activeServer by prefs.activeServer.collectAsState(initial = null)
var isGamepadMode by remember { mutableStateOf(!startInKeyboard) }
var showModal by remember { mutableStateOf(false) }
var showQrScanner by remember { mutableStateOf(false) }
var controllerStyle by remember { mutableStateOf(ControllerStyle.DARK) }
var playerId by remember { mutableStateOf(0) } // 0 = broadcast, 1 = P1, 2 = P2
val ws = remember { InputWebSocket(scope) }
// When the kiosk forwards an "open in external browser" app, launch it in
// the phone's default browser.
DisposableEffect(ws) {
ws.onExternalOpen = { url ->
try {
val intent = android.content.Intent(
android.content.Intent.ACTION_VIEW,
android.net.Uri.parse(url),
).apply { addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) }
context.startActivity(intent)
} catch (_: Exception) {}
}
onDispose { ws.onExternalOpen = null }
}
fun togglePlayer() {
playerId = when (playerId) { 0 -> 1; 1 -> 2; else -> 0 }
ws.playerId = playerId
}
fun toggleStyle() {
controllerStyle = if (controllerStyle == ControllerStyle.CLASSIC) ControllerStyle.DARK else ControllerStyle.CLASSIC
}
val connectionState by ws.state.collectAsState()
val lifecycleOwner = LocalLifecycleOwner.current
BackHandler { onBack() }
// Connect on server change + reconnect when app resumes from background
DisposableEffect(lifecycleOwner, activeServer) {
val server = activeServer
if (server != null) {
ws.connect(server.toUrl(), server.password)
}
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME && server != null) {
val state = ws.state.value
if (state != ConnectionState.CONNECTED && state != ConnectionState.CONNECTING) {
ws.connect(server.toUrl(), server.password)
}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
ws.disconnect()
}
}
Box(
Modifier
.fillMaxSize()
.background(Color(0xFF0C0C0C)),
) {
// Reddish synthwave backdrop behind the controller
Image(
painter = painterResource(id = R.drawable.bg_synthwave),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
// Light scrim — the controller body provides its own contrast, so keep
// this subtle and let the backdrop show through around it.
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(
Color.Black.copy(alpha = 0.4f),
Color.Black.copy(alpha = 0.25f),
Color.Black.copy(alpha = 0.45f),
),
)
),
)
Box(Modifier.fillMaxSize().windowInsetsPadding(WindowInsets.safeDrawing)) {
when {
isGamepadMode && isLandscape -> NESController(
style = controllerStyle,
playerId = playerId,
onKey = { ws.sendKey(it) },
onMenu = { showModal = true },
onPlayerToggle = ::togglePlayer,
onToggleStyle = ::toggleStyle,
)
isGamepadMode && !isLandscape -> NESPortraitController(
style = controllerStyle,
playerId = playerId,
onKey = { ws.sendKey(it) },
onMouseMove = { dx, dy -> ws.sendMouseMove(dx, dy) },
onMouseClick = { ws.sendClick(it) },
onMouseScroll = { ws.sendScroll(it) },
onMenu = { showModal = true },
onPlayerToggle = ::togglePlayer,
onToggleStyle = ::toggleStyle,
)
else -> {
// Keyboard mode: trackpad fills top, keyboard pinned bottom
Box(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxSize()) {
Trackpad(
onMove = { dx, dy -> ws.sendMouseMove(dx, dy) },
onClick = { ws.sendClick(it) },
onScroll = { ws.sendScroll(it) },
onThreeFingerHold = { showModal = true },
modifier = Modifier.fillMaxWidth().weight(1f)
.padding(horizontal = 16.dp, vertical = 8.dp),
)
NESKeyboard(
style = controllerStyle,
onKey = { ws.sendKey(it) },
modifier = Modifier.fillMaxWidth(),
)
}
// Settings + style icons top-right in keyboard mode
Row(
Modifier.align(Alignment.TopEnd).padding(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
com.archipelago.app.ui.components.SettingsBtn(
c = com.archipelago.app.ui.components.paletteFor(controllerStyle),
onClick = { showModal = true },
)
com.archipelago.app.ui.components.StyleBtn(
c = com.archipelago.app.ui.components.paletteFor(controllerStyle),
onClick = ::toggleStyle,
)
}
}
}
}
// Connection dot
Box(
Modifier.align(Alignment.TopStart).padding(6.dp).size(8.dp)
.clip(CircleShape).background(
when (connectionState) {
ConnectionState.CONNECTED -> SuccessGreen
ConnectionState.CONNECTING -> BitcoinOrange
ConnectionState.ERROR, ConnectionState.AUTH_FAILED -> ErrorRed
ConnectionState.DISCONNECTED -> TextMuted
}
),
)
}
NESMenu(
visible = showModal,
servers = savedServers,
activeServer = activeServer,
onDismiss = { showModal = false },
onSelectServer = { server ->
scope.launch { ws.disconnect(); prefs.setActiveServer(server) }; showModal = false
},
onAddServer = { server ->
scope.launch { prefs.addSavedServer(server); if (activeServer == null) prefs.setActiveServer(server) }
},
onScanQr = { showQrScanner = true },
onEditServer = { original, updated ->
scope.launch {
prefs.updateSavedServer(original, updated)
// If the edited server is the live one, reconnect with the new
// address/credentials so the change takes effect immediately.
if (original.serialize() == activeServer?.serialize()) {
ws.disconnect()
prefs.setActiveServer(updated)
}
}
},
onRemoveServer = { server ->
scope.launch {
prefs.removeSavedServer(server)
// Deleting the last server leaves nothing to control — drop the
// active server and return to the Connect screen.
val remaining = savedServers.count { it.serialize() != server.serialize() }
if (remaining == 0) {
ws.disconnect()
prefs.clearActiveServer()
showModal = false
onBack()
}
}
},
onRemote = { isGamepadMode = true; showModal = false },
onKeyboard = { isGamepadMode = false; showModal = false },
onBackToWebView = { showModal = false; onBack() },
onMeshParty = onMeshParty?.let { open -> { showModal = false; open() } },
)
// Pairing-QR scan launched from the menu's Add Server row. The menu stays
// open behind the scanner so the new entry appears as soon as it closes.
QrScannerOverlay(
visible = showQrScanner,
onDismiss = { showQrScanner = false },
onServerScanned = { scan ->
showQrScanner = false
scope.launch {
val merged = prefs.upsertServer(scan.server)
FipsManager.registerNode(context, scan.fips, merged.displayName())
if (activeServer == null) prefs.setActiveServer(merged)
}
},
)
}
}
@@ -0,0 +1,772 @@
package com.archipelago.app.ui.screens
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.LockOpen
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.archipelago.app.R
import com.archipelago.app.data.PairResult
import com.archipelago.app.data.ServerEntry
import com.archipelago.app.data.ServerPreferences
import com.archipelago.app.fips.FipsManager
import com.archipelago.app.ui.components.MeshLoadingScreen
import com.archipelago.app.ui.components.SlidingLoader
import com.archipelago.app.ui.components.QrScannerOverlay
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.ErrorRed
import com.archipelago.app.ui.theme.SurfaceBlack
import com.archipelago.app.ui.theme.SurfaceCard
import com.archipelago.app.ui.theme.SuccessGreen
import com.archipelago.app.ui.theme.TextMuted
import com.archipelago.app.ui.theme.TextPrimary
import com.archipelago.app.ui.theme.TextSecondary
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.net.HttpURLConnection
import java.net.URL
import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.SSLContext
import javax.net.ssl.X509TrustManager
@Composable
fun ServerConnectScreen(
onConnected: (String) -> Unit,
onRemoteInput: () -> Unit = {},
// Prefill from a pairing deep link (archipelago://pair) that carried no
// password — opens the manual form on the password prompt for that server.
initialServer: ServerEntry? = null,
) {
val context = LocalContext.current
val prefs = remember { ServerPreferences(context) }
val scope = rememberCoroutineScope()
val keyboard = LocalSoftwareKeyboardController.current
val savedServers by prefs.savedServers.collectAsState(initial = emptyList())
// Warm the mesh tunnel the moment the screen appears — starting it only
// after the LAN probe failed put full tunnel bring-up + session discovery
// inside the user's wait. By connect-tap time it's usually already up.
//
// Only when there is actually a mesh node to warm for, though: raising the
// tunnel on a phone whose saved nodes are all plain HTTP boxes takes
// Android's single VPN slot for nothing.
LaunchedEffect(savedServers.any { it.isFipsNode() }) {
if (savedServers.none { it.isFipsNode() }) return@LaunchedEffect
withContext(Dispatchers.IO) { FipsManager.autoStartIfReady(context) }
}
var name by remember { mutableStateOf("") }
var address by remember { mutableStateOf("") }
var port by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var passwordVisible by remember { mutableStateOf(false) }
var useHttps by remember { mutableStateOf(false) }
var isConnecting by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf<String?>(null) }
// The saved server currently being edited, or null when adding/connecting.
var editingServer by remember { mutableStateOf<ServerEntry?>(null) }
// Landing shows Scan/Manual choice; the form appears in manual mode or while editing.
var manualMode by remember { mutableStateOf(false) }
var showScanner by remember { mutableStateOf(false) }
// Is the connect currently running aimed at a mesh node? Drives whether
// the loader wears the FIPS brand — see MeshLoadingScreen.
var connectingOverMesh by remember { mutableStateOf(false) }
var connectingName by remember { mutableStateOf("") }
// Brief green landing on the loader before the kiosk takes over, matching
// the platform's install overlay.
var connectSucceeded by remember { mutableStateOf(false) }
fun clearForm() {
name = ""
address = ""
port = ""
password = ""
useHttps = false
passwordVisible = false
errorMessage = null
}
fun startEdit(server: ServerEntry) {
editingServer = server
name = server.name
address = server.address
port = server.port
password = server.password
useHttps = server.useHttps
passwordVisible = false
errorMessage = null
}
fun cancelEdit() {
editingServer = null
clearForm()
}
fun saveEdit() {
val original = editingServer ?: return
if (address.isBlank()) {
errorMessage = "Enter a server address"
return
}
val updated = ServerEntry(address, useHttps, port, password, name)
scope.launch {
prefs.updateSavedServer(original, updated)
cancelEdit()
}
}
fun connect(server: ServerEntry) {
if (isConnecting) return
if (server.address.isBlank()) {
errorMessage = "Enter a server address"
return
}
isConnecting = true
errorMessage = null
connectingOverMesh = server.isFipsNode()
connectingName = server.displayName()
connectSucceeded = false
scope.launch {
// LAN and mesh race IN PARALLEL — the serial LAN-then-mesh chain
// burned a guaranteed-dead 5 s LAN probe before the mesh path even
// started (the off-LAN QR-pairing case, exactly where speed shows).
// The scanned IP was only ever a dial hint; the node's real
// identity is its npub and its ULA is reachable from anywhere over
// the mesh. Mesh discovery + first session can take 15s+ through
// the public tree (per node diagnosis), and on a
// first-ever pairing the VPN consent dialog is on screen at the
// same time — so the mesh side keeps probing inside its budget
// while the tunnel (already started at screen entry, and kicked
// again here) warms up underneath.
val meshServer = server.meshIp.takeIf { it.isNotBlank() }?.let {
FipsManager.autoStartIfReady(context)
server.copy(address = it, useHttps = false, port = "")
}
val reachable = kotlinx.coroutines.coroutineScope {
val lan = async { testConnection(server, timeoutMs = 4_000) }
val mesh = async {
if (meshServer == null) return@async false
val deadline = System.currentTimeMillis() + 45_000
var ok = false
while (!ok && System.currentTimeMillis() < deadline) {
ok = testConnection(meshServer, timeoutMs = 8_000)
if (!ok) delay(2000)
}
ok
}
val first = kotlinx.coroutines.selects.select<Boolean> {
lan.onAwait { it }
mesh.onAwait { it }
}
if (first) {
lan.cancel(); mesh.cancel()
true
} else {
// One side gave up — the verdict is whatever the other says.
if (lan.isCompleted) mesh.await() else lan.await()
}
}
if (reachable) {
// Land the loader green before handing over, so the last thing
// seen is "done", not a bar cut mid-sweep.
connectSucceeded = true
prefs.setActiveServer(server)
delay(320)
isConnecting = false
onConnected(server.toUrl())
} else {
isConnecting = false
errorMessage = context.getString(R.string.connection_failed)
}
}
}
fun prefill(server: ServerEntry) {
name = server.name
address = server.address
port = server.port
password = server.password
useHttps = server.useHttps
}
// Pairing QR scanned: dedupe against saved servers, then either auto-connect
// (payload carried a credential — demo password or a real node's device
// token) or land on the password prompt with everything else filled in.
// Mesh info (when present) is registered so the FIPS tunnel comes up too.
fun onQrScanned(scan: PairResult.Success) {
showScanner = false
scope.launch {
val merged = prefs.upsertServer(scan.server)
FipsManager.registerNode(context, scan.fips, merged.displayName())
prefill(merged)
if (merged.password.isNotBlank()) {
connect(merged)
} else {
manualMode = true
}
}
}
LaunchedEffect(initialServer) {
if (initialServer != null) {
prefill(prefs.upsertServer(initialServer))
manualMode = true
}
}
Box(
modifier = Modifier
.fillMaxSize()
.background(SurfaceBlack),
) {
// Reddish synthwave backdrop
Image(
painter = painterResource(id = R.drawable.bg_synthwave),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
// Dark scrim so the form stays legible over the art
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(
Color.Black.copy(alpha = 0.6f),
Color.Black.copy(alpha = 0.45f),
Color.Black.copy(alpha = 0.8f),
),
)
),
)
Column(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing)
.verticalScroll(state = rememberScrollState())
.drawWithContent { drawContent() }
.padding(horizontal = 24.dp)
.padding(top = 48.dp, bottom = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
// Center the content vertically — the landing (logo + two buttons) is
// short and looks stranded at the top otherwise. Taller content (the
// manual form, saved servers) still scrolls from the top as normal.
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
) {
// Circular badge logo
Image(
painter = painterResource(id = R.drawable.ic_logo),
contentDescription = "Archipelago",
modifier = Modifier.size(96.dp),
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = if (editingServer != null) stringResource(R.string.edit_server_title) else stringResource(R.string.connect_to_node),
style = MaterialTheme.typography.headlineMedium,
color = TextPrimary,
textAlign = TextAlign.Center,
)
val showForm = manualMode || editingServer != null
Text(
text = if (showForm) stringResource(R.string.server_address_hint) else stringResource(R.string.connect_landing_hint),
style = MaterialTheme.typography.bodyMedium,
color = TextMuted,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(4.dp))
if (!showForm) {
// Landing: scan the pairing QR, or fall back to manual entry
GlassButton(
text = stringResource(R.string.scan_node_qr),
onClick = { showScanner = true },
modifier = Modifier.fillMaxWidth().height(56.dp),
)
GlassButton(
text = stringResource(R.string.enter_manually),
onClick = {
errorMessage = null
manualMode = true
},
modifier = Modifier.fillMaxWidth().height(56.dp),
)
}
// Glass card with form
if (showForm) Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(Color.Black.copy(alpha = 0.6f))
.background(
Brush.verticalGradient(
colors = listOf(
Color.White.copy(alpha = 0.06f),
Color.White.copy(alpha = 0.02f),
),
)
)
.border(1.dp, Color.White.copy(alpha = 0.1f), RoundedCornerShape(16.dp))
.padding(20.dp),
) {
Column {
OutlinedTextField(
value = name,
onValueChange = {
name = it
errorMessage = null
},
label = { Text(stringResource(R.string.server_name_label)) },
placeholder = { Text(stringResource(R.string.server_name_placeholder)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Next,
),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.White.copy(alpha = 0.3f),
unfocusedBorderColor = Color.White.copy(alpha = 0.12f),
cursorColor = Color.White,
focusedLabelColor = Color.White.copy(alpha = 0.7f),
unfocusedLabelColor = TextMuted,
focusedTextColor = TextPrimary,
unfocusedTextColor = TextPrimary,
),
shape = RoundedCornerShape(12.dp),
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = address,
onValueChange = {
address = sanitizeAddress(it)
errorMessage = null
},
label = { Text(stringResource(R.string.server_address_label)) },
placeholder = { Text(stringResource(R.string.server_address_placeholder)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Next,
),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.White.copy(alpha = 0.3f),
unfocusedBorderColor = Color.White.copy(alpha = 0.12f),
cursorColor = Color.White,
focusedLabelColor = Color.White.copy(alpha = 0.7f),
unfocusedLabelColor = TextMuted,
focusedTextColor = TextPrimary,
unfocusedTextColor = TextPrimary,
),
shape = RoundedCornerShape(12.dp),
)
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedTextField(
value = port,
onValueChange = {
port = it.filter { c -> c.isDigit() }.take(5)
errorMessage = null
},
label = { Text(stringResource(R.string.port_label)) },
placeholder = { Text("80") },
modifier = Modifier.weight(1f),
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Next,
),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.White.copy(alpha = 0.3f),
unfocusedBorderColor = Color.White.copy(alpha = 0.12f),
cursorColor = Color.White,
focusedLabelColor = Color.White.copy(alpha = 0.7f),
unfocusedLabelColor = TextMuted,
focusedTextColor = TextPrimary,
unfocusedTextColor = TextPrimary,
),
shape = RoundedCornerShape(12.dp),
)
OutlinedTextField(
value = password,
onValueChange = {
password = it
errorMessage = null
},
label = { Text("Password") },
modifier = Modifier.weight(2f),
singleLine = true,
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
imageVector = if (passwordVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility,
contentDescription = if (passwordVisible) "Hide password" else "Show password",
tint = TextMuted,
modifier = Modifier.size(20.dp),
)
}
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Go,
),
keyboardActions = KeyboardActions(
onGo = {
keyboard?.hide()
if (editingServer != null) {
saveEdit()
} else {
connect(ServerEntry(address, useHttps, port, password, name))
}
},
),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.White.copy(alpha = 0.3f),
unfocusedBorderColor = Color.White.copy(alpha = 0.12f),
cursorColor = Color.White,
focusedLabelColor = Color.White.copy(alpha = 0.7f),
unfocusedLabelColor = TextMuted,
focusedTextColor = TextPrimary,
unfocusedTextColor = TextPrimary,
),
shape = RoundedCornerShape(12.dp),
)
}
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = if (useHttps) Icons.Default.Lock else Icons.Default.LockOpen,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = if (useHttps) SuccessGreen else TextMuted,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.use_https),
style = MaterialTheme.typography.bodyMedium,
color = TextSecondary,
)
}
Switch(
checked = useHttps,
onCheckedChange = { useHttps = it },
colors = SwitchDefaults.colors(
checkedThumbColor = SurfaceBlack,
checkedTrackColor = BitcoinOrange,
uncheckedThumbColor = TextMuted,
uncheckedTrackColor = SurfaceCard,
),
)
}
}
}
// Error
AnimatedVisibility(visible = errorMessage != null, enter = fadeIn(), exit = fadeOut()) {
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(ErrorRed.copy(alpha = 0.12f))
.border(1.dp, ErrorRed.copy(alpha = 0.25f), RoundedCornerShape(12.dp))
.padding(12.dp),
) {
Text(text = errorMessage ?: "", color = ErrorRed, style = MaterialTheme.typography.bodyMedium)
}
}
if (editingServer != null) {
// Save / Cancel while editing an existing saved server
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
GlassButton(
text = stringResource(R.string.cancel),
onClick = {
keyboard?.hide()
cancelEdit()
},
modifier = Modifier.weight(1f).height(56.dp),
)
GlassButton(
text = stringResource(R.string.save_changes),
onClick = {
keyboard?.hide()
saveEdit()
},
modifier = Modifier.weight(1f).height(56.dp),
)
}
} else if (manualMode) {
// Back to the Scan/Manual landing + Connect
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
GlassButton(
text = stringResource(R.string.back),
onClick = {
keyboard?.hide()
manualMode = false
clearForm()
},
modifier = Modifier.weight(1f).height(56.dp),
)
GlassButton(
text = if (isConnecting) stringResource(R.string.connecting) else stringResource(R.string.connect),
onClick = {
keyboard?.hide()
connect(ServerEntry(address, useHttps, port, password, name))
},
modifier = Modifier.weight(2f).height(56.dp),
)
}
}
if (isConnecting) {
SlidingLoader(
modifier = Modifier.fillMaxWidth(),
done = connectSucceeded,
)
}
// Saved servers (hidden while editing one to keep focus on the form)
if (editingServer == null && savedServers.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.saved_servers),
style = MaterialTheme.typography.labelMedium,
color = TextMuted,
letterSpacing = 1.sp,
modifier = Modifier.fillMaxWidth(),
)
savedServers.forEach { server ->
SavedServerItem(
server = server,
onConnect = { connect(it) },
onEdit = { startEdit(it) },
onRemove = { scope.launch { prefs.removeSavedServer(it) } },
)
}
}
}
QrScannerOverlay(
visible = showScanner,
onDismiss = { showScanner = false },
onServerScanned = { onQrScanned(it) },
)
// Full-screen branded loader while the first connect runs — most
// visibly right after a pairing-QR scan, when the mesh may still be
// establishing (LAN probe → tunnel up → ULA probe can take a while).
// The small inline spinner stays for context; this owns the screen.
if (isConnecting) {
MeshLoadingScreen(
mesh = connectingOverMesh,
nodeName = connectingName,
done = connectSucceeded,
)
}
}
}
@Composable
private fun SavedServerItem(
server: ServerEntry,
onConnect: (ServerEntry) -> Unit,
onEdit: (ServerEntry) -> Unit,
onRemove: (ServerEntry) -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(Color.Black.copy(alpha = 0.6f))
.background(
Brush.verticalGradient(
colors = listOf(
Color.White.copy(alpha = 0.06f),
Color.White.copy(alpha = 0.02f),
),
)
)
.border(1.dp, Color.White.copy(alpha = 0.1f), RoundedCornerShape(12.dp))
.clickable { onConnect(server) }
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
Icon(
imageVector = if (server.useHttps) Icons.Default.Lock else Icons.Default.LockOpen,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = if (server.useHttps) SuccessGreen else BitcoinOrange,
)
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(text = server.displayName(), style = MaterialTheme.typography.bodyMedium, color = TextPrimary, maxLines = 1, overflow = TextOverflow.Ellipsis)
val secondary = buildString {
if (server.name.isNotBlank()) append(server.address)
if (server.port.isNotBlank()) {
if (isNotEmpty()) append(":${server.port}") else append("Port ${server.port}")
}
}
if (secondary.isNotBlank()) {
Text(text = secondary, style = MaterialTheme.typography.labelMedium, color = TextMuted, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
IconButton(onClick = { onEdit(server) }) {
Icon(imageVector = Icons.Default.Edit, contentDescription = stringResource(R.string.edit_server), modifier = Modifier.size(18.dp), tint = TextMuted)
}
IconButton(onClick = { onRemove(server) }) {
Icon(imageVector = Icons.Default.Close, contentDescription = stringResource(R.string.remove_server), modifier = Modifier.size(18.dp), tint = TextMuted)
}
}
}
/** Strip protocol prefixes and trailing slashes from address input. */
private fun sanitizeAddress(input: String): String {
return input.trim()
.removePrefix("https://")
.removePrefix("http://")
.trimEnd('/')
}
// Built once — the connect loop probed up to 20 times, and each attempt was
// paying a fresh SSLContext + SecureRandom init.
private val trustAllSslFactory: javax.net.ssl.SSLSocketFactory by lazy {
val trustAll = arrayOf<javax.net.ssl.TrustManager>(object : X509TrustManager {
override fun checkClientTrusted(chain: Array<java.security.cert.X509Certificate>?, authType: String?) {}
override fun checkServerTrusted(chain: Array<java.security.cert.X509Certificate>?, authType: String?) {}
override fun getAcceptedIssuers(): Array<java.security.cert.X509Certificate> = arrayOf()
})
SSLContext.getInstance("TLS").apply { init(null, trustAll, java.security.SecureRandom()) }.socketFactory
}
/** Test RPC connectivity. Accepts self-signed certs for local LAN servers.
* [timeoutMs] is per-phase (connect / read) — mesh probes need far more
* patience than LAN ones (first session through the tree can take 15s+). */
private suspend fun testConnection(server: ServerEntry, timeoutMs: Int = 5000): Boolean {
return withContext(Dispatchers.IO) {
try {
val url = URL("${server.toUrl()}/rpc/v1")
val connection = url.openConnection() as HttpURLConnection
// Trust self-signed certs for local HTTPS (Archipelago nodes rarely have CA certs)
if (connection is HttpsURLConnection) {
connection.sslSocketFactory = trustAllSslFactory
connection.hostnameVerifier = javax.net.ssl.HostnameVerifier { _, _ -> true }
}
connection.requestMethod = "POST"
connection.connectTimeout = timeoutMs
connection.readTimeout = timeoutMs
connection.setRequestProperty("Content-Type", "application/json")
connection.doOutput = true
val body = """{"method":"server.echo","params":{"message":"ping"}}"""
connection.outputStream.use { it.write(body.toByteArray()) }
val code = connection.responseCode
connection.disconnect()
code in 200..499
} catch (_: Exception) {
false
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,24 @@
package com.archipelago.app.ui.theme
import androidx.compose.ui.graphics.Color
// Archipelago brand palette — Bitcoin orange on dark
val BitcoinOrange = Color(0xFFF7931A)
val BitcoinOrangeLight = Color(0xFFFFB74D)
val BitcoinOrangeDark = Color(0xFFE07C00)
val SurfaceBlack = Color(0xFF000000)
val SurfaceDark = Color(0xFF0A0A0A)
val SurfaceCard = Color(0xFF1A1A1A)
val SurfaceCardHover = Color(0xFF222222)
val SurfaceElevated = Color(0xFF2A2A2A)
val TextPrimary = Color(0xFFF5F5F5)
val TextSecondary = Color(0xFFB0B0B0)
val TextMuted = Color(0xFF666666)
val BorderSubtle = Color(0xFF2A2A2A)
val BorderDefault = Color(0xFF3A3A3A)
val ErrorRed = Color(0xFFEF4444)
val SuccessGreen = Color(0xFF22C55E)
@@ -0,0 +1,44 @@
package com.archipelago.app.ui.theme
import androidx.compose.ui.graphics.Color
/** NES/8BitDo controller palettes */
object NES {
// ── Classic (light body, red buttons) ──────────────
val ClassicBody = Color(0xFFD4D0C8) // warm light gray plastic
val ClassicFace = Color(0xFF1C1C1C) // dark face plate
val ClassicAccent = Color(0xFF8A8A8A) // mid gray trim
val ClassicRidge = Color(0xFFBBB8B0) // grip lines
val ClassicButtonRed = Color(0xFFC1121C) // A/B red
val ClassicButtonRedPress = Color(0xFF8A0D14)
val ClassicButtonGray = Color(0xFF5A5A5A) // turbo buttons
val ClassicButtonGrayPress = Color(0xFF3A3A3A)
val ClassicDPad = Color(0xFF1A1A1A)
val ClassicDPadPress = Color(0xFF2A2A2A)
val ClassicLabel = Color(0xFFC1121C) // red text labels
val ClassicLabelMuted = Color(0xFF6A6A6A)
val ClassicSelect = Color(0xFF2A2A2A) // START/SELECT
// ── Transparent Dark ───────────────────────────────
val DarkBody = Color(0xFF2A2A2E) // smoky translucent dark
val DarkFace = Color(0xFF151518) // darker face
val DarkAccent = Color(0xFF3A3A3E) // trim
val DarkRidge = Color(0xFF222226) // grip lines
val DarkButtonMain = Color(0xFF3A3A3E) // all buttons dark
val DarkButtonMainPress = Color(0xFF222226)
val DarkDPad = Color(0xFF0E0E10)
val DarkDPadPress = Color(0xFF1A1A1E)
val DarkLabel = Color(0xFF5A5A60) // muted labels
val DarkLabelMuted = Color(0xFF3A3A3E)
val DarkSelect = Color(0xFF1A1A1E)
// ── Menu UI (NES-style) ────────────────────────────
val MenuBg = Color(0xFF000000)
val MenuPanel = Color(0xFF0B1B4A) // dark navy
val MenuBorder = Color(0xFFFFFFFF)
val MenuText = Color(0xFFFFFFFF)
val MenuSelected = Color(0xFFC1121C)
val MenuMuted = Color(0xFF7A7A7A)
}
enum class ControllerStyle { CLASSIC, DARK }
@@ -0,0 +1,106 @@
package com.archipelago.app.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Paint
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
object Neo {
// ── Dark ───────────────────────────────────────────
val DarkSurface = Color(0xFF0A0A0A)
val DarkSurfaceRaised = Color(0xFF0F0F11)
val DarkShadowLight = Color(0xFF151517)
val DarkShadowDark = Color(0xFF000000)
val DarkBorder = Color(0x0AFFFFFF)
// ── Light ──────────────────────────────────────────
val LightSurface = Color(0xFFE0E0E4)
val LightSurfaceRaised = Color(0xFFE6E6EA)
val LightShadowLight = Color(0xFFF2F2F6)
val LightShadowDark = Color(0xFFB4B4BA)
val LightBorder = Color(0x0A000000)
val LightTextPrimary = Color(0xFF141414)
val LightTextSecondary = Color(0xFF5A5A5A)
val LightTextMuted = Color(0xFF9A9A9A)
// ── Accessors ──────────────────────────────────────
@Composable @ReadOnlyComposable
fun surface() = if (isSystemInDarkTheme()) DarkSurface else LightSurface
@Composable @ReadOnlyComposable
fun surfaceRaised() = if (isSystemInDarkTheme()) DarkSurfaceRaised else LightSurfaceRaised
@Composable @ReadOnlyComposable
fun shadowLight() = if (isSystemInDarkTheme()) DarkShadowLight else LightShadowLight
@Composable @ReadOnlyComposable
fun shadowDark() = if (isSystemInDarkTheme()) DarkShadowDark else LightShadowDark
@Composable @ReadOnlyComposable
fun border() = if (isSystemInDarkTheme()) DarkBorder else LightBorder
@Composable @ReadOnlyComposable
fun textPrimary() = if (isSystemInDarkTheme()) Color(0xFFD0D0D0) else LightTextPrimary
@Composable @ReadOnlyComposable
fun textSecondary() = if (isSystemInDarkTheme()) Color(0xFF666666) else LightTextSecondary
@Composable @ReadOnlyComposable
fun textMuted() = if (isSystemInDarkTheme()) Color(0xFF333333) else LightTextMuted
}
/** Subtle neomorphic raised shadow */
fun Modifier.neoRaised(
lightShadow: Color,
darkShadow: Color,
radius: Dp = 14.dp,
shadowOffset: Dp = 2.dp,
shadowBlur: Dp = 4.dp,
) = this.drawBehind {
val r = radius.toPx()
val off = shadowOffset.toPx()
val blur = shadowBlur.toPx()
drawIntoCanvas { canvas ->
val path = Path().apply { addRoundRect(RoundRect(0f, 0f, size.width, size.height, CornerRadius(r))) }
canvas.drawPath(path, Paint().also {
it.asFrameworkPaint().apply { isAntiAlias = true; color = android.graphics.Color.TRANSPARENT; setShadowLayer(blur, off, off, darkShadow.toArgb()) }
})
canvas.drawPath(path, Paint().also {
it.asFrameworkPaint().apply { isAntiAlias = true; color = android.graphics.Color.TRANSPARENT; setShadowLayer(blur, -off, -off, lightShadow.toArgb()) }
})
}
}
/** Subtle neomorphic inset shadow */
fun Modifier.neoInset(
lightShadow: Color,
darkShadow: Color,
radius: Dp = 14.dp,
shadowOffset: Dp = 1.dp,
shadowBlur: Dp = 3.dp,
) = this.drawBehind {
val r = radius.toPx()
val off = shadowOffset.toPx()
val blur = shadowBlur.toPx()
drawIntoCanvas { canvas ->
val path = Path().apply { addRoundRect(RoundRect(0f, 0f, size.width, size.height, CornerRadius(r))) }
canvas.drawPath(path, Paint().also {
it.asFrameworkPaint().apply { isAntiAlias = true; color = android.graphics.Color.TRANSPARENT; setShadowLayer(blur, -off, -off, darkShadow.toArgb()) }
})
canvas.drawPath(path, Paint().also {
it.asFrameworkPaint().apply { isAntiAlias = true; color = android.graphics.Color.TRANSPARENT; setShadowLayer(blur, off, off, lightShadow.toArgb()) }
})
}
}
@@ -0,0 +1,56 @@
package com.archipelago.app.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
private val DarkColorScheme = darkColorScheme(
primary = BitcoinOrange,
onPrimary = SurfaceBlack,
primaryContainer = BitcoinOrangeDark,
onPrimaryContainer = TextPrimary,
secondary = BitcoinOrangeLight,
onSecondary = SurfaceBlack,
background = SurfaceBlack,
onBackground = TextPrimary,
surface = SurfaceDark,
onSurface = TextPrimary,
surfaceVariant = SurfaceCard,
onSurfaceVariant = TextSecondary,
outline = BorderDefault,
outlineVariant = BorderSubtle,
error = ErrorRed,
onError = TextPrimary,
)
private val LightColorScheme = lightColorScheme(
primary = BitcoinOrange,
onPrimary = SurfaceBlack,
primaryContainer = BitcoinOrangeLight,
onPrimaryContainer = SurfaceBlack,
secondary = BitcoinOrangeDark,
onSecondary = TextPrimary,
background = Neo.LightSurface,
onBackground = Neo.LightTextPrimary,
surface = Neo.LightSurfaceRaised,
onSurface = Neo.LightTextPrimary,
surfaceVariant = Neo.LightSurface,
onSurfaceVariant = Neo.LightTextSecondary,
outline = Neo.LightBorder,
outlineVariant = Neo.LightBorder,
error = ErrorRed,
onError = TextPrimary,
)
@Composable
fun ArchipelagoTheme(content: @Composable () -> Unit) {
val colorScheme = if (isSystemInDarkTheme()) DarkColorScheme else LightColorScheme
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content,
)
}
@@ -0,0 +1,99 @@
package com.archipelago.app.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import com.archipelago.app.R
/**
* The platform's brand face. neode-ui sets `font-archipelago: Montserrat` and
* uses it for every heading, title and button label, with body copy left to
* `Avenir Next, system-ui` which on Android resolves to the system sans
* anyway. Mirroring that split exactly is what makes companion text read as
* the same product as the node UI.
*
* Montserrat is SIL OFL 1.1 (see Android/MONTSERRAT-OFL.txt); the files are
* the ones already vendored for the web UI, so both halves ship the same
* outlines.
*/
val Montserrat = FontFamily(
Font(R.font.montserrat_medium, FontWeight.Medium),
Font(R.font.montserrat_semibold, FontWeight.SemiBold),
Font(R.font.montserrat_bold, FontWeight.Bold),
Font(R.font.montserrat_extrabold, FontWeight.ExtraBold),
)
val Typography = Typography(
// ── Display / headings: Montserrat, tight and heavy like the web hero
// copy (the platform sets tracking negative on its big type).
displayLarge = TextStyle(
fontFamily = Montserrat,
fontWeight = FontWeight.ExtraBold,
fontSize = 32.sp,
lineHeight = 40.sp,
letterSpacing = (-0.8).sp,
),
headlineLarge = TextStyle(
fontFamily = Montserrat,
fontWeight = FontWeight.Bold,
fontSize = 28.sp,
lineHeight = 36.sp,
letterSpacing = (-0.5).sp,
),
headlineMedium = TextStyle(
fontFamily = Montserrat,
fontWeight = FontWeight.Bold,
fontSize = 24.sp,
lineHeight = 32.sp,
letterSpacing = (-0.4).sp,
),
titleLarge = TextStyle(
fontFamily = Montserrat,
fontWeight = FontWeight.SemiBold,
fontSize = 20.sp,
lineHeight = 28.sp,
letterSpacing = (-0.2).sp,
),
titleMedium = TextStyle(
fontFamily = Montserrat,
fontWeight = FontWeight.SemiBold,
fontSize = 16.sp,
lineHeight = 24.sp,
),
// ── Body: system sans, exactly as the web falls back to.
bodyLarge = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.2.sp,
),
bodyMedium = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp,
),
bodySmall = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 13.sp,
lineHeight = 18.sp,
),
// ── Buttons / labels: Montserrat again, matching .glass-button.
labelLarge = TextStyle(
fontFamily = Montserrat,
fontWeight = FontWeight.SemiBold,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp,
),
labelMedium = TextStyle(
fontFamily = Montserrat,
fontWeight = FontWeight.Medium,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp,
),
)
Binary file not shown.

After

Width:  |  Height:  |  Size: 869 KiB

@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Whole badge lives here (background renders to the mask edge with no
safe-zone cropping, unlike the foreground): dark fill + metallic ring pulled
inward to ~0.88 so the mask can't clip it + grid at ~0.58. Matches the
locally-rendered preview. Foreground is transparent. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="752"
android:viewportHeight="752">
<path
android:fillColor="#0A0A0A"
android:pathData="M0,0h752v752H0z" />
<!-- Ring matching logo.svg's gradient (#000->#666). Scale 0.65 places it at
the home-screen's visible edge (calibrated from a device home screenshot;
launcher3 crops less than the Settings App-info view). -->
<group
android:pivotX="376"
android:pivotY="376"
android:scaleX="0.65"
android:scaleY="0.65">
<path
android:fillColor="#00000000"
android:strokeWidth="22.8834"
android:pathData="M11.441,375.669a364.227,364.227 0 1,0 728.454,0a364.227,364.227 0 1,0 -728.454,0z">
<aapt:attr name="android:strokeColor">
<gradient
android:type="linear"
android:startX="751.337"
android:startY="751.338"
android:endX="0"
android:endY="0.000976562">
<item android:offset="0" android:color="#FF000000" />
<item android:offset="1" android:color="#FF666666" />
</gradient>
</aapt:attr>
</path>
</group>
<!-- White Archipelago grid -->
<group
android:pivotX="376"
android:pivotY="376"
android:scaleX="0.55"
android:scaleY="0.55">
<path
android:fillColor="#FFFFFF"
android:pathData="M253.805,278.37V222.28H309.853V278.37H253.805ZM315.797,278.37V222.28H372.694V278.37H315.797ZM378.639,278.37V222.28H435.536V278.37H378.639ZM441.481,278.37V222.28H497.529V278.37H441.481ZM441.481,341.259V284.319H497.529V341.259H441.481ZM503.473,341.259V284.319H560.37V341.259H503.473ZM190.963,404.148V347.208H247.86V404.148H190.963ZM253.805,404.148V347.208H309.853V404.148H253.805ZM315.797,404.148V347.208H372.694V404.148H315.797ZM378.639,404.148V347.208H435.536V404.148H378.639ZM441.481,404.148V347.208H497.529V404.148H441.481ZM503.473,404.148V347.208H560.37V404.148H503.473ZM190.963,466.187V410.097H247.86V466.187H190.963ZM253.805,466.187V410.097H309.853V466.187H253.805ZM441.481,466.187V410.097H497.529V466.187H441.481ZM503.473,466.187V410.097H560.37V466.187H503.473ZM253.805,529.076V472.136H309.853V529.076H253.805ZM315.797,529.076V472.136H372.694V529.076H315.797ZM378.639,529.076V472.136H435.536V529.076H378.639ZM441.481,529.076V472.136H497.529V529.076H441.481Z" />
</group>
</vector>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Transparent — the whole badge (ring + grid) is in the background layer so it
renders to the mask edge without safe-zone cropping. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#00000000"
android:pathData="M0,0h108v108H0z" />
</vector>
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Archipelago circular badge logo (from logo.svg):
dark circle with a black→grey gradient ring + white pixel-grid mark. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="120dp"
android:height="120dp"
android:viewportWidth="752"
android:viewportHeight="752">
<!-- Ringed circle (circle converted to a path; stroke carries the gradient) -->
<path
android:fillColor="#0A0A0A"
android:strokeWidth="22.8834"
android:pathData="M11.441,375.669a364.227,364.227 0 1,0 728.454,0a364.227,364.227 0 1,0 -728.454,0z">
<aapt:attr name="android:strokeColor">
<gradient
android:type="linear"
android:startX="751.337"
android:startY="751.338"
android:endX="0"
android:endY="0">
<item android:offset="0" android:color="#FF000000" />
<item android:offset="1" android:color="#FF666666" />
</gradient>
</aapt:attr>
</path>
<!-- White Archipelago pixel grid -->
<path
android:fillColor="#FFFFFF"
android:pathData="M253.805,278.37V222.28H309.853V278.37H253.805ZM315.797,278.37V222.28H372.694V278.37H315.797ZM378.639,278.37V222.28H435.536V278.37H378.639ZM441.481,278.37V222.28H497.529V278.37H441.481ZM441.481,341.259V284.319H497.529V341.259H441.481ZM503.473,341.259V284.319H560.37V341.259H503.473ZM190.963,404.148V347.208H247.86V404.148H190.963ZM253.805,404.148V347.208H309.853V404.148H253.805ZM315.797,404.148V347.208H372.694V404.148H315.797ZM378.639,404.148V347.208H435.536V404.148H378.639ZM441.481,404.148V347.208H497.529V404.148H441.481ZM503.473,404.148V347.208H560.37V404.148H503.473ZM190.963,466.187V410.097H247.86V466.187H190.963ZM253.805,466.187V410.097H309.853V466.187H253.805ZM441.481,466.187V410.097H497.529V466.187H441.481ZM503.473,466.187V410.097H560.37V466.187H503.473ZM253.805,529.076V472.136H309.853V529.076H253.805ZM315.797,529.076V472.136H372.694V529.076H315.797ZM378.639,529.076V472.136H435.536V529.076H378.639ZM441.481,529.076V472.136H497.529V529.076H441.481Z" />
</vector>
@@ -0,0 +1,51 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="240dp"
android:height="30dp"
android:viewportWidth="2079"
android:viewportHeight="263">
<!-- A -->
<path android:fillColor="#FFFFFF"
android:pathData="M29.6,85.6V59.2H56V85.6H29.6ZM58.8,85.6V59.2H85.6V85.6H58.8ZM88.4,85.6V59.2H115.2V85.6H88.4ZM118,85.6V59.2H144.4V85.6H118ZM118,115.2V88.4H144.4V115.2H118ZM147.2,115.2V88.4H174V115.2H147.2ZM0,144.8V118H26.8V144.8H0ZM29.6,144.8V118H56V144.8H29.6ZM58.8,144.8V118H85.6V144.8H58.8ZM88.4,144.8V118H115.2V144.8H88.4ZM118,144.8V118H144.4V144.8H118ZM147.2,144.8V118H174V144.8H147.2ZM0,174V147.6H26.8V174H0ZM29.6,174V147.6H56V174H29.6ZM118,174V147.6H144.4V174H118ZM147.2,174V147.6H174V174H147.2ZM29.6,203.6V176.8H56V203.6H29.6ZM58.8,203.6V176.8H85.6V203.6H58.8ZM88.4,203.6V176.8H115.2V203.6H88.4ZM118,203.6V176.8H144.4V203.6H118Z" />
<!-- R -->
<path android:fillColor="#FFFFFF"
android:pathData="M243.663,85.6V59.2H270.062V85.6H243.663ZM272.863,85.6V59.2H299.663V85.6H272.863ZM302.463,85.6V59.2H329.263V85.6H302.463ZM332.062,85.6V59.2H358.462V85.6H332.062ZM332.062,115.2V88.4H358.462V115.2H332.062ZM361.263,115.2V88.4H388.062V115.2H361.263ZM214.062,115.2V88.4H240.863V115.2H214.062ZM243.663,115.2V88.4H270.062V115.2H243.663ZM214.062,144.8V118H240.863V144.8H214.062ZM243.663,144.8V118H270.062V144.8H243.663ZM214.062,174V147.6H240.863V174H214.062ZM243.663,174V147.6H270.062V174H243.663ZM243.663,203.6V176.8H270.062V203.6H243.663Z" />
<!-- C -->
<path android:fillColor="#FFFFFF"
android:pathData="M457.725,85.6V59.2H484.125V85.6H457.725ZM486.925,85.6V59.2H513.725V85.6H486.925ZM516.525,85.6V59.2H543.325V85.6H516.525ZM546.125,85.6V59.2H572.525V85.6H546.125ZM428.125,115.2V88.4H454.925V115.2H428.125ZM457.725,115.2V88.4H484.125V115.2H457.725ZM546.125,115.2V88.4H572.525V115.2H546.125ZM575.325,115.2V88.4H602.125V115.2H575.325ZM428.125,144.8V118H454.925V144.8H428.125ZM457.725,144.8V118H484.125V144.8H457.725ZM428.125,174V147.6H454.925V174H428.125ZM457.725,174V147.6H484.125V174H457.725ZM546.125,174V147.6H572.525V174H546.125ZM575.325,174V147.6H602.125V174H575.325ZM457.725,203.6V176.8H484.125V203.6H457.725ZM486.925,203.6V176.8H513.725V203.6H486.925ZM516.525,203.6V176.8H543.325V203.6H516.525ZM546.125,203.6V176.8H572.525V203.6H546.125Z" />
<!-- H -->
<path android:fillColor="#FFFFFF"
android:pathData="M671.787,26.8V0H698.188V26.8H671.787ZM642.188,56.4V29.6H668.987V56.4H642.188ZM671.787,56.4V29.6H698.188V56.4H671.787ZM642.188,85.6V59.2H668.987V85.6H642.188ZM671.787,85.6V59.2H698.188V85.6H671.787ZM700.987,85.6V59.2H727.787V85.6H700.987ZM730.588,85.6V59.2H757.388V85.6H730.588ZM760.188,85.6V59.2H786.588V85.6H760.188ZM642.188,115.2V88.4H668.987V115.2H642.188ZM671.787,115.2V88.4H698.188V115.2H671.787ZM760.188,115.2V88.4H786.588V115.2H760.188ZM789.388,115.2V88.4H816.188V115.2H789.388ZM642.188,144.8V118H668.987V144.8H642.188ZM671.787,144.8V118H698.188V144.8H671.787ZM760.188,144.8V118H786.588V144.8H760.188ZM789.388,144.8V118H816.188V144.8H789.388ZM642.188,174V147.6H668.987V174H642.188ZM671.787,174V147.6H698.188V174H671.787ZM760.188,174V147.6H786.588V174H760.188ZM789.388,174V147.6H816.188V174H789.388ZM671.787,203.6V176.8H698.188V203.6H671.787ZM760.188,203.6V176.8H786.588V203.6H760.188Z" />
<!-- I -->
<path android:fillColor="#FFFFFF"
android:pathData="M856.25,26.8V0H883.05V26.8H856.25ZM885.85,26.8V0H912.25V26.8H885.85ZM856.25,85.6V59.2H883.05V85.6H856.25ZM856.25,115.2V88.4H883.05V115.2H856.25ZM885.85,115.2V88.4H912.25V115.2H885.85ZM856.25,144.8V118H883.05V144.8H856.25ZM885.85,144.8V118H912.25V144.8H885.85ZM856.25,174V147.6H883.05V174H856.25ZM885.85,174V147.6H912.25V174H885.85ZM885.85,203.6V176.8H912.25V203.6H885.85Z" />
<!-- P -->
<path android:fillColor="#FFFFFF"
android:pathData="M981.944,85.6V59.2H1008.34V85.6H981.944ZM1011.14,85.6V59.2H1037.94V85.6H1011.14ZM1040.74,85.6V59.2H1067.54V85.6H1040.74ZM1070.34,85.6V59.2H1096.74V85.6H1070.34ZM952.344,115.2V88.4H979.144V115.2H952.344ZM981.944,115.2V88.4H1008.34V115.2H981.944ZM1070.34,115.2V88.4H1096.74V115.2H1070.34ZM1099.54,115.2V88.4H1126.34V115.2H1099.54ZM952.344,144.8V118H979.144V144.8H952.344ZM981.944,144.8V118H1008.34V144.8H981.944ZM1070.34,144.8V118H1096.74V144.8H1070.34ZM1099.54,144.8V118H1126.34V144.8H1099.54ZM952.344,174V147.6H979.144V174H952.344ZM981.944,174V147.6H1008.34V174H981.944ZM1070.34,174V147.6H1096.74V174H1070.34ZM1099.54,174V147.6H1126.34V174H1099.54ZM952.344,203.6V176.8H979.144V203.6H952.344ZM981.944,203.6V176.8H1008.34V203.6H981.944ZM1011.14,203.6V176.8H1037.94V203.6H1011.14ZM1040.74,203.6V176.8H1067.54V203.6H1040.74ZM1070.34,203.6V176.8H1096.74V203.6H1070.34ZM952.344,233.2V206.4H979.144V233.2H952.344ZM981.944,233.2V206.4H1008.34V233.2H981.944ZM981.944,262.4V236H1008.34V262.4H981.944Z" />
<!-- E -->
<path android:fillColor="#FFFFFF"
android:pathData="M1196.01,85.6V59.2H1222.41V85.6H1196.01ZM1225.21,85.6V59.2H1252.01V85.6H1225.21ZM1254.81,85.6V59.2H1281.61V85.6H1254.81ZM1284.41,85.6V59.2H1310.81V85.6H1284.41ZM1166.41,115.2V88.4H1193.21V115.2H1166.41ZM1196.01,115.2V88.4H1222.41V115.2H1196.01ZM1284.41,115.2V88.4H1310.81V115.2H1284.41ZM1313.61,115.2V88.4H1340.41V115.2H1313.61ZM1166.41,144.8V118H1193.21V144.8H1166.41ZM1196.01,144.8V118H1222.41V144.8H1196.01ZM1225.21,144.8V118H1252.01V144.8H1225.21ZM1254.81,144.8V118H1281.61V144.8H1254.81ZM1284.41,144.8V118H1310.81V144.8H1284.41ZM1313.61,144.8V118H1340.41V144.8H1313.61ZM1166.41,174V147.6H1193.21V174H1166.41ZM1196.01,174V147.6H1222.41V174H1196.01ZM1196.01,203.6V176.8H1222.41V203.6H1196.01ZM1225.21,203.6V176.8H1252.01V203.6H1225.21ZM1254.81,203.6V176.8H1281.61V203.6H1254.81ZM1284.41,203.6V176.8H1310.81V203.6H1284.41Z" />
<!-- L -->
<path android:fillColor="#FFFFFF"
android:pathData="M1380.47,26.8V0H1407.27V26.8H1380.47ZM1380.47,56.4V29.6H1407.27V56.4H1380.47ZM1410.07,56.4V29.6H1436.47V56.4H1410.07ZM1380.47,85.6V59.2H1407.27V85.6H1380.47ZM1410.07,85.6V59.2H1436.47V85.6H1410.07ZM1380.47,115.2V88.4H1407.27V115.2H1380.47ZM1410.07,115.2V88.4H1436.47V115.2H1410.07ZM1380.47,144.8V118H1407.27V144.8H1380.47ZM1410.07,144.8V118H1436.47V144.8H1410.07ZM1380.47,174V147.6H1407.27V174H1380.47ZM1410.07,174V147.6H1436.47V174H1410.07ZM1410.07,203.6V176.8H1436.47V203.6H1410.07Z" />
<!-- A (second) -->
<path android:fillColor="#FFFFFF"
android:pathData="M1506.16,85.6V59.2H1532.56V85.6H1506.16ZM1535.36,85.6V59.2H1562.16V85.6H1535.36ZM1564.96,85.6V59.2H1591.76V85.6H1564.96ZM1594.56,85.6V59.2H1620.96V85.6H1594.56ZM1594.56,115.2V88.4H1620.96V115.2H1594.56ZM1623.76,115.2V88.4H1650.56V115.2H1623.76ZM1476.56,144.8V118H1503.36V144.8H1476.56ZM1506.16,144.8V118H1532.56V144.8H1506.16ZM1535.36,144.8V118H1562.16V144.8H1535.36ZM1564.96,144.8V118H1591.76V144.8H1564.96ZM1594.56,144.8V118H1620.96V144.8H1594.56ZM1623.76,144.8V118H1650.56V144.8H1623.76ZM1476.56,174V147.6H1503.36V174H1476.56ZM1506.16,174V147.6H1532.56V174H1506.16ZM1594.56,174V147.6H1620.96V174H1594.56ZM1623.76,174V147.6H1650.56V174H1623.76ZM1506.16,203.6V176.8H1532.56V203.6H1506.16ZM1535.36,203.6V176.8H1562.16V203.6H1535.36ZM1564.96,203.6V176.8H1591.76V203.6H1564.96ZM1594.56,203.6V176.8H1620.96V203.6H1594.56Z" />
<!-- G -->
<path android:fillColor="#FFFFFF"
android:pathData="M1720.22,85.6V59.2H1746.62V85.6H1720.22ZM1749.43,85.6V59.2H1776.22V85.6H1749.43ZM1779.03,85.6V59.2H1805.82V85.6H1779.03ZM1808.62,85.6V59.2H1835.03V85.6H1808.62ZM1690.62,115.2V88.4H1717.43V115.2H1690.62ZM1720.22,115.2V88.4H1746.62V115.2H1720.22ZM1808.62,115.2V88.4H1835.03V115.2H1808.62ZM1837.82,115.2V88.4H1864.62V115.2H1837.82ZM1690.62,144.8V118H1717.43V144.8H1690.62ZM1720.22,144.8V118H1746.62V144.8H1720.22ZM1808.62,144.8V118H1835.03V144.8H1808.62ZM1837.82,144.8V118H1864.62V144.8H1837.82ZM1690.62,174V147.6H1717.43V174H1690.62ZM1720.22,174V147.6H1746.62V174H1720.22ZM1808.62,174V147.6H1835.03V174H1808.62ZM1837.82,174V147.6H1864.62V174H1837.82ZM1720.22,203.6V176.8H1746.62V203.6H1720.22ZM1749.43,203.6V176.8H1776.22V203.6H1749.43ZM1779.03,203.6V176.8H1805.82V203.6H1779.03ZM1808.62,203.6V176.8H1835.03V203.6H1808.62ZM1837.82,203.6V176.8H1864.62V203.6H1837.82ZM1808.62,233.2V206.4H1835.03V233.2H1808.62ZM1837.82,233.2V206.4H1864.62V233.2H1837.82ZM1720.22,262.4V236H1746.62V262.4H1720.22ZM1749.43,262.4V236H1776.22V262.4H1749.43ZM1779.03,262.4V236H1805.82V262.4H1779.03ZM1808.62,262.4V236H1835.03V262.4H1808.62Z" />
<!-- O -->
<path android:fillColor="#FFFFFF"
android:pathData="M1934.29,85.6V59.2H1960.69V85.6H1934.29ZM1963.49,85.6V59.2H1990.29V85.6H1963.49ZM1993.09,85.6V59.2H2019.89V85.6H1993.09ZM2022.69,85.6V59.2H2049.09V85.6H2022.69ZM1904.69,115.2V88.4H1931.49V115.2H1904.69ZM1934.29,115.2V88.4H1960.69V115.2H1934.29ZM2022.69,115.2V88.4H2049.09V115.2H2022.69ZM2051.89,115.2V88.4H2078.69V115.2H2051.89ZM1904.69,144.8V118H1931.49V144.8H1904.69ZM1934.29,144.8V118H1960.69V144.8H1934.29ZM2022.69,144.8V118H2049.09V144.8H2022.69ZM2051.89,144.8V118H2078.69V144.8H2051.89ZM1904.69,174V147.6H1931.49V174H1904.69ZM1934.29,174V147.6H1960.69V174H1934.29ZM2022.69,174V147.6H2049.09V174H2022.69ZM2051.89,174V147.6H2078.69V174H2051.89ZM1963.49,203.6V176.8H1990.29V203.6H1963.49ZM1993.09,203.6V176.8H2019.89V203.6H1993.09ZM1934.29,203.6V176.8H1960.69V203.6H1934.29ZM2022.69,203.6V176.8H2049.09V203.6H2022.69Z" />
</vector>
@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M15,19l-7,-7 7,-7"
android:strokeColor="#FFFFFF"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
</vector>
@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M6,18L18,6M6,6l12,12"
android:strokeColor="#FFFFFF"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
</vector>
@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M9,5l7,7 -7,7"
android:strokeColor="#FFFFFF"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
</vector>
@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M10,6H6a2,2 0,0 0,-2 2v10a2,2 0,0 0,2 2h10a2,2 0,0 0,2 -2v-4M14,4h6m0,0v6m0,-6L10,14"
android:strokeColor="#FFFFFF"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
</vector>
@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M4,4v6h6M20,20v-6h-6M5.64,15.36A8,8 0,0 0,18.36 18M18.36,8.64A8,8 0,0 0,5.64 6"
android:strokeColor="#FFFFFF"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
</vector>
@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- System splash icon — deliberately the SAME mark as the adaptive launcher
icon (ic_launcher_background.xml): dark disc + metallic ring + white
Archipelago grid. Tapping the icon and watching the splash should show
one badge, not two different logos.
Geometry is copied from the launcher: the Android 12 splash draws its icon
on a 288dp canvas whose inner 2/3 is the safe area — the same 0.667 ratio
the adaptive-icon mask uses — so the launcher's 0.65 (ring) / 0.55 (grid)
group scales land identically here. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="288dp"
android:height="288dp"
android:viewportWidth="752"
android:viewportHeight="752">
<!-- Dark disc + gradient ring (#000 -> #666), matching logo.svg -->
<group
android:pivotX="376"
android:pivotY="376"
android:scaleX="0.65"
android:scaleY="0.65">
<path
android:fillColor="#0A0A0A"
android:strokeWidth="22.8834"
android:pathData="M11.441,375.669a364.227,364.227 0 1,0 728.454,0a364.227,364.227 0 1,0 -728.454,0z">
<aapt:attr name="android:strokeColor">
<gradient
android:type="linear"
android:startX="751.337"
android:startY="751.338"
android:endX="0"
android:endY="0.000976562">
<item android:offset="0" android:color="#FF000000" />
<item android:offset="1" android:color="#FF666666" />
</gradient>
</aapt:attr>
</path>
</group>
<!-- White Archipelago grid -->
<group
android:pivotX="376"
android:pivotY="376"
android:scaleX="0.55"
android:scaleY="0.55">
<path
android:fillColor="#FFFFFF"
android:pathData="M253.805,278.37V222.28H309.853V278.37H253.805ZM315.797,278.37V222.28H372.694V278.37H315.797ZM378.639,278.37V222.28H435.536V278.37H378.639ZM441.481,278.37V222.28H497.529V278.37H441.481ZM441.481,341.259V284.319H497.529V341.259H441.481ZM503.473,341.259V284.319H560.37V341.259H503.473ZM190.963,404.148V347.208H247.86V404.148H190.963ZM253.805,404.148V347.208H309.853V404.148H253.805ZM315.797,404.148V347.208H372.694V404.148H315.797ZM378.639,404.148V347.208H435.536V404.148H378.639ZM441.481,404.148V347.208H497.529V404.148H441.481ZM503.473,404.148V347.208H560.37V404.148H503.473ZM190.963,466.187V410.097H247.86V466.187H190.963ZM253.805,466.187V410.097H309.853V466.187H253.805ZM441.481,466.187V410.097H497.529V466.187H441.481ZM503.473,466.187V410.097H560.37V466.187H503.473ZM253.805,529.076V472.136H309.853V529.076H253.805ZM315.797,529.076V472.136H372.694V529.076H315.797ZM378.639,529.076V472.136H435.536V529.076H378.639ZM441.481,529.076V472.136H497.529V529.076H441.481Z" />
</group>
</vector>
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
<color name="bitcoin_orange">#FFF7931A</color>
<color name="surface_dark">#FF0A0A0A</color>
<color name="surface_card">#FF1A1A1A</color>
<color name="splash_background">#FF000000</color>
</resources>
@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Archipelago</string>
<string name="server_address_label">Server Address</string>
<string name="server_address_placeholder">192.168.1.100</string>
<string name="server_address_hint">Enter your Archipelago server IP or hostname</string>
<string name="connect">Connect</string>
<string name="connecting">Connecting…</string>
<string name="connection_failed">Could not reach server. Check the address and try again.</string>
<string name="connection_timeout">Connection timed out. Is the server running?</string>
<string name="welcome_title">Your Sovereign\nPersonal Server</string>
<string name="welcome_subtitle">Bitcoin node, app platform, and private cloud — all in one box you control.</string>
<string name="get_started">Get Started</string>
<string name="mesh_party">Mesh Party</string>
<string name="use_https">Use HTTPS</string>
<string name="port_label">Port (optional)</string>
<string name="saved_servers">Saved Servers</string>
<string name="no_saved_servers">No saved servers yet</string>
<string name="remove_server">Remove</string>
<string name="disconnect">Disconnect</string>
<string name="server_unreachable">Server unreachable</string>
<string name="retry">Retry</string>
<string name="remote_input">Remote Control</string>
<string name="remote_input_hint">Use your phone as a keyboard and mouse for the kiosk</string>
<string name="close">Close</string>
<string name="open_in_browser">Open in browser</string>
<string name="back">Back</string>
<string name="forward">Forward</string>
<string name="refresh">Refresh</string>
<string name="server_name_label">Server Name (optional)</string>
<string name="server_name_placeholder">My Archipelago</string>
<string name="edit_server">Edit</string>
<string name="scan_node_qr">Scan Node\'s QR</string>
<string name="enter_manually">Enter Manually</string>
<string name="connect_landing_hint">Scan the pairing QR from your node\'s Companion popup, or enter the address manually</string>
<string name="scan_qr_hint">Point the camera at the pairing QR shown in the Companion popup</string>
<string name="camera_permission_needed">Camera access is needed to scan the pairing QR. You can also enter the server details manually.</string>
<string name="grant_camera_access">Grant Camera Access</string>
<string name="invalid_pairing_qr">Not an Archipelago pairing code</string>
<string name="update_app_for_qr">This pairing code needs a newer app version — please update the companion app</string>
<string name="add_server_qr">Add server by QR</string>
<string name="edit_server_title">Edit Server</string>
<string name="save_changes">Save Changes</string>
<string name="cancel">Cancel</string>
<string name="gesture_hint_title">Hold with three fingers</string>
<string name="gesture_hint_body">Anywhere in the app — opens the remote control and menu</string>
<string name="gesture_hint_got_it">Got it</string>
<string name="scan_to_send">Scan to send</string>
<string name="scan_wallet_hint">Point the camera at a Lightning invoice, Bitcoin address, Cashu or Fedimint code</string>
<string name="upload_qr_image">Upload image</string>
<string name="no_qr_in_image">No QR code found in that image — try another, closer and well-lit</string>
<string name="torch_on">Turn on the torch</string>
<string name="torch_off">Turn off the torch</string>
<!-- Launch node picker (more than one node saved) -->
<string name="pick_node_title">Which node?</string>
<string name="pick_node_hint">Choose the Archipelago this session connects to. The FIPS mesh only comes up for mesh nodes.</string>
<string name="pick_node_add">Add another node</string>
<string name="connect_to_node">Connect to your node</string>
</resources>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Archipelago" parent="android:Theme.Material.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowBackground">@color/black</item>
</style>
<style name="Theme.Archipelago.Splash" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">@color/splash_background</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/ic_splash_logo</item>
<item name="postSplashScreenTheme">@style/Theme.Archipelago</item>
</style>
</resources>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- FileProvider scope for the party-screen "Share this app" APK handoff. -->
<paths>
<cache-path name="share" path="share/" />
</paths>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!-- Allow cleartext for local network Archipelago servers -->
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
</network-security-config>
+4
View File
@@ -0,0 +1,4 @@
plugins {
id("com.android.application") version "8.4.0" apply false
id("org.jetbrains.kotlin.android") version "1.9.24" apply false
}
+5
View File
@@ -0,0 +1,5 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
android.suppressUnsupportedCompileSdk=35
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+249
View File
@@ -0,0 +1,249 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+92
View File
@@ -0,0 +1,92 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+10
View File
@@ -0,0 +1,10 @@
<svg width="752" height="752" viewBox="0 0 752 752" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="375.668" cy="375.669" r="364.227" fill="#0A0A0A" stroke="url(#paint0_linear_877_1990)" stroke-width="22.8834"/>
<path d="M253.805 278.37V222.28H309.853V278.37H253.805ZM315.797 278.37V222.28H372.694V278.37H315.797ZM378.639 278.37V222.28H435.536V278.37H378.639ZM441.481 278.37V222.28H497.529V278.37H441.481ZM441.481 341.259V284.319H497.529V341.259H441.481ZM503.473 341.259V284.319H560.37V341.259H503.473ZM190.963 404.148V347.208H247.86V404.148H190.963ZM253.805 404.148V347.208H309.853V404.148H253.805ZM315.797 404.148V347.208H372.694V404.148H315.797ZM378.639 404.148V347.208H435.536V404.148H378.639ZM441.481 404.148V347.208H497.529V404.148H441.481ZM503.473 404.148V347.208H560.37V404.148H503.473ZM190.963 466.187V410.097H247.86V466.187H190.963ZM253.805 466.187V410.097H309.853V466.187H253.805ZM441.481 466.187V410.097H497.529V466.187H441.481ZM503.473 466.187V410.097H560.37V466.187H503.473ZM253.805 529.076V472.136H309.853V529.076H253.805ZM315.797 529.076V472.136H372.694V529.076H315.797ZM378.639 529.076V472.136H435.536V529.076H378.639ZM441.481 529.076V472.136H497.529V529.076H441.481Z" fill="white"/>
<defs>
<linearGradient id="paint0_linear_877_1990" x1="751.337" y1="751.338" x2="0" y2="0.000976562" gradientUnits="userSpaceOnUse">
<stop/>
<stop offset="1" stop-color="#666666"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

File diff suppressed because it is too large Load Diff
+48
View File
@@ -0,0 +1,48 @@
# Embedded FIPS mesh node for the Archipelago companion app.
#
# Built for Android via cargo-ndk (see Android/app/build.gradle.kts, task
# buildRustArm64) into app/src/main/jniLibs/arm64-v8a/libarchy_fips_core.so.
# Also builds on the host so `cargo test` covers the non-JNI logic.
#
# `fips` is pinned to the fips-native fork rev that this integration was
# developed against — the fork carries Android support upstream lacks
# (Tun::from_fd for a VpnService-owned fd, cfg(target_os = "android") paths).
# Override with a local checkout when hacking on fips itself:
# CARGO_NET_OFFLINE=false cargo ndk ... --config 'patch."https://github.com/9qeklajc/fips-native".fips.path="/path/to/fips-native/fips"'
[package]
name = "archy-fips-core"
version = "0.1.0"
edition = "2021"
license = "MIT"
publish = false
[lib]
name = "archy_fips_core"
# rlib for host tests; cdylib for the Android shared library.
crate-type = ["lib", "cdylib"]
[dependencies]
# default-features drops the ratatui TUI; tun-support enables Node::start_with_tun_fd.
# Zazawowow/fips-native `fast-join-pinned` = upstream pinned rev 46494a74 +
# discovery re-fire on topology change (fresh 5G join: first route no longer
# waits out doomed pre-join lookups). Upstream 9qeklajc denies pushes.
fips = { git = "https://github.com/Zazawowow/fips-native", rev = "07d21d4482be56b14295d2525e41f8386d1bfe6f", default-features = false, features = ["tun-support"] }
anyhow = "1.0"
serde_json = "1.0"
hex = "0.4"
# OS CSPRNG for identity generation (crypto rule: no thread-local RNG for keys).
getrandom = "0.2"
tokio = { version = "1", features = ["rt-multi-thread", "sync", "time", "macros"] }
tracing = "0.1"
# fcntl: force the VpnService TUN fd into blocking mode (see mesh::start).
libc = "0.2"
# The JNI surface only exists on Android; host builds skip it and drive the
# mesh module directly (tests).
[target.'cfg(target_os = "android")'.dependencies]
jni = "0.21"
# Bridge `tracing` (ours + fips) to logcat: `adb logcat -s archy-fips`.
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
paranoid-android = "0.2"
[workspace]
@@ -0,0 +1,129 @@
//! JNI surface for `com.archipelago.app.fips.FipsNative` — JSON over strings,
//! no codegen (the myco / nostr-vpn embedding pattern). Errors come back as
//! `{"error": "…"}` so Kotlin never sees a raw exception from native code.
use std::sync::Once;
use jni::objects::{JClass, JString};
use jni::sys::{jboolean, jint, jstring};
use jni::JNIEnv;
use crate::mesh;
static LOG_INIT: Once = Once::new();
fn init_logging() {
LOG_INIT.call_once(|| {
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
let _ = tracing_subscriber::registry()
.with(tracing_subscriber::EnvFilter::new("info"))
.with(paranoid_android::layer("archy-fips"))
.try_init();
});
}
fn jstr(env: &mut JNIEnv, s: &JString) -> String {
env.get_string(s).map(|s| s.into()).unwrap_or_default()
}
fn out(env: &JNIEnv, s: String) -> jstring {
env.new_string(s)
.map(|s| s.into_raw())
.unwrap_or(std::ptr::null_mut())
}
fn err_json(e: impl std::fmt::Display) -> String {
serde_json::json!({ "error": e.to_string() }).to_string()
}
fn identity_json(info: &mesh::IdentityInfo) -> String {
serde_json::json!({
"secret": info.secret_hex,
"npub": info.npub,
"address": info.address,
})
.to_string()
}
/// Kotlin: `external fun generateIdentity(): String`
#[no_mangle]
pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_generateIdentity(
env: JNIEnv,
_class: JClass,
) -> jstring {
init_logging();
let json = match mesh::generate_identity() {
Ok(info) => identity_json(&info),
Err(e) => err_json(e),
};
out(&env, json)
}
/// Kotlin: `external fun deriveIdentity(secret: String): String`
#[no_mangle]
pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_deriveIdentity(
mut env: JNIEnv,
_class: JClass,
secret: JString,
) -> jstring {
init_logging();
let secret = jstr(&mut env, &secret);
let json = match mesh::derive_identity(&secret) {
Ok(info) => identity_json(&info),
Err(e) => err_json(e),
};
out(&env, json)
}
/// Kotlin: `external fun start(secret: String, peersJson: String, tunFd: Int, listenPort: Int): String`
/// Returns `{"npub": "...", "address": "..."}` or `{"error": "..."}`.
/// `listenPort` 0 = outbound-only; non-zero = fixed UDP bind (party mode).
#[no_mangle]
pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_start(
mut env: JNIEnv,
_class: JClass,
secret: JString,
peers_json: JString,
tun_fd: jint,
listen_port: jint,
) -> jstring {
init_logging();
let secret = jstr(&mut env, &secret);
let peers = jstr(&mut env, &peers_json);
let listen_port = u16::try_from(listen_port).unwrap_or(0);
let json = match mesh::start(&secret, &peers, tun_fd, listen_port) {
Ok((npub, address)) => {
serde_json::json!({ "npub": npub, "address": address }).to_string()
}
Err(e) => err_json(e),
};
out(&env, json)
}
/// Kotlin: `external fun stop()`
#[no_mangle]
pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_stop(
_env: JNIEnv,
_class: JClass,
) {
mesh::stop();
}
/// Kotlin: `external fun isRunning(): Boolean`
#[no_mangle]
pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_isRunning(
_env: JNIEnv,
_class: JClass,
) -> jboolean {
mesh::is_running() as jboolean
}
/// Kotlin: `external fun statusJson(): String`
#[no_mangle]
pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_statusJson(
env: JNIEnv,
_class: JClass,
) -> jstring {
out(&env, mesh::status_json())
}
+17
View File
@@ -0,0 +1,17 @@
//! Embedded FIPS mesh node for the Archipelago companion app.
//!
//! The phone runs a real, leaf-only FIPS node in-process: Android's
//! `VpnService` owns the TUN fd (routing only `fd00::/8`, so normal traffic
//! never touches the tunnel) and hands it to [`fips::Node::start_with_tun_fd`].
//! Peering is outbound-only — the pairing QR carries the node's npub and
//! transport endpoints, and FIPS nodes accept inbound peers without prior
//! registration, so no server-side enrollment step exists.
//!
//! The JNI surface (`jni_glue`, Android-only) is deliberately tiny and
//! JSON-over-strings, mirroring the myco / nostr-vpn embedding pattern:
//! `generateIdentity`, `deriveIdentity`, `start`, `stop`, `isRunning`.
pub mod mesh;
#[cfg(target_os = "android")]
mod jni_glue;
+295
View File
@@ -0,0 +1,295 @@
//! Mesh lifecycle: identity, config assembly, and the node task.
//!
//! Host-buildable (no JNI) so the config/identity logic is unit-testable;
//! only [`start`] needs a real TUN fd and therefore only runs on-device.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use anyhow::{anyhow, Context, Result};
use fips::config::{PeerConfig, TransportInstances, UdpConfig};
use fips::{Config, Identity, Node};
use tokio::sync::Notify;
/// How long `start` waits for the node to come up (TUN attach + transports).
const START_TIMEOUT: Duration = Duration::from_secs(15);
/// How long `stop` waits for the node task to drain.
const STOP_TIMEOUT: Duration = Duration::from_secs(5);
struct MeshHandle {
runtime: tokio::runtime::Runtime,
task: Option<tokio::task::JoinHandle<()>>,
shutdown: Arc<Notify>,
running: Arc<AtomicBool>,
npub: String,
address: String,
}
static MESH: Mutex<Option<MeshHandle>> = Mutex::new(None);
#[derive(Debug, Clone)]
pub struct IdentityInfo {
pub secret_hex: String,
pub npub: String,
/// The phone's own ULA on the mesh (fd::/8), for VpnService.addAddress.
pub address: String,
}
/// Generate a fresh mesh identity from the OS CSPRNG.
pub fn generate_identity() -> Result<IdentityInfo> {
// ~1 in 2^128 chance a candidate is off the curve; loop regardless.
loop {
let mut bytes = [0u8; 32];
getrandom::getrandom(&mut bytes).context("OS RNG")?;
if let Ok(id) = Identity::from_secret_bytes(&bytes) {
return Ok(IdentityInfo {
secret_hex: hex::encode(bytes),
npub: id.npub(),
address: id.address().to_ipv6().to_string(),
});
}
}
}
/// Re-derive npub + ULA from a stored secret.
pub fn derive_identity(secret: &str) -> Result<IdentityInfo> {
let id = Identity::from_secret_str(secret).map_err(|e| anyhow!("bad secret: {e}"))?;
Ok(IdentityInfo {
secret_hex: secret.to_string(),
npub: id.npub(),
address: id.address().to_ipv6().to_string(),
})
}
/// Build the phone-side node config: leaf-only (never routes third-party
/// traffic — battery), no DNS responder, TUN enabled but attached to the
/// VpnService fd rather than created.
///
/// `listen_port` 0 = ephemeral UDP (outbound-only, the default posture).
/// Non-zero = fixed UDP bind so a nearby phone can dial us directly over a
/// local link (party mode); leaf_only still guarantees we never carry
/// third-party transit even while accepting an inbound link.
pub fn build_config(secret: &str, peers: Vec<PeerConfig>, listen_port: u16) -> Config {
let mut cfg = Config::default();
cfg.node.identity.nsec = Some(secret.to_string());
cfg.node.identity.persistent = false;
cfg.node.leaf_only = true;
cfg.tun.enabled = true;
cfg.tun.mtu = Some(1280);
cfg.dns.enabled = false;
cfg.transports.udp = TransportInstances::Single(UdpConfig {
bind_addr: Some(format!("0.0.0.0:{listen_port}")),
..Default::default()
});
// TCP with no bind_addr = outbound-only (fallback when UDP is blocked).
cfg.transports.tcp = TransportInstances::Single(Default::default());
// Fast-connect profile — a phone opens the app and expects the node NOW.
// Stock pacing is tuned for always-on routers: a failed discovery backs
// off 30s, session resends gap out to 8-16s, dead links redial at
// 5s→300s. Over 5G that stacked into a ~40s first connect (observed
// 2026-07-24: anchor +10s, session +40s). Retries only fire while a
// link/session is down, so steady-state traffic is unchanged.
cfg.node.retry.base_interval_secs = 1; // dead-link redial 1s,2s,4s…
cfg.node.retry.max_backoff_secs = 30; // …capped at 30s, not 5 min
cfg.node.retry.max_retries = 30;
cfg.node.rate_limit.handshake_resend_interval_ms = 400;
cfg.node.rate_limit.handshake_resend_backoff = 1.5;
cfg.node.rate_limit.handshake_max_resends = 10;
cfg.node.discovery.backoff_base_secs = 1; // failed lookup retries fast
cfg.node.discovery.backoff_max_secs = 30;
cfg.node.discovery.retry_interval_secs = 2;
cfg.node.discovery.max_attempts = 3;
// Lookups launched before the tree position settles are doomed; a 10s
// completion timeout made each one cost 10s before the 1s retry could
// fire (observed: 19s to a route on a fresh join). Fail fast instead —
// the resend-within-window above still gives each attempt two shots.
cfg.node.discovery.timeout_secs = 5;
cfg.peers = peers;
cfg
}
/// Parse the peers JSON handed over from Kotlin. Shape = fips `PeerConfig`:
/// `[{"npub":"…","alias":"…","addresses":[{"transport":"udp","addr":"host:2121","priority":10},…]}]`
pub fn parse_peers(peers_json: &str) -> Result<Vec<PeerConfig>> {
serde_json::from_str(peers_json).context("peers JSON")
}
/// Start the mesh node on the given TUN fd (from `VpnService.establish()`,
/// detached — the node owns it from here). Returns (npub, ula) on success.
/// Any previously running node is stopped first.
pub fn start(secret: &str, peers_json: &str, tun_fd: i32, listen_port: u16) -> Result<(String, String)> {
stop();
// Android hands the VpnService TUN fd over in non-blocking mode on some
// OS builds. The fips TUN reader is a dedicated blocking-read thread that
// treats EAGAIN as fatal — the loop died at startup ("TUN read error …
// Try again (os error 11)" on-device), so sessions came up but no packet
// ever entered the mesh. Force the fd into the blocking mode the reader
// is designed for.
unsafe {
let flags = libc::fcntl(tun_fd, libc::F_GETFL);
if flags >= 0 && (flags & libc::O_NONBLOCK) != 0 {
libc::fcntl(tun_fd, libc::F_SETFL, flags & !libc::O_NONBLOCK);
}
}
let peers = parse_peers(peers_json)?;
let config = build_config(secret, peers, listen_port);
let mut node = Node::new(config).map_err(|e| anyhow!("node init: {e}"))?;
let npub = node.npub();
let address = node.identity().address().to_ipv6().to_string();
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.thread_name("archy-fips")
.build()
.context("tokio runtime")?;
let shutdown = Arc::new(Notify::new());
let running = Arc::new(AtomicBool::new(false));
let (started_tx, started_rx) = tokio::sync::oneshot::channel::<Result<()>>();
let task = {
let shutdown = shutdown.clone();
let running = running.clone();
runtime.spawn(async move {
match node.start_with_tun_fd(tun_fd).await {
Ok(()) => {
running.store(true, Ordering::SeqCst);
let _ = started_tx.send(Ok(()));
}
Err(e) => {
let _ = started_tx.send(Err(anyhow!("node start: {e}")));
return;
}
}
tokio::select! {
result = node.run_rx_loop() => {
if let Err(e) = result {
tracing::error!("mesh rx loop error: {e}");
}
}
_ = shutdown.notified() => {}
}
if let Err(e) = node.stop().await {
tracing::warn!("mesh stop: {e}");
}
running.store(false, Ordering::SeqCst);
})
};
let started = runtime
.block_on(async { tokio::time::timeout(START_TIMEOUT, started_rx).await })
.map_err(|_| anyhow!("node start timed out"))?
.map_err(|_| anyhow!("node task died during start"))?;
if let Err(e) = started {
runtime.shutdown_background();
return Err(e);
}
*MESH.lock().unwrap() = Some(MeshHandle {
runtime,
task: Some(task),
shutdown,
running,
npub: npub.clone(),
address: address.clone(),
});
Ok((npub, address))
}
/// Stop the mesh node if running. Idempotent.
pub fn stop() {
let Some(mut handle) = MESH.lock().unwrap().take() else {
return;
};
handle.shutdown.notify_waiters();
if let Some(task) = handle.task.take() {
let _ = handle
.runtime
.block_on(async { tokio::time::timeout(STOP_TIMEOUT, task).await });
}
handle.runtime.shutdown_background();
}
pub fn is_running() -> bool {
MESH.lock()
.unwrap()
.as_ref()
.map(|h| h.running.load(Ordering::SeqCst))
.unwrap_or(false)
}
/// `{running, npub, address}` for the Kotlin status surface.
pub fn status_json() -> String {
let guard = MESH.lock().unwrap();
match guard.as_ref() {
Some(h) => serde_json::json!({
"running": h.running.load(Ordering::SeqCst),
"npub": h.npub,
"address": h.address,
})
.to_string(),
None => serde_json::json!({ "running": false }).to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn identity_roundtrip() {
let a = generate_identity().unwrap();
let b = derive_identity(&a.secret_hex).unwrap();
assert_eq!(a.npub, b.npub);
assert_eq!(a.address, b.address);
// ULA in fd::/8
assert!(a.address.starts_with("fd"));
}
#[test]
fn peers_json_parses_into_peer_config() {
let peers = parse_peers(
r#"[{
"npub": "npub1abc",
"alias": "My Archipelago",
"addresses": [
{"transport": "udp", "addr": "192.0.2.10:2121", "priority": 10},
{"transport": "tcp", "addr": "192.0.2.10:8443", "priority": 20}
]
}]"#,
)
.unwrap();
assert_eq!(peers.len(), 1);
assert_eq!(peers[0].addresses.len(), 2);
assert!(peers[0].is_auto_connect());
}
#[test]
fn config_is_leaf_only_with_tun() {
let id = generate_identity().unwrap();
let cfg = build_config(&id.secret_hex, vec![], 0);
assert!(cfg.node.leaf_only);
assert!(cfg.tun.enabled);
assert_eq!(cfg.tun.mtu(), 1280);
assert!(!cfg.dns.enabled);
assert!(!cfg.transports.udp.is_empty());
assert!(!cfg.transports.tcp.is_empty());
}
#[test]
fn listen_port_sets_fixed_udp_bind() {
let id = generate_identity().unwrap();
let cfg = build_config(&id.secret_hex, vec![], 2121);
// Party mode keeps leaf_only — accepting a link is not routing transit.
assert!(cfg.node.leaf_only);
let TransportInstances::Single(udp) = &cfg.transports.udp else {
panic!("expected single UDP transport");
};
assert_eq!(udp.bind_addr.as_deref(), Some("0.0.0.0:2121"));
}
}
+18
View File
@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "Archipelago"
include(":app")
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
#
# Build the Android companion app and publish it as the served download
# (neode-ui/public/packages/archipelago-companion.apk — a plain APK a phone can
# install straight from the link), then commit + push.
#
# Use this INSTEAD of `git push` when shipping the companion app, so the
# downloadable APK on the node always matches what's on main.
#
# ./Android/ship-companion.sh
#
# The actual build/sign/verify/stage is done by scripts/publish-companion-apk.sh
# (single source of truth, shared with the pre-push hook). It does a CLEAN build,
# forces v1+v2+v3 signing, and ABORTS if any signature scheme is missing — so a
# broken or v2-only APK can never be shipped.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
export JAVA_HOME="${JAVA_HOME:-/opt/homebrew/opt/openjdk@17}"
export ANDROID_HOME="${ANDROID_HOME:-$HOME/Library/Android/sdk}"
DEST="neode-ui/public/packages/archipelago-companion.apk"
echo "==> Building + signing + verifying companion APK"
bash scripts/publish-companion-apk.sh
[ -f "$DEST" ] || { echo "ERROR: served APK not found at $DEST" >&2; exit 1; }
if git diff --cached --quiet -- "$DEST"; then
echo "==> Nothing to commit (APK unchanged)"
else
git commit -q -m "chore(android): update companion apk download"
echo "==> Committed"
fi
echo "==> Pushing $(git branch --show-current)"
# SHIP_COMPANION lets the pre-push guard know the APK was just refreshed.
SHIP_COMPANION=1 git push origin "$(git branch --show-current)"
echo "==> Done — companion APK published and pushed."
+1320
View File
File diff suppressed because it is too large Load Diff
+72
View File
@@ -0,0 +1,72 @@
# Archipelago — contributor guide
This file orients anyone (human or AI) working in this repository: the
invariants that must hold, how to build and verify, and where the deeper
design docs live. The authoritative behaviour is always the code in `core/`.
**Read [`docs/ROADMAP.md`](docs/ROADMAP.md) for where the project is going** and
[`docs/README.md`](docs/README.md) for the full documentation index.
The north star: a world-class, **developer-ready app platform** — every app
manifest-driven, rootless, secure, and 100%-uptime-capable, with third-party
developers publishing via an external/decentralized registry.
Detailed sub-plans:
- App platform / packaging phases + security model → [`docs/APP-PACKAGING-MIGRATION-PLAN.md`](docs/APP-PACKAGING-MIGRATION-PLAN.md)
- Registry-distributed manifests → [`docs/registry-manifest-design.md`](docs/registry-manifest-design.md)
- External/decentralized marketplace for devs → [`docs/marketplace-protocol.md`](docs/marketplace-protocol.md)
- App manifest schema → [`docs/app-manifest-spec.md`](docs/app-manifest-spec.md)
- Production test gate → [`tests/lifecycle/TESTING.md`](tests/lifecycle/TESTING.md)
## Commit & push every unit of work
Work is not "done" until it is committed **and** pushed. Finished work has been
lost by sitting uncommitted in a shared tree across sessions. To prevent that:
- **Commit each feature/fix the moment it works** — one focused, self-contained
commit per logical change (it compiles and its targeted tests pass). Don't let
unrelated changes accumulate uncommitted.
- **Push immediately after committing** so nothing lives only on one machine.
- **Never leave a stack of finished work uncommitted** overnight or when handing
off — if you must pause mid-change, commit a clearly-labelled WIP checkpoint
rather than leaving the tree dirty.
- **Stage explicitly by path** (`git add <paths>`) when another contributor's
uncommitted work shares the tree — never `git add -A` / `git commit -a`, which
clobbers or entangles their changes.
- **Never commit secrets** (mnemonics, private keys, API tokens). Signing is done
offline; artifacts (catalog/manifest) are signed, not the keys.
## Invariants (never violate)
- **Rootless Podman only.** No rootful, no Docker-socket mounts, no privileged
containers unless explicitly approved.
- **No per-app Rust installers / no OS-level reliance.** Apps are declarative;
the orchestrator owns the lifecycle. A hardcoded `podman run` + `sudo chown`
installer is the anti-pattern being deleted, not a template.
- **Secrets are manifest-declared** (`generated_secrets`, materialised by
`container::secrets`, 0600/rootless) — never hardcoded, per-app, or logged.
- **Migrations never destroy data** — preserve `/var/lib/archipelago/<app>`,
secrets, credentials, ports, and adoption container names; keep a rollback path.
- **Verify on a real node before any release tag.**
## Build / verify
- Rust workspace root is `core/` (no Cargo.toml at repo root). Run `cargo` from `core/`.
- If a `cargo test`/build hits `rust-lld: undefined hidden symbol`, it's
incremental-cache corruption — rebuild with `CARGO_INCREMENTAL=0`.
- Frontend: `neode-ui/``npm run build` outputs to `web/dist/neode-ui/`.
Grep the built bundle for new strings before shipping (the build can silently
no-op).
- App manifests are delivered inside the **signed catalog** (`releases/app-catalog.json`),
whose entry overrides the on-disk `/opt/archipelago/apps/*/manifest.yml`
(origin-wins; disk is the fallback). Editing a disk manifest alone does **not**
change a catalog-covered app — regenerate and re-sign the catalog.
## Production test gate (definition of done)
`tests/lifecycle/run-gate.sh` must be green across install / UI / stop / start /
restart / reinstall / reboot-survive / archipelago-restart-survive / uninstall.
**Run the gate on the node** (it uses local podman/systemctl/bitcoin probes), not
via RPC from another host, and re-run it after any orchestrator/lifecycle change.
Multinode / fleet testing is a separate pass. See
[`tests/lifecycle/TESTING.md`](tests/lifecycle/TESTING.md).
+19
View File
@@ -0,0 +1,19 @@
# Code of Conduct
## Our standard
Be direct, respectful, and focused on the work. Healthy disagreement is welcome;
harassment, personal attacks, and discriminatory language are not.
## Scope
This code of conduct applies to project repositories, issue trackers, pull
requests, documentation, chat, and community spaces connected to Archipelago.
## Enforcement
Maintainers may edit, hide, or remove comments and may restrict participation
for behavior that makes collaboration unsafe or unproductive.
Report conduct concerns privately through the repository owner account or the
private contact channel listed on the project homepage.
+100
View File
@@ -0,0 +1,100 @@
# Contributing to Archipelago
This project is preparing for public developer contribution. The highest-value
contributions are focused fixes, tests, app manifests, documentation
improvements, and clear bug reports with reproducible evidence.
## Development setup
### Frontend
```bash
cd neode-ui
npm install
npm start
npm run type-check
npm test
```
### Backend
```bash
cd core
cargo fmt --all -- --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
```
Linux is required for host integration work involving Podman, systemd,
networking, or image builds. Frontend development works locally with the mock
backend.
## App manifests
App packages live under `apps/<app-id>/manifest.yml` and use the schema
documented in [docs/app-manifest-spec.md](docs/app-manifest-spec.md). Validate
before submitting:
```bash
./scripts/validate-app-manifest.sh apps/<app-id>/manifest.yml
python3 scripts/generate-app-catalog.py
python3 scripts/check-app-catalog-drift.py --release --strict
```
App submissions must:
- pin container image versions;
- avoid hardcoded secrets;
- use `security.no_new_privileges: true`;
- use `security.readonly_root: true` unless the manifest explains why writable
root is required;
- request only necessary Linux capabilities;
- store durable data under `/var/lib/archipelago/<app-id>/`;
- define truthful health checks and launch interfaces for user-facing UIs.
## Code style
- Rust: prefer `?` over `unwrap()`/`expect()` in production paths.
- Rust: use `tracing` for structured logs.
- TypeScript: avoid `any`; use explicit types or `unknown`.
- Vue: prefer `<script setup lang="ts">`.
- Keep changes scoped; do not mix drive-by refactors with behavioral changes.
- Remove dead code rather than commenting it out.
- Add tests for new behavior and regression tests for bug fixes.
## Pull requests
1. Open one focused PR per behavior or documentation change.
2. Explain what changed, why it changed, and how it was verified.
3. Include screenshots for UI changes.
4. Link relevant issues or docs.
5. Keep generated catalog changes in sync with manifest changes.
Suggested commit format:
```text
feat: add backup scheduling
fix: reject unsafe manifest volume
docs: clarify app deployment flow
test: cover catalog drift check
```
## Reporting bugs
Include:
- exact version or commit;
- host platform and architecture;
- steps to reproduce;
- expected and actual behavior;
- logs from the relevant component;
- screenshots for UI issues.
## Security
Do not report vulnerabilities in public issues. Follow [SECURITY.md](SECURITY.md).
## License
By contributing, you agree that your contribution is licensed under the
project's MIT License.

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