Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:49 +00:00
commit e3bcd9fca9
2056 changed files with 468069 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
```
+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 = 45
versionName = "0.5.25"
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,5 @@
package com.archipelago.app
import android.app.Application
class ArchipelagoApp : Application()
@@ -0,0 +1,41 @@
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.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?) {
installSplashScreen()
enableEdgeToEdge()
super.onCreate(savedInstanceState)
pendingPairUri.value = intent?.dataString
setContent {
ArchipelagoTheme {
val pairUri by pendingPairUri.collectAsState()
AppNavHost(
pairUri = pairUri,
onPairUriConsumed = { pendingPairUri.value = null },
)
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
pendingPairUri.value = intent.dataString
}
}
@@ -0,0 +1,241 @@
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.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 }
/** 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")
val activeServer: Flow<ServerEntry?> = context.dataStore.data.map { prefs ->
val address = prefs[activeAddressKey] ?: return@map null
ServerEntry(
address = address,
useHttps = prefs[activeHttpsKey] ?: false,
port = prefs[activePortKey] ?: "",
password = prefs[activePasswordKey] ?: "",
name = prefs[activeNameKey] ?: "",
meshIp = prefs[activeMeshIpKey] ?: "",
npub = prefs[activeNpubKey] ?: "",
)
}
val savedServers: Flow<List<ServerEntry>> = context.dataStore.data.map { prefs ->
val raw = prefs[savedServersKey] ?: emptySet()
raw.mapNotNull { ServerEntry.deserialize(it) }
}
val introSeen: Flow<Boolean> = context.dataStore.data.map { prefs ->
prefs[introSeenKey] ?: false
}
/** One-shot flag for the three-finger-hold teaching overlay. */
val gestureHintSeen: Flow<Boolean> = context.dataStore.data.map { prefs ->
prefs[gestureHintSeenKey] ?: false
}
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,324 @@
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
// 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, and later BLE). 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 immediately; 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.
*/
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
// requestNetwork tracks the BEST network of the request; when the
// phone moves Wi-Fi→5G the callback re-fires onAvailable with the new
// one. (registerDefaultNetworkCallback would also work; requestNetwork
// lets us extend to BLE-capable transports later.)
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")
// Fresh warmer pass drives immediate rediscovery/session rebuild
// on the new path instead of waiting out dead-link timeouts.
startSessionWarmer()
}
}
private fun unregisterNetworkHandoff() {
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,103 @@
package com.archipelago.app.fips
import android.content.Context
import android.content.Intent
import android.net.VpnService
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* 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 || !FipsNative.available) return
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).
if (VpnService.prepare(context) == null) {
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) {
if (!FipsNative.available) return
val prefs = FipsPreferences(context)
if (prefs.identity() == null || !prefs.hasPeers()) return
if (VpnService.prepare(context) != null) return // consent missing — don't prompt here
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,77 @@
package com.archipelago.app.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.size
import androidx.compose.material3.CircularProgressIndicator
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.Color
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.ui.screens.PixelArtLogo
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.SurfaceBlack
import com.archipelago.app.ui.theme.TextMuted
/**
* The branded "F*CK IPs" full-screen loader — shown whenever the app is
* dialing the node over the mesh (relaunch race, post-scan first connect),
* instead of an anonymous spinner. The point of the brand: what's loading
* is a connection to a cryptographic identity, not an IP.
*/
@Composable
fun MeshLoadingScreen(message: String = "Dialing your node by its key — no IPs harmed") {
Box(
Modifier
.fillMaxSize()
.background(SurfaceBlack),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
// The brand's circle-container logo (as on the connect screen /
// web login): pixel-art "a" centered in a black disc.
Box(
Modifier
.size(120.dp)
.clip(androidx.compose.foundation.shape.CircleShape)
.background(Color.Black)
.border(
1.dp,
Color.White.copy(alpha = 0.14f),
androidx.compose.foundation.shape.CircleShape,
),
contentAlignment = Alignment.Center,
) {
PixelArtLogo(Modifier.size(64.dp))
}
Spacer(Modifier.height(20.dp))
Text(
text = "F*CK IPs MESH",
color = BitcoinOrange,
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 4.sp,
)
Spacer(Modifier.height(8.dp))
Text(
text = message,
color = TextMuted,
fontSize = 13.sp,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(24.dp))
CircularProgressIndicator(color = BitcoinOrange)
}
}
}
@@ -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,620 @@
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.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.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
}
if (FipsNative.available) {
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.
}
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,352 @@
package com.archipelago.app.ui.components
import android.Manifest
import android.content.pm.PackageManager
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
import androidx.camera.core.Preview
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.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.windowInsetsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
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.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource
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.archipelago.app.ui.theme.TextMuted
import com.archipelago.app.ui.theme.TextPrimary
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.PlanarYUVLuminanceSource
import com.google.zxing.common.HybridBinarizer
import kotlinx.coroutines.delay
import java.util.concurrent.Executors
/**
* Full-screen camera overlay that 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
* and scanning continues.
*/
@Composable
fun QrScannerOverlay(
visible: Boolean,
onDismiss: () -> Unit,
onServerScanned: (PairResult.Success) -> Unit,
) {
val context = LocalContext.current
var hasPermission by remember {
mutableStateOf(
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED
)
}
var hintRes by remember { mutableStateOf<Int?>(null) }
var handled by remember { mutableStateOf(false) }
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted -> hasPermission = granted }
LaunchedEffect(visible) {
if (visible) {
handled = false
hintRes = null
val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED
hasPermission = granted
if (!granted) permissionLauncher.launch(Manifest.permission.CAMERA)
}
}
// Foreign-code hint fades after a moment so scanning feels live again.
LaunchedEffect(hintRes) {
if (hintRes != null) {
delay(2500)
hintRes = null
}
}
AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) {
BackHandler { onDismiss() }
Box(
Modifier
.fillMaxSize()
.background(Color.Black),
) {
if (hasPermission) {
CameraQrPreview(
onDecoded = { text ->
if (!handled) {
when (val result = ServerQrParser.parse(text)) {
is PairResult.Success -> {
handled = true
onServerScanned(result)
}
is PairResult.UnsupportedVersion -> hintRes = R.string.update_app_for_qr
is PairResult.Invalid -> hintRes = R.string.invalid_pairing_qr
}
}
},
)
// Aim frame
Box(
Modifier
.align(Alignment.Center)
.size(260.dp)
.border(2.dp, BitcoinOrange.copy(alpha = 0.85f), RoundedCornerShape(20.dp)),
)
} else {
Column(
Modifier
.align(Alignment.Center)
.padding(horizontal = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = stringResource(R.string.camera_permission_needed),
color = TextPrimary,
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
)
GlassButton(
text = stringResource(R.string.grant_camera_access),
onClick = { permissionLauncher.launch(Manifest.permission.CAMERA) },
modifier = Modifier.fillMaxWidth().height(56.dp),
)
}
}
// Top bar: title + close
Row(
Modifier
.fillMaxWidth()
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.scan_node_qr),
color = TextPrimary,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(start = 12.dp),
)
IconButton(onClick = onDismiss) {
Icon(Icons.Default.Close, stringResource(R.string.close), tint = TextPrimary)
}
}
// Bottom hints
Column(
Modifier
.align(Alignment.BottomCenter)
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(horizontal = 32.dp, vertical = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
hintRes?.let { res ->
Text(
text = stringResource(res),
color = BitcoinOrange,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(8.dp))
}
if (hasPermission) {
Text(
text = stringResource(R.string.scan_qr_hint),
color = TextMuted,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
)
}
}
}
}
}
/** Shared by the pairing scanner and the wallet scan modal. */
@Composable
internal fun CameraQrPreview(onDecoded: (String) -> Unit) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val currentOnDecoded by rememberUpdatedState(onDecoded)
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 (wallet modal).
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
}
}
DisposableEffect(Unit) {
val analysisExecutor = Executors.newSingleThreadExecutor()
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 preview = Preview.Builder().build().also {
it.setSurfaceProvider(previewView.surfaceProvider)
}
// Dense Lightning-invoice QRs need BOTH enough pixels per module and
// sharp focus. 1280x720 + a far-focused camera (e.g. Pixel 9a's main
// lens, which won't focus close) left dense invoices undecodable
// while sparse address QRs still read — the "scanner doesn't pick up
// invoices" report. 1920x1080 roughly doubles module resolution so a
// QR held at the camera's actual focus distance still resolves.
@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 -> mainExecutor.execute { currentOnDecoded(text) } },
)
}
try {
p.unbindAll()
val cam = p.bindToLifecycle(lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis)
// Force a centre autofocus on a repeating tick. A hand-held QR is
// a static scene, so continuous-AF often never retriggers and the
// lens sits at its resting (far) focus — fatal for dense codes.
// A normalized centre point works before the view is measured.
val point = androidx.camera.core.SurfaceOrientedMeteringPointFactory(1f, 1f)
.createPoint(0.5f, 0.5f)
val focusAction = androidx.camera.core.FocusMeteringAction.Builder(
point,
androidx.camera.core.FocusMeteringAction.FLAG_AF,
).disableAutoCancel().build()
focusScheduler.scheduleWithFixedDelay({
runCatching { cam.cameraControl.startFocusAndMetering(focusAction) }
}, 0, 2, java.util.concurrent.TimeUnit.SECONDS)
} catch (_: Exception) {
// Camera unavailable — the user can dismiss and enter details manually.
}
}, mainExecutor)
onDispose {
focusScheduler.shutdownNow()
provider?.unbindAll()
analysisExecutor.shutdown()
}
}
AndroidView(factory = { previewView }, modifier = Modifier.fillMaxSize())
}
/** ZXing-based QR decoder over the camera's Y (luminance) plane. */
private class QrCodeAnalyzer(private val onDecoded: (String) -> Unit) : ImageAnalysis.Analyzer {
private val reader = MultiFormatReader().apply {
setHints(
mapOf(
DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE),
// Screen-displayed QRs come with moiré, glare, and soft focus at
// close range — the exhaustive search is worth the milliseconds.
DecodeHintType.TRY_HARDER to true,
)
)
}
private var lastAttempt = 0L
override fun analyze(image: ImageProxy) {
// Decode ~7x/s, not on every frame: TRY_HARDER (plus the inverted
// retry) pegs a core when run at camera rate, and that CPU contention
// is what made the preview itself stutter. KEEP_ONLY_LATEST means the
// frames skipped here are simply dropped, so decodes stay current.
val now = System.currentTimeMillis()
if (now - lastAttempt < 140) {
image.close()
return
}
lastAttempt = now
try {
val plane = image.planes[0]
val buffer = plane.buffer
// Copy into a rowStride-wide array; the last row of the plane buffer
// may be short of the full stride, so the tail stays zero-padded.
val data = ByteArray(plane.rowStride * image.height)
buffer.get(data, 0, minOf(buffer.remaining(), data.size))
val source = PlanarYUVLuminanceSource(
data, plane.rowStride, image.height,
0, 0, image.width, image.height,
false,
)
val result = try {
reader.decodeWithState(BinaryBitmap(HybridBinarizer(source)))
} catch (_: NotFoundException) {
// Dark-themed pages can render light-on-dark QRs — retry inverted.
reader.reset()
reader.decodeWithState(BinaryBitmap(HybridBinarizer(source.invert())))
}
onDecoded(result.text)
} catch (_: NotFoundException) {
// No QR in this frame — keep scanning.
} catch (_: Exception) {
// Malformed frame; skip it.
} finally {
reader.reset()
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,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,294 @@
package com.archipelago.app.ui.components
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
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.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.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.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.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.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import com.archipelago.app.R
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.NotFoundException
import com.google.zxing.RGBLuminanceSource
import com.google.zxing.common.HybridBinarizer
/**
* Native replacement for the web wallet's scan pane — same visual design as
* neode-ui's WalletScanModal (dark glass card, square preview, orange
* viewfinder, status strip) but the camera and decoding run 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
var hasPermission by remember {
mutableStateOf(
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED
)
}
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted -> hasPermission = granted }
// 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
val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED
hasPermission = granted
if (!granted) permissionLauncher.launch(Manifest.permission.CAMERA)
}
}
LaunchedEffect(status) { if (status != null) uploadError = null }
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),
) {
// Header — mirrors the web modal's title row
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.scan_to_send),
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))
// Square camera preview with the orange viewfinder
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) {
// Throttle repeat frames: a static QR decodes ~20x/s 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) }
CameraQrPreview(onDecoded = { text ->
val now = System.currentTimeMillis()
if (text != lastText || now - lastSentAt > 250) {
lastText = text
lastSentAt = now
onDecoded(text)
}
})
Box(
Modifier
.fillMaxSize(0.62f)
.border(
2.dp,
BitcoinOrange.copy(alpha = 0.85f),
RoundedCornerShape(16.dp),
),
)
} else {
Column(
Modifier.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = stringResource(R.string.camera_permission_needed),
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))
// Status strip — same slot the web modal uses for hints/errors
val message = uploadError ?: status?.first
val isError = uploadError != null || status?.second == true
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 = message?.takeIf { it.isNotBlank() }
?: stringResource(R.string.scan_wallet_hint),
style = MaterialTheme.typography.bodySmall,
color = if (isError) Color(0xFFF87171) else Color.White.copy(alpha = 0.6f),
textAlign = TextAlign.Center,
)
}
Spacer(Modifier.height(16.dp))
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,225 @@
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.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.launch
object Routes {
const val INTRO = "intro"
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"
}
@Composable
fun AppNavHost(
pairUri: String? = null,
onPairUriConsumed: () -> Unit = {},
) {
val context = LocalContext.current
val prefs = remember { ServerPreferences(context) }
val navController = rememberNavController()
val scope = rememberCoroutineScope()
val introSeen by prefs.introSeen.collectAsState(initial = null)
val activeServer by prefs.activeServer.collectAsState(initial = null)
// 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)
}
}
// Paired + previously consented → the mesh comes back silently on launch.
LaunchedEffect(Unit) {
FipsManager.autoStartIfReady(context)
}
if (introSeen == null) return
// 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
activeServer != null -> Routes.WEB_VIEW
else -> Routes.SERVER_CONNECT
}
NavHost(
navController = navController,
startDestination = startDestination,
) {
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,359 @@
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 }
val messages = 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()) {
val bmp = remember(msg.photoPath) { BitmapFactory.decodeFile(msg.photoPath) }
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. */
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,256 @@
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.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) {
logoAlpha.animateTo(1f, animationSpec = tween(800))
delay(300)
showContent = 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 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)
.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,519 @@
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 phonephone 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.
while (true) {
localIp = withContext(Dispatchers.IO) { PartyQr.localWifiIpv4() }
delay(3_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,
)
}
val qrBitmap = remember(qrPayload) { qrPayload?.let { renderQr(it) } }
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) {
val dlQr = remember { 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 { 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). */
private fun renderQr(payload: String, size: Int = 640): 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). */
private fun shareCompanionApk(context: android.content.Context) {
try {
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")
src.copyTo(out, overwrite = true)
val uri = 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,725 @@
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.CircularProgressIndicator
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.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.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
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) }
val savedServers by prefs.savedServers.collectAsState(initial = emptyList())
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
scope.launch {
var reachable = testConnection(server)
// LAN address didn't answer — phone off-LAN (5G) or DHCP moved the
// node. 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. Bring the tunnel up and probe the ULA before failing.
if (!reachable && server.meshIp.isNotBlank()) {
FipsManager.autoStartIfReady(context)
val meshServer = server.copy(
address = server.meshIp,
useHttps = false,
port = "",
)
// 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 probe patiently inside a 60s budget with
// per-attempt timeouts wide enough to ride out TCP
// retransmit backoff. The VPN service pre-warms the session
// in parallel (ArchyVpnService.startSessionWarmer).
val deadline = System.currentTimeMillis() + 60_000
while (!reachable && System.currentTimeMillis() < deadline) {
reachable = testConnection(meshServer, timeoutMs = 15_000)
if (!reachable) delay(3000)
}
}
isConnecting = false
if (reachable) {
prefs.setActiveServer(server)
onConnected(server.toUrl())
} else {
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 "Connect to Server",
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) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = Color.White.copy(alpha = 0.6f),
strokeWidth = 2.dp,
)
}
// 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()
}
}
}
@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('/')
}
/** 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) {
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()
})
val sc = SSLContext.getInstance("TLS")
sc.init(null, trustAll, java.security.SecureRandom())
connection.sslSocketFactory = sc.socketFactory
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,60 @@
package com.archipelago.app.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val Typography = Typography(
displayLarge = TextStyle(
fontWeight = FontWeight.Bold,
fontSize = 32.sp,
lineHeight = 40.sp,
letterSpacing = (-0.5).sp,
),
headlineLarge = TextStyle(
fontWeight = FontWeight.SemiBold,
fontSize = 28.sp,
lineHeight = 36.sp,
),
headlineMedium = TextStyle(
fontWeight = FontWeight.SemiBold,
fontSize = 24.sp,
lineHeight = 32.sp,
),
titleLarge = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 20.sp,
lineHeight = 28.sp,
),
titleMedium = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.15.sp,
),
bodyLarge = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp,
),
bodyMedium = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.25.sp,
),
labelLarge = TextStyle(
fontWeight = FontWeight.SemiBold,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp,
),
labelMedium = TextStyle(
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,36 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Archipelago pixel-art "A" for splash screen -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="1024"
android:viewportHeight="1024">
<group
android:pivotX="512"
android:pivotY="512"
android:scaleX="0.55"
android:scaleY="0.55">
<path android:fillColor="#FFFFFF" android:pathData="M357.614,318h71.007v70.936h-71.007z" />
<path android:fillColor="#FFFFFF" android:pathData="M436.152,318h72.082v70.936h-72.082z" />
<path android:fillColor="#FFFFFF" android:pathData="M515.766,318h72.082v70.936h-72.082z" />
<path android:fillColor="#FFFFFF" android:pathData="M595.379,318h71.007v70.936h-71.007z" />
<path android:fillColor="#FFFFFF" android:pathData="M595.379,396.46h71.007v72.011h-71.007z" />
<path android:fillColor="#FFFFFF" android:pathData="M673.917,396.46h72.083v72.011h-72.083z" />
<path android:fillColor="#FFFFFF" android:pathData="M278,475.994h72.083v72.012h-72.083z" />
<path android:fillColor="#FFFFFF" android:pathData="M357.614,475.994h71.007v72.012h-71.007z" />
<path android:fillColor="#FFFFFF" android:pathData="M436.152,475.994h72.082v72.012h-72.082z" />
<path android:fillColor="#FFFFFF" android:pathData="M515.766,475.994h72.082v72.012h-72.082z" />
<path android:fillColor="#FFFFFF" android:pathData="M595.379,475.994h71.007v72.012h-71.007z" />
<path android:fillColor="#FFFFFF" android:pathData="M673.917,475.994h72.083v72.012h-72.083z" />
<path android:fillColor="#FFFFFF" android:pathData="M278,555.529h72.083v70.936h-72.083z" />
<path android:fillColor="#FFFFFF" android:pathData="M357.614,555.529h71.007v70.936h-71.007z" />
<path android:fillColor="#FFFFFF" android:pathData="M595.379,555.529h71.007v70.936h-71.007z" />
<path android:fillColor="#FFFFFF" android:pathData="M673.917,555.529h72.083v70.936h-72.083z" />
<path android:fillColor="#FFFFFF" android:pathData="M357.614,633.989h71.007v72.011h-71.007z" />
<path android:fillColor="#FFFFFF" android:pathData="M436.152,633.989h72.082v72.011h-72.082z" />
<path android:fillColor="#FFFFFF" android:pathData="M515.766,633.989h72.082v72.011h-72.082z" />
<path android:fillColor="#FFFFFF" android:pathData="M595.379,633.989h71.007v72.011h-71.007z" />
</group>
</vector>
@@ -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,52 @@
<?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>
</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."
+1296
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.
Executable
+166
View File
@@ -0,0 +1,166 @@
#!/bin/bash
# Archipelago Installation Script
# This script installs all dependencies needed to run the Archipelago project
set -e
echo "============================================"
echo "Archipelago Dependencies Installation"
echo "============================================"
echo ""
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Function to check if command exists
command_exists() {
command -v "$1" >/dev/null 2>&1
}
# Function to print status
print_status() {
if [ $1 -eq 0 ]; then
echo -e "${GREEN}${NC} $2"
else
echo -e "${RED}${NC} $2"
fi
}
# Check and install Homebrew (macOS package manager)
echo "Checking Homebrew..."
if command_exists brew; then
print_status 0 "Homebrew already installed"
else
echo -e "${YELLOW}Installing Homebrew...${NC}"
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
# Add Homebrew to PATH for Apple Silicon Macs
if [[ $(uname -m) == 'arm64' ]]; then
echo 'eval "$(/opt/homebrew/bin/brew shellenv)"' >> ~/.zprofile
eval "$(/opt/homebrew/bin/brew shellenv)"
fi
print_status 0 "Homebrew installed"
fi
echo ""
# Install Rust
echo "Checking Rust..."
if command_exists rustc && command_exists cargo; then
print_status 0 "Rust already installed ($(rustc --version))"
else
echo -e "${YELLOW}Installing Rust...${NC}"
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
source "$HOME/.cargo/env"
print_status 0 "Rust installed ($(rustc --version))"
fi
echo ""
# Install Node.js
echo "Checking Node.js..."
if command_exists node && command_exists npm; then
NODE_VERSION=$(node --version)
print_status 0 "Node.js already installed ($NODE_VERSION)"
else
echo -e "${YELLOW}Installing Node.js...${NC}"
brew install node
print_status 0 "Node.js installed ($(node --version))"
fi
echo ""
# Install Podman
echo "Checking Podman..."
if command_exists podman; then
print_status 0 "Podman already installed ($(podman --version))"
else
echo -e "${YELLOW}Installing Podman...${NC}"
brew install podman
print_status 0 "Podman installed"
echo -e "${YELLOW}Initializing Podman machine...${NC}"
podman machine init
podman machine start
print_status 0 "Podman machine initialized and started"
fi
echo ""
# Install PostgreSQL
echo "Checking PostgreSQL..."
if command_exists psql; then
print_status 0 "PostgreSQL already installed"
else
echo -e "${YELLOW}Installing PostgreSQL...${NC}"
brew install postgresql@15
print_status 0 "PostgreSQL installed"
echo -e "${YELLOW}Starting PostgreSQL service...${NC}"
brew services start postgresql@15
print_status 0 "PostgreSQL service started"
fi
echo ""
echo "============================================"
echo "Installing Project Dependencies"
echo "============================================"
echo ""
# Install frontend dependencies
echo "Installing frontend dependencies (neode-ui)..."
cd neode-ui
npm install
print_status 0 "Frontend dependencies installed"
cd ..
echo ""
# Install custom app dependencies
echo "Installing custom app dependencies..."
for app in did-wallet morphos-server router; do
if [ -d "apps/$app" ]; then
echo " - Installing $app dependencies..."
cd "apps/$app"
npm install
cd ../..
print_status 0 "$app dependencies installed"
fi
done
echo ""
# Build Rust backend
echo "Building Rust backend..."
cd core
cargo build
print_status 0 "Backend built successfully"
cd ..
echo ""
echo "============================================"
echo "Installation Complete!"
echo "============================================"
echo ""
echo "Next steps:"
echo ""
echo "1. Start the backend:"
echo " cd core"
echo " cargo run --bin archipelago"
echo ""
echo "2. In another terminal, start the frontend:"
echo " cd neode-ui"
echo " npm run dev"
echo ""
echo "3. Open your browser to:"
echo " http://localhost:8100"
echo ""
echo "For more information, see:"
echo " - README.md"
echo " - docs/developer-guide.md"
echo " - apps/QUICKSTART.md"
echo ""
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Dorian and the Archipelago Project contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+73
View File
@@ -0,0 +1,73 @@
# Archipelago — Third-Party Notices
Archipelago is licensed under the MIT License (see LICENSE).
This file lists third-party components included in this repository and its
release artifacts, with their licenses and required attributions.
## Embedded / vendored components
- **FIPS mesh networking** — https://github.com/jmcorgan/fips
Copyright (c) 2026 Johnathan Corgan. MIT License.
Used as the embedded mesh VPN in the OS (`fips` daemon, pinned v0.4.1) and
compiled into the Android companion app (`Android/rust/archy-fips-core`).
- **QR Code Generator for JavaScript** — http://www.d-project.com/
Copyright (c) 2009 Kazuhiko Arase. MIT License.
Vendored at `docker/lnd-ui/qrcode.js` and `docker/electrs-ui/qrcode.js`
(original headers preserved).
- **nostr-rs-relay** — https://github.com/scsibug/nostr-rs-relay — MIT License.
Binary extracted into the OS image at `/opt/archipelago/bin/`.
- **Reticulum (RNS) and LXMF** — https://github.com/markqvist/Reticulum
Copyright Mark Qvist. Distributed under the Reticulum License (an MIT-style
license with field-of-use restrictions: no use in systems designed to harm
human beings, and no use in AI/ML training datasets). The optional
`archy-reticulum-daemon` binary bundles RNS 1.3.5 and LXMF 1.0.1. The
Reticulum License is NOT an OSI-approved open-source license; it applies
only to that optional component, not to Archipelago itself.
## Fonts
- **Montserrat** — SIL Open Font License 1.1
(`neode-ui/public/assets/fonts/Montserrat/OFL.txt`).
- **Open Sans** — Apache License 2.0
(`neode-ui/public/assets/fonts/Open_Sans/LICENSE.txt`).
## Artwork and icons
- **Mesh device artwork** (`neode-ui/public/assets/img/mesh-devices/`):
device illustrations from the Meshtastic project — https://meshtastic.org
© Meshtastic contributors, GPL-3.0. Meshtastic® is a registered trademark
of Meshtastic LLC. See the ATTRIBUTION.md in that directory.
- Some UI icons are derived from **game-icons.net** (CC BY 3.0 — see
ATTRIBUTION.md in `neode-ui/public/assets/icon/`) and **pixelarticons**
(MIT, https://github.com/halfmage/pixelarticons).
- Third-party application logos under `neode-ui/public/assets/img/app-icons/`
and `service-icons/` are trademarks of their respective owners, used solely
to identify the corresponding applications. No endorsement is implied.
## Original media
All demo content (music, photos, posters in `demo/`), UI sound effects,
background images, and intro video in `neode-ui/public/assets/` are original
works created and owned by the Archipelago project author, released with the
project. The welcome voice line (`welcome-noderunner.mp3`) was generated with
ElevenLabs TTS under a commercial-use plan.
## Redistributed software (ISO and container registry)
The Archipelago OS image is based on Debian and redistributes Debian packages
(including the Linux kernel, GRUB, and non-free firmware/microcode blobs
required for hardware support); per-package license texts are preserved at
`/usr/share/doc/*/copyright` in the installed system, and corresponding source
is available via Debian (https://snapshot.debian.org) as referenced in each
release's notes. Container images offered through the app catalog and mirror
registry remain under their upstream licenses (including GPL/AGPL software
such as mempool, Nextcloud, Vaultwarden, SearXNG, PhotoPrism, Immich,
Jellyfin, MariaDB, AdGuard Home, and strfry); source links are provided in
the app catalog. The modified mempool-frontend image is built from
`docker/mempool-frontend/` in this repository (AGPL-3.0 corresponding source).
Full per-crate and per-package license inventories for release binaries are
generated at build time (see THIRD-PARTY-LICENSES files in release artifacts).
+112
View File
@@ -0,0 +1,112 @@
# Archipelago
> Self-sovereign Bitcoin node OS and manifest-driven app platform.
Archipelago is a bootable personal server OS for Bitcoin infrastructure,
self-hosted apps, mesh communication, decentralized identity, and federation.
Apps are packaged as declarative `manifest.yml` files and run as rootless
Podman containers managed by the Rust backend.
[![Debian 13](https://img.shields.io/badge/Debian-13%20Trixie-a80030)](https://www.debian.org/)
[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE)
[![Rust](https://img.shields.io/badge/rust-stable-orange)](https://www.rust-lang.org/)
[![Vue.js](https://img.shields.io/badge/vue.js-3.5-brightgreen)](https://vuejs.org/)
[![Version](https://img.shields.io/badge/version-1.8.0--alpha-blue)]()
## What is here
- `core/` - Rust workspace: backend API, container runtime, security, OpenWrt
helpers, and performance/resource management.
- `neode-ui/` - Vue 3 + TypeScript frontend.
- `apps/` - app manifests and custom app container sources.
- `docker/` - supporting container build contexts for UI companion surfaces.
- `image-recipe/` - bootable image/ISO build inputs.
- `Android/` - Android companion app.
- `scripts/` - development, release, deployment, and validation tooling.
- `docs/` - architecture, app packaging, operations, API, and roadmap docs.
## Platform model
Archipelago is built as a developer-ready app platform, not a fixed appliance:
- Apps are declared in `apps/<app-id>/manifest.yml`.
- The Rust parser in `core/container/src/manifest.rs` is the canonical schema.
- The orchestrator compiles manifests to rootless Podman/Quadlet runtime state.
- App data lives under `/var/lib/archipelago/<app-id>/`.
- Secrets are generated or read from `/var/lib/archipelago/secrets/` and
injected through Podman secrets rather than static environment values.
- Release and app catalogs are signed and verified against a pinned trust
anchor.
Start with:
- [Architecture](docs/architecture.md)
- [Developer Guide](docs/developer-guide.md)
- [App Developer Guide](docs/app-developer-guide.md)
- [App Manifest Spec](docs/app-manifest-spec.md)
- [Nostr Git Source Hosting Plan](docs/nostr-git-source-hosting.md)
- [Troubleshooting](docs/troubleshooting.md)
## Quick start
### Frontend
```bash
cd neode-ui
npm install
npm start
```
The dev UI runs at `http://localhost:8100` with a mock backend on `:5959`.
### Backend
```bash
cd core
cargo build
cargo test --all-features
```
Linux is the supported backend runtime and release-build target. macOS is fine
for frontend work and many Rust compile/test loops, but host integration tests
that touch Podman, systemd, networking, or image build paths require Linux.
### App manifests
```bash
./scripts/validate-app-manifest.sh apps/filebrowser/manifest.yml
python3 scripts/generate-app-catalog.py
python3 scripts/check-app-catalog-drift.py --release --strict
```
`scripts/generate-app-catalog.py` requires Python with PyYAML installed.
## Documentation map
The full, grouped index lives at **[docs/README.md](docs/README.md)**. The most
common entry points:
| Doc | Purpose |
|-----|---------|
| [Architecture](docs/architecture.md) | System layers, crates, data paths, security model |
| [Developer Guide](docs/developer-guide.md) | Local setup, code workflow, testing |
| [API Reference](docs/api-reference.md) | JSON-RPC API overview |
| [App Developer Guide](docs/app-developer-guide.md) | How to package and test apps |
| [App Manifest Spec](docs/app-manifest-spec.md) | Manifest schema and validation rules |
| [Nostr Git Source Hosting Plan](docs/nostr-git-source-hosting.md) | ngit/NIP-34 contribution workflow and maintainer model |
| [Apps README](apps/README.md) | Packaged app catalog overview |
| [Image Recipe](image-recipe/README.md) | Bootable image build flow |
| [Roadmap](docs/ROADMAP.md) | Shipped, in-progress, and planned work |
| [Archive](docs/archive/) | Historical plans, audits, and handoffs |
## Contributing
Read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request. For
security issues, follow [SECURITY.md](SECURITY.md) and do not open a public
issue.
## License
Archipelago is licensed under the [MIT License](LICENSE). Third-party notices
are listed in [NOTICE](NOTICE) and generated license inventories in component
release artifacts.
+38
View File
@@ -0,0 +1,38 @@
# Security Policy
## Reporting vulnerabilities
Please do not open a public issue for a security vulnerability.
Until a dedicated security intake address is published, report privately to the
project maintainer through the repository owner account or the private contact
channel listed on the project homepage.
Include:
- affected commit, version, or release;
- affected component;
- reproduction steps;
- expected impact;
- logs, proof of concept, or packet captures when relevant;
- whether the issue is already public.
We aim to acknowledge credible reports within 48 hours and coordinate fixes
before public disclosure.
## Scope
Security-sensitive areas include:
- authentication, session handling, CSRF, and rate limiting;
- release and app-catalog signature verification;
- container manifest validation and runtime compilation;
- Podman/Quadlet isolation, capabilities, volumes, and secret injection;
- backup encryption and key derivation;
- federation, Tor, Nostr, mesh, DID, and credential flows;
- Android companion pairing and device-token handling.
## Supported versions
Archipelago is currently pre-1.0 alpha software. Security fixes target the
current `main` branch and the latest published alpha release.
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env bash
# PreToolUse Bash guard: block dangerous shell commands.
# Denies: rm -rf, git reset --hard, git push -f, git clean -fd, chmod -R 777,
# fork bombs, block device overwrites, mkfs, paths escaping project root.
# Uses python3 instead of jq for JSON (guaranteed on macOS).
set -euo pipefail
INPUT=$(cat)
CMD=$(python3 -c "
import json, sys
try:
data = json.loads(sys.stdin.read())
print(data.get('tool_input', {}).get('command', ''))
except: pass
" <<< "$INPUT")
BASE="${CLAUDE_PROJECT_DIR:-}"
[[ -z "$BASE" ]] && BASE=$(python3 -c "
import json, sys
try:
data = json.loads(sys.stdin.read())
print(data.get('cwd', ''))
except: pass
" <<< "$INPUT")
[[ -z "$BASE" ]] && BASE="$(pwd)"
# Normalize: collapse whitespace, strip leading/trailing
CMD_NORM=$(echo "$CMD" | tr -s '[:space:]' ' ' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
deny() {
local reason="$1"
python3 -c "
import json
print(json.dumps({
'hookSpecificOutput': {
'hookEventName': 'PreToolUse',
'permissionDecision': 'deny',
'permissionDecisionReason': '$reason'
}
}))
"
exit 0
}
# Dangerous patterns (case-insensitive where sensible)
case "$CMD_NORM" in
*"rm -rf"*|*"rm -fr"*|*"rm -f -r"*|*"rm -r -f"*) deny "Destructive rm -rf blocked by security hook" ;;
*"git reset --hard"*) deny "git reset --hard would lose uncommitted work" ;;
*"git push --force"*|*"git push -f"*|*"git push -f "*) deny "git push --force would rewrite history" ;;
*"git clean -fd"*|*"git clean -f -d"*) deny "git clean -fd deletes untracked files" ;;
*"chmod -R 777"*|*"chmod -R 0777"*) deny "chmod -R 777 is a security risk" ;;
*":(){ :"*"};:"*) deny "Fork bomb pattern blocked" ;;
*"> /dev/sd"*|*">/dev/sd"*) deny "Block device overwrite blocked" ;;
*"mkfs "*|*"mkfs."*) deny "Disk format command blocked" ;;
esac
# Check for path traversal escaping project root (../ outside project)
# Only if we have a sensible base
if [[ -n "$BASE" ]] && [[ -d "$BASE" ]]; then
# Simple heuristic: command contains .. and would resolve outside project
if echo "$CMD_NORM" | grep -qE '\.\./|/\.\.'; then
# Extract plausible paths and check - allow ../ within project
if echo "$CMD_NORM" | grep -qE '(rm|mv|cp|cat|chmod|chown)\s+.*\.\.'; then
# Could be risky; be conservative for rm/mv/cp
if echo "$CMD_NORM" | grep -qE '\brm\b.*\.\.'; then
deny "Path traversal with rm blocked"
fi
fi
fi
fi
exit 0
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
# PostToolUse Bash hook: detect git push/commit and prompt Claude to update PROGRESS.md.
# Returns structured feedback with recent commits so Claude can write a session log entry.
# Uses python3 instead of jq for JSON (guaranteed on macOS).
set -euo pipefail
INPUT=$(cat)
# Extract command from JSON using python3
CMD=$(python3 -c "
import json, sys
try:
data = json.loads(sys.stdin.read())
print(data.get('tool_input', {}).get('command', ''))
except: pass
" <<< "$INPUT")
# Only trigger on git push or git commit commands
if ! echo "$CMD" | grep -qE '\bgit\s+(push|commit)\b'; then
exit 0
fi
# Gather context for the progress update
BASE="${CLAUDE_PROJECT_DIR:-$(pwd)}"
BRANCH=$(git -C "$BASE" branch --show-current 2>/dev/null || echo "unknown")
PROGRESS_FILE="$BASE/PROGRESS.md"
TIMESTAMP=$(date '+%Y-%m-%d %H:%M')
# Get recent commits (branch vs main, or last 10)
if git -C "$BASE" rev-parse --verify main &>/dev/null; then
COMMITS=$(git -C "$BASE" log --oneline main..HEAD 2>/dev/null | head -15)
if [ -z "$COMMITS" ]; then
COMMITS=$(git -C "$BASE" log --oneline -10 2>/dev/null)
fi
else
COMMITS=$(git -C "$BASE" log --oneline -10 2>/dev/null)
fi
# Get changed files in recent commits
CHANGED_FILES=$(git -C "$BASE" diff --name-only main..HEAD 2>/dev/null | head -20 || \
git -C "$BASE" diff --name-only HEAD~5..HEAD 2>/dev/null | head -20 || \
echo "unknown")
# Build the feedback message and output as JSON using python3
python3 -c "
import json, sys
message = '''Progress Update Needed
A git push/commit was detected on branch \`$BRANCH\` at $TIMESTAMP.
Recent commits:
\`\`\`
$COMMITS
\`\`\`
Changed files:
\`\`\`
$CHANGED_FILES
\`\`\`
Please update PROGRESS.md:
1. Add a session log entry under '## Session Log' with format: ### $TIMESTAMP — $BRANCH
2. Summarize what was accomplished (2-4 bullet points based on the commits above)
3. Update any roadmap checkboxes if tasks were completed
4. Commit the PROGRESS.md update'''
output = {
'hookSpecificOutput': {
'hookEventName': 'PostToolUse',
'progressUpdate': message
}
}
print(json.dumps(output))
"

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