diff --git a/.github/workflows/android-app.yml b/.github/workflows/android-app.yml new file mode 100644 index 0000000000..277b4fc9a3 --- /dev/null +++ b/.github/workflows/android-app.yml @@ -0,0 +1,71 @@ +# Android APP lane: Kotlin unit tests + an APK that actually assembles. +# +# Gated on `android/**` so a pull request that does not touch the app costs +# nothing here. The Go side is NOT gated the same way and is not duplicated: +# the `android` job in test.yml builds the arm64 payload and enforces its size +# budget on every PR, which is what catches a Go change breaking the mobile +# variant. This lane is the other half — the app that wraps it. +name: Android app + +on: + pull_request: + paths: + - 'android/**' + - '.github/workflows/android-app.yml' + +# A new push to the same PR makes the previous run irrelevant. +concurrency: + group: android-app-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + fetch-tags: true + + - uses: actions/setup-go@v6 + with: + go-version: '1.26.5' + cache: true + cache-dependency-path: go.sum + + # AGP 9 / Gradle 9.6 want a modern JDK; 21 is the LTS both are happy on. + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + + - uses: gradle/actions/setup-gradle@v4 + + # The pure-Go payload rather than the NDK one: this lane is a build + # check, and the cgo/bionic-DNS difference does not change whether the + # Kotlin compiles or the APK packages. Releases use the NDK lane. + # Built here even though test.yml also builds it, because an APK + # assembled without the .so is not the artifact we ship — packaging is + # part of what this job is checking. + - name: Build Go payload (pure-Go lane) + run: make android-mobile + + # LiveNodeTest is skipped unless SKYWIRE_NET_TESTS=1, so this stays + # offline. :app has no unit tests yet; :wallet-core holds the crypto + # vectors and golden fixtures, which are the ones worth gating on. + - name: Unit tests + run: cd android && ./gradlew --no-daemon :wallet-core:test + + - name: Assemble debug APK + run: cd android && ./gradlew --no-daemon :app:assembleDebug + + # Kept for triage: when a run fails on a device-only problem, having the + # exact APK the job built beats rebuilding it locally and guessing. + - name: Upload debug APK + if: success() + uses: actions/upload-artifact@v4 + with: + name: skywire-debug-apk + path: android/app/build/outputs/apk/debug/app-debug.apk + retention-days: 14 + if-no-files-found: error diff --git a/.github/workflows/android-release.yml b/.github/workflows/android-release.yml new file mode 100644 index 0000000000..ebae8d0b67 --- /dev/null +++ b/.github/workflows/android-release.yml @@ -0,0 +1,167 @@ +# Android release: push a `mobile-vX.Y.Z` tag, get a signed APK attached to a +# GitHub pre-release. +# +# The tag prefix keeps this lane separate from the desktop one: release.yml +# fires on `v*`, and `mobile-v1.0.0` does not match that, so tagging the phone +# never starts a Skywire release. +name: Android release + +on: + push: + tags: + - 'mobile-v*' + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + fetch-tags: true + + # Fail before doing ten minutes of work, and fail with the fix rather + # than a stack trace. Publishing an unsigned APK would mean shipping a + # file that cannot be installed; the debug-signed one is debuggable and + # must never be handed to users, least of all for an app holding wallet + # seeds. So an unsigned build is not a fallback here — it is the error. + - name: Require signing secrets + env: + KEYSTORE: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + STORE_PASS: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + KEY_PASS: ${{ secrets.ANDROID_KEY_PASSWORD }} + run: | + missing="" + [ -n "$KEYSTORE" ] || missing="$missing ANDROID_KEYSTORE_BASE64" + [ -n "$STORE_PASS" ] || missing="$missing ANDROID_KEYSTORE_PASSWORD" + [ -n "$KEY_ALIAS" ] || missing="$missing ANDROID_KEY_ALIAS" + [ -n "$KEY_PASS" ] || missing="$missing ANDROID_KEY_PASSWORD" + if [ -n "$missing" ]; then + echo "::error::Missing repository secrets:$missing" + echo "" + echo "Create the signing key ONCE and keep it forever — Android" + echo "refuses to upgrade an app signed with a different key, so" + echo "this key is what lets every future release reach the people" + echo "who installed this one. Back it up somewhere you will still" + echo "have in five years." + echo "" + echo " keytool -genkeypair -v -keystore skywire-release.jks \\" + echo " -alias skywire -keyalg RSA -keysize 4096 -validity 10000" + echo "" + echo " gh secret set ANDROID_KEYSTORE_BASE64 < <(base64 -i skywire-release.jks)" + echo " gh secret set ANDROID_KEYSTORE_PASSWORD" + echo " gh secret set ANDROID_KEY_ALIAS # 'skywire' above" + echo " gh secret set ANDROID_KEY_PASSWORD" + exit 1 + fi + + # mobile-v1.2.3 -> 1.2.3. The versionCode is NOT computed here: `make + # android-apk` derives it, so the tag and a local build cannot disagree. + # The same shape is validated up front anyway, so a malformed tag fails + # in seconds instead of after the payload build. + - name: Derive version from tag + id: version + run: | + raw="${GITHUB_REF_NAME#mobile-v}" + if ! printf '%s' "$raw" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "::error::Tag '${GITHUB_REF_NAME}' is not mobile-vX.Y.Z" + exit 1 + fi + rest=${raw#*.}; minor=${rest%%.*}; patch=${rest#*.} + if [ "$minor" -gt 99 ] || [ "$patch" -gt 99 ]; then + echo "::error::minor/patch must be <= 99 to keep versionCode monotonic" + exit 1 + fi + if [ "$raw" = "0.0.0" ]; then + echo "::error::0.0.0 derives versionCode 0; Android requires a positive integer (lowest is 0.0.1)" + exit 1 + fi + echo "name=$raw" >> "$GITHUB_OUTPUT" + + - uses: actions/setup-go@v6 + with: + go-version: '1.26.5' + cache: true + cache-dependency-path: go.sum + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + + - uses: gradle/actions/setup-gradle@v4 + + # The NDK lane, not the pure-Go one. cgo builds resolve DNS through + # bionic's getaddrinfo, which is the only path that honours the phone's + # actual resolver configuration — the pure-Go resolver is fine for CI + # and the emulator but is not what should ship. + # ANDROID_NDK_LATEST_HOME is set by the runner image, which means it is + # a shell variable and NOT in the `env` context — ${{ env.X }} would + # expand to empty and the Makefile would stop on its own guard. + - name: Build Go payload (NDK/cgo release lane) + run: | + test -n "$ANDROID_NDK_LATEST_HOME" || { + echo "::error::runner exposes no Android NDK"; exit 1; } + ANDROID_NDK_HOME="$ANDROID_NDK_LATEST_HOME" make android-mobile-ndk + + - name: Decode keystore + env: + KEYSTORE: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + run: printf '%s' "$KEYSTORE" | base64 -d > "$RUNNER_TEMP/release.jks" + + # Through make, so this is the same command a maintainer runs locally. + # ANDROID_JAVA_HOME points at the runner's JDK instead of the Makefile's + # macOS default. + - name: Assemble signed release APK + env: + ANDROID_KEYSTORE_FILE: ${{ runner.temp }}/release.jks + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + run: | + make android-apk \ + APK_VERSION="${{ steps.version.outputs.name }}" \ + ANDROID_JAVA_HOME="$JAVA_HOME" + + # An unsigned APK reaching this point would mean the signingConfig + # silently did not apply — catch it here rather than after someone + # downloads a file that will not install. + - name: Verify the APK is signed + run: | + apk=android/app/build/outputs/apk/release/app-release.apk + test -f "$apk" || { echo "::error::$apk not found"; exit 1; } + # Newest build-tools present, rather than a pinned version that + # silently disappears the next time the runner image is rebuilt. + signer=$(find "$ANDROID_HOME/build-tools" -maxdepth 2 -name apksigner \ + 2>/dev/null | sort -V | tail -n1) + test -n "$signer" || { echo "::error::apksigner not found"; exit 1; } + "$signer" verify --print-certs "$apk" \ + || { echo "::error::APK is not signed"; exit 1; } + out="skywire-${{ steps.version.outputs.name }}-arm64-v8a.apk" + mv "$apk" "$out" + sha256sum "$out" > "$out.sha256" + + - name: Publish pre-release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + out="skywire-${{ steps.version.outputs.name }}-arm64-v8a.apk" + # Range the notes over the previous MOBILE tag. Left to itself + # --generate-notes walks back to whatever tag came last, which on + # this repo is usually a desktop `v*` release, and the changelog + # would be every Skywire commit since then. + args=(--prerelease --generate-notes) + prev=$(git describe --tags --abbrev=0 --match 'mobile-v*' \ + "${GITHUB_REF_NAME}^" 2>/dev/null || true) + if [ -n "$prev" ]; then + args+=(--notes-start-tag "$prev") + fi + gh release create "${GITHUB_REF_NAME}" \ + --repo "${{ github.repository }}" \ + --title "Skywire Mobile ${{ steps.version.outputs.name }}" \ + "${args[@]}" \ + "$out" "$out.sha256" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7c8366fe91..6ec418d874 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -228,6 +228,28 @@ jobs: - name: Compile-check js/wasm binaries run: make build-wasm + # Build lane for the android payload: cmd/skywire-mobile with tags + # mobile,withoutsystray (GOOS=android GOARCH=arm64, pure-Go), then the size + # budget — so the lite variant can't silently rot or regain stripped assets. + # fetch-depth/tags mirror the linux job: `git describe` needs them so the + # stamped $(BUILDINFO) version isn't a bare short SHA. + android: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + fetch-tags: true + + - uses: actions/setup-go@v6 + with: + go-version: '1.26.5' + cache: true + cache-dependency-path: go.sum + + - name: Build android payload + enforce size budget + run: make android-mobile-check + # Headless RUNTIME smoke of the real wasm-visor blob (Tier B of the # wasm-visor test plan): Node ≥22 executes the compiled js/wasm binary via # Go's wasm_exec.js — no browser — boots the visor against a loopback dmsg diff --git a/.gitignore b/.gitignore index e05bc2c2d6..a09d45e351 100644 --- a/.gitignore +++ b/.gitignore @@ -137,4 +137,17 @@ skysocks-client !cmd/wasm-visor/cacert.pem /skywire-cli + + +# Android app (android/ Gradle project lands in Step 1; the Go payload +# libskywire-mobile.so is already covered by the bare *.so above). +# Build outputs + machine-local config + signing keys — keys NEVER enter git. +android/.gradle/ +android/build/ +android/app/build/ +android/local.properties +android/app/src/main/jniLibs/ +*.keystore +*.jks /wasm-visor + diff --git a/Makefile b/Makefile index fd075b2bc9..02728ddb5d 100644 --- a/Makefile +++ b/Makefile @@ -2,6 +2,7 @@ .PHONY : check lint install-linters dep test lint-extra .PHONY : update-deps update-dmsg update-skycoin push-deps .PHONY : build clean install format bin build-race deploy wasm-visor embed-wasm-visor embed-wasm-visor-tinygo prune-wasm-embed-history +.PHONY : build-mobile android-mobile android-mobile-check android-mobile-ndk android-apk android-apk-debug .PHONY : host-apps bin .PHONY : docker-image docker-clean docker-network .PHONY : docker-apps docker-bin docker-volume @@ -100,7 +101,13 @@ INFO?=$(VERSION) $(DATE) $(COMMIT) $(BUILDTAG) # so the embedded blob carries a CLEAN version from a possibly-uncommitted tree # instead of Go's VCS "+dirty" stamp — and is deterministic (same source → same # blob), which matters for the committed pkg/wasmhv/wasmbin blob. -WASM_BUILDINFO_PATH := $(PROJECT_BASE)/pkg/buildinfo +# The repo's OWN pkg/buildinfo (vs skywire-utilities' above). Despite the +# historical WASM_ prefix there is nothing wasm about the path — the wasm-visor +# was simply the first target that needed to stamp it. The skywire-mobile +# targets stamp it too (MOBILE_APPINFO below): /api/about + cobra --version +# read this package. +SKYWIRE_BUILDINFO_PATH := $(PROJECT_BASE)/pkg/buildinfo +WASM_BUILDINFO_PATH := $(SKYWIRE_BUILDINFO_PATH) WASM_BUILDINFO := -X $(WASM_BUILDINFO_PATH).version=$(VERSION) -X $(WASM_BUILDINFO_PATH).commit=$(COMMIT) -X $(WASM_BUILDINFO_PATH).date=$(DATE) BUILD_OPTS?="-ldflags=$(BUILDINFO)" -mod=vendor @@ -191,6 +198,83 @@ build-merged: ## Install dependencies, build apps and binaries. `go build` with build-merged-cgo: ## Build with CGO optimization for faster DMSG handshakes (requires libsecp256k1-dev) ${OPTS} go build -tags=cgo ${BUILD_OPTS} -o $(BUILD_PATH)skywire . +# ---- skywire-mobile: the lite multicall core for the Android app ---------- +# One binary: visor + cli `config` subtree + the 4 client apps (in-proc via the +# launcher registry). The `mobile` tag strips the embedded desktop assets +# (geoip db 30 MB, manager UI 6.8 MB, vendored browser wallet 11 MB, tpviz +# legacy 2.8 MB). Output lands in the android project's jniLibs so Android +# Studio picks it up directly. -checklinkname=0 is REQUIRED on GOOS=android: +# the vendored wlynxg/anet uses //go:linkname into net (Go ≥1.23 blocks it). +ANDROID_JNILIBS := android/app/src/main/jniLibs/arm64-v8a +MOBILE_TAGS := mobile,withoutsystray +# Size budget for the android payload, in bytes (80 MB). The CI lane fails +# over it so the lite variant can't silently rot or regain the stripped fat. +ANDROID_MOBILE_MAX_BYTES := 83886080 + +# Both buildinfo paths are stamped: the visor internals read skywire-utilities' +# buildinfo ($(BUILDINFO)), but /api/about + cobra --version — what the phone +# app's info card shows — read the repo-local pkg/buildinfo. That version MUST +# parse as semver (visorconfig.Parse semver-checks it and FATALs otherwise), and +# on a checkout with no reachable tag `git describe --always` is a bare hash — +# so fall back to a v0.0.0- pseudo-version. +MOBILE_VERSION := $(shell git describe --tags 2>/dev/null || echo "v0.0.0-$(VERSION)") +MOBILE_APPINFO := -X $(SKYWIRE_BUILDINFO_PATH).version=$(MOBILE_VERSION) -X $(SKYWIRE_BUILDINFO_PATH).commit=$(COMMIT) -X $(SKYWIRE_BUILDINFO_PATH).date=$(DATE) +build-mobile: ## Build the skywire-mobile lite core for the HOST OS (desktop smoke of the mobile build variant) + ${OPTS} go build -tags $(MOBILE_TAGS) "-ldflags=$(BUILDINFO) $(MOBILE_APPINFO)" -mod=vendor -o $(BUILD_PATH)skywire-mobile ./cmd/skywire-mobile + +android-mobile: ## Build libskywire-mobile.so (android/arm64) into android jniLibs — pure-Go lane (CI/emulator); use android-mobile-ndk for release (bionic DNS) + mkdir -p $(ANDROID_JNILIBS) + GOOS=android GOARCH=arm64 CGO_ENABLED=0 ${OPTS} go build -tags $(MOBILE_TAGS) "-ldflags=$(BUILDINFO) $(MOBILE_APPINFO) -w -s -checklinkname=0" -mod=vendor -o $(ANDROID_JNILIBS)/libskywire-mobile.so ./cmd/skywire-mobile + @ls -la $(ANDROID_JNILIBS)/libskywire-mobile.so + +android-mobile-check: android-mobile ## CI lane: android-mobile + fail over the size budget + @size=$$(wc -c < $(ANDROID_JNILIBS)/libskywire-mobile.so | tr -d '[:space:]'); \ + echo "libskywire-mobile.so: $$size bytes (budget $(ANDROID_MOBILE_MAX_BYTES))"; \ + if [ "$$size" -gt "$(ANDROID_MOBILE_MAX_BYTES)" ]; then \ + echo "ERROR: libskywire-mobile.so exceeds the size budget — the mobile variant regained fat"; \ + exit 1; \ + fi + +android-mobile-ndk: ## Release lane: NDK/cgo android build (DNS via bionic getaddrinfo); requires ANDROID_NDK_HOME + @set -e; \ + test -n "$(ANDROID_NDK_HOME)" || { echo "ANDROID_NDK_HOME is not set"; exit 1; }; \ + CC_BIN=$$(ls $(ANDROID_NDK_HOME)/toolchains/llvm/prebuilt/*/bin/aarch64-linux-android26-clang 2>/dev/null | head -n1); \ + test -n "$$CC_BIN" || { echo "aarch64-linux-android26-clang not found under ANDROID_NDK_HOME"; exit 1; }; \ + mkdir -p $(ANDROID_JNILIBS); \ + GOOS=android GOARCH=arm64 CGO_ENABLED=1 CC="$$CC_BIN" go build -tags $(MOBILE_TAGS) "-ldflags=$(BUILDINFO) $(MOBILE_APPINFO) -w -s -checklinkname=0" -mod=vendor -o $(ANDROID_JNILIBS)/libskywire-mobile.so ./cmd/skywire-mobile; \ + ls -la $(ANDROID_JNILIBS)/libskywire-mobile.so + +# Android Studio's bundled JDK (gradle needs JDK 17+); override if yours differs. +ANDROID_JAVA_HOME ?= /Applications/Android Studio.app/Contents/jbr/Contents/Home + +# APK_VERSION=X.Y.Z stamps the build; without it the committed gradle +# fallbacks apply. The versionCode formula lives HERE and nowhere else — the +# release workflow calls this target rather than computing its own — so a tag +# build and a local build can never disagree about the number. Minor and patch +# are capped at 99, past which the scheme stops being monotonic. +ifdef APK_VERSION +APK_VERSION_CODE := $(shell printf '%s' '$(APK_VERSION)' | awk -F. \ + '{ if (NF==3 && $$1$$2$$3 ~ /^[0-9]+$$/ && $$2<=99 && $$3<=99) print $$1*10000+$$2*100+$$3 }') +APK_GRADLE_ARGS := -PskywireVersionName=$(APK_VERSION) -PskywireVersionCode=$(APK_VERSION_CODE) +endif + +android-apk: ## Build the Android APK (release; APK_VERSION=X.Y.Z to stamp it; signed when ANDROID_KEYSTORE_FILE/_PASSWORD + ANDROID_KEY_ALIAS/_PASSWORD are set, else unsigned) — run android-mobile-ndk first for a fresh Go payload + @if [ -n "$(APK_VERSION)" ] && [ -z "$(APK_VERSION_CODE)" ]; then \ + echo "APK_VERSION='$(APK_VERSION)' is not X.Y.Z with minor/patch <= 99"; exit 1; fi + @if [ "$(APK_VERSION_CODE)" = "0" ]; then \ + echo "APK_VERSION=0.0.0 derives versionCode 0, and Android requires a"; \ + echo "positive integer — the lowest usable version is 0.0.1."; exit 1; fi + @if [ -n "$(APK_VERSION)" ]; then \ + echo "version: $(APK_VERSION) (versionCode $(APK_VERSION_CODE))"; fi + @if [ -n "$$ANDROID_KEYSTORE_FILE" ]; then \ + echo "signing with $$ANDROID_KEYSTORE_FILE"; \ + else \ + echo "ANDROID_KEYSTORE_FILE unset — building UNSIGNED (app-release-unsigned.apk)"; fi + cd android && JAVA_HOME="$(ANDROID_JAVA_HOME)" ./gradlew assembleRelease $(APK_GRADLE_ARGS) + +android-apk-debug: ## Build + the debug-signed APK (installable via adb) — the dev loop's CLI twin of Android Studio Run + cd android && JAVA_HOME="$(ANDROID_JAVA_HOME)" ./gradlew assembleDebug + build-merged-windows: clean-windows powershell '${OPTS} go build ${BUILD_OPTS} -o $(BUILD_PATH)skywire.exe .' diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000000..aab68c5a9e --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,13 @@ +# Build outputs + machine-local config. Signing keys NEVER enter git +# (release signing config arrives with release hardening). +.gradle/ +build/ +app/build/ +local.properties +.kotlin/ +captures/ +.idea/ +*.keystore +*.jks +# The Go payload is a build artifact — `make android-mobile` regenerates it. +app/src/main/jniLibs/ diff --git a/android/README.md b/android/README.md new file mode 100644 index 0000000000..a4caf918f1 --- /dev/null +++ b/android/README.md @@ -0,0 +1,292 @@ +# Skywire Android + +The native Android app for Skywire: a Kotlin / Jetpack Compose (Material 3) +shell around the **Skywire mobile core** — a single Go binary +(`libskywire-mobile.so`) bundling the visor, config generation, and the four +client apps (skychat, skysocks-client, vpn-client, skydex-client), which run +in-process inside the visor. The app drives the core through the visor's +authenticated local REST API on `127.0.0.1:8000`. + +This directory is a self-contained Gradle project; **Android Studio opens +`android/`**, not the repo root. The Go payload is built by the repo-root +Makefile and lands in `app/src/main/jniLibs/arm64-v8a/` (gitignored — always +rebuilt, never committed). + +--- + +## Prerequisites + +| Tool | Notes | +|---|---| +| Go (see repo `go.mod`) | builds the core payload | +| Android SDK | platform + build-tools; Android Studio installs these | +| Android NDK | only for the **release** payload lane (`android-mobile-ndk`) | +| Android Studio (or any JDK 17+) | Gradle needs JDK 17+; Studio's bundled JDK works. The Makefile defaults to it via `ANDROID_JAVA_HOME` | +| A device or AVD | **arm64 only** — the payload has no x86_64 build, never pick an x86_64 emulator image | + +`android/local.properties` (gitignored) must point at your SDK: + +``` +sdk.dir=/Users//Library/Android/sdk +``` + +Android Studio writes this automatically on first open. + +## Building + +Everything runs from the **repo root**: + +```sh +# 1. The Go core payload → android/app/src/main/jniLibs/arm64-v8a/libskywire-mobile.so +make android-mobile # pure-Go lane: CI + emulator work +make android-mobile-ndk # NDK/cgo lane: RELEASE builds (fixes Android DNS via + # bionic getaddrinfo); needs ANDROID_NDK_HOME set +make android-mobile-check # pure-Go build + the CI size budget (fails > 80 MB) + +# 2. The APK +make android-apk # release (unsigned until release signing lands) +make android-apk-debug # debug-signed → installable directly via adb +``` + +Or with Gradle directly (`JAVA_HOME` must be a JDK 17+): + +```sh +cd android && ./gradlew assembleDebug +``` + +APKs land in `android/app/build/outputs/apk/{debug,release}/`. + +Rule of thumb: **whenever the Go side changed, run `make android-mobile` +(or `-ndk`) first**, then rebuild/reinstall the APK — Android Studio's Run ▶ +does not rebuild the payload. + +## Running + +**Android Studio (the normal dev loop):** open `android/`, pick the device or +AVD, Run ▶. + +**CLI:** + +```sh +make android-apk-debug +adb install -r android/app/build/outputs/apk/debug/app-debug.apk +adb shell am start -n com.skycoin.skywire/.MainActivity +``` + +**Emulator:** the project AVD is named `skywire` (Pixel-class, arm64, +`google_apis`). If it doesn't exist yet: + +```sh +sdkmanager "system-images;android-37.0;google_apis;arm64-v8a" +avdmanager create avd -n skywire -k "system-images;android-37.0;google_apis;arm64-v8a" --device pixel_7 +emulator -avd skywire +``` + +Emulator networking is NATed — the core's outbound dmsg/transport dialing +works out of the box. + +## How the app runs the core + +Home's **Connect** starts `SkywireCoreService` (a `specialUse` foreground +service) which execs `libskywire-mobile.so` from `nativeLibraryDir` as a +child process — the phone equivalent of `skywire visor -c skywire-config.json` +under a supervisor: + +- **First run:** the service runs the payload's own `config gen` (keys made + on-device, never leave it), then applies the phone profile in Kotlin + (`core/ConfigManager.kt`): API pinned to `127.0.0.1:8000` with auth, RPC + listener off, `pty`/`skywire-tcp`/`lan_dmsg_server` removed, all paths + under `files/skywire/`, all apps in-proc with autostart off. The profile is + re-applied on every start, so the pins survive config rewrites and app + updates. +- **Auth:** single `admin` account with a random device-local password + (AndroidKeyStore-encrypted at rest, `core/SecretStore.kt`); the client + (`api/VisorApi.kt`) creates the account on first run and re-logins on 401 + (visor restarts invalidate sessions). +- **Lifecycle:** crash → restart with backoff (1 s → 30 s, reset after a + stable minute); Disconnect → SIGTERM via `Os.kill` (Android's + `Process.destroy()` is SIGKILL and would skip the visor's graceful + shutdown), escalating only if it hangs. Swiping the app away does not stop + the core; Disconnect or Force stop does. +- **Process log:** the child's combined output lands in + `files/skywire/skywire-process.log` (rotating) — readable in-app via the + log viewer's Process source, which works even when the visor won't start. + +## App screens + +Each app screen drives one of the visor's apps over the local API — list, +configure, start, observe — and carries a `Logs` action scoped to that app. + +**SkySOCKS** (`ui/socks/`) lists public proxy servers from service discovery +(`/api/svc-fetch?service=sd&path=/api/services?type=proxy`, which the visor +fetches over DMSG on the app's behalf), points `skysocks-client` at the tapped +one (`PUT …/apps/skysocks-client` with `pk`, then `status`), and polls +`…/apps/skysocks-client` + `…/connections` for state and traffic. The chosen +server is remembered across restarts, so the screen opens on a one-tap +**Reconnect**. + +Two of the app's flags are owned by the phone profile rather than the screen, +and re-pinned on every launch (`core/ConfigManager.kt`): + +- `--addr` host is forced to `127.0.0.1` — the generated `:1080` listens on + every interface, which on a phone means any device on the same Wi-Fi could + use the proxy. The port stays editable from the screen. +- `--reconnect` is always on. Phones lose mesh routes routinely (a cell + handover is enough); without it the app *exits* when its route group dies + and the proxy is silently dead until the user notices. + +Starting or reconfiguring goes through an explicit stop first: the visor's +restart-on-args-change races the outgoing proc, whose blocked `accept` returns +"use of closed network connection" and leaves the app stuck in `Errored`. + +**SkyVPN** (`ui/vpn/` + `core/SkyVpnService.kt`) repeats that shape against +`type=vpn` and routes the **whole phone**. An app may never open +`/dev/net/tun`, so the interface is created by `SkyVpnService` through +`VpnService.Builder` and its file descriptor is passed to the visor process +over an abstract unix socket as `SCM_RIGHTS` ancillary data. The Go end is +`pkg/vpn/tun_device_android.go`, which documents the (newline-delimited JSON) +protocol; `pkg/vpn/os_client_android.go` is the Android spelling of the route +and DNS calls the shared client makes — `Builder` declares all of it, so +`SetupTUN` *is* the whole configuration and deleting the default route means +dropping the interface. + +Three things are worth knowing before changing any of it: + +- **`addDisallowedApplication(self)` is not optional.** The visor is a child of + this app and shares its UID, so without the exclusion its dmsg traffic — the + traffic *carrying* the tunnel — would be routed into the tunnel. +- **The service keeps its own copy of the descriptor**, so the interface + outlives the core closing its copy. That is what makes `--killswitch` real: + the tunnel drops, nothing drains the interface, packets go nowhere. It is + released on an explicit `down`, on a control-socket EOF *with the killswitch + off*, or on disconnect — never on a visor crash-restart. +- **The killswitch is set through the API's `killswitch` field**, not by + rewriting argv; the visor owns both spellings. The phone's stored preference + wins and is re-applied whenever the core comes up. + +**SkyDEX** (`ui/dex/`) is native above, embedded below. The market public key +is a native field with a recent-markets dropdown; connecting writes +`--market-pk` into the app's argv (`PUT …/apps/skydex-client` with `args`, +then `status` — `pk` is allow-listed to skysocks/vpn and would be rejected), +starts the app, and then POSTs `/api/connect` on **skydex-client's own** API +at `127.0.0.1:8051`. That last call is what makes it one step: the flag alone +only pre-fills the page's connect form, because the engine never dials on its +own. The trading UI then loads in a WebView already connected, and +`/api/status` is polled so the native header mirrors the app rather than +guessing. + +A stylesheet is injected at page-finished (again at `onPageFinished`, because +the page is React and a removed node comes back on the next render). It drops +the page's own header — brand, connected dot, market name and key, Disconnect +— which duplicated the native row exactly, and adds the phone breakpoint the +page never shipped: its own ~10 kB of CSS has no media query at all, so the +tab strip hid *Settings* off the end of a scroll, banners kept their actions +in a corner, and the grids held desktop column minimums. + +Its tables become cards. My Listings is ten columns wide; a phone showed five +and cut off the rest, including **Actions** — the column holding Cancel. A +closed card shows the fields named `Type`/`Amount`/`Price`/`Status` (four at +most) plus the action button, one status badge rather than the whole lifecycle +chain, and everything else behind a *Details* toggle. That part needs script, +not just CSS — a `` carries no clue which column it is in — and a +MutationObserver re-applies it, because the page re-renders its tables every +eight seconds while polling. + +`chromeClient` answers `onJsConfirm`/`onJsAlert` with a native dialog. Without +that, a WebView suppresses `window.confirm()` and returns `false`, and the +page guards cancelling a listing or an order behind exactly that call — so +Cancel silently did nothing. + +All of the above binds to class names inside a **vendored, pre-built** bundle. +If upstream rebuilds it with different markup nothing errors — the page just +returns to being unusable on a phone. The durable fix is landing these +breakpoints in the skycoin repo; skychat has no such exposure because its UI +source lives here. + +`core/SkydexProfile` pins two flags on every launch: + +- `--addr` host to `127.0.0.1`, as for the proxy. +- `--password-file`, holding a device-local secret. The trading UI ships with + no authentication, and on Android a loopback listener is reachable by every + app holding INTERNET — with the live market session, the registered wallet + addresses and order placement behind it. The gate is skywire's own + (`cmd/apps/skydex-client/commands/auth.go`): with a password set the wrapper + serves basic auth on `--addr` and moves the vendored engine to a loopback + port drawn fresh at every start. Off by default, so desktop is unchanged. + + It closes the documented port, not the engine's own: that answers ungated to + anything that scans for it. Closing it fully needs `skydexclient.Run` to + accept a `net.Listener` upstream, after which the proxy becomes a handler + wrapper and the second port disappears. + +## Testing & debugging + +App logs: + +```sh +adb logcat --pid=$(adb shell pidof -s com.skycoin.skywire) +``` + +The visor's own logs, in-app: Home → **View logs** (core ring buffer while +the API is up, the captured process output otherwise). + +The core's local API from your desk (once the app runs the core — or with the +payload run manually, below): + +```sh +adb forward tcp:8000 tcp:8000 # http://127.0.0.1:8000/api/ping → "PONG!" +adb forward tcp:1080 tcp:1080 # your desktop browser rides the phone's SOCKS5 +curl --socks5-hostname 127.0.0.1:1080 https://example.com +``` + +SkySOCKS end-to-end, entirely on the phone (the device ships `curl`, and the +shell runs as a different UID — so this is the same reachability a third-party +app gets): + +```sh +adb shell 'curl -s https://api.ipify.org; echo' # your real IP +adb shell 'curl -s --socks5-hostname 127.0.0.1:1080 https://api.ipify.org; echo' # the exit node's +``` + +The API is authenticated (session cookie) and CSRF-protected: `GET /api/csrf` +→ send the token as `X-CSRF-Token` on every mutating request. `/api/ping` is +open; everything else needs `POST /api/login` first. + +**Payload-only smoke (no app involved)** — run the core straight from a shell, +useful to bisect "app problem vs core problem": + +```sh +adb push android/app/src/main/jniLibs/arm64-v8a/libskywire-mobile.so /data/local/tmp/skywire/ +adb shell + cd /data/local/tmp/skywire && chmod +x libskywire-mobile.so + ./libskywire-mobile.so config gen -i -e -o ./skywire-config.json --binpath /data/local/tmp/skywire + # apply the phone-profile edits (see the desktop smoke notes in the repo), then: + ./libskywire-mobile.so visor -c ./skywire-config.json +``` + +There are no unit tests in the app module yet; UI/integration tests will be +added alongside the feature screens. Go-side tests run from the repo root as +usual (`make test`); the `mobile` build variant is compile-checked in CI by +the `android` job with a size budget. + +## Project layout + +``` +android/ +├── settings.gradle.kts / build.gradle.kts / gradle/libs.versions.toml +├── gradlew, gradle/wrapper/ # committed wrapper (Gradle 9.6) +└── app/src/main/ + ├── AndroidManifest.xml + ├── java/com/skycoin/skywire/ + │ ├── MainActivity.kt # the one Activity: splash → gate → scaffold + │ ├── core/ # foreground service, config, secrets, prefs + │ ├── api/ # local-API client + DTOs + │ └── ui/ # theme/, navigation/, components/, logs/, + │ # home/ chat/ hub/ socks/ vpn/ dex/ + │ # fleet/ wallet/ settings/ + ├── res/ # brand logo (drawable-nodpi), Skycoin + │ # fonts (font/), splash + adaptive icon + └── jniLibs/arm64-v8a/ # Go payload (gitignored build artifact) +``` + +Progress log: [implementation-report.md](implementation-report.md). diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000000..149d36502f --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,110 @@ +plugins { + alias(libs.plugins.android.application) + // No org.jetbrains.kotlin.android: Kotlin support is built into AGP ≥9. + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) +} + +// Version comes from the release tag when there is one, and from the +// fallbacks below otherwise, so a local `./gradlew assembleDebug` needs no +// arguments. The release workflow derives both from `mobile-vX.Y.Z` — see +// .github/workflows/android-release.yml. +val appVersionName = (project.findProperty("skywireVersionName") as String?) ?: "0.1.0" +val appVersionCode = (project.findProperty("skywireVersionCode") as String?)?.toInt() ?: 1 + +// Release signing is supplied by the environment, never committed. Absent +// (every local build), `release` stays unsigned exactly as before; the +// release workflow refuses to publish in that state rather than shipping an +// APK nobody can install. +val keystoreFile = System.getenv("ANDROID_KEYSTORE_FILE") + +android { + namespace = "com.skycoin.skywire" + compileSdk = 37 + + defaultConfig { + applicationId = "com.skycoin.skywire" + // minSdk 26 / target latest, arm64-only first release. + minSdk = 26 + targetSdk = 36 + versionCode = appVersionCode + versionName = appVersionName + ndk { + // The Go payload (libskywire-mobile.so) is arm64-only. + abiFilters += "arm64-v8a" + } + } + + signingConfigs { + if (keystoreFile != null) { + create("release") { + storeFile = file(keystoreFile) + storePassword = System.getenv("ANDROID_KEYSTORE_PASSWORD") + keyAlias = System.getenv("ANDROID_KEY_ALIAS") + keyPassword = System.getenv("ANDROID_KEY_PASSWORD") + } + } + } + + buildTypes { + release { + // Minification/shrinking is deferred to release hardening. + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + // findByName, not getByName: null is the valid "unsigned" state. + signingConfig = signingConfigs.findByName("release") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + buildFeatures { + compose = true + } + + packaging { + jniLibs { + // The core service EXECS libskywire-mobile.so from applicationInfo.nativeLibraryDir + // (the visor is a child process, not a linked library). That requires the + // .so extracted to disk at install time — the modern "serve straight from + // the APK" packaging leaves nativeLibraryDir empty and exec would fail. + // This is why the installed-size estimate exceeds the APK download size. + useLegacyPackaging = true + } + } +} + +dependencies { + implementation(platform(libs.compose.bom)) + implementation(libs.compose.ui) + implementation(libs.compose.material3) + implementation(libs.compose.ui.tooling.preview) + // Placeholder app icons until the designed logos land. + implementation(libs.compose.material.icons.extended) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.splashscreen) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.datastore.preferences) + // App lock + every secret-revealing confirmation. Brings androidx.fragment + // with it, which is why MainActivity is a FragmentActivity: BiometricPrompt + // hosts itself in a fragment and takes nothing less. + implementation(libs.androidx.biometric) + // Local-API client — declared now so the module dep set is final. + implementation(libs.okhttp) + implementation(libs.kotlinx.serialization.json) + // The core service runs its own supervisor scope outside any lifecycle. + implementation(libs.kotlinx.coroutines.android) + // Wallet crypto + node clients. Pure JVM — the seed never crosses a JNI + // boundary and the same bytes run under unit tests on the host. + implementation(project(":wallet-core")) + // QR: journeyapps hosts the scan activity, zxing core renders the + // receive-address code into a Bitmap. + implementation(libs.zxing.embedded) + implementation(libs.zxing.core) + debugImplementation(libs.compose.ui.tooling) +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000000..9c8f694774 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,2 @@ +# Project-specific ProGuard/R8 rules. Minification itself is off until +# Step 10 (hardening & release); rules collected here as they surface. diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..2a47a7bf58 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,162 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/com/skycoin/skywire/MainActivity.kt b/android/app/src/main/java/com/skycoin/skywire/MainActivity.kt new file mode 100644 index 0000000000..23d881f6f0 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/MainActivity.kt @@ -0,0 +1,106 @@ +package com.skycoin.skywire + +import android.content.Intent +import android.os.Bundle +import android.view.WindowManager +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import androidx.fragment.app.FragmentActivity +import com.skycoin.skywire.core.AppLock +import com.skycoin.skywire.core.AppPreferences +import com.skycoin.skywire.core.AppVisibility +import com.skycoin.skywire.core.DeepLinks +import com.skycoin.skywire.core.ThemeMode +import com.skycoin.skywire.ui.SkywireApp +import com.skycoin.skywire.ui.components.BiometricGate +import com.skycoin.skywire.ui.theme.SkywireTheme + +/** + * The one Activity: splash → [BiometricGate] → scaffold + NavHost. + * + * A [FragmentActivity] rather than a plain ComponentActivity because + * `BiometricPrompt` hosts itself in a fragment and takes nothing less. It + * changes nothing else — FragmentActivity *is* a ComponentActivity, so the + * splash, edge-to-edge and `setContent` are the same calls as before, and the + * theme stays ours (only AppCompatActivity would demand an AppCompat one). + * + * It is also where a link another app opened us for lands. Both arrival + * paths matter and neither can be skipped: [onCreate] is a cold start, and + * [onNewIntent] is the far more common warm one — `singleTask` delivers the + * link to the Activity that is already running rather than building a second + * one. Handling it is [DeepLinks]' job; taking it is this one's. + */ +class MainActivity : FragmentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + val splash = installSplashScreen() + super.onCreate(savedInstanceState) + enableEdgeToEdge() + DeepLinks.offer(intent) + // Short fade from the logo splash into Home. + splash.setOnExitAnimationListener { provider -> + provider.view.animate() + .alpha(0f) + .setDuration(250L) + .withEndAction { provider.remove() } + .start() + } + setContent { + val prefs = remember { AppPreferences(this) } + val theme by prefs.string(ThemeMode.PREF_KEY).collectAsState(initial = null) + val lockEnabled by prefs.boolean(AppLock.PREF_KEY, AppLock.DEFAULT) + .collectAsState(initial = AppLock.DEFAULT) + + // The half of the app lock an overlay cannot do. The recents + // snapshot is taken as the app leaves — before it is locked, and + // with the last screen still on it — so blocking it has to be a + // window flag that was already set. Screenshots go with it, which + // is the same promise stated the other way round. + LaunchedEffect(lockEnabled) { + if (lockEnabled) { + window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + } + } + + SkywireTheme(darkTheme = ThemeMode.of(theme).isDark(isSystemInDarkTheme())) { + BiometricGate { + SkywireApp() + } + } + } + } + + override fun onStart() { + super.onStart() + AppVisibility.set(true) + // Re-locks unless the user has only been gone a moment; the gate is + // what decides whether that matters (see AppLock). + AppLock.onForeground() + } + + override fun onResume() { + super.onResume() + AppVisibility.onResumed() + } + + override fun onStop() { + AppVisibility.set(false) + AppLock.onBackground() + super.onStop() + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + // setIntent so anything reading getIntent() later sees the link that + // actually brought the app forward, not the one it was launched with. + setIntent(intent) + DeepLinks.offer(intent) + } +} diff --git a/android/app/src/main/java/com/skycoin/skywire/SkywireApplication.kt b/android/app/src/main/java/com/skycoin/skywire/SkywireApplication.kt new file mode 100644 index 0000000000..2aef6708e5 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/SkywireApplication.kt @@ -0,0 +1,9 @@ +package com.skycoin.skywire + +import android.app.Application + +/** + * Application singleton. The core-process plumbing (SkywireCoreService wiring, + * first-run config gen, log capture) hangs off this when it lands. + */ +class SkywireApplication : Application() diff --git a/android/app/src/main/java/com/skycoin/skywire/api/SkychatApi.kt b/android/app/src/main/java/com/skycoin/skywire/api/SkychatApi.kt new file mode 100644 index 0000000000..a4fdbca85d --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/api/SkychatApi.kt @@ -0,0 +1,117 @@ +package com.skycoin.skywire.api + +import android.content.Context +import com.skycoin.skywire.core.SecretStore +import com.skycoin.skywire.core.SkychatProfile +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.json.Json +import okhttp3.OkHttpClient +import okhttp3.Request +import java.util.concurrent.TimeUnit + +/** + * Client for skychat's *own* HTTP surface (`127.0.0.1:8001` by default) — + * separate from [VisorApi], which talks to the visor's API on `:8000`. + * + * The app needs three things from it: the basic-auth credential the WebView + * answers the password gate with, a readiness probe so the screen can wait for + * the listener instead of showing the WebView's error page, and the address + * book — the one piece of the embedded UI's state that native screens need + * too, because a call screen showing 66 hex characters for someone the user + * has named is not a call screen. Everything else is the UI's business. + */ +class SkychatApi private constructor(context: Context) { + + private val secrets = SecretStore(context.applicationContext) + + // Loopback and a server that either answers at once or isn't up yet — + // short timeouts keep the readiness poll on its own schedule. + private val client = OkHttpClient.Builder() + .connectTimeout(2, TimeUnit.SECONDS) + .readTimeout(5, TimeUnit.SECONDS) + .callTimeout(6, TimeUnit.SECONDS) + .build() + + /** The device-local password the gate is configured with. */ + suspend fun password(): String = secrets.skychatPassword() + + /** `Basic …` header value for the WebView and for downloads. */ + suspend fun authorization(): String = + okhttp3.Credentials.basic(SkychatProfile.USER, password()) + + /** + * Status of a `GET` on [baseUrl], or null when nothing answered — the + * ordinary case while the app is still coming up. 401 is reported rather + * than swallowed: it means the running skychat loaded an older password + * file, which the caller fixes by restarting the app. + */ + suspend fun probe(baseUrl: String): Int? = withContext(Dispatchers.IO) { + runCatching { + client.newCall( + Request.Builder() + .url(baseUrl) + .header("Authorization", authorization()) + .build(), + ).execute().use { it.code } + }.getOrNull() + } + + /** + * The operator's names for public keys, from skychat's address book. + * + * Empty on any failure rather than throwing: a missing name costs a + * shortened key on screen, which is exactly what the caller falls back to + * anyway, and a call must never fail because a nickname could not be read. + */ + suspend fun contacts(baseUrl: String): Map = withContext(Dispatchers.IO) { + runCatching { + client.newCall( + Request.Builder() + .url(baseUrl.trimEnd('/') + "/contacts") + .header("Authorization", authorization()) + .build(), + ).execute().use { resp -> + if (!resp.isSuccessful) return@use emptyMap() + json.decodeFromString( + MapSerializer(String.serializer(), String.serializer()), + resp.body.string(), + ) + } + }.getOrDefault(emptyMap()) + } + + /** + * The unread estimate skychat keeps for surfaces that are not the page — + * the hub card's badge. Null on any failure: no badge beats a wrong one. + */ + suspend fun unread(baseUrl: String): Int? = withContext(Dispatchers.IO) { + runCatching { + client.newCall( + Request.Builder() + .url(baseUrl.trimEnd('/') + "/unread") + .header("Authorization", authorization()) + .build(), + ).execute().use { resp -> + if (!resp.isSuccessful) return@use null + json.decodeFromString(UnreadCount.serializer(), resp.body.string()).unread + } + }.getOrNull() + } + + @kotlinx.serialization.Serializable + private data class UnreadCount(val unread: Int = 0) + + companion object { + private val json = Json { ignoreUnknownKeys = true; isLenient = true } + + @Volatile private var instance: SkychatApi? = null + + fun get(context: Context): SkychatApi = + instance ?: synchronized(this) { + instance ?: SkychatApi(context.applicationContext).also { instance = it } + } + } +} diff --git a/android/app/src/main/java/com/skycoin/skywire/api/SkydexApi.kt b/android/app/src/main/java/com/skycoin/skywire/api/SkydexApi.kt new file mode 100644 index 0000000000..3d4aeaabab --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/api/SkydexApi.kt @@ -0,0 +1,158 @@ +package com.skycoin.skywire.api + +import android.content.Context +import com.skycoin.skywire.core.SecretStore +import com.skycoin.skywire.core.SkydexProfile +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import java.io.IOException +import java.util.concurrent.TimeUnit + +/** skydex-client's answer about its one market connection. */ +@Serializable +data class MarketStatus( + @SerialName("connected") val connected: Boolean = false, + @SerialName("market_pk") val marketPk: String = "", + /** Operator-set display name; often empty. */ + @SerialName("market_name") val marketName: String = "", +) + +/** + * Client for skydex-client's *own* control API (`127.0.0.1:8051` by default) — + * separate from [VisorApi], which talks to the visor on `:8000`. + * + * This is the seam that lets the market public key be entered natively while + * the trading UI stays the page the desktop already serves. The engine never + * dials a market on its own: `--market-pk` only pre-fills the page's connect + * form, and something has to POST `/api/connect` for a connection to exist. + * Doing that here is what makes "type a key, get the market" one step on the + * phone instead of two — and the page, which polls `/api/status` on load, comes + * up already on the market rather than on a second connect form. + * + * The reverse direction matters just as much: [status] is the truth the screen + * mirrors rather than whatever the native header last did. + * + * Every request carries the basic-auth credential of the phone's gate (see + * [SkydexProfile]) — on this device that surface is not open, including to us. + */ +class SkydexApi private constructor(context: Context) { + + private val secrets = SecretStore(context.applicationContext) + + // Loopback, and a server that either answers at once or isn't up yet. + private val client = OkHttpClient.Builder() + .connectTimeout(2, TimeUnit.SECONDS) + .readTimeout(5, TimeUnit.SECONDS) + .callTimeout(6, TimeUnit.SECONDS) + .build() + + /** + * For `/api/connect` alone, which does not return until a dmsg route to + * the market is built *and* a `get_currencies` handshake has come back. + * That is the same route setup the SkySOCKS screen measured at ~10 s on a + * good network and minutes on a bad cell — the client above would abort it + * long before the visor gave up. + */ + private val dialClient = client.newBuilder() + .readTimeout(90, TimeUnit.SECONDS) + .callTimeout(95, TimeUnit.SECONDS) + .build() + + /** The device-local password the gate is configured with. */ + suspend fun password(): String = secrets.skydexPassword() + + /** `Basic …` header value the gate accepts. */ + suspend fun authorization(): String = + okhttp3.Credentials.basic(SkydexProfile.USER, password()) + + /** True once the trading UI answers — the gate for showing the WebView. */ + suspend fun probe(baseUrl: String): Boolean = withContext(Dispatchers.IO) { + runCatching { + client.newCall(request(baseUrl)).execute().use { it.isSuccessful } + }.getOrDefault(false) + } + + /** + * The current market connection, or null when the app isn't answering — + * the ordinary case while it is still coming up, and not worth an error + * on a poll that runs every couple of seconds. + */ + suspend fun status(baseUrl: String): MarketStatus? = withContext(Dispatchers.IO) { + runCatching { + client.newCall(request(baseUrl + "api/status")).execute().use { resp -> + if (resp.isSuccessful) decode(resp.body.string()) else null + } + }.getOrNull() + } + + /** + * Dial [pk] and handshake with it. The failure here is the one the user + * most needs to read — an unreachable market, a rejected key — so it is + * raised with the market's own wording rather than swallowed. + */ + suspend fun connect(baseUrl: String, pk: String): MarketStatus = + withContext(Dispatchers.IO) { + val body = json.encodeToString( + ConnectRequest.serializer(), + ConnectRequest(pk), + ).toRequestBody(JSON_MEDIA) + dialClient.newCall(request(baseUrl + "api/connect", body)) + .execute().use { resp -> + val text = resp.body.string() + if (!resp.isSuccessful) throw IOException(errorMessage(text, resp.code)) + decode(text) + } + } + + /** Drop the market connection. Best-effort: there is nothing to retry. */ + suspend fun disconnect(baseUrl: String): Unit = withContext(Dispatchers.IO) { + runCatching { + val body = EMPTY_BODY.toRequestBody(JSON_MEDIA) + client.newCall(request(baseUrl + "api/disconnect", body)).execute().close() + } + } + + // --- plumbing --- + + /** A GET, or a POST when [body] is given, carrying the gate credential. */ + private suspend fun request(url: String, body: RequestBody? = null): Request = + Request.Builder() + .url(url) + .header("Authorization", authorization()) + .apply { if (body != null) post(body) } + .build() + + private fun decode(body: String): MarketStatus = + json.decodeFromString(MarketStatus.serializer(), body) + + /** The engine answers `{"error": "…"}`; fall back to the bare status. */ + private fun errorMessage(body: String, code: Int): String = + runCatching { json.decodeFromString(ApiError.serializer(), body).error } + .getOrNull() + ?.takeIf { it.isNotEmpty() } + ?: "market connect failed ($code)" + + @Serializable + private data class ConnectRequest(@SerialName("market_pk") val marketPk: String) + + companion object { + private val json = Json { ignoreUnknownKeys = true; isLenient = true } + private val JSON_MEDIA = "application/json".toMediaType() + private const val EMPTY_BODY = "{}" + + @Volatile private var instance: SkydexApi? = null + + fun get(context: Context): SkydexApi = + instance ?: synchronized(this) { + instance ?: SkydexApi(context.applicationContext).also { instance = it } + } + } +} diff --git a/android/app/src/main/java/com/skycoin/skywire/api/VisorApi.kt b/android/app/src/main/java/com/skycoin/skywire/api/VisorApi.kt new file mode 100644 index 0000000000..4d4f698245 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/api/VisorApi.kt @@ -0,0 +1,613 @@ +package com.skycoin.skywire.api + +import android.content.Context +import com.skycoin.skywire.core.SecretStore +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import okhttp3.Cookie +import okhttp3.CookieJar +import okhttp3.HttpUrl +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import java.io.IOException +import java.net.URLEncoder +import java.util.concurrent.TimeUnit + +/** Login/create-account cannot succeed with the stored password. */ +class AuthFailedException(message: String) : IOException(message) + +/** + * Client for the visor's local API on 127.0.0.1:8000. + * + * Auth model (all verified against the server): + * - single account, username fixed to "admin"; password is the app's + * device-local random secret ([SecretStore]); + * - `swm-session` cookie, in-memory server side — every visor restart + * invalidates it, so any 401 triggers one transparent re-login; + * - CSRF (`X-CSRF-Token`, 30 s TTL) is required only for mutating + * `/api/visors/{pk}/…` calls — fetch a fresh token per mutation. + */ +class VisorApi(context: Context) { + + private val secrets = SecretStore(context.applicationContext) + private val json = Json { + ignoreUnknownKeys = true + isLenient = true + coerceInputValues = true + } + + private val cookies = object : CookieJar { + private val store = mutableMapOf>() + + @Synchronized + override fun saveFromResponse(url: HttpUrl, cookies: List) { + store[url.host] = cookies + } + + @Synchronized + override fun loadForRequest(url: HttpUrl): List = + store[url.host].orEmpty().filter { it.expiresAt > System.currentTimeMillis() } + } + + private val client = OkHttpClient.Builder() + .cookieJar(cookies) + .connectTimeout(2, TimeUnit.SECONDS) + .readTimeout(10, TimeUnit.SECONDS) + .callTimeout(15, TimeUnit.SECONDS) + .build() + + /** + * For the routes the visor answers by going out over the network + * itself. `/api/svc-fetch` dials a deployment service over DMSG with + * its own 15-second budget per hop, so the loopback client has to + * outlast it — the default above would abort the call at the very + * moment the visor is still waiting on the first hop. + */ + private val relayClient = client.newBuilder() + .readTimeout(45, TimeUnit.SECONDS) + .callTimeout(50, TimeUnit.SECONDS) + .build() + + /** + * For the two voice-audio streams, which last as long as the call does. + * Every timeout is off: a call is minutes, and a quiet moment is not a + * dead connection. Liveness is the server's business — each stream + * re-arms its own deadline per frame — and on this side the loops stop + * when the call does. + */ + private val streamClient = client.newBuilder() + .readTimeout(0, TimeUnit.MILLISECONDS) + .writeTimeout(0, TimeUnit.MILLISECONDS) + .callTimeout(0, TimeUnit.MILLISECONDS) + .build() + + private val sessionMutex = Mutex() + + @Volatile private var cachedPk: String? = null + + // --- liveness --- + + /** True once the local API answers at all (no session needed). */ + suspend fun ping(): Boolean = withContext(Dispatchers.IO) { + runCatching { + client.newCall(Request.Builder().url("$BASE/api/ping").build()) + .execute().use { it.isSuccessful } + }.getOrDefault(false) + } + + // --- session bootstrap --- + + /** + * Make sure a valid session exists: first run creates the single + * `admin` account with the device-local password, later runs just log + * in. Throws [AuthFailedException] when the stored password is rejected + * (keystore rotated under an existing users.db) — callers recover by + * resetting the account DB with the visor stopped. + */ + suspend fun ensureSession(): Unit = withContext(Dispatchers.IO) { + sessionMutex.withLock { + if (get("/api/user").use { it.isSuccessful }) return@withLock + val password = secrets.apiPassword() + val exists = get("/api/user-exists").use { resp -> + resp.isSuccessful && decode(resp).exists + } + if (!exists) { + postJson("/api/create-account", Credentials("admin", password)).use { resp -> + // 500 "user exists" just means we raced/lost state — the + // login below is the real gate. + if (!resp.isSuccessful && resp.code != 500) { + throw AuthFailedException("create-account failed: ${errorBody(resp)}") + } + } + } + postJson("/api/login", Credentials("admin", password)).use { resp -> + when { + resp.isSuccessful -> Unit + // "not logged out" — a live session cookie already exists. + resp.code == 403 -> Unit + resp.code == 401 -> + throw AuthFailedException("stored password rejected: ${errorBody(resp)}") + else -> throw IOException("login failed (${resp.code}): ${errorBody(resp)}") + } + } + } + } + + // --- data endpoints (session-authed GETs; one re-login retry on 401) --- + + suspend fun about(): About = authedGet("/api/about") + + /** The visor's own public key, fetched once per process. */ + suspend fun localPk(): String = + cachedPk ?: about().publicKey.also { cachedPk = it } + + /** + * Forget the cached key. This client is a process-lifetime singleton and + * every `/api/visors/{pk}/…` route is built from [localPk], so after an + * identity change the cache would address the visor by a key it no longer + * has — every call 404s until the app is killed. Settings calls this the + * moment it replaces the identity. + */ + fun forgetIdentity() { + cachedPk = null + } + + suspend fun summary(): VisorSummary = authedGet("/api/visors/${localPk()}/summary") + + suspend fun serviceHealth(): List = authedGet("/api/service-health") + + /** + * Runtime-log page of [pk], defaulting to this phone's own visor. Fleet + * passes a remote key: the same route resolves it through the hypervisor + * mux and answers from that visor's ring buffer. + */ + suspend fun runtimeLogs(since: Long, pk: String? = null): RuntimeLogsDelta = + authedGet("/api/visors/${pk ?: localPk()}/runtime-logs?since=$since") + + // --- fleet (remote visors that dialed in over dmsg) --- + + /** + * Every visor this one knows about — the local visor first, then each + * remote that connected in over dmsg. Offline remotes stay in the list, + * served from the last snapshot with `online: false`. + * + * The list is only ever longer than one when the Fleet opt-in is on: with + * the ingest off there is no listener for a remote visor to reach. + */ + suspend fun visorsSummary(): List = authedGet("/api/visors-summary") + + /** + * Restart [pk] — the visor closes its module stack, re-reads its config and + * runs again, in the same process. + * + * The server answers 202 without waiting, and it has to: the restart tears + * down the RPC connection carrying the request, so there is no success to + * report. What actually happened shows up in the next [visorsSummary] — + * the visor drops to offline and comes back. + */ + suspend fun restartVisor(pk: String): Unit = withContext(Dispatchers.IO) { + repeat(RESTART_ATTEMPTS) { attempt -> + postWithRelogin("/api/visors/$pk/restart", "{}").use { resp -> + if (resp.isSuccessful) return@withContext + // 503 means "known but not connected right now, retrying" — + // and that is a window the phone hits routinely, not an error: + // a managed visor's RPC connection idle-closes after about two + // minutes and redials within seconds, while the summary this + // screen was drawn from still reports it online. The server + // says "retrying" and expects the caller to; failing the tap + // for a visor that is fine would be the wrong answer. + if (resp.code != HTTP_UNAVAILABLE || attempt == RESTART_ATTEMPTS - 1) { + throw IOException("visor restart failed (${resp.code}): ${errorBody(resp)}") + } + } + delay(RESTART_RETRY_MS) + } + } + + /** + * Per-app log page. The server answers HTTP 500 `"no new available + * logs"` when nothing is new and 500 `"proc … is not found"` when the + * app isn't running — both are empty pages here, not errors. + */ + suspend fun appLogs(app: String, since: String?): AppLogs = withContext(Dispatchers.IO) { + val query = since?.takeIf { it.isNotEmpty() } + ?.let { "?since=" + URLEncoder.encode(it, "UTF-8") } ?: "" + val resp = getWithRelogin("/api/visors/${localPk()}/apps/$app/logs$query") + resp.use { + if (it.isSuccessful) return@withContext decode(it) + val error = errorBody(it) + if (it.code == 500 && + (error.contains("no new available logs") || error.contains("is not found")) + ) { + AppLogs(lastLogTimestamp = since ?: "") + } else { + throw IOException("app logs failed (${it.code}): $error") + } + } + } + + // --- service discovery --- + + /** + * Public servers of one service type, straight from service discovery. + * + * There is no dedicated REST route for this: `/api/svc-fetch` is the + * visor's generic deployment-service proxy (`service` picks the + * configured endpoint, `path` is passed through verbatim over + * DMSG-HTTP), and SD's own `/api/services` takes the `type` filter — + * `proxy` is the skysocks family, `vpn` is SkyVPN's. + */ + suspend fun services(type: String): List = withContext(Dispatchers.IO) { + val path = URLEncoder.encode("/api/services?type=$type", "UTF-8") + getWithRelogin("/api/svc-fetch?service=sd&path=$path", relayClient).use { resp -> + if (!resp.isSuccessful) { + throw IOException("service discovery failed (${resp.code}): ${errorBody(resp)}") + } + // The body is the upstream's payload verbatim, so it can be a + // bare `null` for an empty result set — not a JSON array. + val body = resp.body.string().trim() + if (body.isEmpty() || body == "null") { + emptyList() + } else { + json.decodeFromString(ListSerializer(ServiceEntry.serializer()), body) + } + } + } + + // --- apps --- + + suspend fun app(name: String): AppState = authedGet("/api/visors/${localPk()}/apps/$name") + + /** + * Live connections of an app. The server answers 500 when the app has + * no running proc, and JSON `null` when it runs but holds no + * connection yet — both are "nothing to show", not errors. + */ + suspend fun appConnections(name: String): List = withContext(Dispatchers.IO) { + getWithRelogin("/api/visors/${localPk()}/apps/$name/connections").use { resp -> + if (resp.code == 500) return@withContext emptyList() + if (!resp.isSuccessful) { + throw IOException("app connections failed (${resp.code}): ${errorBody(resp)}") + } + val body = resp.body.string().trim() + if (body.isEmpty() || body == "null") { + emptyList() + } else { + json.decodeFromString(ListSerializer(AppConnection.serializer()), body) + } + } + } + + /** + * Runtime stats of an app. Same "500 means no running proc" contract as + * [appConnections]; an empty [AppStats] stands for it. + */ + suspend fun appStats(name: String): AppStats = withContext(Dispatchers.IO) { + getWithRelogin("/api/visors/${localPk()}/apps/$name/stats").use { resp -> + if (resp.code == 500) return@withContext AppStats() + if (!resp.isSuccessful) { + throw IOException("app stats failed (${resp.code}): ${errorBody(resp)}") + } + decode(resp) + } + } + + /** + * One mutating PUT on an app, carrying only the fields given: + * - [pk] sets the remote server (`--srv`), + * - [args] replaces the whole argv (shell-quoted string, parsed + * server-side), + * - [killswitch] adds or removes vpn-client's `--killswitch` flag (the + * visor rejects it for any other app), + * - [status] 1 starts, 0 stops. + * + * [pk], [args] and [killswitch] all restart a *running* app server-side; + * on a stopped one they only rewrite the config, so configure-then-start + * is a safe order in a single call. + */ + suspend fun updateApp( + name: String, + pk: String? = null, + args: String? = null, + killswitch: Boolean? = null, + status: Int? = null, + ): AppState = withContext(Dispatchers.IO) { + val body = buildJsonObject { + pk?.let { put("pk", JsonPrimitive(it)) } + args?.let { put("args", JsonPrimitive(it)) } + killswitch?.let { put("killswitch", JsonPrimitive(it)) } + status?.let { put("status", JsonPrimitive(it)) } + } + putWithRelogin("/api/visors/${localPk()}/apps/$name", body.toString()).use { resp -> + if (!resp.isSuccessful) { + throw IOException("app update failed (${resp.code}): ${errorBody(resp)}") + } + decode(resp) + } + } + + // --- router settings --- + + suspend fun routerSettings(): RouterSettings = + authedGet("/api/visors/${localPk()}/router-settings") + + /** + * Install [order] as the transport-type priority order, live — the + * visor applies it without a restart and persists it to its config. + */ + suspend fun setTransportPreference(order: List): RouterSettings = + updateRouterSettings { it.copy(transportPreference = order) } + + /** + * Set the minimum number of route hops the visor dials through. 1 allows + * a direct route; 2 or more forces the traffic through intermediaries, + * which is what buys sender privacy at the cost of latency. + */ + suspend fun setMinHops(hops: Int): RouterSettings = + updateRouterSettings { it.copy(minHops = hops) } + + /** + * Read-modify-write of the router knobs, and it has to be: the PUT + * applies every field of the settings struct, so sending one field alone + * would also send `min_hops: 0` — which the router reads as *routing + * disabled* — along with a zeroed mux_routes. Every caller goes through + * here so no future setter can rediscover that the hard way. + */ + private suspend fun updateRouterSettings( + edit: (RouterSettings) -> RouterSettings, + ): RouterSettings = withContext(Dispatchers.IO) { + val body = json.encodeToString(RouterSettings.serializer(), edit(routerSettings())) + putWithRelogin("/api/visors/${localPk()}/router-settings", body).use { resp -> + if (!resp.isSuccessful) { + throw IOException( + "router settings update failed (${resp.code}): ${errorBody(resp)}", + ) + } + decode(resp) + } + } + + // --- voice calls --- + + /** Calls ringing right now, awaiting an answer. */ + suspend fun voiceIncoming(): List = + voiceList("incoming").mapNotNull(VoiceInvite::parse) + + /** Ids of calls that are connected. */ + suspend fun voiceActive(): List = voiceList("active") + + /** + * Calls this phone is PLACING and that have not been answered yet — the + * caller's half of the picture. A call being dialed is in neither the + * ringing list (that is the callee's) nor the active list (that starts at + * "answered"), so without this there is nothing to show for the whole ring. + */ + suspend fun voiceDialing(): List = withContext(Dispatchers.IO) { + getWithRelogin("/api/visors/${localPk()}/skychat/voice/dialing").use { resp -> + if (resp.code == HTTP_UNAVAILABLE || !resp.isSuccessful) { + return@withContext emptyList() + } + val body = resp.body.string().trim() + if (body.isEmpty() || body == "null") { + emptyList() + } else { + json.decodeFromString(ListSerializer(VoiceDialing.serializer()), body) + .map { VoiceInvite(it.callId, it.peer) } + } + } + } + + suspend fun voiceAnswer(callId: String) = voiceAction("answer", callId) + + suspend fun voiceDecline(callId: String) = voiceAction("decline", callId) + + suspend fun voiceHangup(callId: String) = voiceAction("hangup", callId) + + /** [mic] silences what the peer hears from us; [speaker] what we hear. */ + suspend fun voiceMute(callId: String, mic: Boolean, speaker: Boolean): Unit = + withContext(Dispatchers.IO) { + val body = buildJsonObject { + put("call_id", JsonPrimitive(callId)) + put("mic", JsonPrimitive(mic)) + put("speaker", JsonPrimitive(speaker)) + }.toString() + postWithRelogin("/api/visors/${localPk()}/skychat/voice/mute", body).use { resp -> + if (!resp.isSuccessful) { + throw IOException("voice mute failed (${resp.code}): ${errorBody(resp)}") + } + } + } + + /** + * Opens the microphone stream: the visor reads captured PCM from the + * request body until [body] stops producing. + * + * [body] is a factory, not a body: a request body can only be written + * once, so the re-login retry needs a fresh one. The caller owns closing + * the returned response. + */ + suspend fun voiceMicStream(body: () -> RequestBody): okhttp3.Response = + withContext(Dispatchers.IO) { + val path = "/api/voice-audio/${localPk()}/mic" + val first = post(path, body(), csrfToken(), streamClient) + if (first.code != 401) return@withContext first + first.close() + ensureSession() + post(path, body(), csrfToken(), streamClient) + } + + /** + * Opens the playback stream: the response body is PCM to play, for as long + * as it is read. The caller owns closing the returned response. + */ + suspend fun voiceSpeakerStream(): okhttp3.Response = withContext(Dispatchers.IO) { + getWithRelogin("/api/voice-audio/${localPk()}/speaker", streamClient) + } + + /** + * Opens the visor's notification stream (SSE): everything its apps + * publish, for as long as the response is read. The caller owns closing it. + */ + suspend fun notificationStream(): okhttp3.Response = withContext(Dispatchers.IO) { + getWithRelogin("/api/notifications/stream", streamClient) + } + + /** + * 503 means the visor has voice off (or opens its own audio device) — a + * state the poller lives with, not an error to raise every two seconds. + */ + private suspend fun voiceList(name: String): List = withContext(Dispatchers.IO) { + getWithRelogin("/api/visors/${localPk()}/skychat/voice/$name").use { resp -> + if (resp.code == HTTP_UNAVAILABLE) return@withContext emptyList() + if (!resp.isSuccessful) { + throw IOException("voice $name failed (${resp.code}): ${errorBody(resp)}") + } + val body = resp.body.string().trim() + if (body.isEmpty() || body == "null") { + emptyList() + } else { + json.decodeFromString(ListSerializer(String.serializer()), body) + } + } + } + + private suspend fun voiceAction(name: String, callId: String): Unit = + withContext(Dispatchers.IO) { + val body = buildJsonObject { put("call_id", JsonPrimitive(callId)) }.toString() + postWithRelogin("/api/visors/${localPk()}/skychat/voice/$name", body).use { resp -> + if (!resp.isSuccessful) { + throw IOException("voice $name failed (${resp.code}): ${errorBody(resp)}") + } + } + } + + /** Fresh 30-second CSRF token for a mutating `/api/visors/{pk}/…` call. */ + suspend fun csrfToken(): String = withContext(Dispatchers.IO) { + get("/api/csrf").use { decode(it).token } + } + + // --- plumbing --- + + private suspend inline fun authedGet(path: String): T = + withContext(Dispatchers.IO) { + getWithRelogin(path).use { resp -> + if (!resp.isSuccessful) { + throw IOException("GET $path failed (${resp.code}): ${errorBody(resp)}") + } + decode(resp) + } + } + + private suspend fun getWithRelogin( + path: String, + client: OkHttpClient = this.client, + ): okhttp3.Response { + val first = get(path, client) + if (first.code != 401) return first + first.close() + ensureSession() + return get(path, client) + } + + /** + * A mutation needs a fresh CSRF token per attempt — the token lives 30 + * seconds and the retry happens after a full re-login round trip. + */ + private suspend fun putWithRelogin(path: String, body: String): okhttp3.Response { + val first = put(path, body, csrfToken()) + if (first.code != 401) return first + first.close() + ensureSession() + return put(path, body, csrfToken()) + } + + private suspend fun postWithRelogin(path: String, body: String): okhttp3.Response { + val payload = { body.toRequestBody("application/json".toMediaType()) } + val first = post(path, payload(), csrfToken()) + if (first.code != 401) return first + first.close() + ensureSession() + return post(path, payload(), csrfToken()) + } + + private fun get(path: String, client: OkHttpClient = this.client): okhttp3.Response = + client.newCall(Request.Builder().url("$BASE$path").build()).execute() + + private fun post( + path: String, + body: RequestBody, + csrf: String, + client: OkHttpClient = this.client, + ): okhttp3.Response = + client.newCall( + Request.Builder() + .url("$BASE$path") + .header(CSRF_HEADER, csrf) + .post(body) + .build(), + ).execute() + + private fun put(path: String, body: String, csrf: String): okhttp3.Response = + client.newCall( + Request.Builder() + .url("$BASE$path") + .header(CSRF_HEADER, csrf) + .put(body.toRequestBody("application/json".toMediaType())) + .build(), + ).execute() + + private inline fun postJson(path: String, body: T): okhttp3.Response { + val payload = json.encodeToString( + kotlinx.serialization.serializer(), + body, + ).toRequestBody("application/json".toMediaType()) + return client.newCall(Request.Builder().url("$BASE$path").post(payload).build()).execute() + } + + private inline fun decode(resp: okhttp3.Response): T = + json.decodeFromString(kotlinx.serialization.serializer(), resp.body.string()) + + private fun errorBody(resp: okhttp3.Response): String = runCatching { + val text = resp.body.string() + runCatching { + json.decodeFromString(ApiError.serializer(), text).error + }.getOrDefault(text) + }.getOrDefault("(no body)").take(500) + + companion object { + /** `status` values [updateApp] takes — start and stop an app. */ + const val APP_STOP = 0 + const val APP_START = 1 + + private const val BASE = "http://127.0.0.1:8000" + private const val CSRF_HEADER = "X-CSRF-Token" + + /** + * The visor's "that feature is off here" answer — and, on the + * `/visors/{pk}/…` routes, "that visor is reconnecting". + */ + private const val HTTP_UNAVAILABLE = 503 + + /** Enough to outlast a hypervisor-client redial (measured ~4 s). */ + private const val RESTART_ATTEMPTS = 4 + private const val RESTART_RETRY_MS = 3_000L + + @Volatile private var instance: VisorApi? = null + + fun get(context: Context): VisorApi = + instance ?: synchronized(this) { + instance ?: VisorApi(context.applicationContext).also { instance = it } + } + } +} diff --git a/android/app/src/main/java/com/skycoin/skywire/api/VisorModels.kt b/android/app/src/main/java/com/skycoin/skywire/api/VisorModels.kt new file mode 100644 index 0000000000..44ab92621e --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/api/VisorModels.kt @@ -0,0 +1,293 @@ +package com.skycoin.skywire.api + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +/** + * DTOs for the visor's local API. Only the fields the app renders are + * declared — the decoder ignores everything else, so server-side additions + * never break the client. + */ + +@Serializable +data class About( + @SerialName("public_key") val publicKey: String, + @SerialName("dmsg_connected") val dmsgConnected: Boolean = false, + @SerialName("dmsg_sessions") val dmsgSessions: Int = 0, +) + +@Serializable +data class VisorSummary( + @SerialName("overview") val overview: Overview = Overview(), + @SerialName("health") val health: HealthInfo? = null, + /** Seconds since visor start. */ + @SerialName("uptime") val uptime: Double = 0.0, + @SerialName("dmsg_servers") val dmsgServers: List = emptyList(), + @SerialName("build_tag") val buildTag: String = "", + @SerialName("config_version") val configVersion: String = "", + /** + * Whether this visor answered the summary RPC. Only meaningful in the + * `/api/visors-summary` list, where an offline visor is still listed — + * served from the last snapshot, with every other field stale. + */ + @SerialName("online") val online: Boolean = false, + /** True for the visor serving the API — this phone, in the Fleet list. */ + @SerialName("is_hypervisor") val isHypervisor: Boolean = false, + /** RFC 3339. Last successful summary; absent for a never-seen visor. */ + @SerialName("last_seen_at") val lastSeenAt: String? = null, + /** RFC 3339, set only while [online] is false. */ + @SerialName("offline_since") val offlineSince: String? = null, +) + +@Serializable +data class Overview( + @SerialName("local_pk") val localPk: String = "", + @SerialName("build_info") val buildInfo: BuildInfo? = null, + @SerialName("apps") val apps: List = emptyList(), + @SerialName("transports") val transports: List = emptyList(), + @SerialName("local_ip") val localIp: String = "", + /** + * The address the visor's STUN probe saw at startup — the phone's + * UNDERLAY address, and not a field to present as "your IP" without + * saying so: the app excludes its own UID from the SkyVPN tunnel (it + * carries that tunnel), so this reads the same whether or not SkyVPN is + * connected. It also carries two non-IP shapes — empty behind symmetric + * NAT, and the NAT-type word itself when the probe failed — which is why + * [publicIpOrNull] exists rather than reading this directly. + */ + @SerialName("public_ip") val publicIp: String = "", + /** Typo is the wire format's, not ours (`is_symmetic_nat` in api.go). */ + @SerialName("is_symmetic_nat") val isSymmetricNat: Boolean = false, + @SerialName("nat_type") val natType: String = "", + /** Where the visor appears to be, resolved by a dmsg server at startup. */ + @SerialName("country_code") val countryCode: String = "", +) { + /** + * [publicIp] when it is actually an address, else null. + * + * The visor writes the NAT type into this field when STUN fails + * (`api_visor.go`: NATError/NATUnknown/NATBlocked all store + * `NATType.String()`), and leaves it empty behind symmetric NAT. Both + * would otherwise be rendered to the user as though they were addresses. + */ + val publicIpOrNull: String? + get() = publicIp.takeIf { candidate -> + candidate.isNotEmpty() && + candidate.any { it == '.' || it == ':' } && + candidate.all { it.isDigit() || it == '.' || it == ':' || it in 'a'..'f' || it in 'A'..'F' } + } +} + +/** + * One live transport. A phone's are almost always dmsg, but which carrier + * actually came up is the first thing to look at when a route won't build — + * hence the type, not just a count. + */ +@Serializable +data class TransportSummary( + @SerialName("id") val id: String = "", + @SerialName("remote_pk") val remotePk: String = "", + @SerialName("type") val type: String = "", + @SerialName("latency_ms") val latencyMs: Double = 0.0, +) + +@Serializable +data class BuildInfo( + @SerialName("version") val version: String = "", + @SerialName("commit") val commit: String = "", + @SerialName("date") val date: String = "", +) + +@Serializable +data class AppState( + @SerialName("name") val name: String, + /** 0 stopped · 1 running · 2 errored · 3 starting. */ + @SerialName("status") val status: Int = 0, + @SerialName("detailed_status") val detailedStatus: String = "", + @SerialName("auto_start") val autoStart: Boolean = false, + @SerialName("port") val port: Int = 0, + /** + * Launcher argv. The API always sends the array form — the + * space-joined string in the config file is an on-disk-only + * rendering that never reaches this client. + */ + @SerialName("args") val args: List = emptyList(), +) { + val running: Boolean get() = status == STATUS_RUNNING + + companion object { + const val STATUS_RUNNING = 1 + const val STATUS_ERRORED = 2 + const val STATUS_STARTING = 3 + } +} + +/** + * One service-discovery entry, as proxied verbatim from SD by + * `/api/svc-fetch`. Only the fields the list renders are declared. + */ +@Serializable +data class ServiceEntry( + /** `":"`. */ + @SerialName("address") val address: String = "", + @SerialName("type") val type: String = "", + @SerialName("geo") val geo: GeoInfo? = null, + @SerialName("version") val version: String = "", +) { + val pk: String get() = address.substringBefore(':') +} + +@Serializable +data class GeoInfo( + @SerialName("country") val country: String = "", + @SerialName("region") val region: String = "", +) + +/** + * One live connection of an app, from `…/apps/{app}/connections`. + * skysocks-client and vpn-client each hold a single connection to their + * server, so the first element is the one the screens render. + */ +@Serializable +data class AppConnection( + @SerialName("is_alive") val isAlive: Boolean = false, + /** + * Milliseconds, despite the field being a Go `time.Duration`: the visor + * converts before serializing (`ConnectionsSummary` in + * pkg/app/appserver/proc.go), so the number on the wire is already a + * millisecond count and must not be divided again. + */ + @SerialName("latency") val latencyMs: Long = 0, + /** Bytes per second. */ + @SerialName("upload_speed") val uploadSpeed: Long = 0, + @SerialName("download_speed") val downloadSpeed: Long = 0, + @SerialName("bandwidth_sent") val bandwidthSent: Long = 0, + @SerialName("bandwidth_received") val bandwidthReceived: Long = 0, + /** Seconds the tunnel has been carrying traffic; 0 when it is not. */ + @SerialName("connection_duration") val connectionSeconds: Long = 0, + @SerialName("error") val error: String = "", +) + +/** + * GET …/apps/{app}/stats. [startTime] is when the app's process started — + * absent while it isn't running, and earlier than the tunnel's own uptime + * ([AppConnection.connectionSeconds]), which only starts at "connected". + */ +@Serializable +data class AppStats( + @SerialName("connections") val connections: List? = null, + /** RFC 3339, as Go renders a `*time.Time`. */ + @SerialName("start_time") val startTime: String? = null, +) + +@Serializable +data class HealthInfo( + @SerialName("services_health") val servicesHealth: String = "", + @SerialName("uptime_tracker_health") val uptimeTrackerHealth: String = "", + @SerialName("autoconnect_health") val autoconnectHealth: String = "", + @SerialName("transportability_health") val transportabilityHealth: String = "", +) + +/** + * One dmsg server this visor holds a session with, from the summary's + * `dmsg_servers`. The visor answers it from its own live session list, so + * it is populated whenever dmsg is up — unlike `/api/dmsg`, whose entries + * come from the round-trip tracker and need a dmsgctrl dial back to the + * tracked visor (for the local one, a self-dial that the phone never wins: + * "dmsg error 202 — cannot connect to delegated server"). + */ +@Serializable +data class DmsgServerInfo( + @SerialName("pk") val pk: String = "", + /** + * Nanoseconds (Go time.Duration). Measured by a self-ping through the + * server, hourly — 0 until the first one lands, and it can stay 0 on a + * phone, so it is only rendered when > 0. + */ + @SerialName("latency") val latencyNs: Long = 0, + /** Raw session carrier: tcp | ws | wt | quic. */ + @SerialName("carrier") val carrier: String = "", + /** Human-readable form of [carrier] (e.g. "tcp", "wss", "quic"). */ + @SerialName("protocol") val protocol: String = "", +) + +/** + * GET/PUT …/router-settings — the visor-wide router knobs, as one struct. + * The PUT applies *every* field, so a caller changing one must send the + * others back unchanged (see [VisorApi.setTransportPreference]). + */ +@Serializable +data class RouterSettings( + @SerialName("force_local_routes") val forceLocalRoutes: Boolean = false, + @SerialName("existing_tp_only") val existingTpOnly: Boolean = false, + @SerialName("mux_routes") val muxRoutes: Int = 0, + @SerialName("min_hops") val minHops: Int = 0, + /** Transport-type priority order, most-preferred first. */ + @SerialName("transport_preference") val transportPreference: List = emptyList(), +) + +@Serializable +data class ServiceHealthEntry( + @SerialName("name") val name: String = "", + @SerialName("status") val status: String = "", + @SerialName("latency_ms") val latencyMs: Double = 0.0, + @SerialName("transport") val transport: String = "", + @SerialName("error") val error: String = "", +) + +/** GET …/runtime-logs?since=N — `entries` is JSON null on an empty buffer. */ +@Serializable +data class RuntimeLogsDelta( + @SerialName("entries") val entries: List? = null, + @SerialName("latest") val latest: Long = 0, + @SerialName("dropped") val dropped: Long = 0, +) + +@Serializable +data class AppLogs( + @SerialName("last_log_timestamp") val lastLogTimestamp: String = "", + @SerialName("logs") val logs: List = emptyList(), +) + +/** One call being placed, from `…/skychat/voice/dialing`. */ +@Serializable +internal data class VoiceDialing( + @SerialName("call_id") val callId: String = "", + @SerialName("peer") val peer: String = "", +) + +/** + * A ringing inbound call. The visor formats these as `" from "` + * — one string, because the surface it was built for is a CLI listing — so the + * shape is parsed here rather than deserialized. + */ +data class VoiceInvite(val callId: String, val fromPk: String) { + companion object { + private const val SEPARATOR = " from " + + /** null when the line isn't the expected shape, so a poll can skip it. */ + fun parse(line: String): VoiceInvite? { + val at = line.indexOf(SEPARATOR) + if (at <= 0) return null + val id = line.substring(0, at).trim() + val pk = line.substring(at + SEPARATOR.length).trim() + return if (id.isEmpty() || pk.isEmpty()) null else VoiceInvite(id, pk) + } + } +} + +@Serializable +internal data class Credentials( + @SerialName("username") val username: String, + @SerialName("password") val password: String, +) + +@Serializable +internal data class UserExists(@SerialName("exists") val exists: Boolean = false) + +@Serializable +internal data class CsrfToken(@SerialName("csrf_token") val token: String = "") + +@Serializable +internal data class ApiError(@SerialName("error") val error: String = "") diff --git a/android/app/src/main/java/com/skycoin/skywire/core/AppArgs.kt b/android/app/src/main/java/com/skycoin/skywire/core/AppArgs.kt new file mode 100644 index 0000000000..9457e66ec7 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/core/AppArgs.kt @@ -0,0 +1,61 @@ +package com.skycoin.skywire.core + +/** + * Argv editing for the app flags the phone owns in the visor config. + * + * Every app profile ([SkychatProfile], [SkydexProfile]) does the same two + * things to the argv the generator produced — read a flag's value, and force a + * flag to the value this device requires — so the parsing lives once. Both + * spellings the config can carry are accepted: `--flag value` and `--flag=value`. + */ + +/** + * Set [flag]'s value to what [next] returns for the current one (null when the + * flag is absent), appending the flag if it wasn't there. Returning null leaves + * the existing value untouched. + */ +internal fun MutableList.pinValue(flag: List, next: (String?) -> String?) { + val i = indexOfFirst { token -> flag.any { token == it || token.startsWith("$it=") } } + when { + i < 0 -> next(null)?.let { this += listOf(flag.first(), it) } + this[i].contains('=') -> + next(this[i].substringAfter('='))?.let { this[i] = flag.first() + "=" + it } + i + 1 < size -> next(this[i + 1])?.let { this[i + 1] = it } + // Trailing flag with no value: a broken argv the visor would reject + // anyway — complete it rather than shifting everything. + else -> next(null)?.let { this += it } + } +} + +/** First value of any of [flags] in [args], or null when none is present. */ +internal fun argValue(args: List, flags: List): String? { + args.forEachIndexed { i, token -> + flags.forEach { flag -> + if (token.startsWith("$flag=")) return token.substringAfter('=') + if (token == flag && i + 1 < args.size) return args[i + 1] + } + } + return null +} + +/** + * The host every listener the phone starts must bind. Android has no per-app + * network namespace, so a listener on any other address is one every device on + * the same network can reach; loopback is the narrowest an app can ask for. + */ +internal const val LOOPBACK_HOST = "127.0.0.1" + +/** + * `:` with the host forced to [LOOPBACK_HOST], keeping whatever + * port [current] carries and falling back to [defaultPort] when it has none. + * Returns null for a value it cannot parse — a malformed address only gets + * worse from rewriting, and the visor reports it. + */ +internal fun loopbackAddr(current: String?, defaultPort: Int): String? { + val port = current?.substringAfterLast(':')?.toIntOrNull() + return when { + port != null -> "$LOOPBACK_HOST:$port" + current == null -> "$LOOPBACK_HOST:$defaultPort" + else -> null + } +} diff --git a/android/app/src/main/java/com/skycoin/skywire/core/AppLock.kt b/android/app/src/main/java/com/skycoin/skywire/core/AppLock.kt new file mode 100644 index 0000000000..1f6886f001 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/core/AppLock.kt @@ -0,0 +1,73 @@ +package com.skycoin.skywire.core + +import android.os.SystemClock +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Whether the app is currently behind its biometric lock. + * + * A process-level object because the lock is a property of the app, not of any + * one screen: the Activity is recreated on every rotation and theme change, and + * a lock that reset with it would be no lock at all. + * + * The state starts *locked*. A fresh process is exactly the case that must ask + * — the phone was rebooted, the app was killed, someone launched it cold — and + * starting unlocked would mean every process death is a free pass. The gate + * ignores this entirely while the preference is off, so the cost of that + * default is nothing for the users who never turn the lock on. + */ +object AppLock { + + /** [AppPreferences] key holding the user's choice. */ + const val PREF_KEY = "app_lock_enabled" + + /** Off until asked for. */ + const val DEFAULT = false + + /** + * How long the app may be away before it locks again. + * + * The point is the interruption, not the absence: answering a message, + * copying a code out of another app and coming back is one task, and a + * lock that fires in the middle of it is the reason people turn locks off. + * Half a minute is long enough for that round trip and far short of + * "someone else picked up the phone". + */ + const val GRACE_MS = 30_000L + + private val locked = MutableStateFlow(true) + val isLocked: StateFlow = locked.asStateFlow() + + /** + * When the app last went to the background. Zero until it has — which is + * what makes a cold start lock: `now - 0` is past any grace period. + */ + @Volatile + private var leftAt = 0L + + /** From the Activity's `onStop`. */ + fun onBackground() { + leftAt = SystemClock.elapsedRealtime() + } + + /** + * From the Activity's `onStart`. Re-locks unless the user has been gone for + * less than [GRACE_MS]. Runs whether or not the lock is enabled — the gate + * is what consults the preference — so that turning the lock on does not + * have to reconstruct a history of absences it never watched. + */ + fun onForeground() { + if (SystemClock.elapsedRealtime() - leftAt > GRACE_MS) locked.value = true + } + + /** + * A biometric check just passed. Also called when the user turns the lock + * *on*, which is gated by the same check: having just proved themselves, + * they should not be asked twice in a row. + */ + fun unlock() { + locked.value = false + } +} diff --git a/android/app/src/main/java/com/skycoin/skywire/core/AppPreferences.kt b/android/app/src/main/java/com/skycoin/skywire/core/AppPreferences.kt new file mode 100644 index 0000000000..33dc24672c --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/core/AppPreferences.kt @@ -0,0 +1,48 @@ +package com.skycoin.skywire.core + +import android.content.Context +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private val Context.settingsDataStore by preferencesDataStore(name = "settings") + +/** + * Non-secret, user-facing preferences — the app-screen state that has to + * survive a process death (last-used server, last chosen options). + * Deliberately separate from [SecretStore]'s store: nothing here is + * encrypted, and nothing here is worth encrypting. + */ +class AppPreferences(context: Context) { + + private val store = context.applicationContext.settingsDataStore + + fun string(key: String): Flow = + store.data.map { it[stringPreferencesKey(key)] } + + suspend fun putString(key: String, value: String?) { + store.edit { prefs -> + val pref = stringPreferencesKey(key) + if (value == null) prefs.remove(pref) else prefs[pref] = value + } + } + + /** [fallback] is what an unset key reads as — there is no "absent". */ + fun boolean(key: String, fallback: Boolean = false): Flow = + store.data.map { it[booleanPreferencesKey(key)] ?: fallback } + + suspend fun putBoolean(key: String, value: Boolean) { + store.edit { it[booleanPreferencesKey(key)] = value } + } + + fun long(key: String, fallback: Long = 0L): Flow = + store.data.map { it[longPreferencesKey(key)] ?: fallback } + + suspend fun putLong(key: String, value: Long) { + store.edit { it[longPreferencesKey(key)] = value } + } +} diff --git a/android/app/src/main/java/com/skycoin/skywire/core/AppVisibility.kt b/android/app/src/main/java/com/skycoin/skywire/core/AppVisibility.kt new file mode 100644 index 0000000000..eb47ebef73 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/core/AppVisibility.kt @@ -0,0 +1,39 @@ +package com.skycoin.skywire.core + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Whether the user is looking at this app right now. + * + * It decides how a call announces itself, and the two answers are genuinely + * different UIs: on screen, the app draws the call itself; off screen, the only + * way to put a call in front of someone is to ask the system to bring the + * Activity up (see the full-screen intent in [VoiceCallWatcher]). + */ +object AppVisibility { + + private val foreground = MutableStateFlow(false) + private val resumeCount = MutableStateFlow(0) + + val isForeground: StateFlow = foreground.asStateFlow() + + /** + * Bumped on every Activity resume. [isForeground] cannot carry this: + * a system dialog over the app (the battery-exemption request, a + * permission prompt) only pauses the Activity, so start/stop — and + * with it the foreground flag — never changes, and anything that + * needs to re-read system state "when the user comes back" would + * never run. Collect this instead for that. + */ + val resumes: StateFlow = resumeCount.asStateFlow() + + fun set(visible: Boolean) { + foreground.value = visible + } + + fun onResumed() { + resumeCount.value += 1 + } +} diff --git a/android/app/src/main/java/com/skycoin/skywire/core/BatteryOptimization.kt b/android/app/src/main/java/com/skycoin/skywire/core/BatteryOptimization.kt new file mode 100644 index 0000000000..d6b333be2c --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/core/BatteryOptimization.kt @@ -0,0 +1,96 @@ +package com.skycoin.skywire.core + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.PowerManager +import android.provider.Settings + +/** + * Whether the system will let the visor keep running when the screen has been + * off for a while, and how to ask it to. + * + * **What Doze actually does to us.** The core runs in a foreground service, so + * it is never killed for being in the background — that is the baseline and it + * is already in place. Doze is a different mechanism: after the screen has + * been off and the phone still for a while, the system suspends network access + * and defers alarms for apps that are not exempt, in maintenance-window + * batches. A foreground service does not opt out of that. + * + * For a short nap this costs nothing worth reporting. dmsg sessions are TCP + * connections with their own keepalives; they survive a suspended radio the + * way any idle connection does, and when the window opens the traffic resumes. + * Over a long idle period they do drop, and the visor reconnects — the app's + * own supervisor also restarts the child if it exits. What the user sees is a + * gap: messages that arrive during a deep Doze land when the phone next wakes, + * not when they were sent. + * + * The exemption removes that gap. It is a real cost in battery and the user is + * the only one who can weigh it, so this is offered and explained, never + * assumed — the app works without it, and the prompt does not come back once + * it has been dismissed. + */ +object BatteryOptimization { + + /** Preference remembering that the user was asked and said no. */ + const val PREF_DISMISSED = "battery_prompt_dismissed" + + /** + * True when the system has been told to leave this app alone. Also true on + * the handful of devices with no power manager to ask, which is the right + * answer for a check whose only use is deciding whether to offer a prompt. + */ + fun isExempt(context: Context): Boolean { + val pm = context.getSystemService(Context.POWER_SERVICE) as? PowerManager ?: return true + return pm.isIgnoringBatteryOptimizations(context.packageName) + } + + /** + * The one-tap system dialog for this app. Needs + * REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, which Google Play restricts to + * apps whose core function genuinely requires it — a VPN service and an + * always-on peer-to-peer node are both on the permitted list, and this app + * is both. It is offered from Settings behind an explanation rather than + * thrown at the user on first launch, which is the part of the policy that + * is about behaviour rather than eligibility. + * + * Suppressed lint: the warning exists to catch apps asking for this + * without cause. The cause is in the class doc above. + */ + @SuppressLint("BatteryLife") + fun requestIntent(context: Context): Intent = + Intent( + Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, + Uri.parse("package:${context.packageName}"), + ) + + /** + * The full list of apps, for OEM builds where the direct request above is + * missing or refuses to resolve. One more tap for the user — they have to + * find Skywire in the list — but it exists everywhere the direct one does + * not. + */ + fun settingsIntent(): Intent = Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS) + + /** + * Start whichever of the two the device actually has. Returns false when + * neither resolves, so the caller can say so rather than appearing to do + * nothing. + * + * FLAG_ACTIVITY_NEW_TASK is not optional here. Both callers are view + * models and hold the Application, and `startActivity` from a non-Activity + * context throws without it — which is exactly what made every tap on + * Allow do nothing on Home and report "this phone has no battery screen" + * in Settings. The flag is skipped for a real Activity so the system + * dialog still opens over the app rather than as a task of its own. + */ + fun openRequest(context: Context): Boolean { + for (intent in listOf(requestIntent(context), settingsIntent())) { + if (context !is Activity) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + if (runCatching { context.startActivity(intent) }.isSuccess) return true + } + return false + } +} diff --git a/android/app/src/main/java/com/skycoin/skywire/core/ChatMedia.kt b/android/app/src/main/java/com/skycoin/skywire/core/ChatMedia.kt new file mode 100644 index 0000000000..869f38fe37 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/core/ChatMedia.kt @@ -0,0 +1,315 @@ +package com.skycoin.skywire.core + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.media.MediaMetadata +import android.media.session.MediaSession +import android.media.session.PlaybackState +import android.os.Handler +import android.os.Looper +import android.util.Base64 +import android.webkit.JavascriptInterface +import android.webkit.WebView +import com.skycoin.skywire.MainActivity +import com.skycoin.skywire.R +import org.json.JSONObject +import java.lang.ref.WeakReference + +/** + * Media controls in the notification shade for whatever SkyChat is playing. + * + * **Why this exists at all.** The chat UI is a web page, and the web already + * has the right API for this: `navigator.mediaSession`, which on a desktop + * browser and on Chrome for Android puts the playing clip on the lock screen + * and under the media keys. Android **WebView implements that API and then + * connects it to nothing** — the JS calls succeed, the metadata is accepted, + * and no session ever reaches the platform. Measured on-device while a voice + * message was playing from the embedded page: `dumpsys media_session` reported + * `Sessions Stack - have 0 sessions` at the same moment `Audio playback` named + * this package. The bridging code lives in Chrome-the-browser, not in WebView. + * + * So the page reports what it is playing across a JS interface, and this posts + * the session and the notification that WebView will not. The page keeps its + * `navigator.mediaSession` calls either way — they are what works when the same + * UI is opened in a real browser, and they cost nothing here. + * + * No service of its own: [SkywireCoreService] is already a foreground service + * whenever there is a chat to play from, so the process is foreground and its + * audio is allowed to keep going. A media service on top would be a second + * always-on notification for something that only exists while a clip plays. + */ +object ChatMedia { + + private const val CHANNEL_ID = "chat-media" + private const val NOTIFICATION_ID = 3 + + /** What the notification's skip buttons move by. */ + private const val SEEK_STEP_MS = 10_000L + + private const val ACTION_PLAY = "com.skycoin.skywire.media.PLAY" + private const val ACTION_PAUSE = "com.skycoin.skywire.media.PAUSE" + private const val ACTION_BACK = "com.skycoin.skywire.media.BACK" + private const val ACTION_FORWARD = "com.skycoin.skywire.media.FORWARD" + private const val ACTION_STOP = "com.skycoin.skywire.media.STOP" + + private val main = Handler(Looper.getMainLooper()) + + private var session: MediaSession? = null + private var page: WeakReference? = null + + @Volatile + private var showing = false + + /** + * Give the chat page a way to report what it is playing, and this a way to + * drive it back. Called once per WebView; [detach] undoes it. + */ + fun attach(view: WebView) { + page = WeakReference(view) + view.addJavascriptInterface(Bridge(view.context.applicationContext), "SkywireMedia") + } + + /** + * The page is going away. The notification goes with it: the WebView is + * the player, so a shade full of controls for a destroyed page would be + * buttons that do nothing. + */ + fun detach(view: WebView) { + if (page?.get() === view) page = null + runCatching { view.removeJavascriptInterface("SkywireMedia") } + clear(view.context.applicationContext) + } + + /** Route a notification button back into the page. */ + fun onAction(context: Context, action: String) { + when (action) { + ACTION_PLAY -> call("play") + ACTION_PAUSE -> call("pause") + ACTION_BACK -> call("seekby", (-SEEK_STEP_MS / 1000).toString()) + ACTION_FORWARD -> call("seekby", (SEEK_STEP_MS / 1000).toString()) + ACTION_STOP -> { + call("stop") + clear(context) + } + } + } + + // --- the page's side --- + + /** + * The JS interface the page talks to. Every method is called on a WebView + * worker thread, so everything it touches hops to the main thread first — + * a JavascriptInterface method that touched the WebView directly would + * crash. + */ + private class Bridge(private val context: Context) { + + @JavascriptInterface + fun update(json: String) { + val state = runCatching { JSONObject(json) }.getOrNull() ?: return + main.post { show(context, state) } + } + + @JavascriptInterface + fun clear() { + main.post { clear(context) } + } + } + + private fun call(action: String, value: String? = null) { + main.post { + val view = page?.get() ?: return@post + val arg = if (value == null) "" else ", ${JSONObject.quote(value)}" + view.evaluateJavascript( + "window.app && app.mediaAction && app.mediaAction(${JSONObject.quote(action)}$arg)", + null, + ) + } + } + + // --- the shade's side --- + + private fun show(context: Context, state: JSONObject) { + val playing = state.optBoolean("playing", false) + val positionMs = (state.optDouble("position", 0.0) * 1000).toLong() + val durationMs = (state.optDouble("duration", 0.0) * 1000).toLong() + // The rate matters to more than the audio: the shade's own scrubber + // advances at whatever speed the session reports, so a clip sped up in + // the page and left at 1x here would drift visibly against itself. + val rate = state.optDouble("rate", 1.0).toFloat().takeIf { it > 0f } ?: 1f + + val session = session(context) + session.setMetadata( + MediaMetadata.Builder() + .putString(MediaMetadata.METADATA_KEY_TITLE, state.optString("title", "Audio")) + .putString(MediaMetadata.METADATA_KEY_ARTIST, state.optString("artist", "SkyChat")) + .putString(MediaMetadata.METADATA_KEY_ALBUM, "SkyChat") + .apply { + // A duration of -1 tells the shade "unknown", which draws no + // scrubber at all — better than a bar that cannot move. A + // clip recorded in the composer has no duration in its + // header until the page has scanned it. + putLong( + MediaMetadata.METADATA_KEY_DURATION, + if (durationMs > 0) durationMs else -1L, + ) + artwork(state.optString("artwork"))?.let { + putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, it) + } + } + .build(), + ) + session.setPlaybackState( + PlaybackState.Builder() + .setActions( + PlaybackState.ACTION_PLAY or PlaybackState.ACTION_PAUSE or + PlaybackState.ACTION_PLAY_PAUSE or PlaybackState.ACTION_SEEK_TO or + PlaybackState.ACTION_STOP or PlaybackState.ACTION_REWIND or + PlaybackState.ACTION_FAST_FORWARD, + ) + // Position plus rate, not position alone: the system + // extrapolates the scrubber from these two, which is why the + // page does not have to report a position four times a second. + .setState( + if (playing) PlaybackState.STATE_PLAYING else PlaybackState.STATE_PAUSED, + positionMs, + if (playing) rate else 0f, + ) + .build(), + ) + session.isActive = true + + notificationManager(context).notify( + NOTIFICATION_ID, + notification(context, session, state, playing), + ) + showing = true + } + + private fun clear(context: Context) { + if (!showing && session == null) return + showing = false + notificationManager(context).cancel(NOTIFICATION_ID) + session?.let { live -> + live.isActive = false + live.release() + } + session = null + } + + private fun notification( + context: Context, + session: MediaSession, + state: JSONObject, + playing: Boolean, + ): Notification { + channel(context) + val open = PendingIntent.getActivity( + context, + 0, + Intent(context, MainActivity::class.java).setAction(Intent.ACTION_MAIN), + PendingIntent.FLAG_IMMUTABLE, + ) + return Notification.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.skywire_logo) + .setContentTitle(state.optString("title", "Audio")) + .setContentText(state.optString("artist", "SkyChat")) + .setContentIntent(open) + .setDeleteIntent(button(context, ACTION_STOP)) + .setVisibility(Notification.VISIBILITY_PUBLIC) + .setOnlyAlertOnce(true) + .setOngoing(playing) + .addAction( + action(context, R.drawable.ic_media_back, "Back 10s", ACTION_BACK), + ) + .addAction( + if (playing) { + action(context, R.drawable.ic_media_pause, "Pause", ACTION_PAUSE) + } else { + action(context, R.drawable.ic_media_play, "Play", ACTION_PLAY) + }, + ) + .addAction( + action(context, R.drawable.ic_media_forward, "Forward 10s", ACTION_FORWARD), + ) + .setStyle( + Notification.MediaStyle() + .setMediaSession(session.sessionToken) + // All three in the collapsed row: skipping is most of what + // a voice message needs, and hiding it behind an expand + // makes it slower than opening the app. + .setShowActionsInCompactView(0, 1, 2), + ) + .build() + } + + private fun action(context: Context, icon: Int, title: String, act: String): Notification.Action = + Notification.Action.Builder( + android.graphics.drawable.Icon.createWithResource(context, icon), + title, + button(context, act), + ).build() + + private fun button(context: Context, act: String): PendingIntent = PendingIntent.getBroadcast( + context, + act.hashCode(), + Intent(context, ChatMediaReceiver::class.java).setAction(act), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + + private fun session(context: Context): MediaSession = + session ?: MediaSession(context, "SkyChat").apply { + setCallback(object : MediaSession.Callback() { + override fun onPlay() = call("play") + override fun onPause() = call("pause") + override fun onStop() { + call("stop") + clear(context) + } + + override fun onRewind() = call("seekby", (-SEEK_STEP_MS / 1000).toString()) + override fun onFastForward() = call("seekby", (SEEK_STEP_MS / 1000).toString()) + + /** The shade's scrubber, dragged. */ + override fun onSeekTo(pos: Long) = call("seekto", (pos / 1000.0).toString()) + }) + }.also { session = it } + + /** + * The peer avatar the page sent, as a bitmap. It arrives as whatever the + * page had on screen: a `data:` URL for a picture the user set, or a URL + * for the default. Only the first is decoded — fetching over HTTP from + * here would need the chat's own credential, and a missing thumbnail is + * not worth that. + */ + private fun artwork(src: String?): Bitmap? { + val payload = src?.substringAfter("base64,", "")?.takeIf { it.isNotEmpty() } ?: return null + return runCatching { + val bytes = Base64.decode(payload, Base64.DEFAULT) + BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + }.getOrNull() + } + + private fun notificationManager(context: Context): NotificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + private fun channel(context: Context) { + val channel = NotificationChannel( + CHANNEL_ID, + context.getString(R.string.chat_media_channel_name), + // Transport controls, not an alert: this appears because the user + // pressed play, so it must never make a sound of its own. + NotificationManager.IMPORTANCE_LOW, + ).apply { + description = context.getString(R.string.chat_media_channel_description) + setShowBadge(false) + } + notificationManager(context).createNotificationChannel(channel) + } +} diff --git a/android/app/src/main/java/com/skycoin/skywire/core/ChatMediaReceiver.kt b/android/app/src/main/java/com/skycoin/skywire/core/ChatMediaReceiver.kt new file mode 100644 index 0000000000..77a2d348c7 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/core/ChatMediaReceiver.kt @@ -0,0 +1,16 @@ +package com.skycoin.skywire.core + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent + +/** + * The notification's transport buttons. A receiver rather than a service: + * every one of these is a single call into the page that is already running, + * with nothing to keep alive afterwards. + */ +class ChatMediaReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + intent.action?.let { ChatMedia.onAction(context.applicationContext, it) } + } +} diff --git a/android/app/src/main/java/com/skycoin/skywire/core/ConfigManager.kt b/android/app/src/main/java/com/skycoin/skywire/core/ConfigManager.kt new file mode 100644 index 0000000000..f2d38b6087 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/core/ConfigManager.kt @@ -0,0 +1,540 @@ +package com.skycoin.skywire.core + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import java.io.File +import java.util.concurrent.TimeUnit + +/** + * Generates and maintains the visor config, on-device, with the same binary + * that runs the visor. Nothing is hand-written: `config gen` produces the + * file, then [applyPhoneProfile] enforces the phone constraints the + * generator has no flags for. + */ +class ConfigManager(private val paths: SkywirePaths, private val secrets: SecretStore) { + + /** + * Opens the config when it is sealed at rest. Held here rather than passed + * in because *every* path that touches the config has to go through it: + * [ensureConfig] treats a missing file as first-run and would generate a + * fresh identity over a sealed one, which is the single worst thing this + * class could do. + */ + private val vault = ConfigVault(paths) + + data class CommandResult(val exitCode: Int, val output: String, val timedOut: Boolean) { + val ok get() = exitCode == 0 && !timedOut + } + + private val json = Json { prettyPrint = true } + + /** + * Make sure a phone-profile config exists. Generation runs only when the + * file is missing; the profile edits are re-applied every time (cheap, + * idempotent) so the security-relevant pins survive any config rewrite + * the visor itself performs at runtime. + */ + suspend fun ensureConfig( + transportPrimary: String, + fleetEnabled: Boolean, + logLevel: String, + ): Result = withContext(Dispatchers.IO) { + paths.ensureDirs() + // Before the existence check below, which is what decides whether to + // generate a new identity. + vault.unseal().getOrElse { return@withContext Result.failure(it) } + if (!paths.visorBinary.canExecute()) { + return@withContext Result.failure( + IllegalStateException("core binary missing or not executable: ${paths.visorBinary}"), + ) + } + if (!paths.configFile.exists()) { + val gen = runGen() + if (!gen.ok) { + return@withContext Result.failure( + IllegalStateException( + "config gen failed (exit ${gen.exitCode}${if (gen.timedOut) ", timed out" else ""}):\n" + + gen.output.takeLast(4000), + ), + ) + } + } + try { + applyPhoneProfile( + transportPrimary, + fleetEnabled, + logLevel, + skychatPasswordFile = ensureGatePassword( + SkychatProfile.passwordFile(paths), + secrets.skychatPassword(), + ), + skydexPasswordFile = ensureGatePassword( + SkydexProfile.passwordFile(paths), + secrets.skydexPassword(), + ), + ) + Result.success(paths.configFile) + } catch (e: Exception) { + // A file that exists but does not parse would otherwise crash-loop + // the visor with an opaque fatal — fail here with a usable message. + Result.failure( + IllegalStateException( + "config file is unreadable (${e.message}) — clearing app data regenerates it", + e, + ), + ) + } + } + + /** + * `config gen` argv. `-r` retains the secret key across regens (and is + * safe on first run); `--hvaddr` pins the API to loopback — the compiled + * default `":8000"` binds every interface; the autostart-off and + * disableapps flags leave exactly the four in-proc apps, all + * user-initiated; `--nofetch` keeps first-run generation offline (the + * visor refreshes service endpoints in-memory at runtime anyway). + */ + private fun genArgs(): List = listOf( + paths.visorBinary.absolutePath, "config", "gen", + "-r", + "-o", paths.configFile.absolutePath, + "-w", + "-i", "--auth", + "--hvaddr", "127.0.0.1:8000", + "--autoconn", + "--servechat=false", "--serveproxy=false", "--servevpn=false", + "--disableapps", "skysocks,vpn-server,vpn-router,skydex-market,skycoin-web", + "--binpath", paths.binDir.absolutePath, + "--nofetch", + ) + + private suspend fun runGen(): CommandResult = runCommand(genArgs(), timeoutSeconds = 90) + + /** + * The edits `config gen` cannot express: + * - `cli_addr: ""` — no RPC listener (an empty flag value falls back to + * localhost:3435, so this must be a post-edit); + * - drop `pty` — dmsgpty would try a unix socket in an unwritable + * system temp dir, and the phone has no use for it; + * - drop `hypervisor.lan_dmsg_server` — force-enabled by `-i`, opens a + * LAN-reachable listener; + * - `hypervisor.dmsg_ingest` — the Fleet opt-in (see [Fleet]), written + * from the app's preference on every launch for the same reason the + * transport order is: the phone, not the file, decides; + * - absolute `local_path` (+ the transport log location derived from + * it) and `hypervisor.db_path`, so nothing depends on the cwd the + * visor happens to get; + * - `launcher.bin_path` pinned to an app-private writable dir, and the + * per-app flags the phone owns (see [pinAppArgs]); + * - drop `skywire-tcp` — skips the `:7777` STCP listener; + * - `dmsgscp.disabled` — on by default when absent, writes scp-root; + * - `tp_viz.enable=false` — cosmetic (field is never read) but keeps + * the config honest about what the phone uses; + * - `routing.transport_preference` — the primary transport the app + * owns (see [TransportPreference]). Written on every launch because + * the app, not the config file, is the source of truth: the visor + * persists its own copy when the setting is changed live, and this + * keeps the two from drifting apart. + * - `log_level` — the same arrangement, for the same reason (see + * [CoreLogLevel]). + */ + private fun applyPhoneProfile( + transportPrimary: String, + fleetEnabled: Boolean, + logLevel: String, + skychatPasswordFile: File, + skydexPasswordFile: File, + ) { + val root = json.parseToJsonElement(paths.configFile.readText()).jsonObject + val edited = buildJsonObject { + for ((key, value) in root) { + when (key) { + "pty", "skywire-tcp" -> Unit // dropped + "cli_addr" -> put(key, JsonPrimitive("")) + "log_level" -> put(key, JsonPrimitive(CoreLogLevel.sanitize(logLevel))) + "local_path" -> put(key, JsonPrimitive(paths.localDir.absolutePath)) + "transport" -> put( + key, + value.jsonObject.edit { + putObject("log_store") { + put( + "location", + JsonPrimitive(File(paths.localDir, "transport_logs").absolutePath), + ) + } + }, + ) + "hypervisor" -> put( + key, + value.jsonObject.edit { + remove("lan_dmsg_server") + put("db_path", JsonPrimitive(File(paths.dataDir, "users.db").absolutePath)) + put(Fleet.CONFIG_KEY, JsonPrimitive(fleetEnabled)) + putObject("tp_viz") { put("enable", JsonPrimitive(false)) } + }, + ) + // Re-pinned on every launch, not just at generation: the + // launcher creates this directory at startup, so a path + // that stopped existing (as the native-library dir does + // on every app update) aborts the visor. + "launcher" -> put( + key, + value.jsonObject.edit { + put("bin_path", JsonPrimitive(paths.binDir.absolutePath)) + this["apps"]?.let { apps -> + this["apps"] = + pinAppArgs(apps, skychatPasswordFile, skydexPasswordFile) + } + }, + ) + "routing" -> put( + key, + value.jsonObject.edit { + this["transport_preference"] = JsonArray( + TransportPreference.order(transportPrimary).map(::JsonPrimitive), + ) + }, + ) + else -> put(key, value) + } + } + putObject("dmsgscp") { put("disabled", JsonPrimitive(true)) } + } + paths.configFile.writeText(json.encodeToString(JsonObject.serializer(), edited)) + } + + /** + * The app flags the phone must own, re-applied on every launch. + * Everything else in each argv — the server key the SkySOCKS screen + * writes, above all — passes through untouched, so this never undoes a + * user's choice. + */ + private fun pinAppArgs( + apps: JsonElement, + skychatPasswordFile: File, + skydexPasswordFile: File, + ): JsonElement { + val list = apps as? JsonArray ?: return apps + return JsonArray( + list.map { entry -> + val app = entry as? JsonObject ?: return@map entry + // On disk the argv is one space-joined string, not an array. + val args = (app["args"] as? JsonPrimitive)?.content.orEmpty().split(" ") + .filter { it.isNotEmpty() } + val name = (app["name"] as? JsonPrimitive)?.content + val pinned = when (name) { + SOCKS_APP -> phoneSocksArgs(args) + SkydexProfile.APP -> SkydexProfile.phoneArgs(args, skydexPasswordFile) + SkychatProfile.APP -> SkychatProfile.phoneArgs( + args, + skychatPasswordFile, + SkychatProfile.historyFile(paths), + ) + else -> return@map entry + } + app.edit { + this["args"] = JsonPrimitive(pinned.joinToString(" ")) + // skychat is the ONE app that must run whether or not the + // user is looking at it. Everything else here is started by + // opening its screen, but a chat app that only runs while + // its tab is open cannot receive anything: no message + // notification, no ringing call, no missed call recorded — + // the phone would simply be offline for chat until tapped. + if (name == SkychatProfile.APP) { + this["auto_start"] = JsonPrimitive(true) + } + } + }, + ) + } + + /** + * A gated app's password file, kept in step with the stored secret. + * Written here rather than through any API route because it has to be in + * place *before* the app is first started — setting it afterwards leaves a + * window in which that app's surface is open to every app on the phone + * (see [SkychatProfile], [SkydexProfile]). + * + * Rewritten only when it does not already stand for [password], so a + * normal launch leaves it alone and a rotated secret (a wiped keystore) + * does not leave the app 401-ing against its own gate. + */ + private fun ensureGatePassword(file: File, password: String): File { + val current = runCatching { file.readText() }.getOrNull() + if (current == null || !PasswordFile.matches(current, password)) { + file.writeText(PasswordFile.record(password)) + } + return file + } + + /** + * - `--addr` host forced to loopback: the generated `:1080` listens on + * every interface, i.e. a SOCKS5 proxy any device on the same Wi-Fi + * could use. Only the host is rewritten — the port is the one knob + * the SkySOCKS screen exposes. + * - `--reconnect` always on: a phone loses mesh routes routinely (a + * cell handover is enough), and without it the app *exits* the + * moment its route group dies, leaving a dead proxy until the user + * notices. With it, the client re-dials in place. + */ + private fun phoneSocksArgs(args: List): List { + val pinned = args.toMutableList() + pinned.pinValue(ADDR) { current -> loopbackAddr(current, DEFAULT_SOCKS_PORT) } + if (pinned.none { it == "--reconnect" || it.startsWith("--reconnect=") }) { + pinned += "--reconnect" + } + return pinned + } + + /** + * Auth bootstrap dead-end recovery: the visor's account DB survives an + * app reinstall of keystore-lost devices; deleting it lets create-account + * run again with the current password. Only call with the visor stopped. + */ + fun deleteUsersDb() { + File(paths.dataDir, "users.db").delete() + } + + // --- identity --- + + /** + * This visor's public key, read from the config on disk. + * + * The API answers the same question, but only while the core runs — and + * the identity screen has to be able to show you who you are before you + * connect, and right after an operation that took the core down. + */ + fun publicKey(): String? = runCatching { + (readConfig()["pk"] as? JsonPrimitive)?.content?.takeIf { it.isNotEmpty() } + }.getOrNull() + + /** + * Derive the public key of [secretKey] — and, in doing so, validate it. + * + * Runs the CLI's own `config pk`, which is the point: key handling stays + * where the key code is. It also has to happen *before* anything is + * touched, because `config gen` will not report a bad key — handed an SK + * whose public half will not derive, it silently generates a fresh random + * keypair (gen.go:674-677), so a mistyped paste would land the user on a + * brand-new identity instead of an error message. + * + * The key travels as an argv element, visible in this child's `/proc` + * entry for the length of the call. That is our own uid on a modern + * Android, where no other app may read it, and the alternative — parsing + * hex and doing secp256k1 in Kotlin — is exactly the hand-rolled key + * handling this design refuses. + */ + suspend fun derivePublicKey(secretKey: String): Result { + val sk = secretKey.trim() + // A shape check first, so an obvious paste error costs no process. + if (sk.length != SK_HEX_LENGTH || !sk.all { it.isDigit() || it.lowercaseChar() in 'a'..'f' }) { + return Result.failure(IllegalArgumentException("not a $SK_HEX_LENGTH-character hex secret key")) + } + val result = runCommand( + listOf(paths.visorBinary.absolutePath, "config", "pk", sk), + timeoutSeconds = 30, + ) + // stdout and stderr are merged, and cobra may add its own lines, so + // take the one line that looks like an answer rather than the last. + val pk = result.output.lineSequence() + .map { it.trim() } + .lastOrNull { it.length == PK_HEX_LENGTH && it.all { c -> c.isDigit() || c.lowercaseChar() in 'a'..'f' } } + return when { + result.ok && pk != null -> Result.success(pk) + else -> Result.failure( + IllegalArgumentException( + result.output.lineSequence() + .map { it.trim() } + .lastOrNull { it.isNotEmpty() } + ?: "the core could not read that secret key", + ), + ) + } + } + + /** + * Rebuild the config around [secretKey], returning the new public key. + * **Call with the core stopped.** + * + * There is no `--sk` flag: `config gen -r` takes the secret key from the + * config it is about to overwrite (gen.go:933-947), so installing a key + * means writing it into that file first and letting the regenerate read it + * back. Everything else about the regenerate is the first-run pipeline — + * same argv, and [applyPhoneProfile] re-applies the phone's pins at the + * next start — with one addition the generator makes for free: the launcher + * apps are merged rather than rebuilt, so per-app argv survives. + */ + suspend fun replaceSecretKey(secretKey: String): Result { + val pk = derivePublicKey(secretKey).getOrElse { return Result.failure(it) } + vault.unseal().getOrElse { return Result.failure(it) } + return withContext(Dispatchers.IO) { + runCatching { + paths.ensureDirs() + val existing = runCatching { readConfig() }.getOrDefault(JsonObject(emptyMap())) + val seeded = JsonObject( + existing.toMutableMap().apply { + this["sk"] = JsonPrimitive(secretKey.trim()) + // Dropped rather than corrected: the generator derives + // it, and a stale pk sitting next to a new sk for the + // length of one command is a lie waiting to be read. + remove("pk") + }, + ) + paths.configFile.writeText(json.encodeToString(JsonObject.serializer(), seeded)) + val gen = runGen() + if (!gen.ok) { + error("config regeneration failed (exit ${gen.exitCode}):\n${gen.output.takeLast(2000)}") + } + clearIdentityData() + pk + } + } + } + + /** + * Throw the identity away. **Call with the core stopped.** + * + * Deletes the config instead of regenerating one, so the next start runs + * the untouched first-run path — one pipeline for a new identity, not two. + */ + fun resetIdentity() { + paths.configFile.delete() + // Both forms go: a sealed config left behind would be adopted by the + // next start as the identity the user just threw away. + paths.sealedConfigFile.delete() + clearIdentityData() + } + + /** + * Everything on this phone that belonged to the identity being replaced. + * + * A visor's key *is* its identity: chat history is a conversation with + * peers who addressed a key that is about to stop existing, and the app + * work dirs and transport logs under `local_path` are the same story. They + * are cleared rather than carried over, so that what the confirmation + * dialog promises is what happens — a phone that keeps a previous + * identity's messages under a new one is showing a conversation nobody can + * continue. + * + * `users.db` deliberately stays: the local API account is this app's own + * device credential ([SecretStore]), not part of the visor's identity. + */ + private fun clearIdentityData() { + paths.localDir.deleteRecursively() + paths.processLogFile.delete() + File(paths.processLogFile.parentFile, paths.processLogFile.name + ".1").delete() + paths.ensureDirs() + } + + // --- export --- + + /** The config exactly as the visor reads it — secret key included. */ + /** + * The config as text for export. Reads the sealed form when that is what + * exists, decrypting into this string alone — exporting a config the user + * asked to keep encrypted must not put a plaintext copy back on the disk. + */ + suspend fun configJson(): String = vault.readText() + + /** + * The config with the secret key removed, for the diagnostics bundle. + * + * Everything else in the file is either public (the visor's own key, the + * service endpoints) or a path, and the app passwords live in files under + * `local_path` that the argv only points at. The one secret in the + * document is `sk`, and a log bundle is a thing people attach to issues. + */ + fun redactedConfigJson(): String { + val redacted = JsonObject(readConfig().toMutableMap().apply { remove("sk") }) + return json.encodeToString(JsonObject.serializer(), redacted) + } + + /** + * Reads whichever form is on disk. Through the vault, so the identity + * screen still shows a public key — and a diagnostics bundle still carries + * a redacted config — while the config is sealed and the core is down. + */ + private fun readConfig(): JsonObject = + json.parseToJsonElement(vault.readTextSync()).jsonObject + + private suspend fun runCommand(argv: List, timeoutSeconds: Long): CommandResult = + withContext(Dispatchers.IO) { + val process = ProcessBuilder(argv) + .directory(paths.dataDir) + .redirectErrorStream(true) + .apply { environment().putAll(coreEnv(paths)) } + .start() + supervisorScope { + val output = StringBuilder() + val reader = launch { + process.inputStream.bufferedReader().forEachLine { line -> + if (output.length < MAX_CAPTURE) output.appendLine(line) + } + } + val finished = try { + runInterruptible { process.waitFor(timeoutSeconds, TimeUnit.SECONDS) } + } catch (e: kotlinx.coroutines.CancellationException) { + // Don't leave an orphan config-gen child behind when the + // service scope is torn down mid-run. + process.destroyForcibly() + throw e + } + if (!finished) process.destroyForcibly().waitFor() + reader.join() + CommandResult( + exitCode = if (finished) process.exitValue() else -1, + output = output.toString(), + timedOut = !finished, + ) + } + } + + private companion object { + const val MAX_CAPTURE = 256 * 1024 + const val SOCKS_APP = "skysocks-client" + const val DEFAULT_SOCKS_PORT = 1080 + val ADDR = listOf("--addr", "-addr") + + /** 32 bytes of secp256k1 secret, 33 of compressed public — as hex. */ + const val SK_HEX_LENGTH = 64 + const val PK_HEX_LENGTH = 66 + } +} + +// --- small JsonObject editing helpers --- + +private inline fun JsonObject.edit(block: MutableMap.() -> Unit): JsonObject { + val map = toMutableMap() + map.block() + return JsonObject(map) +} + +private inline fun MutableMap.putObject( + key: String, + block: MutableMap.() -> Unit, +) { + val nested = (this[key] as? JsonObject)?.toMutableMap() ?: mutableMapOf() + nested.block() + this[key] = JsonObject(nested) +} + +private inline fun kotlinx.serialization.json.JsonObjectBuilder.putObject( + key: String, + block: MutableMap.() -> Unit, +) { + val nested = mutableMapOf() + nested.block() + put(key, JsonObject(nested)) +} + +private suspend fun runInterruptible(block: () -> T): T = + kotlinx.coroutines.runInterruptible(Dispatchers.IO, block) diff --git a/android/app/src/main/java/com/skycoin/skywire/core/ConfigVault.kt b/android/app/src/main/java/com/skycoin/skywire/core/ConfigVault.kt new file mode 100644 index 0000000000..4745dfafd3 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/core/ConfigVault.kt @@ -0,0 +1,217 @@ +package com.skycoin.skywire.core + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +/** + * Optional encryption of `skywire-config.json` while the core is not running. + * + * **What it is for.** That file holds `sk`, the visor's secret key — the whole + * of this phone's identity on the network. It lives in app-private storage, so + * on a healthy device no other app can read it; the threat this closes is the + * device itself being examined. A phone that is off, or unlocked once and then + * imaged, gives up app-private files to anyone who can defeat file-based + * encryption at rest, and the identity goes with them. Sealed, the config is + * AES-256-GCM ciphertext under a key that AndroidKeyStore will not export — + * so a copy of the filesystem is not a copy of the key. + * + * **Why it is optional and off by default.** It buys nothing against a + * running, unlocked phone (the plaintext exists while the visor runs), it + * cannot be recovered if the keystore is wiped — a factory reset, or in some + * OEM cases removing the screen lock — and the honest summary of that trade is + * "more protection against a seized phone, one more way to lose the identity + * on a working one". That is the user's call, not a default. + * + * **The shape of it.** Two files, never both meaningful at once: + * - `skywire-config.json` — plaintext. Exists while the core runs, because + * the visor opens the path it was given and rewrites it at runtime. + * - `skywire-config.json.enc` — ciphertext. Exists while the core is stopped + * and the feature is on. + * + * [unseal] before anything reads or runs the config, [seal] once the visor has + * exited. Sealing at that point rather than at generation is what lets the + * visor's own rewrites survive: whatever it left behind is what gets encrypted. + * + * **The dangerous failure this must not have.** [ConfigManager.ensureConfig] + * treats a missing config file as "first run" and generates a fresh identity. + * A sealed config plus a caller who forgot to unseal would therefore look + * exactly like a new phone, and the user's key would be replaced without a + * word. Every entry point that touches the config unseals first, and + * [sealedExists] is checked before any generation, so the failure mode is a + * refusal rather than a silent new identity. + */ +class ConfigVault(private val paths: SkywirePaths) { + + /** [AppPreferences] key holding the user's choice. */ + companion object { + const val PREF_KEY = "config_encrypted" + + /** Off until asked for — see the class doc for why. */ + const val DEFAULT = false + + private const val KEYSTORE = "AndroidKeyStore" + private const val KEY_ALIAS = "skywire_config_at_rest" + private const val TRANSFORM = "AES/GCM/NoPadding" + private const val IV_BYTES = 12 + } + + /** True when a sealed config is on disk, whatever the preference says. */ + fun sealedExists(): Boolean = paths.sealedConfigFile.exists() + + /** True when the config is sealed and there is no plaintext beside it. */ + fun isSealed(): Boolean = sealedExists() && !paths.configFile.exists() + + /** + * Make the plaintext config available, decrypting it if it is sealed. + * Idempotent, and a no-op when nothing is sealed — which is every call on + * a phone that never turned this on. + * + * A stale plaintext alongside a sealed file means the app died while the + * core was running. The plaintext is the newer of the two (the visor may + * have rewritten it), so it wins and the ciphertext is left to be replaced + * by the next [seal]. + */ + suspend fun unseal(): Result = withContext(Dispatchers.IO) { + if (!sealedExists()) return@withContext Result.success(Unit) + if (paths.configFile.exists()) return@withContext Result.success(Unit) + runCatching { + val plain = decrypt(paths.sealedConfigFile.readBytes()) + ?: error( + "the sealed config cannot be opened — this phone's keystore key is gone " + + "(a factory reset, or a screen lock that was removed and re-added). " + + "The identity in it is unrecoverable; a new one can be generated.", + ) + writePrivate(paths.configFile, plain) + paths.sealedConfigFile.delete() + Unit + } + } + + /** + * Encrypt the plaintext config and remove it, if [enabled]. Called once + * the visor has exited, so it captures any rewrite the visor performed. + * + * Ordering is deliberate: write the ciphertext, verify it reads back, and + * only then delete the plaintext. A power cut in the middle leaves both + * files, which [unseal] resolves in favour of the plaintext — the outcome + * is a config that is briefly not encrypted, never a config that is gone. + */ + suspend fun seal(enabled: Boolean): Result = withContext(Dispatchers.IO) { + if (!enabled || !paths.configFile.exists()) return@withContext Result.success(Unit) + runCatching { + val plain = paths.configFile.readBytes() + writePrivate(paths.sealedConfigFile, encrypt(plain)) + check(decrypt(paths.sealedConfigFile.readBytes()) != null) { + "sealed config failed to read back; leaving the plaintext in place" + } + paths.configFile.delete() + Unit + } + } + + /** + * The config as text, from whichever form is on disk. Lets Settings export + * a config that is currently sealed without unsealing it onto the disk + * first — the plaintext exists only in this string. + */ + suspend fun readText(): String = withContext(Dispatchers.IO) { readTextSync() } + + /** + * Blocking twin of [readText], for the two synchronous readers that + * predate this class — the public key on the identity screen and the + * redacted copy in a diagnostics bundle. Both must keep working with the + * config sealed and the core down, which is exactly when the identity + * screen is being looked at. One AES-GCM open over a few kilobytes is not + * work worth a coroutine. + */ + fun readTextSync(): String { + if (paths.configFile.exists()) return paths.configFile.readText() + val sealed = paths.sealedConfigFile + if (!sealed.exists()) error("no config on disk") + return String( + decrypt(sealed.readBytes()) ?: error("the sealed config cannot be opened"), + Charsets.UTF_8, + ) + } + + /** + * Apply a change to the preference. Turning it **on** seals immediately + * when the core is down, and otherwise waits for the visor to exit — the + * running visor holds the file open and rewrites it. Turning it **off** + * always unseals immediately, because leaving a user who just switched + * encryption off with an encrypted config would be the opposite of what + * they asked for. + */ + suspend fun applyPreference(enabled: Boolean, coreRunning: Boolean): Result = + if (enabled) { + if (coreRunning) Result.success(Unit) else seal(true) + } else { + unseal() + } + + // --- AndroidKeyStore AES-GCM, over bytes rather than strings --- + // + // Same construction as SecretStore and WalletSeedStore, its own alias: a + // config, a password and a recovery phrase must not share a blast radius. + // Deliberately NOT setUserAuthenticationRequired — the core starts from a + // boot-completed revival and from the notification, neither of which can + // show a biometric prompt. + + private fun key(): SecretKey { + val ks = KeyStore.getInstance(KEYSTORE).apply { load(null) } + (ks.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it } + val gen = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE) + gen.init( + KeyGenParameterSpec.Builder( + KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .build(), + ) + return gen.generateKey() + } + + /** `iv || ciphertext` — the IV is generated by the cipher, never reused. */ + private fun encrypt(plain: ByteArray): ByteArray { + val cipher = Cipher.getInstance(TRANSFORM) + cipher.init(Cipher.ENCRYPT_MODE, key()) + return cipher.iv + cipher.doFinal(plain) + } + + private fun decrypt(blob: ByteArray): ByteArray? = runCatching { + require(blob.size > IV_BYTES) + val cipher = Cipher.getInstance(TRANSFORM) + cipher.init( + Cipher.DECRYPT_MODE, + key(), + GCMParameterSpec(128, blob, 0, IV_BYTES), + ) + cipher.doFinal(blob, IV_BYTES, blob.size - IV_BYTES) + }.getOrNull() + + /** + * Write owner-only. App-private storage is already 700, so this is the + * second lock on the same door — and the one that still holds if the file + * is ever moved somewhere less strict. + */ + private fun writePrivate(file: File, bytes: ByteArray) { + file.writeBytes(bytes) + runCatching { + file.setReadable(false, false) + file.setReadable(true, true) + file.setWritable(false, false) + file.setWritable(true, true) + } + } +} diff --git a/android/app/src/main/java/com/skycoin/skywire/core/CoreLogLevel.kt b/android/app/src/main/java/com/skycoin/skywire/core/CoreLogLevel.kt new file mode 100644 index 0000000000..02ecff3498 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/core/CoreLogLevel.kt @@ -0,0 +1,33 @@ +package com.skycoin.skywire.core + +/** + * How much the visor writes to its log — the config's top-level `log_level`. + * + * Written into the config by [ConfigManager] on every launch from the phone's + * preference, for the same reason the transport order and the Fleet opt-in are: + * the app owns the setting, and the visor persists its own copy. + * + * The visor reads it once, while it builds its module graph, so a change means + * restarting the core. + */ +object CoreLogLevel { + + const val PREF_KEY = "core_log_level" + + /** + * What `config gen` writes when nothing asks otherwise. Note the visor's + * own fallback for an *empty* field is `debug` — this default is quieter + * than that on purpose: debug on a phone is a lot of writing for a log + * nobody is reading. + */ + const val DEFAULT = "info" + + /** + * Coarse to fine. `fatal` and `panic` parse too but are not offered: a + * visor logging only its own death is not a diagnostic setting. + */ + val LEVELS = listOf("error", "warn", "info", "debug", "trace") + + fun sanitize(stored: String?): String = + stored?.lowercase()?.takeIf { it in LEVELS } ?: DEFAULT +} diff --git a/android/app/src/main/java/com/skycoin/skywire/core/CoreState.kt b/android/app/src/main/java/com/skycoin/skywire/core/CoreState.kt new file mode 100644 index 0000000000..21d526058f --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/core/CoreState.kt @@ -0,0 +1,32 @@ +package com.skycoin.skywire.core + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** Lifecycle of the visor child process, as the service reports it. */ +sealed interface CoreState { + data object Stopped : CoreState + + /** Config check/generation + first spawn. */ + data class Starting(val attempt: Int) : CoreState + + data class Running(val sinceEpochMs: Long, val attempt: Int) : CoreState + + /** Visor exited unexpectedly; next spawn happens after [delayMs]. */ + data class Restarting(val nextAttempt: Int, val delayMs: Long) : CoreState + + data object Stopping : CoreState + + /** Unrecoverable (missing binary, config gen failure) — see process log. */ + data class Failed(val message: String) : CoreState +} + +/** + * In-process bridge between [SkywireCoreService] and the UI. The service and + * the Compose tree live in the same process, so a StateFlow is enough — no + * binder ceremony. + */ +object CoreServiceState { + internal val mutableState = MutableStateFlow(CoreState.Stopped) + val state = mutableState.asStateFlow() +} diff --git a/android/app/src/main/java/com/skycoin/skywire/core/DeepLinks.kt b/android/app/src/main/java/com/skycoin/skywire/core/DeepLinks.kt new file mode 100644 index 0000000000..c63739e0ff --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/core/DeepLinks.kt @@ -0,0 +1,68 @@ +package com.skycoin.skywire.core + +import android.content.Intent +import android.net.Uri +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.util.concurrent.atomic.AtomicLong + +/** + * Links other apps open Skywire for. + * + * Process-scoped rather than held by a screen, because the two ends never + * line up: the link arrives at the Activity, and the screen that acts on it + * may not exist yet (a cold start), may be mid-way through bringing the core + * up, or may be showing a page that has not finished loading. Parking the + * link here lets each side move at its own pace — it is offered once and + * stays pending until something has actually acted on it. + * + * Only `skychat:` is claimed (see the manifest). `skycoin:` in particular is + * NOT ours to take: the Skycoin wallet app already answers it on the same + * device, and registering a competing filter would put a disambiguation + * chooser in front of every one of its links. + */ +object DeepLinks { + + /** `skychat://`, `skychat:///`, and `skychat:invite:<…>`. */ + const val SKYCHAT_SCHEME = "skychat" + + /** + * A skychat address waiting to be shown, with the id that makes two + * identical links two events. Without it, opening the same link twice + * would set a StateFlow to a value it already holds, which emits nothing + * and leaves the second tap doing nothing at all. + */ + data class ChatLink(val address: String, val id: Long) + + private val seq = AtomicLong() + + private val chat = MutableStateFlow(null) + + /** The skychat link nothing has acted on yet, if any. */ + val pendingChatLink: StateFlow = chat.asStateFlow() + + /** True when [intent] carried a link this app claims. */ + fun offer(intent: Intent?): Boolean { + if (intent?.action != Intent.ACTION_VIEW) return false + return offer(intent.data) + } + + fun offer(uri: Uri?): Boolean { + if (uri == null) return false + if (!uri.scheme.equals(SKYCHAT_SCHEME, ignoreCase = true)) return false + // The original text, not a rebuilt URI: an invite is the opaque form + // `skychat:invite:`, which does not survive a round trip + // through the authority/path accessors, and the chat page's resolver + // takes every form it can be handed as written. + val address = uri.toString().trim() + if (address.isEmpty()) return false + chat.value = ChatLink(address, seq.incrementAndGet()) + return true + } + + /** Drop [link] once it has been shown; a newer one arriving meanwhile stays. */ + fun chatLinkHandled(link: ChatLink) { + chat.compareAndSet(link, null) + } +} diff --git a/android/app/src/main/java/com/skycoin/skywire/core/DiagnosticsExport.kt b/android/app/src/main/java/com/skycoin/skywire/core/DiagnosticsExport.kt new file mode 100644 index 0000000000..b2ea963fbe --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/core/DiagnosticsExport.kt @@ -0,0 +1,157 @@ +package com.skycoin.skywire.core + +import android.content.Context +import android.os.Build +import com.skycoin.skywire.api.VisorApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.io.OutputStream +import java.time.Instant +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +/** + * "Export all" from Logs & diagnostics: every log source this phone has, in + * one zip, plus the two things that make them readable — what the device is + * and what the config says. + * + * The per-screen `Logs` buttons are for reading a feed while it happens; this + * is for handing the whole picture to someone else. So it collects rather than + * tails: each API source is asked for its entire buffer once, and the captured + * process output is copied whole. + * + * **The config is redacted.** A diagnostics bundle is a thing people attach to + * an issue, and the visor's config carries its secret key — the identity + * itself. `sk` is stripped ([ConfigManager.redactedConfigJson]); the full file + * has its own deliberate export in Settings, behind a biometric check and a + * warning that says what is in it. + * + * Nothing here fails the export. A source that cannot be collected — the core + * is down, so the API answers nothing — becomes a line in `collection-notes.txt` + * instead of an error, because the bundle is most wanted exactly when things + * are broken. + */ +object DiagnosticsExport { + + /** + * Write the bundle into [out], which is closed on the way out. [apps] are + * the app names whose per-app feeds to include. + */ + suspend fun writeTo( + context: Context, + out: OutputStream, + apps: List, + ): Unit = withContext(Dispatchers.IO) { + val app = context.applicationContext + val paths = SkywirePaths(app) + val config = ConfigManager(paths, SecretStore(app)) + val api = VisorApi.get(app) + val notes = mutableListOf() + + // Each is asked for once, whole. `since = 0` / `since = null` is the + // entire buffer in one page — the same opening page the log viewer's + // first poll takes. + val sources = buildList String>> { + add("skywire-config.redacted.json" to { config.redactedConfigJson() }) + add("core-runtime.log" to { + api.runtimeLogs(since = 0).entries.orEmpty().joinToString("\n") + }) + apps.forEach { name -> + add("apps/$name.log" to { + api.appLogs(name, since = null).logs.joinToString("\n") + }) + } + } + + // The running visor's own report of what it is. Absent when the core + // is down — which is a state this bundle is often collected in, so it + // is a "?" in device.txt rather than a reason to fail. + val coreVersion = runCatching { + api.summary().let { summary -> + listOfNotNull( + summary.overview.buildInfo?.version?.takeIf { it.isNotEmpty() }, + summary.buildTag.takeIf { it.isNotEmpty() }, + ).joinToString(" · ") + } + }.getOrNull()?.takeIf { it.isNotEmpty() } + + ZipOutputStream(out.buffered()).use { zip -> + zip.text("README.txt", readme()) + zip.text("device.txt", device(app, coreVersion)) + + for ((name, produce) in sources) { + try { + zip.text(name, produce()) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + notes += "$name — not collected: ${e.message ?: e::class.java.simpleName}" + } + } + + // The one source that exists when the visor will not start. + zip.file(notes, "process-output.log", paths.processLogFile) + zip.file( + notes, + "process-output.log.1", + File(paths.processLogFile.parentFile, paths.processLogFile.name + ".1"), + ) + + if (notes.isNotEmpty()) { + zip.text("collection-notes.txt", notes.joinToString("\n")) + } + } + } + + private fun readme(): String = """ + Skywire for Android — diagnostics bundle + Collected ${Instant.now()} + + core-runtime.log the visor's runtime ring buffer (logrus JSON, one entry per line) + apps/.log each client app's own log, as the visor keeps it. + The names are the processes, not the products: + skychat = SkyChat, skysocks-client = SkySOCKS, + vpn-client = SkyVPN, skydex-client = SkyDEX + process-output.log[.1] the visor child process's combined stdout/stderr, captured by + the app — the only source that exists when the visor won't start + skywire-config.redacted.json the visor config WITHOUT its secret key + device.txt phone, Android and version details + collection-notes.txt present only if something could not be collected, and why + + The secret key is not in this bundle. Settings > Export config writes the + complete config, including the key, as a separate deliberate action. + """.trimIndent() + + private fun device(context: Context, coreVersion: String?): String { + val packageInfo = runCatching { + context.packageManager.getPackageInfo(context.packageName, 0) + }.getOrNull() + return buildString { + appendLine("app.version = ${packageInfo?.versionName ?: "?"}") + appendLine("core.version = ${coreVersion ?: "?"}") + appendLine("android.release = ${Build.VERSION.RELEASE}") + appendLine("android.sdk = ${Build.VERSION.SDK_INT}") + appendLine("device = ${Build.MANUFACTURER} ${Build.MODEL}") + appendLine("abis = ${Build.SUPPORTED_ABIS.joinToString(",")}") + } + } + + // --- zip plumbing --- + + private fun ZipOutputStream.text(name: String, content: String) { + putNextEntry(ZipEntry(name)) + write(content.toByteArray(Charsets.UTF_8)) + closeEntry() + } + + /** A missing rotated log is normal and silent; an unreadable one is a note. */ + private fun ZipOutputStream.file(notes: MutableList, name: String, source: File) { + if (!source.exists()) return + runCatching { + putNextEntry(ZipEntry(name)) + source.inputStream().use { it.copyTo(this) } + closeEntry() + }.onFailure { notes += "$name — not collected: ${it.message}" } + } +} diff --git a/android/app/src/main/java/com/skycoin/skywire/core/Fleet.kt b/android/app/src/main/java/com/skycoin/skywire/core/Fleet.kt new file mode 100644 index 0000000000..e50fa2cd4a --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/core/Fleet.kt @@ -0,0 +1,32 @@ +package com.skycoin.skywire.core + +/** + * The Fleet opt-in — one bool, and the only thing on this phone that opens a + * listener the outside world can reach. + * + * The phone's core ships API-only: the visor serves its local HTTP API on + * loopback and nothing else. The section that API lives under is historically + * called `hypervisor`, but on this build it is just the API — no manager, no + * remote visors. Turning Fleet on hands that hypervisor its dmsg client, which + * starts the RPC listener other visors dial in on. From then on any visor + * carrying this phone's PK in its own `hypervisors` list connects over dmsg and + * reports its status here. + * + * Off by default, deliberately: a listener nobody asked for is battery spent on + * connections nobody wants, and the feature is worth nothing until the user has + * actually put this key into another visor's config. + * + * The bool is read exactly once, when the visor builds its module graph, so + * changing it means restarting the core — see [SkywireCoreService.restart]. + */ +object Fleet { + + /** [AppPreferences] key holding the user's choice. The app owns it. */ + const val PREF_KEY = "fleet_enabled" + + /** Off until the user turns it on. */ + const val DEFAULT = false + + /** Field inside the config's `hypervisor` object. */ + const val CONFIG_KEY = "dmsg_ingest" +} diff --git a/android/app/src/main/java/com/skycoin/skywire/core/NotificationBridge.kt b/android/app/src/main/java/com/skycoin/skywire/core/NotificationBridge.kt new file mode 100644 index 0000000000..fc1cb32897 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/core/NotificationBridge.kt @@ -0,0 +1,206 @@ +package com.skycoin.skywire.core + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.skycoin.skywire.MainActivity +import com.skycoin.skywire.R +import com.skycoin.skywire.api.VisorApi +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import java.io.BufferedReader +import java.io.IOException +import kotlin.coroutines.coroutineContext + +/** One notification, exactly as the visor's hub publishes it. */ +@Serializable +internal data class NotifyEvent( + /** The publishing app, stamped by the visor — an app cannot forge it. */ + @SerialName("app") val app: String = "", + @SerialName("title") val title: String = "", + @SerialName("body") val body: String = "", + /** Groups related notifications so a new one REPLACES its predecessor. */ + @SerialName("tag") val tag: String = "", +) + +/** + * This phone as a sink for the visor's notification hub. + * + * The hub is the visor's, not any one app's: an app — or the visor itself — + * publishes a NotifyReq and stops caring, and the hub picks the tier that can + * actually reach the user (an attached UI, a subscribed host app, the desktop + * notification center, or nowhere at all on a headless box). This class is that + * subscribed host app. Everything it knows about a notification is what the hub + * sends: an app name, a title, a body, an optional tag. + * + * **Nothing here is per-feature, and that is the point.** A notification that + * does not exist yet — a market alert, a transport going down, a visor coming + * back online — needs one Publish call on the Go side and NO change in this + * file: an app nobody has heard of gets a channel named after itself at default + * importance, and the user gets a real switch for it in system settings the day + * it first appears. [CHANNELS] exists only to give the apps we DO know a better + * label and a deliberate importance, so tuning one is a single row. + * + * Runs off the core service, so a notification never depends on a screen being + * open — which is the only version of the feature worth having. + */ +internal class NotificationBridge(context: Context) { + + private val app = context.applicationContext + private val notifications = NotificationManagerCompat.from(app) + private val manager = + app.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + private val api = VisorApi.get(app) + private val json = Json { ignoreUnknownKeys = true; isLenient = true } + + /** Channels registered this session, so each is created once. */ + private val ensured = mutableSetOf() + + /** Untagged notifications stack, so each needs an id of its own. */ + private var untagged = UNTAGGED_ID_BASE + + fun watch(scope: CoroutineScope): Job = scope.launch(Dispatchers.IO) { + while (coroutineContext.isActive) { + try { + api.notificationStream().use { resp -> + if (!resp.isSuccessful) { + Log.w(TAG, "notification stream rejected: ${resp.code}") + return@use + } + consume(resp.body.charStream().buffered()) + } + } catch (e: IOException) { + Log.d(TAG, "notification stream ended: ${e.message}") + } + // The visor restarting, the phone waking — reconnect rather than + // going quiet for the rest of the session. The hub is live-only + // (it keeps no backlog), so anything published while this is down + // is gone; reconnecting promptly is the whole mitigation. + coroutineContext.ensureActive() + delay(RETRY_MS) + } + } + + /** Minimal SSE: `data: {json}` lines, blank-line separated, `:` = ping. */ + private suspend fun consume(reader: BufferedReader) { + while (coroutineContext.isActive) { + val line = reader.readLine() ?: return + val payload = line.removePrefix(DATA_PREFIX).takeIf { it != line } ?: continue + val event = runCatching { json.decodeFromString(NotifyEvent.serializer(), payload) } + .getOrNull() ?: continue + if (event.title.isEmpty() && event.body.isEmpty()) continue + show(event) + } + } + + private fun show(event: NotifyEvent) { + val channel = channelFor(event.app) + val open = PendingIntent.getActivity( + app, + 0, + Intent(app, MainActivity::class.java).setAction(Intent.ACTION_MAIN), + PendingIntent.FLAG_IMMUTABLE, + ) + val builder = NotificationCompat.Builder(app, channel.id) + .setSmallIcon(R.drawable.skywire_logo) + .setContentTitle(event.title.ifEmpty { channel.label }) + .setContentText(event.body) + .setStyle(NotificationCompat.BigTextStyle().bigText(event.body)) + .setContentIntent(open) + .setAutoCancel(true) + channel.category?.let(builder::setCategory) + + // The publisher's tag is its own idea of "this again" — a conversation, + // a market, a link. Namespacing it by app is what stops two apps that + // both say "lifecycle" from silently replacing each other's alerts. + // Untagged means "not the same as anything", so those stack. + val tag = event.tag.takeIf { it.isNotEmpty() }?.let { "${event.app}:$it" } + val id = if (tag != null) TAGGED_ID else untagged++ + runCatching { notifications.notify(tag, id, builder.build()) } + .onFailure { Log.w(TAG, "cannot post a notification from ${event.app}", it) } + } + + /** + * The channel an app's notifications belong in, created on first use. + * + * An unknown app is not an error and is never dropped — it gets a channel + * of its own, named after itself. + */ + private fun channelFor(publisher: String): Channel { + val channel = CHANNELS[publisher] ?: Channel( + id = "app_" + publisher.ifEmpty { "visor" }.lowercase().replace(UNSAFE_ID, "_"), + label = publisher.ifEmpty { app.getString(R.string.app_name) }, + importance = NotificationManager.IMPORTANCE_DEFAULT, + category = null, + ) + if (ensured.add(channel.id)) { + manager.createNotificationChannel( + NotificationChannel(channel.id, channel.label, channel.importance), + ) + } + return channel + } + + /** How one app's notifications are presented. */ + private data class Channel( + val id: String, + val label: String, + val importance: Int, + val category: String?, + ) + + companion object { + private const val TAG = "SkywireNotify" + private const val DATA_PREFIX = "data: " + private const val RETRY_MS = 2_000L + + /** One id for every tagged notification; the tag is what separates them. */ + private const val TAGGED_ID = 100 + private const val UNTAGGED_ID_BASE = 1_000 + + private val UNSAFE_ID = Regex("[^a-z0-9_]") + + /** + * Presentation for the apps we already know. Everything else is handled + * by [channelFor]'s fallback, so this table is a nicety and never a + * gate: adding a row changes an app's label or how loudly it + * interrupts; adding a NOTIFICATION needs no row at all. + */ + private val CHANNELS = mapOf( + "skychat" to Channel( + id = "app_skychat", + label = "SkyChat", + // A message from a person interrupts — that is what a chat is. + importance = NotificationManager.IMPORTANCE_HIGH, + category = NotificationCompat.CATEGORY_MESSAGE, + ), + "skydex-client" to Channel( + id = "app_skydex", + label = "SkyDEX", + importance = NotificationManager.IMPORTANCE_DEFAULT, + category = null, + ), + // The visor's own events — transports, reachability, updates. + // Informational, and deliberately quieter than a message. + "visor" to Channel( + id = "app_visor", + label = "Skywire", + importance = NotificationManager.IMPORTANCE_LOW, + category = NotificationCompat.CATEGORY_STATUS, + ), + ) + } +} diff --git a/android/app/src/main/java/com/skycoin/skywire/core/PasswordFile.kt b/android/app/src/main/java/com/skycoin/skywire/core/PasswordFile.kt new file mode 100644 index 0000000000..1ff3280e85 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/core/PasswordFile.kt @@ -0,0 +1,48 @@ +package com.skycoin.skywire.core + +import java.security.MessageDigest +import java.security.SecureRandom + +/** + * The on-disk credential format the gated app surfaces read: a single line of + * `":"`, 16-byte salt — the same + * hashing the hypervisor's user store uses, so one writer serves every gate. + * + * Both skychat (`--password-file`) and skydex-client (`--password-file`) read + * exactly this; the format lives here rather than in either profile so the + * security-relevant half is written once and read twice. + */ +object PasswordFile { + + /** The on-disk form of [password], with a fresh salt. */ + fun record(password: String): String { + val salt = ByteArray(SALT_LEN).also { SecureRandom().nextBytes(it) } + return salt.toHex() + ":" + hash(password, salt).toHex() + } + + /** + * Whether an existing [record] still stands for [password] — re-hashed + * with the record's own salt. The check is what lets the file be left + * alone on a normal launch and rewritten when the stored secret has + * rotated (a wiped keystore), instead of the app 401-ing against its own + * gate. + */ + fun matches(record: String, password: String): Boolean { + val (saltHex, hashHex) = record.trim().split(":", limit = 2) + .takeIf { it.size == 2 } ?: return false + val salt = runCatching { saltHex.fromHex() }.getOrNull() ?: return false + return hash(password, salt).toHex() == hashHex.lowercase() + } + + private fun hash(password: String, salt: ByteArray): ByteArray = + MessageDigest.getInstance("SHA-256") + .digest(password.toByteArray(Charsets.UTF_8) + salt) + + private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } + + private fun String.fromHex(): ByteArray = ByteArray(length / 2) { i -> + substring(i * 2, i * 2 + 2).toInt(16).toByte() + } + + private const val SALT_LEN = 16 +} diff --git a/android/app/src/main/java/com/skycoin/skywire/core/SecretStore.kt b/android/app/src/main/java/com/skycoin/skywire/core/SecretStore.kt new file mode 100644 index 0000000000..0ff7c367d3 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/core/SecretStore.kt @@ -0,0 +1,139 @@ +package com.skycoin.skywire.core + +import android.content.Context +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.security.KeyStore +import java.security.SecureRandom +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +private val Context.coreDataStore by preferencesDataStore(name = "core") + +/** + * The device-local passwords the app generates for the services it starts. + * The plaintext never touches disk — DataStore holds an AES-GCM ciphertext + * whose key lives in AndroidKeyStore (non-exportable). allowBackup=false + * keeps even the ciphertext out of cloud backups. + */ +class SecretStore(private val context: Context) { + + private val mutex = Mutex() + + /** Password of the visor's single `admin` account on 127.0.0.1:8000. */ + suspend fun apiPassword(): String = secret(KEY_API_PASSWORD) + + /** + * Password gating skychat's own HTTP surface. Separate from the API + * password because the two are handed to different servers — and this + * one is the whole reason the chat port is not readable by every other + * app on the phone (see [SkychatProfile]). + */ + suspend fun skychatPassword(): String = secret(KEY_SKYCHAT_PASSWORD) + + /** + * Password gating skydex-client's trading UI — same reasoning as + * [skychatPassword], with a market session and a wallet behind the port + * instead of a chat history (see [SkydexProfile]). Its own secret, so one + * app's surface can never be opened with another's credential. + */ + suspend fun skydexPassword(): String = secret(KEY_SKYDEX_PASSWORD) + + private suspend fun secret(key: androidx.datastore.preferences.core.Preferences.Key): String = + mutex.withLock { + val prefs = context.coreDataStore.data.first() + prefs[key]?.let { stored -> + decrypt(stored)?.let { return it } + // Undecryptable (keystore wiped): fall through and rotate. The + // visor's users.db is then stale too — ConfigManager resets it + // when authentication bootstrap fails. + } + val fresh = generatePassword() + context.coreDataStore.edit { it[key] = encrypt(fresh) } + fresh + } + + /** + * Random password satisfying the API's policy: 6–64 chars, at least one + * upper/lower/digit/special, ASCII, every rune ≥ '!' (no spaces). The + * same shape is valid for skychat, whose policy is a subset of it. + */ + private fun generatePassword(): String { + val upper = "ABCDEFGHJKLMNPQRSTUVWXYZ" + val lower = "abcdefghijkmnopqrstuvwxyz" + val digit = "23456789" + val special = "!@#$%^&*()-_=+[]{}<>.,?/" + val all = upper + lower + digit + special + val rnd = SecureRandom() + val body = (1..20).map { all[rnd.nextInt(all.length)] } + val guaranteed = listOf( + upper[rnd.nextInt(upper.length)], + lower[rnd.nextInt(lower.length)], + digit[rnd.nextInt(digit.length)], + special[rnd.nextInt(special.length)], + ) + return (body + guaranteed).shuffled(rnd.asKotlinRandom()).joinToString("") + } + + // --- AndroidKeyStore AES-GCM --- + + private fun key(): SecretKey { + val ks = KeyStore.getInstance(KEYSTORE).apply { load(null) } + (ks.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it } + val gen = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE) + gen.init( + KeyGenParameterSpec.Builder( + KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .build(), + ) + return gen.generateKey() + } + + private fun encrypt(plain: String): String { + val cipher = Cipher.getInstance(TRANSFORM) + cipher.init(Cipher.ENCRYPT_MODE, key()) + val ct = cipher.doFinal(plain.toByteArray(Charsets.UTF_8)) + return Base64.encodeToString(cipher.iv, Base64.NO_WRAP) + ":" + + Base64.encodeToString(ct, Base64.NO_WRAP) + } + + private fun decrypt(stored: String): String? = runCatching { + val (ivB64, ctB64) = stored.split(":", limit = 2).also { require(it.size == 2) } + val cipher = Cipher.getInstance(TRANSFORM) + cipher.init( + Cipher.DECRYPT_MODE, + key(), + GCMParameterSpec(128, Base64.decode(ivB64, Base64.NO_WRAP)), + ) + String(cipher.doFinal(Base64.decode(ctB64, Base64.NO_WRAP)), Charsets.UTF_8) + }.getOrNull() + + private fun SecureRandom.asKotlinRandom(): kotlin.random.Random = + object : kotlin.random.Random() { + override fun nextBits(bitCount: Int): Int = + this@asKotlinRandom.nextInt() ushr (32 - bitCount) + } + + private companion object { + const val KEYSTORE = "AndroidKeyStore" + const val KEY_ALIAS = "skywire_api_password" + const val TRANSFORM = "AES/GCM/NoPadding" + val KEY_API_PASSWORD = stringPreferencesKey("api_password") + val KEY_SKYCHAT_PASSWORD = stringPreferencesKey("skychat_password") + val KEY_SKYDEX_PASSWORD = stringPreferencesKey("skydex_password") + } +} diff --git a/android/app/src/main/java/com/skycoin/skywire/core/SkyVpnService.kt b/android/app/src/main/java/com/skycoin/skywire/core/SkyVpnService.kt new file mode 100644 index 0000000000..8706e8035b --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/core/SkyVpnService.kt @@ -0,0 +1,403 @@ +package com.skycoin.skywire.core + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.LocalServerSocket +import android.net.LocalSocket +import android.net.VpnService +import android.os.ParcelFileDescriptor +import android.os.Process +import com.skycoin.skywire.MainActivity +import com.skycoin.skywire.R +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import java.io.IOException +import java.util.Collections + +/** + * Owner of the phone's VPN interface. + * + * Android hands out a TUN only through [VpnService], and only after the user + * has granted the VPN consent — an app can never open `/dev/net/tun` itself. + * So the interface is built here, from the parameters the VPN server assigned + * to vpn-client, and its file descriptor is passed to the visor process over + * an abstract unix socket as `SCM_RIGHTS` ancillary data. The Go end of that + * handoff is `pkg/vpn/tun_device_android.go`, which documents the protocol. + * + * Two things about this service carry the design: + * + * - **`addDisallowedApplication(self)`.** The visor runs as a child of this + * app, so it shares this UID; excluding the package excludes it. Without + * that the visor's own dmsg traffic would be routed into the tunnel it is + * carrying, and the VPN would deadlock on the first packet. + * - **The interface outlives the connection.** This service keeps its own + * descriptor open, so the interface stays up even after the core closes + * its copy. While the tunnel is down nothing drains the interface, and the + * packets go nowhere — that is what makes vpn-client's `--killswitch` real + * rather than advisory. With the killswitch off the core says `down` + * before it stops carrying traffic, and the interface goes away so the + * phone gets its normal networking back. + * + * Not a foreground service, deliberately: it shares a process with + * [SkywireCoreService], which is one and runs whenever the VPN could — the + * VPN cannot outlive the visor that carries it (see the core watcher below). + * The system draws its own persistent VPN indicator on top of that. + */ +class SkyVpnService : VpnService() { + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val json = Json { ignoreUnknownKeys = true } + + /** One interface change at a time, whichever connection asks. */ + private val mutex = Mutex() + + private var server: LocalServerSocket? = null + private var acceptor: Job? = null + private var coreWatcher: Job? = null + + /** + * Live control connections. Tracked only so shutdown can close them: a + * reader parked in a blocking `readLine` does not notice its coroutine + * being cancelled, and closing the socket is what wakes it. + */ + private val clients = Collections.synchronizedSet(mutableSetOf()) + + /** Our copy of the live interface — the app's claim on its lifetime. */ + private var tun: ParcelFileDescriptor? = null + + /** The connection that last established; only its EOF means anything. */ + @Volatile private var controller: LocalSocket? = null + + @Volatile private var killswitch = false + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + when (intent?.action) { + ACTION_STOP -> { + shutdown() + stopSelf() + return START_NOT_STICKY + } + ACTION_KILLSWITCH -> + killswitch = intent.getBooleanExtra(EXTRA_KILLSWITCH, killswitch) + else -> { + killswitch = intent?.getBooleanExtra(EXTRA_KILLSWITCH, killswitch) ?: killswitch + listen() + watchCore() + } + } + // The interface dies with this process, so there is nothing for a + // revival to resume — the screen re-drives the whole flow instead. + return START_NOT_STICKY + } + + /** The user turned the VPN off in Settings, or another VPN took over. */ + override fun onRevoke() { + shutdown() + stopSelf() + super.onRevoke() + } + + override fun onDestroy() { + shutdown() + scope.cancel() + super.onDestroy() + } + + // --- the control socket --- + + private fun listen() { + if (acceptor?.isActive == true) return + acceptor = scope.launch { + val socket = try { + LocalServerSocket(SOCKET_NAME) + } catch (e: IOException) { + // Almost always a leftover bind from a service instance that + // has not finished going down; the screen surfaces it. + VpnTunnel.mutableState.value = VpnTunnelState( + error = getString(R.string.vpn_error_socket, e.message.orEmpty()), + ) + return@launch + } + server = socket + VpnTunnel.mutableState.value = VpnTunnelState(serviceUp = true) + try { + while (isActive) { + val client = socket.accept() + launch { serve(client) } + } + } catch (e: IOException) { + // accept() throwing is how closing the socket stops this loop. + } + } + } + + /** + * Reads control lines from one connection until it ends. + * + * The abstract namespace has no filesystem permissions to lean on, so the + * peer's UID is the whole gate: only our own processes — which is to say + * the visor we spawned — may ask for the phone's TUN. + */ + private suspend fun serve(client: LocalSocket) { + clients += client + try { + if (client.peerCredentials.uid != Process.myUid()) return + val reader = client.inputStream.bufferedReader() + while (true) { + val line = reader.readLine() ?: break + handle(client, line) + } + } catch (e: IOException) { + // A broken control channel is the core going away; handled below. + } finally { + clients -= client + runCatching { client.close() } + if (controller === client) { + controller = null + onCoreGone() + } + } + } + + private suspend fun handle(client: LocalSocket, line: String) = mutex.withLock { + val request = runCatching { + json.decodeFromString(TunRequest.serializer(), line) + }.getOrNull() + when (request?.op) { + OP_ESTABLISH -> establish(client, request) + OP_DOWN -> { + closeTun() + reply(client, ok = true) + } + else -> reply(client, ok = false, error = "unknown request") + } + } + + /** + * Builds the interface the core asked for and hands its descriptor over. + * + * Establishing again while one is already up is the sanctioned reconnect: + * the system swaps the interfaces without a gap, which is why the old + * descriptor is only closed *after* the new one exists — closing first + * would open a window with no VPN and leak traffic straight out. + */ + private fun establish(client: LocalSocket, request: TunRequest) { + val address = request.addr.substringBefore('/') + val prefix = request.addr.substringAfter('/', "").toIntOrNull() + if (address.isEmpty() || prefix == null) { + reply(client, ok = false, error = "malformed address ${request.addr}") + return + } + + val established = try { + val builder = Builder() + .setSession(getString(R.string.app_skyvpn)) + .addAddress(address, prefix) + // Everything. The client's own routing declares this as the + // two half-spaces; a default route is the same statement. + .addRoute("0.0.0.0", 0) + .setMtu(request.mtu.takeIf { it > 0 } ?: DEFAULT_MTU) + // A full tunnel with no resolver of its own would leave the + // phone pointed at whatever DNS its Wi-Fi handed out — often + // a LAN address that is unreachable from the other end of the + // tunnel. `--dns` overrides this. + .addDnsServer(request.dns.ifEmpty { DEFAULT_DNS }) + // The Go side polls this descriptor; blocking reads would + // park in the kernel with no way to interrupt them. + .setBlocking(false) + .setConfigureIntent(configureIntent()) + // The visor is our own child process and shares this UID, so its + // dmsg traffic — the traffic CARRYING the tunnel — must stay out + // of it. Excluding the package is what keeps it out. + builder.addDisallowedApplication(packageName) + builder.establish() + } catch (e: PackageManager.NameNotFoundException) { + reply(client, ok = false, error = "cannot exclude self from the tunnel: ${e.message}") + return + } catch (e: IllegalArgumentException) { + reply(client, ok = false, error = "rejected interface parameters: ${e.message}") + return + } catch (e: IllegalStateException) { + reply(client, ok = false, error = "rejected interface parameters: ${e.message}") + return + } + + if (established == null) { + // Consent was never granted, or was revoked while we ran. + reply(client, ok = false, error = getString(R.string.vpn_error_no_consent)) + VpnTunnel.mutableState.value = VpnTunnel.mutableState.value.copy( + established = false, + error = getString(R.string.vpn_error_no_consent), + ) + return + } + + // The fd rides along with the reply as SCM_RIGHTS; the receiver gets + // its own dup, which is why ours stays open and holds the interface. + client.setFileDescriptorsForSend(arrayOf(established.fileDescriptor)) + val sent = reply(client, ok = true) + client.setFileDescriptorsForSend(null) + if (!sent) { + runCatching { established.close() } + return + } + + runCatching { tun?.close() } + tun = established + controller = client + VpnTunnel.mutableState.value = VpnTunnelState( + serviceUp = true, + established = true, + address = request.addr, + ) + } + + /** Drops the interface, handing the phone back its normal networking. */ + private fun closeTun() { + val current = tun ?: return + tun = null + runCatching { current.close() } + VpnTunnel.mutableState.value = VpnTunnel.mutableState.value.copy( + established = false, + address = "", + ) + } + + /** + * The control channel ended without a `down`: the visor was killed rather + * than stopped. With the killswitch off that is the cue to release the + * phone; with it on, keeping the block is precisely what was asked for — + * the user disconnects to lift it. + */ + private fun onCoreGone() { + if (killswitch) return + scope.launch { mutex.withLock { closeTun() } } + } + + /** + * The tunnel cannot outlive the visor that carries it. A crash-restart is + * not that — [CoreState.Restarting] keeps the interface (and the block) + * in place — but a user Disconnect or a terminal failure is, and leaving + * the phone blocked with nothing left to reconnect would be a bug, not a + * killswitch. + */ + private fun watchCore() { + if (coreWatcher?.isActive == true) return + coreWatcher = scope.launch { + CoreServiceState.state.collect { core -> + if (core is CoreState.Stopped || core is CoreState.Failed) { + shutdown() + stopSelf() + } + } + } + } + + private fun shutdown() { + acceptor?.cancel() + acceptor = null + coreWatcher?.cancel() + coreWatcher = null + runCatching { server?.close() } + server = null + controller = null + synchronized(clients) { clients.toList() }.forEach { runCatching { it.close() } } + clients.clear() + runCatching { tun?.close() } + tun = null + VpnTunnel.mutableState.value = VpnTunnelState() + } + + /** Tapping the system's VPN indicator lands on the app. */ + private fun configureIntent(): PendingIntent = PendingIntent.getActivity( + this, + 0, + Intent(this, MainActivity::class.java), + PendingIntent.FLAG_IMMUTABLE, + ) + + /** True when the reply reached the peer. */ + private fun reply(client: LocalSocket, ok: Boolean, error: String? = null): Boolean = + runCatching { + val body = json.encodeToString(TunReply.serializer(), TunReply(ok, error.orEmpty())) + client.outputStream.apply { + write((body + "\n").toByteArray()) + flush() + } + true + }.getOrDefault(false) + + companion object { + /** + * Abstract-namespace socket the visor dials. Kept in step with the + * core's `SKYWIRE_ANDROID_VPN_SOCKET` (see [coreEnv]), which is how + * the Go side learns the name. + */ + const val SOCKET_NAME = "com.skycoin.skywire.vpn" + + private const val ACTION_START = "com.skycoin.skywire.vpn.START" + private const val ACTION_STOP = "com.skycoin.skywire.vpn.STOP" + private const val ACTION_KILLSWITCH = "com.skycoin.skywire.vpn.KILLSWITCH" + private const val EXTRA_KILLSWITCH = "killswitch" + + private const val OP_ESTABLISH = "establish" + private const val OP_DOWN = "down" + + /** Matches pkg/vpn's TUNMTU; the core sends its own value anyway. */ + private const val DEFAULT_MTU = 1500 + + /** Only used when vpn-client was given no `--dns`. */ + private const val DEFAULT_DNS = "1.1.1.1" + + fun start(context: Context, killswitch: Boolean) { + context.startService( + Intent(context, SkyVpnService::class.java) + .setAction(ACTION_START) + .putExtra(EXTRA_KILLSWITCH, killswitch), + ) + } + + fun stop(context: Context) { + context.startService( + Intent(context, SkyVpnService::class.java).setAction(ACTION_STOP), + ) + } + + /** Changes what happens to the interface when the core goes away. */ + fun setKillswitch(context: Context, killswitch: Boolean) { + context.startService( + Intent(context, SkyVpnService::class.java) + .setAction(ACTION_KILLSWITCH) + .putExtra(EXTRA_KILLSWITCH, killswitch), + ) + } + } +} + +/** One control line from the core. See pkg/vpn/tun_device_android.go. */ +@Serializable +private data class TunRequest( + @SerialName("op") val op: String = "", + @SerialName("addr") val addr: String = "", + @SerialName("gateway") val gateway: String = "", + @SerialName("mtu") val mtu: Int = 0, + @SerialName("dns") val dns: String = "", +) + +@Serializable +private data class TunReply( + @SerialName("ok") val ok: Boolean, + @SerialName("error") val error: String = "", +) diff --git a/android/app/src/main/java/com/skycoin/skywire/core/SkychatProfile.kt b/android/app/src/main/java/com/skycoin/skywire/core/SkychatProfile.kt new file mode 100644 index 0000000000..b7272bf32d --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/core/SkychatProfile.kt @@ -0,0 +1,89 @@ +package com.skycoin.skywire.core + +import java.io.File + +/** + * The phone's profile for the skychat app: the argv [ConfigManager] pins on + * every launch, and the password file that argv points at. + * + * **Why skychat gets a password here when the desktop leaves it off.** + * Android has no per-app network namespace: a listener on 127.0.0.1 is + * reachable by *every* other app on the device that holds INTERNET. skychat's + * surface is the whole account — history, contacts, sending — so left open it + * would be readable and writable by any installed app, with no prompt. The + * gate skychat already ships ([--password-file], `commands/auth.go`) closes + * that, and the password is a device-local secret ([SecretStore]) the WebView + * answers the challenge with. Written before the app is ever started, so + * there is no window in which the surface is open. + */ +object SkychatProfile { + + const val APP = "skychat" + + /** Loopback-only by design; the port stays whatever the config says. */ + const val HOST = LOOPBACK_HOST + const val DEFAULT_PORT = 8001 + + /** + * Basic-auth username. skychat ignores it (`auth.go` checks the password + * alone), but a WebView challenge has to send something. + */ + const val USER = "skywire" + + /** Same name the visor's own password management uses, under `local_path`. */ + private const val PASSWORD_FILE = "skychat-password" + + // The visor writes the double-dash form; the single-dash spelling is + // accepted too because a hand-edited config may carry it. + private val ADDR = listOf("--addr", "-addr") + private val PASSWORD_FILE_FLAG = listOf("--password-file", "-password-file") + private val PORTLESS = listOf("--portless", "-portless") + private val PERSIST = listOf("--persist", "-persist") + private val PERSIST_DB = listOf("--persist-db", "-persist-db") + + /** Where the phone keeps its chat history, under [SkywirePaths.localDir]. */ + private const val HISTORY_FILE = "skychat-history.db" + + fun passwordFile(paths: SkywirePaths): File = File(paths.localDir, PASSWORD_FILE) + + fun historyFile(paths: SkywirePaths): File = File(paths.localDir, HISTORY_FILE) + + /** Where the WebView loads the chat UI from. */ + fun baseUrl(port: Int): String = "http://$HOST:$port/" + + fun listenPort(args: List): Int = + argValue(args, ADDR)?.substringAfterLast(':')?.toIntOrNull() ?: DEFAULT_PORT + + /** + * The argv the phone owns, re-applied on every launch. Everything else — + * `--pair-enable`, the visor-managed `--internal-token` — passes through. + * + * - `--portless` is dropped. It exists so a hypervisor-only deployment + * can avoid opening a port at all, but the phone's *only* way into the + * UI is that port: the visor's `/skychat/proxy/…` mount serves the same + * handler under a path prefix, and every fetch in the page is + * root-absolute (`/history`, `/sse`, …), so the UI cannot run there + * without rewriting all of them. + * - `--addr` host forced to loopback (the port is left alone). + * - `--password-file` pinned at [passwordFile] — see the class comment. + * - `--persist` ON, at [historyFile]. It is off by default because a + * desktop can be left with an ephemeral chat, but on a phone that + * default means every conversation is erased the next time the core + * restarts — which it does on every crash, every reconnect and every + * app update. It is also what the call log reads back (the Calls tab + * is call records recovered from history), so without it a missed + * call notifies once and then never happened. + */ + fun phoneArgs(args: List, passwordFile: File, historyFile: File): List { + val pinned = args.filterNot { token -> + PORTLESS.any { token == it || token.startsWith("$it=") } + }.toMutableList() + if (pinned.none { token -> PERSIST.any { token == it || token.startsWith("$it=") } }) { + pinned += "--persist" + } + pinned.pinValue(PERSIST_DB) { historyFile.absolutePath } + pinned.pinValue(ADDR) { current -> loopbackAddr(current, DEFAULT_PORT) } + pinned.pinValue(PASSWORD_FILE_FLAG) { passwordFile.absolutePath } + return pinned + } +} diff --git a/android/app/src/main/java/com/skycoin/skywire/core/SkydexProfile.kt b/android/app/src/main/java/com/skycoin/skywire/core/SkydexProfile.kt new file mode 100644 index 0000000000..51823e3897 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/core/SkydexProfile.kt @@ -0,0 +1,71 @@ +package com.skycoin.skywire.core + +import java.io.File + +/** + * The phone's profile for the skydex-client app: the argv [ConfigManager] pins + * on every launch, and the password file that argv points at. + * + * **Why the trading UI gets a password here when the desktop leaves it off.** + * The same reason skychat does ([SkychatProfile]) — Android has no per-app + * network namespace, so a loopback listener is reachable by every installed app + * holding INTERNET — but with more behind the port: the live market session, + * the wallet addresses registered with it, and placing or cancelling orders. + * Left open, "an app on this phone can trade your coins" is the accurate + * description of the default. + * + * The gate is skywire's own wrapper (`cmd/apps/skydex-client/commands/auth.go`), + * added for this: the engine that serves the UI comes from the skycoin repo and + * has no authentication, so the wrapper takes over `--addr`, puts basic auth on + * it, and moves the engine to a loopback port drawn fresh at every start. The + * password is a device-local secret ([SecretStore]) that the WebView answers the + * challenge with and [com.skycoin.skywire.api.SkydexApi] sends on every call. + * Written before the app is ever started, so there is no window in which the + * surface is open. + */ +object SkydexProfile { + + const val APP = "skydex-client" + + /** Loopback-only by design; the port stays whatever the config says. */ + const val HOST = LOOPBACK_HOST + const val DEFAULT_PORT = 8051 + + /** + * Basic-auth username. The gate ignores it (`auth.go` checks the password + * alone), but a WebView challenge has to send something. + */ + const val USER = "skywire" + + /** Same shape as skychat's, under `local_path`. */ + private const val PASSWORD_FILE = "skydex-password" + + // The visor writes the double-dash form; the single-dash spelling is + // accepted too because a hand-edited config may carry it. + private val ADDR = listOf("--addr", "-addr") + private val PASSWORD_FILE_FLAG = listOf("--password-file", "-password-file") + + fun passwordFile(paths: SkywirePaths): File = File(paths.localDir, PASSWORD_FILE) + + /** Where the WebView loads the trading UI from. */ + fun baseUrl(port: Int): String = "http://$HOST:$port/" + + fun listenPort(args: List): Int = + argValue(args, ADDR)?.substringAfterLast(':')?.toIntOrNull() ?: DEFAULT_PORT + + /** + * The argv the phone owns, re-applied on every launch. Everything else — + * `--market-port`, and above all the `--market-pk` the SkyDEX screen + * writes — passes through untouched, so this never undoes a user's choice. + * + * - `--addr` host forced to loopback (the port is left alone). The + * generated `:8051` listens on every interface. + * - `--password-file` pinned at [passwordFile] — see the class comment. + */ + fun phoneArgs(args: List, passwordFile: File): List { + val pinned = args.toMutableList() + pinned.pinValue(ADDR) { current -> loopbackAddr(current, DEFAULT_PORT) } + pinned.pinValue(PASSWORD_FILE_FLAG) { passwordFile.absolutePath } + return pinned + } +} diff --git a/android/app/src/main/java/com/skycoin/skywire/core/SkywireCoreService.kt b/android/app/src/main/java/com/skycoin/skywire/core/SkywireCoreService.kt new file mode 100644 index 0000000000..5740b532c6 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/core/SkywireCoreService.kt @@ -0,0 +1,385 @@ +package com.skycoin.skywire.core + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.IBinder +import android.os.SystemClock +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import com.skycoin.skywire.MainActivity +import com.skycoin.skywire.R +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.runInterruptible +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import java.io.File +import java.util.concurrent.TimeUnit + +/** + * Foreground service that owns the visor child process — the phone + * equivalent of running `skywire visor -c skywire-config.json` under a + * process supervisor: + * - generates the config on first run (via [ConfigManager]), + * - execs the extracted Go binary with cwd/env pinned to app-private dirs, + * - captures the child's combined stdout/stderr into a rotating file (the + * only log source that exists when the visor won't start), + * - restarts on crash with exponential backoff, + * - stops the child gracefully (SIGTERM unwinds the visor's close stack) + * on user disconnect. + */ +class SkywireCoreService : Service() { + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private lateinit var paths: SkywirePaths + private lateinit var configManager: ConfigManager + private lateinit var vault: ConfigVault + private lateinit var prefs: AppPreferences + private var runner: Job? = null + private var callWatcher: Job? = null + private var notifyBridge: Job? = null + + @Volatile private var child: Process? = null + + @Volatile private var stopRequested = false + + override fun onCreate() { + super.onCreate() + paths = SkywirePaths(this) + configManager = ConfigManager(paths, SecretStore(this)) + vault = ConfigVault(paths) + prefs = AppPreferences(this) + createChannel() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + when (intent?.action) { + ACTION_STOP -> stopCore() + // ACTION_START, or null on a START_STICKY revival: the system + // only revives us if the core was meant to be running — resume. + else -> { + startForeground(NOTIFICATION_ID, notification(getString(R.string.core_notification_starting))) + startCore() + } + } + return START_STICKY + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onDestroy() { + // Last-resort cleanup (system kill): don't leave an orphan visor + // holding :8000 — the next spawn would fail on the bind. + child?.destroy() + scope.cancel() + super.onDestroy() + } + + // --- lifecycle --- + + private fun startCore() { + if (runner?.isActive == true) return + stopRequested = false + runner = scope.launch { + CoreServiceState.mutableState.value = CoreState.Starting(attempt = 1) + val log = ProcessLog(paths.processLogFile) + try { + val primary = TransportPreference.sanitize( + prefs.string(TransportPreference.PREF_KEY).first(), + ) + val fleet = prefs.boolean(Fleet.PREF_KEY, Fleet.DEFAULT).first() + val logLevel = CoreLogLevel.sanitize(prefs.string(CoreLogLevel.PREF_KEY).first()) + val config = configManager.ensureConfig(primary, fleet, logLevel).getOrElse { err -> + log.line("=== config generation failed ===") + log.line(err.message ?: "unknown error") + CoreServiceState.mutableState.value = + CoreState.Failed(err.message ?: "config generation failed") + return@launch + } + + var attempt = 0 + var backoffMs = INITIAL_BACKOFF_MS + while (!stopRequested) { + attempt++ + val startedAt = SystemClock.elapsedRealtime() + log.line("=== starting visor (attempt $attempt) ===") + val process = try { + spawnVisor(config) + } catch (e: Exception) { + log.line("spawn failed: $e") + CoreServiceState.mutableState.value = + CoreState.Failed("could not start the visor: ${e.message}") + return@launch + } + child = process + CoreServiceState.mutableState.value = + CoreState.Running(System.currentTimeMillis(), attempt) + notify(getString(R.string.core_notification_running)) + // Calls are watched for as long as this visor runs, and + // the watcher is rebuilt with it: its poll targets the + // API of the process that just started. + callWatcher?.cancel() + callWatcher = VoiceCallWatcher(this@SkywireCoreService).watch(scope) + notifyBridge?.cancel() + notifyBridge = + NotificationBridge(this@SkywireCoreService).watch(scope) + + val pump = launch(Dispatchers.IO) { + process.inputStream.bufferedReader().forEachLine { log.line(it) } + } + val exit = runInterruptible(Dispatchers.IO) { process.waitFor() } + pump.join() + child = null + callWatcher?.cancel() + callWatcher = null + notifyBridge?.cancel() + notifyBridge = null + + val ranMs = SystemClock.elapsedRealtime() - startedAt + log.line("=== visor exited with code $exit after ${ranMs / 1000}s ===") + if (stopRequested) break + + backoffMs = if (ranMs > STABLE_RUN_MS) INITIAL_BACKOFF_MS + else (backoffMs * 2).coerceAtMost(MAX_BACKOFF_MS) + CoreServiceState.mutableState.value = + CoreState.Restarting(attempt + 1, backoffMs) + notify(getString(R.string.core_notification_restarting, attempt + 1)) + // Sliced so a Disconnect during the backoff takes effect in + // ~250 ms instead of waiting out the whole delay. + val deadline = SystemClock.elapsedRealtime() + backoffMs + while (!stopRequested && SystemClock.elapsedRealtime() < deadline) { + delay(BACKOFF_SLICE_MS) + } + } + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + // Anything unexpected must land in Failed, not crash the app. + log.line("=== core service error: $e ===") + CoreServiceState.mutableState.value = + CoreState.Failed(e.message ?: "core service error") + } finally { + // The visor has exited, so the config on disk is final — + // including anything the visor rewrote while it ran, which is + // why this is here and not at generation. Runs in the finally + // so a crashed or failed core still leaves the identity + // sealed; NonCancellable because a cancelled scope must not be + // able to skip it and leave the secret key in the clear. + withContext(NonCancellable) { + val encrypt = prefs.boolean(ConfigVault.PREF_KEY, ConfigVault.DEFAULT).first() + vault.seal(encrypt).onFailure { log.line("=== config seal failed: ${'$'}it ===") } + } + // Terminal Failed states survive; everything else is Stopped. + if (CoreServiceState.mutableState.value !is CoreState.Failed) { + CoreServiceState.mutableState.value = CoreState.Stopped + } + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + } + } + + private fun stopCore() { + if (runner?.isActive != true) { + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + return + } + stopRequested = true + CoreServiceState.mutableState.value = CoreState.Stopping + scope.launch(Dispatchers.IO) { + val p = child ?: return@launch + // Android's Process.destroy() sends SIGKILL (unlike desktop + // JVMs), which would skip the visor's shutdown stack. Deliver a + // real SIGTERM to the child's pid so it unwinds cleanly (4 s per + // module, exit 0); kill only if it hangs or the pid lookup fails. + val pid = findChildVisorPid() + if (pid != null) { + runCatching { android.system.Os.kill(pid, android.system.OsConstants.SIGTERM) } + .onFailure { p.destroy() } + } else { + p.destroy() + } + if (!runInterruptible { p.waitFor(GRACEFUL_STOP_S, TimeUnit.SECONDS) }) { + p.destroyForcibly() + } + } + } + + /** + * Pid of our direct child running the visor payload, from /proc (own-uid + * entries are readable): `stat` is `pid (comm) state ppid …`. + */ + private fun findChildVisorPid(): Int? { + val myPid = android.os.Process.myPid() + return File("/proc").listFiles() + ?.mapNotNull { it.name.toIntOrNull() } + ?.firstOrNull { pid -> + runCatching { + File("/proc/$pid/cmdline").readText().contains(paths.visorBinary.name) && + File("/proc/$pid/stat").readText() + .substringAfterLast(") ") + .split(' ')[1] + .toInt() == myPid + }.getOrDefault(false) + } + } + + private fun spawnVisor(config: File): Process = + ProcessBuilder(paths.visorBinary.absolutePath, "visor", "-c", config.absolutePath) + .directory(paths.dataDir) + .redirectErrorStream(true) + .apply { environment().putAll(coreEnv(paths)) } + .start() + + // --- notification --- + + private fun createChannel() { + val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val channel = NotificationChannel( + CHANNEL_ID, + getString(R.string.core_channel_name), + NotificationManager.IMPORTANCE_LOW, + ).apply { description = getString(R.string.core_channel_description) } + manager.createNotificationChannel(channel) + } + + private fun notification(text: String): Notification { + val contentIntent = PendingIntent.getActivity( + this, + 0, + Intent(this, MainActivity::class.java), + PendingIntent.FLAG_IMMUTABLE, + ) + return NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.drawable.skywire_logo) + .setContentTitle(getString(R.string.core_notification_title)) + .setContentText(text) + .setContentIntent(contentIntent) + .setOngoing(true) + .setOnlyAlertOnce(true) + .build() + } + + private fun notify(text: String) { + val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + manager.notify(NOTIFICATION_ID, notification(text)) + } + + companion object { + private const val CHANNEL_ID = "core" + private const val NOTIFICATION_ID = 1 + private const val ACTION_START = "com.skycoin.skywire.core.START" + private const val ACTION_STOP = "com.skycoin.skywire.core.STOP" + + private const val INITIAL_BACKOFF_MS = 1_000L + private const val MAX_BACKOFF_MS = 30_000L + private const val BACKOFF_SLICE_MS = 250L + private const val STABLE_RUN_MS = 60_000L + private const val GRACEFUL_STOP_S = 15L + + fun start(context: Context) { + ContextCompat.startForegroundService( + context, + Intent(context, SkywireCoreService::class.java).setAction(ACTION_START), + ) + } + + fun stop(context: Context) { + context.startService( + Intent(context, SkywireCoreService::class.java).setAction(ACTION_STOP), + ) + } + + /** + * Stop the core and bring it straight back up. The visor reads its + * config once, while it builds its module graph, so a config change the + * app makes — the Fleet opt-in is one — reaches it no other way. + * + * Deliberately NOT a suspend function on the caller's scope. The + * screens that change the config are pushed routes, and a back press + * would cancel their view-model scope somewhere between the stop and + * the start — leaving the phone with no core at all. This runs on a + * process-scoped job instead, serialized so two restarts cannot + * interleave, and callers follow it through [CoreServiceState] like any + * other lifecycle change. + * + * The wait before restarting is not politeness: the child holds :8000, + * and a spawn that races it dies on the bind. [CoreState.Failed] is + * terminal and never becomes Stopped, so it ends the wait too, and the + * timeout covers a child that ignores SIGTERM and has to be killed. + * [between] runs with the core down, for callers that need to touch + * its files. + */ + fun restart(context: Context, between: suspend () -> Unit = {}) { + val app = context.applicationContext + restarts.launch { + restartMutex.withLock { + stop(app) + withTimeoutOrNull(STOP_WAIT_MS) { + CoreServiceState.state.first { + it is CoreState.Stopped || it is CoreState.Failed + } + } + between() + // A beat for the stopping instance to finish stopSelf, so + // the start lands on a clean service instance, not the + // dying one. + delay(RESTART_GRACE_MS) + // Android 12+ refuses a background startForegroundService, + // and stopping our own FGS is exactly what can cost us the + // exemption. Say so — silently having no core is worse than + // an error the user can act on with Connect. + runCatching { start(app) }.onFailure { e -> + CoreServiceState.mutableState.value = CoreState.Failed( + app.getString(R.string.core_restart_failed, e.message.orEmpty()), + ) + } + } + } + } + + private val restarts = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val restartMutex = Mutex() + + private const val STOP_WAIT_MS = 20_000L + private const val RESTART_GRACE_MS = 400L + } +} + +/** + * Append-only rotating capture of the child's combined output. One live file + * plus one predecessor; the viewer's Process source tails the live file. + */ +internal class ProcessLog(private val file: File) { + @Synchronized + fun line(text: String) { + runCatching { + if (file.length() > MAX_BYTES) { + val previous = File(file.parentFile, file.name + ".1") + previous.delete() + file.renameTo(previous) + } + file.appendText(text + "\n") + } + } + + private companion object { + const val MAX_BYTES = 1_500_000L + } +} diff --git a/android/app/src/main/java/com/skycoin/skywire/core/SkywirePaths.kt b/android/app/src/main/java/com/skycoin/skywire/core/SkywirePaths.kt new file mode 100644 index 0000000000..6e8e12d3f7 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/core/SkywirePaths.kt @@ -0,0 +1,77 @@ +package com.skycoin.skywire.core + +import android.content.Context +import java.io.File + +/** + * Every on-disk location the core touches, derived from one app-private root. + * The visor is exec'd with cwd = [dataDir] so every relative path inside the + * generated config ("./local", the default config name, gen's cwd-derived + * users.db) resolves under it. + */ +class SkywirePaths(context: Context) { + /** Root for everything the visor reads/writes: /skywire. */ + val dataDir: File = File(context.filesDir, "skywire") + + val configFile: File = File(dataDir, "skywire-config.json") + + /** + * The config, encrypted, while the core is stopped and the user has asked + * for it. Never present at the same time as a meaningful [configFile] — + * see [ConfigVault]. Sits beside it rather than elsewhere so a config + * backup or a manual copy of the data dir carries whichever form exists. + */ + val sealedConfigFile: File = File(dataDir, "skywire-config.json.enc") + + /** Config `local_path` — app workdirs, log DBs, uptime.db land here. */ + val localDir: File = File(dataDir, "local") + + /** Captured stdout/stderr of the visor child process (rotating). */ + val processLogFile: File = File(dataDir, "skywire-process.log") + + /** + * The extracted, executable Go payload. Valid because the module packs + * jniLibs with useLegacyPackaging=true. + */ + val visorBinary: File = File(context.applicationInfo.nativeLibraryDir, "libskywire-mobile.so") + + /** + * config `launcher.bin_path`. Deliberately NOT the native-library dir: + * the launcher calls MkdirAll on this path at startup, and the library + * dir both is read-only and changes on every app update — a stale one + * makes the visor abort with "failed to create dir … permission denied". + * Apps run in-process (empty `binary`), so nothing is ever executed from + * here; it just has to be stable and writable. + */ + val binDir: File = File(dataDir, "bin") + + /** TMPDIR for the child — os.TempDir() must be app-writable. */ + val tmpDir: File = File(context.cacheDir, "skywire-tmp") + + fun ensureDirs() { + dataDir.mkdirs() + localDir.mkdirs() + binDir.mkdirs() + tmpDir.mkdirs() + } +} + +/** + * Environment for every core invocation (config gen and the visor itself). + * + * `HOME`/`TMPDIR` keep the Go code's home- and temp-dir lookups inside + * app-private storage. `SKYWIRE_ANDROID_API_LEVEL` is what lets the core + * enumerate network interfaces at all: Android 11+ denies app processes the + * netlink dump Go's standard library uses, and the workaround only engages + * when the core knows the device API level — which a CGO-free build cannot + * discover for itself. `SKYWIRE_ANDROID_VPN_SOCKET` is where vpn-client asks + * for a TUN: the app owns the interface, so it also owns the name of the + * socket it hands descriptors out on. The leading `@` is Go's spelling of the + * abstract namespace [SkyVpnService] binds in. + */ +internal fun coreEnv(paths: SkywirePaths): Map = mapOf( + "HOME" to paths.dataDir.absolutePath, + "TMPDIR" to paths.tmpDir.absolutePath, + "SKYWIRE_ANDROID_API_LEVEL" to android.os.Build.VERSION.SDK_INT.toString(), + "SKYWIRE_ANDROID_VPN_SOCKET" to "@" + SkyVpnService.SOCKET_NAME, +) diff --git a/android/app/src/main/java/com/skycoin/skywire/core/ThemeMode.kt b/android/app/src/main/java/com/skycoin/skywire/core/ThemeMode.kt new file mode 100644 index 0000000000..6a39f690b5 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/core/ThemeMode.kt @@ -0,0 +1,27 @@ +package com.skycoin.skywire.core + +/** + * The user's theme override. The palette itself is brand-locked (see + * `ui/theme/Theme.kt`) — this only chooses which of its two halves is drawn. + */ +enum class ThemeMode { + SYSTEM, + LIGHT, + DARK, + ; + + /** [systemDark] is what the phone would have chosen on its own. */ + fun isDark(systemDark: Boolean): Boolean = when (this) { + SYSTEM -> systemDark + LIGHT -> false + DARK -> true + } + + companion object { + const val PREF_KEY = "theme_mode" + + /** Anything unrecognised — an older build's value — reads as [SYSTEM]. */ + fun of(stored: String?): ThemeMode = + entries.firstOrNull { it.name == stored } ?: SYSTEM + } +} diff --git a/android/app/src/main/java/com/skycoin/skywire/core/TransportPreference.kt b/android/app/src/main/java/com/skycoin/skywire/core/TransportPreference.kt new file mode 100644 index 0000000000..2acc6ce545 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/core/TransportPreference.kt @@ -0,0 +1,67 @@ +package com.skycoin.skywire.core + +/** + * Which transport type the visor reaches for FIRST — the primary — with + * every other type left in place behind it as a fallback. + * + * One visor-wide order drives two decisions in the core: which type it + * tries to *create* when a route needs a transport that isn't there yet, + * and which existing transport a route *rides* when several reach the same + * peer. So picking a primary here is exactly "use this one if you can, + * otherwise the others" — never "only this one". + * + * It is a visor setting, not a per-app one: SkySOCKS is where the phone + * exposes it first, and SkyVPN will show the same value. + */ +object TransportPreference { + + /** Relayed through a dmsg server. Always reachable — no NAT to beat. */ + const val DMSG = "dmsg" + + /** Direct TCP, peer address from the address resolver. */ + const val STCPR = "stcpr" + + /** Direct UDP with hole punching; needs a STUN-friendly NAT. */ + const val SUDPH = "sudph" + + /** + * The types offered as a primary. Deliberately the three the visor's + * transport-creation path can be asked to establish on demand — the + * rest of the taxonomy (quic, ws, wt, webrtc, stcp) is listener- or + * browser-side and never created for an outgoing app dial, so offering + * it here would promise something the core would not honor. + */ + val choices = listOf(DMSG, STCPR, SUDPH) + + /** + * dmsg, on a phone. A mobile link sits behind carrier NAT with no + * inbound reachability, so stcpr almost never establishes and sudph + * needs a NAT type the carrier rarely gives — yet the core's built-in + * order tries both first, spending up to ~20 s (the STUN wait plus the + * dial retries) before it reaches dmsg anyway. Starting at dmsg is the + * shortest path to a working route here; the others stay as fallbacks + * for the Wi-Fi case where they can win. + */ + const val DEFAULT = DMSG + + /** [AppPreferences] key holding the user's choice. */ + const val PREF_KEY = "transport_primary" + + /** + * The core's own default order. Kept whole so hoisting a primary out of + * it preserves the relative order of everything else, instead of + * flattening the untouched types to "unranked" (which is what sending a + * one-element order would do). A type the core adds later simply sorts + * last until this list catches up. + */ + private val CORE_ORDER = listOf( + "stcpr", "squicr", "sudph", "stcp", "webrtc", "swsr", "swtr", "dmsg", + ) + + /** The full priority order to hand the visor, [primary] in front. */ + fun order(primary: String): List = + listOf(primary) + CORE_ORDER.filter { it != primary } + + /** A stored/unknown value mapped back onto a supported choice. */ + fun sanitize(value: String?): String = value?.takeIf { it in choices } ?: DEFAULT +} diff --git a/android/app/src/main/java/com/skycoin/skywire/core/VisorNames.kt b/android/app/src/main/java/com/skycoin/skywire/core/VisorNames.kt new file mode 100644 index 0000000000..73c80b9c42 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/core/VisorNames.kt @@ -0,0 +1,56 @@ +package com.skycoin.skywire.core + +import android.content.Context +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.json.Json + +/** + * The names the user gives their own visors, so a Fleet row says "home server" + * instead of 66 hex characters. + * + * Kept on the phone, not on the visor. Fleet is a read-only window onto those + * machines — it would be a strange first exception to that for a label — and + * the label is the phone user's private note about which box is which, not a + * fact about the visor. The consequence is honest and worth stating: names do + * not travel to another device. + * + * One JSON object under one preference key: the map is a handful of entries, + * and a per-key preference would leave orphans behind whenever a visor is + * renamed or retired. + */ +class VisorNames(context: Context) { + + private val prefs = AppPreferences(context.applicationContext) + private val json = Json { ignoreUnknownKeys = true } + private val serializer = MapSerializer(String.serializer(), String.serializer()) + + /** Public key → name. Missing and blank are the same thing: unnamed. */ + fun names(): Flow> = prefs.string(KEY).map { stored -> + if (stored.isNullOrEmpty()) { + emptyMap() + } else { + runCatching { json.decodeFromString(serializer, stored) }.getOrDefault(emptyMap()) + } + } + + /** A blank [name] clears it — that is how the dialog removes one. */ + suspend fun setName(pk: String, name: String) { + val current = currentMap() + val trimmed = name.trim().take(MAX_LENGTH) + val updated = if (trimmed.isEmpty()) current - pk else current + (pk to trimmed) + prefs.putString(KEY, json.encodeToString(serializer, updated)) + } + + private suspend fun currentMap(): Map = names().first() + + private companion object { + const val KEY = "fleet_visor_names" + + /** Long enough for "office rack, top shelf"; short enough for a card. */ + const val MAX_LENGTH = 40 + } +} diff --git a/android/app/src/main/java/com/skycoin/skywire/core/VoiceAudio.kt b/android/app/src/main/java/com/skycoin/skywire/core/VoiceAudio.kt new file mode 100644 index 0000000000..8bcbf4339b --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/core/VoiceAudio.kt @@ -0,0 +1,354 @@ +package com.skycoin.skywire.core + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.media.AudioAttributes +import android.media.AudioFormat +import android.media.AudioManager +import android.media.AudioRecord +import android.media.AudioTrack +import android.media.MediaRecorder +import android.media.audiofx.AcousticEchoCanceler +import android.media.audiofx.AutomaticGainControl +import android.media.audiofx.NoiseSuppressor +import android.util.Log +import androidx.core.content.ContextCompat +import com.skycoin.skywire.api.VisorApi +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody +import okio.BufferedSink +import java.io.IOException +import java.nio.ByteBuffer +import java.nio.ByteOrder +import kotlin.coroutines.coroutineContext + +/** + * The phone's microphone and speaker, lent to the visor for the duration of a + * call. + * + * The visor has no audio device of its own on Android — it is a headless + * process, and the Go audio backends target PulseAudio, miniaudio and WebAudio, + * none of which exist here. So it exposes the two ends of its call mixer over + * the local API and this class plays the device: capture goes up the + * microphone stream, playback comes down the speaker stream. + * + * **Everything here is raw little-endian int16 PCM at [SAMPLE_RATE], mono.** + * That is the format the call package fixes for every backend, so there is + * nothing to negotiate — and nothing to notice if it were wrong, since a + * mismatched rate is not an error, just the wrong pitch. + * + * Both directions run for as long as the engine does and reconnect on their + * own: a stream that drops mid-call (the visor restarting under us, the + * process being frozen and thawed) should cost a moment of audio, not the call. + */ +class VoiceAudioEngine(context: Context) { + + private val app = context.applicationContext + private val api = VisorApi.get(app) + private val audioManager = app.getSystemService(Context.AUDIO_SERVICE) as AudioManager + + private var capture: Job? = null + private var playback: Job? = null + private var priorMode: Int? = null + + /** True while the mic loop is actually recording — false if the permission is missing. */ + @Volatile + var capturing: Boolean = false + private set + + /** + * Start both directions. + * + * [onMicrophoneReady] is invoked each time capture is about to open, once + * the permission is in hand — the host uses it to claim the microphone + * foreground-service type, which the platform refuses while the permission + * is missing and which must be in place before the first frame is recorded. + * + * Idempotent: a second call while running is a no-op, since the call + * watcher may re-observe the same active call. + */ + fun start(scope: CoroutineScope, onMicrophoneReady: () -> Unit = {}) { + if (playback?.isActive == true) return + // Voice-call routing: earpiece rather than the media speaker, and the + // input tuned for speech. Also what makes the platform's echo + // canceller apply — the Go side has none, so without this the peer + // hears themselves back through our speaker. + priorMode = audioManager.mode + runCatching { audioManager.mode = AudioManager.MODE_IN_COMMUNICATION } + playback = scope.launch(Dispatchers.IO) { playbackLoop() } + capture = scope.launch(Dispatchers.IO) { captureLoop(onMicrophoneReady) } + } + + /** Stop both directions and hand the device back to the system. */ + fun stop() { + capture?.cancel() + playback?.cancel() + capture = null + playback = null + capturing = false + priorMode?.let { mode -> runCatching { audioManager.mode = mode } } + priorMode = null + } + + // --- microphone → visor --- + + /** + * Holds one long-lived POST open, writing captured frames into it. + * + * The permission is re-checked rather than required up front: a call can + * arrive before the user has ever granted the microphone (they answer from + * the notification), and the honest behaviour then is a call they can HEAR + * while the grant is still outstanding — so this waits and picks the grant + * up whenever it lands, instead of failing the call. + */ + private suspend fun captureLoop(onMicrophoneReady: () -> Unit) { + while (coroutineContext.isActive) { + if (!hasMicPermission()) { + capturing = false + delay(PERMISSION_RETRY_MS) + continue + } + onMicrophoneReady() + try { + api.voiceMicStream { micBody() }.use { resp -> + if (!resp.isSuccessful) { + Log.w(TAG, "mic stream rejected: ${resp.code}") + delay(RETRY_MS) + } + } + } catch (e: IOException) { + // The body writer ends by throwing when the recorder stops or + // the socket goes; both are "reopen", not "give up". + Log.d(TAG, "mic stream ended: ${e.message}") + } finally { + capturing = false + } + coroutineContext.ensureActive() + delay(RETRY_MS) + } + } + + /** + * The request body IS the microphone: OkHttp calls [RequestBody.writeTo] + * once and we stay inside it, recording and writing, until the call ends. + * + * Recording is opened here rather than in the loop above so the device is + * held for exactly as long as the stream that carries it — an open + * AudioRecord with nowhere to send its audio is a microphone indicator in + * the status bar and nothing else. + */ + private fun micBody(): RequestBody = object : RequestBody() { + override fun contentType() = "application/octet-stream".toMediaType() + + // Unknown length: this is a stream, and declaring a length would make + // OkHttp buffer the whole call in memory before sending a byte. + override fun contentLength(): Long = -1 + + override fun isOneShot(): Boolean = true + + override fun writeTo(sink: BufferedSink) { + val record = openRecorder() ?: throw IOException("microphone unavailable") + val effects = attachEffects(record.audioSessionId) + try { + record.startRecording() + capturing = true + val samples = ShortArray(FRAME_SAMPLES * CHUNK_FRAMES) + val bytes = ByteBuffer.allocate(samples.size * 2).order(ByteOrder.LITTLE_ENDIAN) + while (true) { + val n = record.read(samples, 0, samples.size) + if (n <= 0) { + // A negative result is a dead recorder (device stolen + // by another app, session invalidated); ending the body + // is what reopens it. + throw IOException("capture ended ($n)") + } + bytes.clear() + for (i in 0 until n) bytes.putShort(samples[i]) + sink.write(bytes.array(), 0, n * 2) + sink.flush() + } + } finally { + capturing = false + runCatching { record.stop() } + record.release() + effects.forEach { runCatching { it.release() } } + } + } + } + + private fun openRecorder(): AudioRecord? { + val minBuffer = AudioRecord.getMinBufferSize(SAMPLE_RATE, CHANNEL_IN, ENCODING) + if (minBuffer <= 0) { + Log.w(TAG, "no $SAMPLE_RATE Hz mono capture on this device ($minBuffer)") + return null + } + val record = try { + @Suppress("MissingPermission") // hasMicPermission() gates every path here. + AudioRecord( + // VOICE_COMMUNICATION is what asks the platform for the + // echo-cancelled, speech-tuned capture path. MIC would give us + // a rawer signal and no AEC at all. + MediaRecorder.AudioSource.VOICE_COMMUNICATION, + SAMPLE_RATE, + CHANNEL_IN, + ENCODING, + maxOf(minBuffer, FRAME_SAMPLES * CHUNK_FRAMES * 2) * BUFFER_FACTOR, + ) + } catch (e: IllegalArgumentException) { + Log.w(TAG, "could not open capture", e) + return null + } + if (record.state != AudioRecord.STATE_INITIALIZED) { + record.release() + Log.w(TAG, "capture did not initialize") + return null + } + return record + } + + /** + * Platform echo cancellation, noise suppression and gain control, where + * the device offers them. Best-effort by design: they are hardware + * features on most phones and simply absent on some, and a call without + * them is worse, not broken. Nothing else in the pipeline provides them — + * the visor's Opus path has no APM. + */ + private fun attachEffects(sessionId: Int): List { + val effects = mutableListOf() + if (AcousticEchoCanceler.isAvailable()) { + AcousticEchoCanceler.create(sessionId)?.let { + it.enabled = true + effects += it + } + } + if (NoiseSuppressor.isAvailable()) { + NoiseSuppressor.create(sessionId)?.let { + it.enabled = true + effects += it + } + } + if (AutomaticGainControl.isAvailable()) { + AutomaticGainControl.create(sessionId)?.let { + it.enabled = true + effects += it + } + } + return effects + } + + // --- visor → speaker --- + + private suspend fun playbackLoop() { + while (coroutineContext.isActive) { + try { + api.voiceSpeakerStream().use { resp -> + if (!resp.isSuccessful) { + Log.w(TAG, "speaker stream rejected: ${resp.code}") + return@use + } + playInto(resp.body.byteStream()) + } + } catch (e: IOException) { + Log.d(TAG, "speaker stream ended: ${e.message}") + } + coroutineContext.ensureActive() + delay(RETRY_MS) + } + } + + private fun playInto(stream: java.io.InputStream) { + val track = openTrack() ?: return + try { + track.play() + val bytes = ByteArray(FRAME_SAMPLES * CHUNK_FRAMES * 2) + val samples = ShortArray(bytes.size / 2) + // A frame can arrive split across reads; a half sample left in the + // buffer has to lead the next read or every sample after it is + // built from the wrong pair of bytes. + var pending = 0 + while (true) { + val n = stream.read(bytes, pending, bytes.size - pending) + if (n < 0) return + val total = pending + n + val whole = total / 2 + val buffer = ByteBuffer.wrap(bytes, 0, whole * 2).order(ByteOrder.LITTLE_ENDIAN) + for (i in 0 until whole) samples[i] = buffer.short + if (whole > 0) track.write(samples, 0, whole) + pending = total % 2 + if (pending == 1) bytes[0] = bytes[total - 1] + } + } finally { + runCatching { track.pause() } + runCatching { track.flush() } + track.release() + } + } + + private fun openTrack(): AudioTrack? { + val minBuffer = AudioTrack.getMinBufferSize(SAMPLE_RATE, CHANNEL_OUT, ENCODING) + if (minBuffer <= 0) { + Log.w(TAG, "no $SAMPLE_RATE Hz mono playback on this device ($minBuffer)") + return null + } + val track = AudioTrack.Builder() + .setAudioAttributes( + AudioAttributes.Builder() + // VOICE_COMMUNICATION routes to the earpiece and rides the + // in-call volume stream, the way a phone call does. + .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .build(), + ) + .setAudioFormat( + AudioFormat.Builder() + .setEncoding(ENCODING) + .setSampleRate(SAMPLE_RATE) + .setChannelMask(CHANNEL_OUT) + .build(), + ) + .setBufferSizeInBytes(maxOf(minBuffer, FRAME_SAMPLES * CHUNK_FRAMES * 2) * BUFFER_FACTOR) + .setTransferMode(AudioTrack.MODE_STREAM) + .build() + if (track.state != AudioTrack.STATE_INITIALIZED) { + track.release() + Log.w(TAG, "playback did not initialize") + return null + } + return track + } + + private fun hasMicPermission(): Boolean = + ContextCompat.checkSelfPermission(app, Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED + + companion object { + private const val TAG = "SkywireVoice" + + /** + * Fixed by the visor's call package (`call.SampleRate` / + * `call.FrameSamples`). Changing either here alone corrupts audio + * silently — both ends must move together. + */ + const val SAMPLE_RATE = 48_000 + private const val FRAME_SAMPLES = 960 // 20 ms + private const val CHUNK_FRAMES = 2 + + private const val CHANNEL_IN = AudioFormat.CHANNEL_IN_MONO + private const val CHANNEL_OUT = AudioFormat.CHANNEL_OUT_MONO + private const val ENCODING = AudioFormat.ENCODING_PCM_16BIT + + /** Room for a scheduling hiccup without dropping frames. */ + private const val BUFFER_FACTOR = 4 + + private const val RETRY_MS = 500L + private const val PERMISSION_RETRY_MS = 1_500L + } +} diff --git a/android/app/src/main/java/com/skycoin/skywire/core/VoiceCallService.kt b/android/app/src/main/java/com/skycoin/skywire/core/VoiceCallService.kt new file mode 100644 index 0000000000..3a14c7ff43 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/core/VoiceCallService.kt @@ -0,0 +1,113 @@ +package com.skycoin.skywire.core + +import android.app.Notification +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.IBinder +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.app.ServiceCompat +import androidx.core.content.ContextCompat +import com.skycoin.skywire.MainActivity +import com.skycoin.skywire.R +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel + +/** + * Runs for exactly as long as a call is connected, and does one thing: lend + * the visor this phone's microphone and speaker ([VoiceAudioEngine]). + * + * **Why a service of its own** rather than a few coroutines in the core + * service. Recording from the background is only allowed to a foreground + * service that declares `microphone`, and declaring that on the always-on core + * service would claim the microphone for every minute the visor is connected — + * true for seconds a day, and visible to the user as a permanent in-use + * indicator. Here the claim starts and ends with the call, which is also what + * makes the privacy indicator mean something. + * + * Started and stopped by [VoiceCallWatcher] off the visor's own call list, so + * a call answered from the notification, from the chat page, or from anywhere + * else lands here the same way. + */ +class VoiceCallService : android.app.Service() { + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private lateinit var engine: VoiceAudioEngine + + override fun onCreate() { + super.onCreate() + VoiceCallWatcher.ensureChannels(this) + engine = VoiceAudioEngine(this) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + // Starts as playback-only, and that is not a formality: the platform + // REFUSES a `microphone` foreground service outright — SecurityException, + // not a silent mute — unless RECORD_AUDIO is already granted. A call can + // arrive before the user has ever been asked, so claiming the microphone + // up front would crash the app on the very call that needed it. + foreground(ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK) + engine.start(scope, onMicrophoneReady = { + // Promote before a single frame is recorded. Recording from the + // background is what the type buys, and a call may well be running + // with the app off-screen. + foreground(ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE) + }) + // Not sticky: a revived service with no call would hold the microphone + // for nothing. The watcher starts us again if a call is still up. + return START_NOT_STICKY + } + + private fun foreground(type: Int) { + runCatching { ServiceCompat.startForeground(this, NOTIFICATION_ID, notification(), type) } + .onFailure { Log.w(TAG, "could not enter the foreground as type $type", it) } + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onDestroy() { + engine.stop() + scope.cancel() + super.onDestroy() + } + + private fun notification(): Notification { + val open = PendingIntent.getActivity( + this, + 0, + // An explicit action, or StrictMode flags it as an unsafe intent + // launch on API 35+. + Intent(this, MainActivity::class.java).setAction(Intent.ACTION_MAIN), + PendingIntent.FLAG_IMMUTABLE, + ) + return NotificationCompat.Builder(this, VoiceCallWatcher.CHANNEL_ONGOING) + .setSmallIcon(R.drawable.skywire_logo) + .setContentTitle(getString(R.string.call_ongoing_title)) + .setContentText(getString(R.string.call_ongoing_text)) + .setCategory(NotificationCompat.CATEGORY_CALL) + .setContentIntent(open) + .setOngoing(true) + .setOnlyAlertOnce(true) + .build() + } + + companion object { + private const val TAG = "SkywireVoice" + private const val NOTIFICATION_ID = 3 + + fun start(context: Context) { + ContextCompat.startForegroundService( + context, + Intent(context, VoiceCallService::class.java), + ) + } + + fun stop(context: Context) { + context.stopService(Intent(context, VoiceCallService::class.java)) + } + } +} diff --git a/android/app/src/main/java/com/skycoin/skywire/core/VoiceCalls.kt b/android/app/src/main/java/com/skycoin/skywire/core/VoiceCalls.kt new file mode 100644 index 0000000000..9102bdf1b3 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/core/VoiceCalls.kt @@ -0,0 +1,300 @@ +package com.skycoin.skywire.core + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.skycoin.skywire.MainActivity +import com.skycoin.skywire.R +import com.skycoin.skywire.api.SkychatApi +import com.skycoin.skywire.api.VisorApi +import com.skycoin.skywire.api.VoiceInvite +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.coroutines.coroutineContext + +/** What the phone knows about calls right now. */ +data class VoiceCallState( + val ringing: List = emptyList(), + val dialing: List = emptyList(), + val activeIds: List = emptyList(), +) { + val inCall: Boolean get() = activeIds.isNotEmpty() + + /** The call to offer an answer for — one at a time is all a phone shows. */ + val invite: VoiceInvite? get() = ringing.firstOrNull() + + /** The call being placed, if any. */ + val outgoing: VoiceInvite? get() = dialing.firstOrNull() + + /** True whenever there is a call to put on screen, in either direction. */ + val busy: Boolean get() = inCall || invite != null || outgoing != null +} + +/** + * Process-wide view of the visor's calls. + * + * Read by the UI, written by the watcher below, which runs inside the core + * service — so a call rings and connects whether or not any screen is looking, + * which is the whole point of a phone. + */ +object VoiceCalls { + + private val mutable = MutableStateFlow(VoiceCallState()) + val state: StateFlow = mutable.asStateFlow() + + /** + * Calls this phone has hung up or declined but the visor still reports. + * + * The watcher polls, so without this the call screen stayed up for the + * rest of the tick after the red button — several seconds of a phone that + * looks like it did not hear you. Hanging up removes the call here at + * once and suppresses it until the visor agrees it is gone, which is the + * order every phone does this in. Nothing is lost if the visor disagrees: + * a hang-up that fails un-suppresses the id (see [endFailed]) and the + * next poll puts the call back. + */ + private val ended = MutableStateFlow>(emptySet()) + + /** + * Wakes the watcher out of its poll delay. Conflated and non-blocking: + * one nudge before the next tick is all it can usefully carry. + */ + private val nudges = MutableSharedFlow(extraBufferCapacity = 1) + internal val nudge: SharedFlow = nudges.asSharedFlow() + + /** The user ended [callId] — drop it now, confirm with the visor after. */ + fun endLocally(callId: String) { + ended.update { it + callId } + mutable.update { it.without(callId) } + nudges.tryEmit(Unit) + } + + /** The end never reached the visor: let the call come back on the next poll. */ + fun endFailed(callId: String) { + ended.update { it - callId } + nudges.tryEmit(Unit) + } + + /** + * The operator's names for public keys, from skychat's address book. + * + * Held here rather than fetched per screen because a call screen has to + * name its peer the instant it appears — a name that arrives a second + * later, after the user has already read a wall of hex, is not much of an + * improvement. Refreshed by the watcher on the same tick as the calls. + */ + private val names = MutableStateFlow>(emptyMap()) + + internal fun setNames(book: Map) { + names.value = book.mapKeys { (pk, _) -> pk.lowercase() } + } + + /** + * What to call [pk]: the operator's name for it, else the shortened key. + * The same order skychat uses for a notification title, so the two never + * disagree about who called. + */ + fun displayName(pk: String): String = + names.value[pk.lowercase()]?.takeIf { it.isNotEmpty() } ?: VoiceCallWatcher.shortPk(pk) + + internal fun set( + ringing: List, + dialing: List, + activeIds: List, + ) { + // Ids the visor has stopped reporting have served their purpose and + // leave the suppression set with it — kept any longer this would grow + // for the life of the process. + val reported = ringing.map { it.callId } + dialing.map { it.callId } + activeIds + ended.update { it intersect reported.toSet() } + val hidden = ended.value + mutable.update { + it.copy( + ringing = ringing.filterNot { call -> call.callId in hidden }, + dialing = dialing.filterNot { call -> call.callId in hidden }, + activeIds = activeIds.filterNot { id -> id in hidden }, + ) + } + } + + internal fun clear() { + ended.value = emptySet() + mutable.update { VoiceCallState() } + } +} + +/** This state minus one call, whichever of the three lists it is in. */ +private fun VoiceCallState.without(callId: String) = copy( + ringing = ringing.filterNot { it.callId == callId }, + dialing = dialing.filterNot { it.callId == callId }, + activeIds = activeIds.filterNot { it == callId }, +) + +/** + * Polls the visor for ringing and connected calls, and turns the answer into + * the two things the phone owes a call: a notification you can answer from, + * and — while connected — the service that lends the visor the microphone and + * speaker. + * + * Polling rather than a subscription because that is the surface the visor + * offers; at a two-second tick against a loopback API the cost is noise. + */ +internal class VoiceCallWatcher(context: Context) { + + private val app = context.applicationContext + private val api = VisorApi.get(app) + private val skychat = SkychatApi.get(app) + private val notifications = NotificationManagerCompat.from(app) + private var namesAt = 0L + + fun watch(scope: CoroutineScope): Job = scope.launch(Dispatchers.IO) { + ensureChannels(app) + var wasInCall = false + try { + while (coroutineContext.isActive) { + // Before the calls, so a name is already in hand when one + // arrives — a call screen that shows hex and corrects itself a + // moment later is barely better than one that never knew. + refreshNames() + val ringing = runCatching { api.voiceIncoming() }.getOrNull() + val active = runCatching { api.voiceActive() }.getOrNull() + val dialing = runCatching { api.voiceDialing() }.getOrNull().orEmpty() + // A failed poll is left alone rather than reported as "no + // calls": the visor restarting mid-call would otherwise cancel + // a live call's notification and drop the audio service. + if (ringing != null && active != null) { + VoiceCalls.set(ringing, dialing, active) + showRinging(ringing.firstOrNull()) + val inCall = active.isNotEmpty() + if (inCall != wasInCall) { + if (inCall) VoiceCallService.start(app) else VoiceCallService.stop(app) + wasInCall = inCall + } + } + // The tick, unless something happened that the next answer + // will be about. Hanging up nudges this so the visor is asked + // straight away rather than up to a tick later — the screen + // has already closed, and this is what makes the state behind + // it catch up while the user is still looking at the phone. + withTimeoutOrNull(POLL_MS) { VoiceCalls.nudge.first() } + } + } finally { + // The core is going away, so the calls are too — leave nothing + // ringing on screen and no service holding the microphone. + VoiceCalls.clear() + notifications.cancel(RINGING_NOTIFICATION_ID) + if (wasInCall) VoiceCallService.stop(app) + } + } + + /** + * Re-read the address book, rarely. Names change when a human edits one, + * so polling it at the call rate would be a request every two seconds for + * a file that changes twice a month. + */ + private suspend fun refreshNames() { + val now = System.currentTimeMillis() + if (now - namesAt < NAMES_REFRESH_MS) return + namesAt = now + val url = SkychatProfile.baseUrl(SkychatProfile.DEFAULT_PORT) + VoiceCalls.setNames(skychat.contacts(url)) + } + + /** + * Put a ringing call in front of the user — as the call SCREEN, never as + * something to read in a shade. + * + * With the app on screen there is nothing to do here: the UI draws the + * call itself the moment [VoiceCalls] changes. With the app backgrounded + * or closed, an app may not simply start an Activity — Android has + * forbidden background activity launches since 10 — and the sanctioned + * way to show a call is a **full-screen intent**: a notification whose + * only job is to carry the Activity that replaces it. On an unlocked, + * idle device the system launches that Activity directly and the + * notification is never seen; if it cannot (the user is mid-gesture, or + * has revoked the permission) it degrades to a heads-up banner, which is + * the floor, not the design. + */ + private fun showRinging(invite: VoiceInvite?) { + if (invite == null || AppVisibility.isForeground.value) { + notifications.cancel(RINGING_NOTIFICATION_ID) + return + } + val screen = PendingIntent.getActivity( + app, + 0, + Intent(app, MainActivity::class.java) + .setAction(Intent.ACTION_MAIN) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + val note = NotificationCompat.Builder(app, CHANNEL_RINGING) + .setSmallIcon(R.drawable.skywire_logo) + .setContentTitle(app.getString(R.string.call_incoming_title)) + .setContentText(VoiceCalls.displayName(invite.fromPk)) + .setCategory(NotificationCompat.CATEGORY_CALL) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setContentIntent(screen) + .setFullScreenIntent(screen, true) + .build() + runCatching { notifications.notify(RINGING_NOTIFICATION_ID, note) } + .onFailure { Log.w(TAG, "cannot raise the call screen", it) } + } + + companion object { + private const val TAG = "SkywireVoice" + private const val POLL_MS = 2_000L + private const val NAMES_REFRESH_MS = 30_000L + const val RINGING_NOTIFICATION_ID = 2 + + const val CHANNEL_RINGING = "call_incoming" + const val CHANNEL_ONGOING = "call_ongoing" + + /** + * Two channels because they are two different interruptions: a call + * arriving has to break through (sound, heads-up), and a call in + * progress must not make a sound at all. + */ + fun ensureChannels(context: Context) { + val manager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + manager.createNotificationChannel( + NotificationChannel( + CHANNEL_RINGING, + context.getString(R.string.call_channel_incoming), + NotificationManager.IMPORTANCE_HIGH, + ), + ) + manager.createNotificationChannel( + NotificationChannel( + CHANNEL_ONGOING, + context.getString(R.string.call_channel_ongoing), + NotificationManager.IMPORTANCE_LOW, + ), + ) + } + + /** `03ab…c9d1` — a 66-character key is not a caller ID. */ + fun shortPk(pk: String): String = + if (pk.length <= 12) pk else pk.take(6) + "…" + pk.takeLast(4) + } +} diff --git a/android/app/src/main/java/com/skycoin/skywire/core/VpnTunnel.kt b/android/app/src/main/java/com/skycoin/skywire/core/VpnTunnel.kt new file mode 100644 index 0000000000..4781140814 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/core/VpnTunnel.kt @@ -0,0 +1,31 @@ +package com.skycoin.skywire.core + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** What [SkyVpnService] has done with the phone's network interface. */ +data class VpnTunnelState( + /** The service is up and the visor can ask it for an interface. */ + val serviceUp: Boolean = false, + /** + * An interface is established: every other app's traffic is being routed + * into it. That is not the same as connected — while the tunnel is down + * and the killswitch holds this open, the traffic goes nowhere, which is + * the whole point of the setting. + */ + val established: Boolean = false, + /** Address the core asked for, e.g. `192.168.255.6/29`. */ + val address: String = "", + /** Last failure putting an interface up; cleared by the next success. */ + val error: String? = null, +) + +/** + * In-process bridge between [SkyVpnService] and the UI, the same shape as + * [CoreServiceState] — service and Compose tree share a process, so a + * StateFlow is the whole of it. + */ +object VpnTunnel { + internal val mutableState = MutableStateFlow(VpnTunnelState()) + val state = mutableState.asStateFlow() +} diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/SkywireApp.kt b/android/app/src/main/java/com/skycoin/skywire/ui/SkywireApp.kt new file mode 100644 index 0000000000..dc961aa721 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/SkywireApp.kt @@ -0,0 +1,547 @@ +package com.skycoin.skywire.ui + +import android.Manifest +import android.content.pm.PackageManager +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +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.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +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.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.Chat +import androidx.compose.material.icons.automirrored.rounded.Chat +import androidx.compose.material.icons.outlined.AccountBalanceWallet +import androidx.compose.material.icons.outlined.Home +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material.icons.rounded.AccountBalanceWallet +import androidx.compose.material.icons.rounded.Home +import androidx.compose.material.icons.rounded.Settings +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +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.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.NavHostController +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import com.skycoin.skywire.R +import com.skycoin.skywire.core.DeepLinks +import com.skycoin.skywire.core.VoiceCalls +import com.skycoin.skywire.ui.call.CallScreen +import com.skycoin.skywire.ui.chat.ChatScreen +import com.skycoin.skywire.ui.dex.DexScreen +import com.skycoin.skywire.ui.fleet.FleetScreen +import com.skycoin.skywire.ui.home.HomeScreen +import com.skycoin.skywire.ui.hub.HubScreen +import com.skycoin.skywire.ui.logs.LogSources +import com.skycoin.skywire.ui.logs.LogViewerScreen +import com.skycoin.skywire.ui.navigation.Routes +import com.skycoin.skywire.ui.components.PulseRing +import com.skycoin.skywire.ui.settings.DiagnosticsScreen +import com.skycoin.skywire.ui.settings.SettingsScreen +import com.skycoin.skywire.ui.theme.SkyButtonGradient +import androidx.lifecycle.viewmodel.compose.viewModel +import com.skycoin.skywire.ui.socks.SocksScreen +import com.skycoin.skywire.ui.vpn.VpnScreen +import com.skycoin.skywire.ui.wallet.WalletAddCoinScreen +import com.skycoin.skywire.ui.wallet.WalletHistoryScreen +import com.skycoin.skywire.ui.wallet.WalletManageScreen +import com.skycoin.skywire.ui.wallet.WalletReceiveScreen +import com.skycoin.skywire.ui.wallet.WalletRestoreScreen +import com.skycoin.skywire.ui.wallet.WalletResultScreen +import com.skycoin.skywire.ui.wallet.WalletRevealScreen +import com.skycoin.skywire.ui.wallet.WalletScreen +import com.skycoin.skywire.ui.wallet.WalletSeedScreen +import com.skycoin.skywire.ui.wallet.WalletSendScreen +import com.skycoin.skywire.ui.wallet.WalletTxScreen +import com.skycoin.skywire.ui.wallet.WalletVerifyScreen +import com.skycoin.skywire.ui.wallet.WalletViewModel + +/** + * One of the four labelled bottom-bar slots. The fifth — the raised Skycoin + * cloud in the middle, which opens the apps hub — is not a slot: it is its + * own shape, drawn over the bar in [SkyNavBar]. + */ +private data class BarSlot( + val route: String, + val label: String, + /** Outlined at rest, its Rounded (filled) form when the tab is current. */ + val idleIcon: ImageVector, + val activeIcon: ImageVector, +) + +@Composable +fun SkywireApp() { + val navController = rememberNavController() + + val backStackEntry by navController.currentBackStackEntryAsState() + val currentRoute = backStackEntry?.destination?.route + + // A skychat link opened from elsewhere belongs on the Chat tab. Only the + // navigation happens here — the link stays pending until the chat surface + // is up and has shown it, which can be a while after the tab is on screen + // (the core may still be connecting). + val chatLink by DeepLinks.pendingChatLink.collectAsState() + LaunchedEffect(chatLink) { + if (chatLink != null && currentRoute != Routes.CHAT) { + navController.navigateToTab(Routes.CHAT) + } + } + + // A connected call needs the microphone, and a call can be answered from + // the notification with no screen in front of the user — so the ask + // happens here, app-wide, the moment there IS one. Until it is granted the + // call runs receive-only rather than failing, and the capture loop picks + // the grant up whenever it lands. + val context = LocalContext.current + // One ViewModel for the whole wallet flow, scoped to the Activity — the + // freshly generated seed and the send draft must survive route changes + // inside the flow without ever riding in navigation arguments. + val walletViewModel: WalletViewModel = viewModel() + val inCall by VoiceCalls.state.collectAsState() + val micPermission = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { /* Granted or not, the call carries on; the engine re-checks. */ } + LaunchedEffect(inCall.inCall) { + val granted = ContextCompat.checkSelfPermission( + context, + Manifest.permission.RECORD_AUDIO, + ) == PackageManager.PERMISSION_GRANTED + if (inCall.inCall && !granted) micPermission.launch(Manifest.permission.RECORD_AUDIO) + } + + // A call owns the whole screen, in either direction and on every tab — + // the Chat tab included. The embedded page draws its own banner and panel + // underneath, but a call is not a thing to notice inside a list: it is + // what the phone is doing. + if (inCall.busy) { + CallScreen() + return + } + + Scaffold( + bottomBar = { + SkyNavBar( + currentRoute = currentRoute, + onSelect = { navController.navigateToTab(it) }, + onOpenHub = { navController.navigateToHub() }, + ) + }, + ) { innerPadding -> + NavHost( + navController = navController, + startDestination = Routes.HOME, + // consumeWindowInsets so a destination can still ask for the + // insets it needs — the Chat WebView's imePadding would otherwise + // count the navigation bar twice, once here and once in the + // keyboard inset it is measured from. + modifier = Modifier + .padding(innerPadding) + .consumeWindowInsets(innerPadding), + ) { + composable(Routes.HOME) { + HomeScreen( + onOpenLogs = { source -> + // singleTop: a double tap must not stack two viewers + // (the hidden one would keep polling the API). + navController.navigate(Routes.logs(source)) { launchSingleTop = true } + }, + ) + } + composable(Routes.CHAT) { + ChatScreen(onBack = { navController.leaveTab() }) + } + composable(Routes.HUB) { + HubScreen( + onBack = { navController.leaveTab() }, + onOpenRoute = { navController.navigate(it) }, + // Pushed, not switched-to: SkyChat and the Wallet are the + // two hub tiles that are also bottom-bar tabs, and going + // to them the tab way rewinds the stack to Home — so back + // out of either landed on Home rather than on the hub the + // user opened it from. Pushing keeps the hub behind them, + // which is what back means here. + onOpenTab = { navController.navigate(it) { launchSingleTop = true } }, + ) + } + // The wallet flow shares one ViewModel across its routes — the + // freshly generated seed and the send draft live in it, never in + // navigation arguments. + composable(Routes.WALLET) { + WalletScreen( + viewModel = walletViewModel, + onBack = { navController.leaveTab() }, + onCreate = { navController.navigate(Routes.WALLET_CREATE) { launchSingleTop = true } }, + onRestore = { navController.navigate(Routes.WALLET_RESTORE) { launchSingleTop = true } }, + onReceive = { navController.navigate(Routes.WALLET_RECEIVE) { launchSingleTop = true } }, + onSend = { navController.navigate(Routes.WALLET_SEND) { launchSingleTop = true } }, + onHistory = { navController.navigate(Routes.WALLET_HISTORY) { launchSingleTop = true } }, + onTx = { txid -> navController.navigate(Routes.walletTx(txid)) { launchSingleTop = true } }, + onWallets = { navController.navigate(Routes.WALLET_WALLETS) { launchSingleTop = true } }, + onAddCoin = { navController.navigate(Routes.WALLET_ADD_COIN) { launchSingleTop = true } }, + ) + } + composable(Routes.WALLET_CREATE) { + WalletSeedScreen( + viewModel = walletViewModel, + onContinue = { navController.navigate(Routes.WALLET_VERIFY) { launchSingleTop = true } }, + onBack = { navController.popBackStack() }, + ) + } + composable(Routes.WALLET_VERIFY) { + WalletVerifyScreen( + viewModel = walletViewModel, + onActivated = { navController.popBackStack(Routes.WALLET, inclusive = false) }, + onShowSeed = { navController.popBackStack() }, + onBack = { navController.popBackStack() }, + ) + } + composable(Routes.WALLET_RESTORE) { + WalletRestoreScreen( + viewModel = walletViewModel, + onRestored = { navController.popBackStack(Routes.WALLET, inclusive = false) }, + onBack = { navController.popBackStack() }, + ) + } + composable(Routes.WALLET_RECEIVE) { + WalletReceiveScreen( + viewModel = walletViewModel, + onBack = { navController.popBackStack() }, + ) + } + composable(Routes.WALLET_SEND) { + WalletSendScreen( + viewModel = walletViewModel, + onSent = { + navController.navigate(Routes.WALLET_RESULT) { + launchSingleTop = true + popUpTo(Routes.WALLET) { inclusive = false } + } + }, + onBack = { navController.popBackStack() }, + ) + } + composable(Routes.WALLET_RESULT) { + WalletResultScreen( + viewModel = walletViewModel, + onDone = { navController.popBackStack(Routes.WALLET, inclusive = false) }, + onHistory = { + navController.navigate(Routes.WALLET_HISTORY) { + launchSingleTop = true + popUpTo(Routes.WALLET) { inclusive = false } + } + }, + ) + } + composable(Routes.WALLET_HISTORY) { + WalletHistoryScreen( + viewModel = walletViewModel, + onTx = { txid -> navController.navigate(Routes.walletTx(txid)) { launchSingleTop = true } }, + onReceive = { navController.navigate(Routes.WALLET_RECEIVE) { launchSingleTop = true } }, + onBack = { navController.popBackStack() }, + ) + } + composable( + Routes.WALLET_TX, + arguments = listOf(navArgument("txid") { type = NavType.StringType }), + ) { entry -> + WalletTxScreen( + viewModel = walletViewModel, + txid = entry.arguments?.getString("txid") ?: "", + onBack = { navController.popBackStack() }, + ) + } + composable(Routes.WALLET_WALLETS) { + WalletManageScreen( + viewModel = walletViewModel, + onAddWallet = { navController.navigate(Routes.WALLET_CREATE) { launchSingleTop = true } }, + onRestoreWallet = { navController.navigate(Routes.WALLET_RESTORE) { launchSingleTop = true } }, + onReveal = { id -> navController.navigate(Routes.walletReveal(id)) { launchSingleTop = true } }, + onBack = { navController.popBackStack() }, + ) + } + composable( + Routes.WALLET_REVEAL, + arguments = listOf(navArgument("walletId") { type = NavType.StringType }), + ) { entry -> + WalletRevealScreen( + viewModel = walletViewModel, + walletId = entry.arguments?.getString("walletId") ?: "", + onBack = { navController.popBackStack() }, + ) + } + composable(Routes.WALLET_ADD_COIN) { + WalletAddCoinScreen( + viewModel = walletViewModel, + onAdded = { navController.popBackStack(Routes.WALLET, inclusive = false) }, + onBack = { navController.popBackStack() }, + ) + } + composable(Routes.SETTINGS) { + SettingsScreen( + onBack = { navController.leaveTab() }, + onOpenDiagnostics = { + navController.navigate(Routes.DIAGNOSTICS) { launchSingleTop = true } + }, + ) + } + composable(Routes.DIAGNOSTICS) { + DiagnosticsScreen( + onBack = { navController.popBackStack() }, + onOpenLogs = { source -> + navController.navigate(Routes.logs(source)) { launchSingleTop = true } + }, + ) + } + + // Full-screen routes pushed from the hub — back returns to the hub. + composable(Routes.SOCKS) { + SocksScreen(onBack = { navController.popBackStack() }) + } + composable(Routes.VPN) { + VpnScreen(onBack = { navController.popBackStack() }) + } + composable(Routes.DEX) { + DexScreen(onBack = { navController.popBackStack() }) + } + composable(Routes.FLEET) { + FleetScreen( + onBack = { navController.popBackStack() }, + onOpenLogs = { source -> + navController.navigate(Routes.logs(source)) { launchSingleTop = true } + }, + ) + } + + // One log viewer, reached from Home and (later) every app screen. + composable( + Routes.LOGS, + arguments = listOf(navArgument("source") { type = NavType.StringType }), + ) { entry -> + LogViewerScreen( + source = entry.arguments?.getString("source") ?: LogSources.CORE, + onBack = { navController.popBackStack() }, + ) + } + } + } +} + +/** + * Standard bottom-bar navigation: single top, state saved/restored per tab. + * Also used by the hub's SkyChat/Wallet tiles so they land on the *same* + * destinations as the Chat/Wallet tabs (one screen, two entry points). + */ +private fun NavHostController.navigateToTab(route: String) { + navigate(route) { + popUpTo(graph.findStartDestination().id) { saveState = true } + launchSingleTop = true + restoreState = true + } +} + +/** + * The cloud is a jump to the services list, not a tab. With restoreState it + * would bring back whatever the hub had pushed — tap the cloud from inside + * SkyVPN and the restored stack put SkyVPN right back on top, so the button + * appeared to do nothing. No restore: the hub itself, every time. + */ +private fun NavHostController.navigateToHub() { + navigate(Routes.HUB) { + popUpTo(graph.findStartDestination().id) { saveState = true } + launchSingleTop = true + } +} + +/** + * The ← at the top of a tab: back to wherever the tab was opened from, and to + * Home only when there is genuinely nothing behind it. A tab reached from the + * bar has Home behind it and lands there either way; one reached from the hub + * now returns to the hub instead of skipping past it. + */ +private fun NavHostController.leaveTab() { + if (!popBackStack()) navigateToTab(Routes.HOME) +} + +/** + * The floating bottom bar: a rounded shell with two labelled slots on each + * side, and the Skycoin cloud on a raised circular button in the middle — + * the one slot people aim for by shape rather than by reading, so it gets a + * shape instead of a label. It opens the apps hub. + * + * The cloud button rides [LIFT] above the shell via [offset], which moves + * drawing but not measurement — the jut lands on top of the screen's content, + * which is what a raised button is. Scaffold places the bottom bar last, so + * it wins the overlap. + */ +@Composable +private fun SkyNavBar( + currentRoute: String?, + onSelect: (String) -> Unit, + onOpenHub: () -> Unit, +) { + val slots = listOf( + BarSlot(Routes.HOME, stringResource(R.string.tab_home), Icons.Outlined.Home, Icons.Rounded.Home), + BarSlot(Routes.CHAT, stringResource(R.string.tab_chat), Icons.AutoMirrored.Outlined.Chat, Icons.AutoMirrored.Rounded.Chat), + BarSlot(Routes.WALLET, stringResource(R.string.tab_wallet), Icons.Outlined.AccountBalanceWallet, Icons.Rounded.AccountBalanceWallet), + BarSlot(Routes.SETTINGS, stringResource(R.string.tab_settings), Icons.Outlined.Settings, Icons.Rounded.Settings), + ) + val selected = { slot: BarSlot -> + currentRoute == slot.route || + (slot.route == Routes.SETTINGS && currentRoute in Routes.settingsPushed) || + (slot.route == Routes.WALLET && currentRoute in Routes.walletPushed) + } + val shellColor = MaterialTheme.colorScheme.surfaceContainerLowest + + Box( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + // top = LIFT reserves the jut. The cloud is drawn with an offset, + // which moves painting but not measurement, so without this the + // bar measures as the shell alone and Scaffold hands the screen + // content the strip the cloud is standing in — which is how the + // logo ended up sitting inside the chat composer. Paying for the + // lift here costs every screen 21dp it was never really allowed + // to use, and no screen has to know the cloud exists. + .padding(top = LIFT, start = 12.dp, end = 12.dp, bottom = 8.dp), + ) { + Surface( + shape = MaterialTheme.shapes.extraLarge, + color = shellColor, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + shadowElevation = 8.dp, + modifier = Modifier.fillMaxWidth(), + ) { + Row(Modifier.height(BAR_HEIGHT)) { + NavSlot(slots[0], selected(slots[0]), onSelect, Modifier.weight(1f)) + NavSlot(slots[1], selected(slots[1]), onSelect, Modifier.weight(1f)) + Spacer(Modifier.weight(1f)) // the cloud's column + NavSlot(slots[2], selected(slots[2]), onSelect, Modifier.weight(1f)) + NavSlot(slots[3], selected(slots[3]), onSelect, Modifier.weight(1f)) + } + } + CloudButton( + ringColor = shellColor, + onClick = onOpenHub, + modifier = Modifier + .align(Alignment.TopCenter) + .offset(y = -LIFT), + ) + } +} + +@Composable +private fun NavSlot( + slot: BarSlot, + selected: Boolean, + onSelect: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val tint = if (selected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outline + } + // Icon only — the label lives in the content description. Four glyphs + // and the cloud read cleaner than four glyphs with four captions. + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = modifier + .fillMaxHeight() + // No bounded ripple: the shell is one rounded surface and four + // rectangular flashes inside it read as seams. + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = { onSelect(slot.route) }, + ), + ) { + Icon( + imageVector = if (selected) slot.activeIcon else slot.idleIcon, + contentDescription = slot.label, + tint = tint, + modifier = Modifier.size(26.dp), + ) + } +} + +/** + * The raised cloud: brand gradient under the Skycoin mark, a ring of the + * shell's own color so it reads as punched *through* the bar, and a slow + * pulse behind it — the bar's one piece of motion, on the one control that + * is always worth a look. + */ +@Composable +private fun CloudButton( + ringColor: Color, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val hubLabel = stringResource(R.string.tab_hub_description) + + Box(contentAlignment = Alignment.Center, modifier = modifier) { + PulseRing(size = CLOUD_SIZE, color = MaterialTheme.colorScheme.primary) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(CLOUD_SIZE) + .clip(CircleShape) + .background(ringColor) + .clickable(onClick = onClick) + .padding(4.dp) + .clip(CircleShape) + .background(SkyButtonGradient), + ) { + Icon( + painter = painterResource(R.drawable.skywire_logo), + contentDescription = hubLabel, + tint = Color.White, + // The mark is a 4:3 cloud; 34×26 keeps it inside the 58dp + // gradient disc with the ring's weight around it. + modifier = Modifier.size(34.dp), + ) + } + } +} + +private val BAR_HEIGHT = 64.dp +private val CLOUD_SIZE = 66.dp + +/** How far the cloud rides above the shell — mock's 0.32 × its size. */ +private val LIFT = 21.dp diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/call/CallScreen.kt b/android/app/src/main/java/com/skycoin/skywire/ui/call/CallScreen.kt new file mode 100644 index 0000000000..1dbf79f297 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/call/CallScreen.kt @@ -0,0 +1,246 @@ +package com.skycoin.skywire.ui.call + +import androidx.activity.compose.BackHandler +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.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.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Call +import androidx.compose.material.icons.filled.CallEnd +import androidx.compose.material.icons.filled.Mic +import androidx.compose.material.icons.filled.MicOff +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.VolumeDown +import androidx.compose.material.icons.filled.VolumeUp +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +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.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.skycoin.skywire.R +import com.skycoin.skywire.ui.theme.SkyAccents + +/** + * The call, full screen — in either direction, on every tab. + * + * Placing one, being rung, and being in one are the same screen because they + * are the same event seen at three moments; the controls are what changes. + * It covers the Chat tab too: the embedded page has its own banner and panel, + * but a call is not something to notice inside a conversation list, it is what + * the phone is doing. + */ +@Composable +fun CallScreen(viewModel: CallViewModel = viewModel()) { + val state by viewModel.uiState.collectAsState() + val peer = state.peer ?: return + + // Back must not dismiss a call. The way out is Decline or Hang up — a + // swipe that silently left a live call running behind the UI would be a + // call the user cannot get back to. + BackHandler(enabled = true) { } + + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 32.dp, vertical = 48.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceBetween, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Spacer(Modifier.height(24.dp)) + Box( + modifier = Modifier + .size(112.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Default.Person, + contentDescription = null, + modifier = Modifier.size(56.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.height(20.dp)) + Text( + text = peer, + style = MaterialTheme.typography.titleLarge, + fontFamily = FontFamily.Monospace, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = when { + state.connected -> state.elapsed + state.dialing -> stringResource(R.string.call_dialing) + else -> stringResource(R.string.call_incoming_title) + }, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + when { + state.connected -> ConnectedControls( + micMuted = state.micMuted, + speakerphone = state.speakerphone, + onToggleMic = viewModel::toggleMic, + onToggleSpeaker = viewModel::toggleSpeakerphone, + onHangUp = viewModel::hangUp, + ) + // Placing a call: the only thing to offer is giving up on it, + // which cancels the invite rather than closing a session. + state.dialing -> DialingControls(onCancel = viewModel::hangUp) + else -> RingingControls( + onDecline = viewModel::decline, + onAnswer = viewModel::answer, + ) + } + } + } +} + +@Composable +private fun RingingControls(onDecline: () -> Unit, onAnswer: () -> Unit) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + CallButton( + icon = Icons.Default.CallEnd, + label = stringResource(R.string.call_decline), + background = DeclineRed, + onClick = onDecline, + ) + CallButton( + icon = Icons.Default.Call, + label = stringResource(R.string.call_answer), + background = AnswerGreen, + onClick = onAnswer, + ) + } +} + +@Composable +private fun DialingControls(onCancel: () -> Unit) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + CallButton( + icon = Icons.Default.CallEnd, + label = stringResource(R.string.call_hang_up), + background = DeclineRed, + onClick = onCancel, + ) + } +} + +@Composable +private fun ConnectedControls( + micMuted: Boolean, + speakerphone: Boolean, + onToggleMic: () -> Unit, + onToggleSpeaker: () -> Unit, + onHangUp: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + CallButton( + icon = if (micMuted) Icons.Default.MicOff else Icons.Default.Mic, + label = stringResource( + if (micMuted) R.string.call_unmute else R.string.call_mute, + ), + background = if (micMuted) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.surfaceVariant, + content = if (micMuted) MaterialTheme.colorScheme.onPrimary + else MaterialTheme.colorScheme.onSurface, + onClick = onToggleMic, + ) + CallButton( + icon = Icons.Default.CallEnd, + label = stringResource(R.string.call_hang_up), + background = DeclineRed, + onClick = onHangUp, + ) + CallButton( + icon = if (speakerphone) Icons.Default.VolumeUp else Icons.Default.VolumeDown, + label = stringResource(R.string.call_speaker), + background = if (speakerphone) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.surfaceVariant, + content = if (speakerphone) MaterialTheme.colorScheme.onPrimary + else MaterialTheme.colorScheme.onSurface, + onClick = onToggleSpeaker, + ) + } +} + +/** + * One round control. [content] is the ink on it, and it is a parameter rather + * than a constant because only the two filled buttons — Answer green, Hang up + * red — are guaranteed dark enough for white. Mic and Speaker sit on + * `surfaceVariant`, which on the light theme is a near-white card tint: a + * white glyph on it is the invisible button, so those pass the theme's own + * ink and only switch to `onPrimary` once the blue fill is under them. + */ +@Composable +private fun CallButton( + icon: androidx.compose.ui.graphics.vector.ImageVector, + label: String, + background: Color, + onClick: () -> Unit, + content: Color = Color.White, +) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Surface( + shape = CircleShape, + color = background, + onClick = onClick, + modifier = Modifier.size(72.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + icon, + contentDescription = label, + modifier = Modifier.size(32.dp), + tint = content, + ) + } + } + Spacer(Modifier.height(8.dp)) + Text(label, style = MaterialTheme.typography.labelMedium) + } +} + +// Answer/decline keep their conventional colours in both themes: on a call +// screen these two buttons are read by colour before they are read at all. +// The green is the palette's success accent; the red stays its own — the +// theme's error role shifts between themes and these two must not. +private val AnswerGreen = SkyAccents.success +private val DeclineRed = Color(0xFFD93B34) diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/call/CallViewModel.kt b/android/app/src/main/java/com/skycoin/skywire/ui/call/CallViewModel.kt new file mode 100644 index 0000000000..237e7bde77 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/call/CallViewModel.kt @@ -0,0 +1,183 @@ +package com.skycoin.skywire.ui.call + +import android.app.Application +import android.media.AudioManager +import android.media.Ringtone +import android.media.RingtoneManager +import android.os.SystemClock +import android.util.Log +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.skycoin.skywire.api.VisorApi +import com.skycoin.skywire.core.VoiceCalls +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** What the call screen draws. */ +data class CallUiState( + /** Short form of the other side's key; null when there is no call. */ + val peer: String? = null, + val callId: String? = null, + val connected: Boolean = false, + /** Placing a call, waiting for the other side to pick up. */ + val dialing: Boolean = false, + val micMuted: Boolean = false, + val speakerphone: Boolean = false, + /** `m:ss`, counted from when this device saw the call connect. */ + val elapsed: String = "0:00", +) + +/** + * Drives the full-screen call UI off [VoiceCalls] — the same list of calls the + * visor answers with, so this screen and the chat page's own panel can never + * disagree about whether there is a call. + * + * The elapsed time is counted here rather than read from the visor: the visor + * does not publish a start time, and a timer that begins when THIS phone saw + * the call connect is the honest thing to show anyway. + */ +class CallViewModel(app: Application) : AndroidViewModel(app) { + + private val api = VisorApi.get(app) + private val audioManager = + app.getSystemService(Application.AUDIO_SERVICE) as AudioManager + + private val mutable = MutableStateFlow(CallUiState()) + val uiState: StateFlow = mutable.asStateFlow() + + private var ringtone: Ringtone? = null + private var connectedAt: Long = 0 + + init { + viewModelScope.launch { + VoiceCalls.state.collect { calls -> + val invite = calls.invite + val outgoing = calls.outgoing + val active = calls.activeIds.firstOrNull() + val connected = active != null + if (connected && connectedAt == 0L) connectedAt = SystemClock.elapsedRealtime() + if (!connected) connectedAt = 0 + + mutable.update { + it.copy( + // Once connected the peer is kept from whichever side + // named it: the active list carries ids only, so + // re-deriving it every poll would blank the name the + // moment the call was answered. + peer = when { + connected -> it.peer + ?: invite?.fromPk?.let(::short) + ?: outgoing?.fromPk?.let(::short) + invite != null -> short(invite.fromPk) + outgoing != null -> short(outgoing.fromPk) + else -> null + }, + callId = active ?: invite?.callId ?: outgoing?.callId, + connected = connected, + dialing = !connected && outgoing != null, + // A call that ended takes its mute state with it. + micMuted = if (calls.busy) it.micMuted else false, + ) + } + // Ring only for a call coming IN, and only here: the point of + // the full-screen UI is that it, not a notification, is the + // call. A call we are placing rings at the other end. + if (invite != null && !connected) startRinging() else stopRinging() + } + } + viewModelScope.launch { + while (true) { + if (connectedAt != 0L) { + val seconds = (SystemClock.elapsedRealtime() - connectedAt) / 1000 + mutable.update { it.copy(elapsed = "%d:%02d".format(seconds / 60, seconds % 60)) } + } + delay(500) + } + } + } + + fun answer() = act { id -> api.voiceAnswer(id) } + + fun decline() = end { id -> api.voiceDecline(id) } + + fun hangUp() = end { id -> api.voiceHangup(id) } + + fun toggleMic() { + val muted = !mutable.value.micMuted + mutable.update { it.copy(micMuted = muted) } + act { id -> api.voiceMute(id, mic = muted, speaker = false) } + } + + /** + * Earpiece ⇄ speakerphone. Routing is the phone's business, not the + * visor's — the audio has already arrived by the time this matters. + */ + fun toggleSpeakerphone() { + val on = !mutable.value.speakerphone + runCatching { + @Suppress("DEPRECATION") // setCommunicationDevice needs a device list walk; this is one line and still honoured. + audioManager.isSpeakerphoneOn = on + }.onFailure { Log.w(TAG, "could not switch the call route", it) } + mutable.update { it.copy(speakerphone = on) } + } + + private fun act(block: suspend (String) -> Unit) { + val id = mutable.value.callId ?: return + viewModelScope.launch { + runCatching { block(id) } + .onFailure { Log.w(TAG, "call action failed", it) } + } + } + + /** + * Hang up / decline: the two actions whose whole point is that the call + * stops *now*. The call leaves the shared state before the request goes + * out, so the screen closes on the tap rather than on the watcher's next + * poll — which is a tick away and read as a phone ignoring the button. + * A request that fails hands the call back, and the poll behind it puts + * the screen up again. + */ + private fun end(block: suspend (String) -> Unit) { + val id = mutable.value.callId ?: return + stopRinging() + VoiceCalls.endLocally(id) + viewModelScope.launch { + runCatching { block(id) }.onFailure { + Log.w(TAG, "call action failed", it) + VoiceCalls.endFailed(id) + } + } + } + + private fun startRinging() { + if (ringtone?.isPlaying == true) return + runCatching { + val uri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE) + ringtone = RingtoneManager.getRingtone(getApplication(), uri).also { + it.isLooping = true + it.play() + } + }.onFailure { Log.w(TAG, "no ringtone", it) } + } + + private fun stopRinging() { + runCatching { ringtone?.stop() } + ringtone = null + } + + override fun onCleared() { + stopRinging() + super.onCleared() + } + + /** The operator's name for the key when there is one — see VoiceCalls. */ + private fun short(pk: String) = VoiceCalls.displayName(pk) + + private companion object { + const val TAG = "SkywireVoice" + } +} diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/chat/ChatScreen.kt b/android/app/src/main/java/com/skycoin/skywire/ui/chat/ChatScreen.kt new file mode 100644 index 0000000000..9f734f85e5 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/chat/ChatScreen.kt @@ -0,0 +1,312 @@ +package com.skycoin.skywire.ui.chat + +import android.content.ActivityNotFoundException +import android.net.Uri +import android.webkit.PermissionRequest +import android.webkit.ValueCallback +import android.webkit.WebChromeClient +import android.webkit.WebView +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +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.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +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.lifecycle.viewmodel.compose.viewModel +import com.skycoin.skywire.R +import com.skycoin.skywire.core.ChatMedia +import com.skycoin.skywire.core.CoreState +import com.skycoin.skywire.core.DeepLinks +import com.skycoin.skywire.ui.components.HelpTopic +import com.skycoin.skywire.ui.components.SkyTopBar +import com.skycoin.skywire.ui.theme.LocalDarkTheme + +/** + * Chat tab — skychat's own web UI, embedded. + * + * Deliberately the *same* UI the desktop serves rather than a native rewrite: + * one chat codebase, and its phone layout is a breakpoint inside it (the + * conversation list is the screen until a chat is opened, which slides in + * over it). Everything the page cannot do for itself — the password gate, + * the microphone, the file picker, saving an attachment, and the phone's own + * back gesture — is what [ChatWebView] adds around it. + */ +@Composable +fun ChatScreen(onBack: () -> Unit, viewModel: ChatViewModel = viewModel()) { + val state by viewModel.uiState.collectAsState() + val context = LocalContext.current + + var webView by remember { mutableStateOf(null) } + var loadedUrl by remember { mutableStateOf(null) } + var canGoBack by remember { mutableStateOf(false) } + // The page is loaded and can be driven. Distinct from "the WebView + // exists": a link handed to a page that is still loading would be + // evaluated against the document it is replacing. + var pageReady by remember { mutableStateOf(false) } + // The page's own error, kept apart from the view model's: one is "the + // surface never came up", the other "it came up and then broke". + var pageError by remember { mutableStateOf(null) } + + // Read by the WebView clients, which are built once — these keep them + // looking at the current values instead of the ones at creation time. + val url = rememberUpdatedState(state.url) + val password = rememberUpdatedState(state.password) + + // --- uploads: the page's --- + var pendingFiles by remember { mutableStateOf>?>(null) } + val filePicker = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult(), + ) { result -> + // Always answer the callback, cancel included: an unanswered file + // chooser leaves the page's input permanently unusable. + pendingFiles?.onReceiveValue( + WebChromeClient.FileChooserParams.parseResult(result.resultCode, result.data), + ) + pendingFiles = null + } + + // --- getUserMedia: voice messages now, video messages next --- + var pendingMedia by remember { mutableStateOf(null) } + val mediaPermissions = rememberLauncherForActivityResult( + ActivityResultContracts.RequestMultiplePermissions(), + ) { grants -> + val request = pendingMedia + pendingMedia = null + request?.grantOrDeny { resource -> + ChatWebView.androidPermission(resource)?.let { grants[it] == true } ?: false + } + } + + // One rule for both ways back, so the ← in the header and the phone's own + // gesture cannot disagree: inside the page (a conversation open over the + // list) back is the page's step, and only a page with nowhere left to go + // leaves the tab. The header button used to skip straight to the second + // half, which made ← from an open chat throw the reader out of SkyChat + // entirely. + val goBack: () -> Unit = { + val view = webView + if (canGoBack && view != null) view.goBack() else onBack() + } + BackHandler(enabled = canGoBack) { webView?.goBack() } + + // A skychat:// link another app opened us for. It waits here rather than + // at the Activity for as long as it has to: the core may still be + // connecting, and skychat only answers once it is. Cleared once the page + // has it — including when the page declines it, since retrying a link the + // page has already refused would only loop. + val chatLink by DeepLinks.pendingChatLink.collectAsState() + LaunchedEffect(chatLink, pageReady) { + val link = chatLink ?: return@LaunchedEffect + val view = webView + if (!pageReady || view == null) return@LaunchedEffect + ChatWebView.openAddress(view, link.address) + DeepLinks.chatLinkHandled(link) + } + + // The app's own Light/Dark/System choice, after that choice is resolved — + // not the system's, which is only one of its three inputs. Changing it + // while the chat is open moves the page over with the rest of the app + // instead of leaving one screen in the other theme until it reloads. + val darkTheme = LocalDarkTheme.current + LaunchedEffect(darkTheme, pageReady) { + val view = webView + if (pageReady && view != null) ChatWebView.applyTheme(view, darkTheme) + } + + Scaffold( + topBar = { + // Back, title, ? — the same three as every other tab. The + // overflow menu that used to sit here held Reload and Logs: + // Logs moved to Settings ▸ Diagnostics with the other apps', + // and a one-item menu is not worth a button, so a wedged page + // is reloaded from the Retry the error state already offers. + SkyTopBar( + title = stringResource(R.string.app_skychat), + onBack = goBack, + help = HelpTopic(R.string.help_chat_title, R.string.help_chat_body), + ) + }, + // The system bars belong to the app scaffold this tab sits in, and + // are consumed there — claiming them again would offset the page and + // make the keyboard inset below double-count the navigation bar. + contentWindowInsets = WindowInsets(0), + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + // The composer sits at the bottom of the page, and the page + // is a fixed-height document — without this the keyboard + // covers the field it belongs to. + .imePadding(), + ) { + val ready = state.url != null && state.password != null && pageError == null + if (ready) { + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { ctx -> + ChatWebView.create(ctx).also { view -> + view.webViewClient = ChatWebView.client( + baseUrl = { url.value }, + password = { password.value }, + onHistoryChanged = { canGoBack = it }, + onPageReady = { pageReady = true }, + onError = { pageError = it }, + ) + view.webChromeClient = ChatWebView.chromeClient( + onPermissionRequest = { request -> + val needed = request.resources + .mapNotNull(ChatWebView::androidPermission) + .filterNot { ChatWebView.hasPermission(ctx, it) } + if (needed.isEmpty()) { + request.grantOrDeny { resource -> + ChatWebView.androidPermission(resource) != null + } + } else { + pendingMedia = request + mediaPermissions.launch(needed.toTypedArray()) + } + }, + onFileChooser = { callback, params -> + pendingFiles?.onReceiveValue(null) + pendingFiles = callback + try { + filePicker.launch(params.createIntent()) + true + } catch (e: ActivityNotFoundException) { + pendingFiles = null + false + } + }, + ) + view.setDownloadListener { link, _, disposition, mime, _ -> + ChatWebView.download(ctx, link, disposition, mime, password.value) + } + // Lets the page put whatever it is playing in the + // notification shade — which WebView will not do + // for it, see ChatMedia. + ChatMedia.attach(view) + webView = view + } + }, + update = { view -> + val target = state.url + if (target != null && target != loadedUrl) { + loadedUrl = target + pageReady = false + // The theme rides in the URL so the page can pick + // one before it paints; `loadedUrl` deliberately + // holds the address *without* it, so flipping the + // app's theme never looks like a new URL and + // never reloads the conversation out from under + // the reader. Live changes go through applyTheme. + view.loadUrl(target + ChatWebView.themeQuery(darkTheme)) + } + }, + onRelease = { view -> + // A live SSE stream survives the composable otherwise. + // The media notification must not: the page IS the + // player, so controls for a destroyed one are buttons + // that do nothing. + ChatMedia.detach(view) + ChatWebView.release(view) + webView = null + loadedUrl = null + canGoBack = false + pageReady = false + }, + ) + } else { + ChatStatus( + state = state, + pageError = pageError, + onRetry = { + pageError = null + loadedUrl = null + viewModel.retry() + }, + ) + } + } + } +} + +/** Grant exactly the resources [allowed] accepts; deny the request outright otherwise. */ +private inline fun PermissionRequest.grantOrDeny(allowed: (String) -> Boolean) { + val granted = resources.filter(allowed).toTypedArray() + if (granted.isEmpty()) deny() else grant(granted) +} + +/** + * What the tab shows while there is no chat surface to show: the core has to + * be running before skychat can be, and skychat has to answer before the + * WebView is worth creating. + */ +@Composable +private fun ChatStatus( + state: ChatUiState, + pageError: String?, + onRetry: () -> Unit, +) { + val error = pageError ?: state.error + val core = state.coreState + val message = when { + error != null -> error + core is CoreState.Failed -> core.message + core is CoreState.Running -> + if (state.apiUp) stringResource(R.string.chat_starting) + else stringResource(R.string.chat_core_starting) + core is CoreState.Starting || core is CoreState.Restarting -> + stringResource(R.string.chat_core_starting) + else -> stringResource(R.string.chat_core_offline) + } + Column( + modifier = Modifier + .fillMaxSize() + .padding(32.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (error == null && state.connecting) { + CircularProgressIndicator() + Spacer(Modifier.height(20.dp)) + } + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + if (error != null && state.coreReady) { + Spacer(Modifier.height(8.dp)) + FilledTonalButton(onClick = onRetry) { Text(stringResource(R.string.socks_retry)) } + } + } +} diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/chat/ChatViewModel.kt b/android/app/src/main/java/com/skycoin/skywire/ui/chat/ChatViewModel.kt new file mode 100644 index 0000000000..a18507928a --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/chat/ChatViewModel.kt @@ -0,0 +1,155 @@ +package com.skycoin.skywire.ui.chat + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.skycoin.skywire.api.AppState +import com.skycoin.skywire.api.SkychatApi +import com.skycoin.skywire.api.VisorApi +import com.skycoin.skywire.core.CoreServiceState +import com.skycoin.skywire.core.CoreState +import com.skycoin.skywire.core.SkychatProfile +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** Everything the Chat screen renders. */ +data class ChatUiState( + val coreState: CoreState = CoreState.Stopped, + /** The visor's API answered — nothing can be started before it does. */ + val apiUp: Boolean = false, + /** Bringing skychat up: start issued, waiting for its listener. */ + val starting: Boolean = false, + /** Set once the surface answered; the WebView loads exactly this. */ + val url: String? = null, + /** Secret the WebView answers the password gate with — see [SkychatProfile]. */ + val password: String? = null, + val error: String? = null, +) { + val coreReady: Boolean get() = coreState is CoreState.Running && apiUp + + /** True while the phone is on its way to a usable chat surface. */ + val connecting: Boolean + get() = error == null && url == null && + (starting || coreState is CoreState.Starting || coreState is CoreState.Restarting || + (coreState is CoreState.Running && !apiUp)) +} + +/** + * Brings the chat surface up and hands the screen a URL to load. + * + * skychat autostarts nowhere on the phone (the profile turns every app's + * autostart off), so opening the tab is what starts it: wait for the visor's + * API, start the app, then poll its own listener until it answers. The + * WebView is only shown after that — pointing it at a port nothing is + * listening on would replace the chat with Chromium's error page and need a + * manual reload. + */ +class ChatViewModel(app: Application) : AndroidViewModel(app) { + + private val visor = VisorApi.get(app) + private val skychat = SkychatApi.get(app) + + private val mutable = MutableStateFlow(ChatUiState()) + val uiState: StateFlow = mutable.asStateFlow() + + private var bringUpJob: Job? = null + + init { + viewModelScope.launch { + val secret = skychat.password() + mutable.update { it.copy(password = secret) } + } + viewModelScope.launch { + CoreServiceState.state.collectLatest { core -> + // The URL is dropped with the core: a visor restart restarts + // the app too, and the page must be reloaded rather than left + // talking to a listener that went away. + mutable.update { + it.copy(coreState = core, apiUp = false, url = null, error = null) + } + if (core !is CoreState.Running) return@collectLatest + while (!visor.ping()) delay(PING_INTERVAL_MS) + mutable.update { it.copy(apiUp = true) } + bringUp() + } + } + } + + /** Retry after a failure, from the screen's Retry button. */ + fun retry() { + if (!mutable.value.coreReady) return + bringUpJob?.cancel() + bringUpJob = viewModelScope.launch { bringUp() } + } + + /** + * Start skychat if it isn't up, then wait for its HTTP listener. + * + * The start error is held rather than raised: `status: 1` on an app the + * visor already considers started is a 500, and the polled state can be a + * beat behind. The probe is the real answer — the error is only surfaced + * if nothing ever answers. + */ + private suspend fun bringUp() { + mutable.update { it.copy(starting = true, error = null) } + var startError: String? = null + val app = try { + val current = visor.app(SkychatProfile.APP) + if (current.status == AppState.STATUS_RUNNING || + current.status == AppState.STATUS_STARTING + ) { + current + } else { + runCatching { visor.updateApp(SkychatProfile.APP, status = VisorApi.APP_START) } + .onFailure { startError = it.message } + .getOrDefault(current) + } + } catch (e: Exception) { + mutable.update { it.copy(starting = false, error = e.message) } + return + } + + val url = SkychatProfile.baseUrl(SkychatProfile.listenPort(app.args)) + var reloadedGate = false + repeat(READY_ATTEMPTS) { + when (skychat.probe(url)) { + HTTP_OK -> { + mutable.update { it.copy(starting = false, url = url, error = null) } + return + } + // The running app is holding an older password file (the + // stored secret rotated under it). The file is rewritten at + // config time, so restarting the app is enough to reload it — + // once; a second 401 is a real failure worth reporting. + HTTP_UNAUTHORIZED -> if (!reloadedGate) { + reloadedGate = true + runCatching { + visor.updateApp(SkychatProfile.APP, status = VisorApi.APP_STOP) + visor.updateApp(SkychatProfile.APP, status = VisorApi.APP_START) + }.onFailure { startError = it.message } + } + } + delay(READY_INTERVAL_MS) + } + mutable.update { + it.copy(starting = false, error = startError ?: "SkyChat did not answer on $url") + } + } + + private companion object { + const val PING_INTERVAL_MS = 700L + const val READY_INTERVAL_MS = 500L + + /** ~15 s: an in-proc app binds its listener in well under a second. */ + const val READY_ATTEMPTS = 30 + + const val HTTP_OK = 200 + const val HTTP_UNAUTHORIZED = 401 + } +} diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/chat/ChatWebView.kt b/android/app/src/main/java/com/skycoin/skywire/ui/chat/ChatWebView.kt new file mode 100644 index 0000000000..35445f6590 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/chat/ChatWebView.kt @@ -0,0 +1,332 @@ +package com.skycoin.skywire.ui.chat + +import android.app.DownloadManager +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.util.Log +import android.view.ViewGroup +import android.webkit.ConsoleMessage +import android.webkit.PermissionRequest +import android.webkit.URLUtil +import android.webkit.ValueCallback +import android.webkit.WebChromeClient +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.core.content.ContextCompat +import androidx.core.net.toUri +import com.skycoin.skywire.core.SkychatProfile +import kotlinx.coroutines.delay +import kotlinx.coroutines.suspendCancellableCoroutine +import org.json.JSONObject +import kotlin.coroutines.resume + +/** + * The Android glue around skychat's embedded web UI. Kept out of the Compose + * file because most of it is WebView plumbing with nothing composable about + * it: the password gate, the media-permission bridge, uploads, downloads, and + * the rule for what may leave the page. + */ +internal object ChatWebView { + + private const val TAG = "SkychatWebView" + + /** + * Media capture the page can ask for, and the runtime permission each one + * needs. Anything else it requests (protected media, MIDI) is denied — + * the chat UI has no use for it. + */ + fun androidPermission(resource: String): String? = when (resource) { + PermissionRequest.RESOURCE_AUDIO_CAPTURE -> android.Manifest.permission.RECORD_AUDIO + PermissionRequest.RESOURCE_VIDEO_CAPTURE -> android.Manifest.permission.CAMERA + else -> null + } + + fun hasPermission(context: Context, permission: String): Boolean = + ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED + + /** + * A WebView configured for the chat UI. JavaScript and DOM storage are + * both load-bearing (the app is one page, and Saved Messages, the tab + * choice and the notification prefs live in localStorage). File and + * content access stay off — nothing in the page loads from either, and + * they are the two settings that turn a rendering bug into a file read. + */ + fun create(context: Context): WebView = WebView(context).apply { + // MATCH_PARENT is load-bearing, not cosmetic. A WebView left at the + // default wrap_content is measured with an AT_MOST height, and + // Chromium then treats the layout viewport height as *indefinite*: + // `html, body { height: 100% }` computes to 0px even though + // innerHeight reports the real value, so every flex column in the + // page collapses — the conversation list vanishes and the composer + // rides up under the header instead of sitting at the bottom. + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.allowFileAccess = false + settings.allowContentAccess = false + // The page ships its own responsive layout with a width=device-width + // viewport; the legacy desktop-width fallbacks would fight it. + settings.useWideViewPort = true + settings.loadWithOverviewMode = false + settings.setSupportZoom(false) + settings.builtInZoomControls = false + // Inline playback for received voice/video messages — full-screen + // handoff is the WebChromeClient path we deliberately don't take. + settings.mediaPlaybackRequiresUserGesture = true + // chrome://inspect on a debug build; never on a release APK. + val debuggable = + (context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0 + WebView.setWebContentsDebuggingEnabled(debuggable) + setBackgroundColor(android.graphics.Color.TRANSPARENT) + } + + /** + * Client for the page itself: answers the password gate and decides what + * a navigation means. + * + * The UI never navigates — it is one document driven by `pushState` — so + * a main-frame navigation is always a `` on an attachment. + * WebView ignores the `download` attribute and would happily *render* the + * image or video in place of the chat, so those are turned into real + * downloads here. Anything off-origin goes to the browser instead of + * loading inside the chat's own origin. + */ + fun client( + baseUrl: () -> String?, + password: () -> String?, + onHistoryChanged: (Boolean) -> Unit, + onPageReady: () -> Unit, + onError: (String) -> Unit, + ): WebViewClient = object : WebViewClient() { + + private var authAttempts = 0 + + override fun onReceivedHttpAuthRequest( + view: WebView, + handler: android.webkit.HttpAuthHandler, + host: String, + realm: String, + ) { + val secret = password() + // Re-challenged with the same credential means the credential is + // wrong; proceeding again would spin forever. + if (secret == null || authAttempts++ > 0) { + handler.cancel() + onError("SkyChat rejected the stored password") + return + } + handler.proceed(SkychatProfile.USER, secret) + } + + override fun shouldOverrideUrlLoading( + view: WebView, + request: WebResourceRequest, + ): Boolean { + if (!request.isForMainFrame) return false + val base = baseUrl()?.toUri() + val target = request.url + if (base != null && target.host == base.host && target.port == base.port) { + // The document itself (a reload) is not a download. + if (target.path.orEmpty().trimEnd('/').isEmpty()) return false + download(view.context, target.toString(), null, null, password()) + return true + } + return openExternally(view.context, target) + } + + override fun doUpdateVisitedHistory(view: WebView, url: String, isReload: Boolean) { + onHistoryChanged(view.canGoBack()) + } + + override fun onPageFinished(view: WebView, url: String) { + authAttempts = 0 + onHistoryChanged(view.canGoBack()) + // about:blank is the teardown load, not a chat page. + if (url != "about:blank") onPageReady() + } + + override fun onReceivedError( + view: WebView, + request: WebResourceRequest, + error: android.webkit.WebResourceError, + ) { + // Subresource failures are the page's own business (a pruned + // attachment 404s routinely); only a dead main frame is a screen + // state. + if (request.isForMainFrame) onError(error.description.toString()) + } + } + + /** + * Chrome client: the parts of the page that need the phone — the + * microphone and camera for voice/video messages, and the file picker + * for attachments. + */ + fun chromeClient( + onPermissionRequest: (PermissionRequest) -> Unit, + onFileChooser: (ValueCallback>, WebChromeClient.FileChooserParams) -> Boolean, + ): WebChromeClient = object : WebChromeClient() { + + override fun onPermissionRequest(request: PermissionRequest) { + onPermissionRequest.invoke(request) + } + + override fun onShowFileChooser( + view: WebView, + callback: ValueCallback>, + params: FileChooserParams, + ): Boolean = onFileChooser(callback, params) + + override fun onConsoleMessage(message: ConsoleMessage): Boolean { + // Chat problems on a phone are otherwise invisible: the page's + // own errors land next to the app's logcat, and its server side + // is one tap away under the bar's Logs action. + Log.d(TAG, "${message.sourceId()}:${message.lineNumber()} ${message.message()}") + return true + } + } + + /** + * Show [address] in the page's Add-by-address dialog: the field is filled + * and the lookup runs, and that is where it stops — the page deliberately + * does not add, open or join on a link's say-so. + * + * Driving the page rather than loading a URL for it is what keeps a link + * arriving mid-session from throwing away what is on screen: this is one + * document, and reloading it would drop the open conversation, the scroll + * position, and the live event stream with it. + * + * The retry covers the gap between "the document finished loading" and + * "the page's own object exists" — the caller only has the first signal. + * Returns false if the page never became ready, or had nothing to do with + * the address. + */ + /** + * The query the page reads on its very first line to pick a theme. Handed + * over in the URL rather than injected after load because injection can + * only run once there is a document to inject into, and by then the page + * has already painted — a light-themed phone would show a black flash on + * every open. Changes *after* that go through [applyTheme]. + */ + fun themeQuery(dark: Boolean): String = if (dark) "?theme=dark" else "?theme=light" + + /** + * Move the open page to the other theme, for when the app's own setting + * changes while the chat is on screen. Sets the same attribute the URL + * above does, so there is one mechanism and the CSS has one thing to + * react to. Silent if the page has not built its document yet: the next + * load will carry the theme in its URL anyway. + */ + fun applyTheme(view: WebView, dark: Boolean) { + val value = if (dark) "dark" else "light" + view.evaluateJavascript( + "document.documentElement.dataset.theme = ${JSONObject.quote(value)};", + null, + ) + } + + suspend fun openAddress(view: WebView, address: String): Boolean { + val script = """ + (function () { + var chat = window.app; + if (!chat || typeof chat.openAddressFromLink !== 'function') return 'wait'; + return chat.openAddressFromLink(${JSONObject.quote(address)}) ? 'ok' : 'ignored'; + })() + """.trimIndent() + repeat(ADDRESS_ATTEMPTS) { + // evaluateJavascript answers with the JSON encoding of the value, + // so a string result comes back quoted. + when (evaluate(view, script)) { + "\"ok\"" -> return true + "\"wait\"" -> delay(ADDRESS_RETRY_MS) + else -> return false + } + } + Log.w(TAG, "chat page never exposed its address dialog") + return false + } + + private suspend fun evaluate(view: WebView, script: String): String? = + suspendCancellableCoroutine { continuation -> + view.evaluateJavascript(script) { result -> continuation.resume(result) } + } + + /** ~3 s; the page builds its object synchronously with the document. */ + private const val ADDRESS_ATTEMPTS = 20 + private const val ADDRESS_RETRY_MS = 150L + + /** + * Hand an attachment to the system downloader. The Authorization header + * has to ride along — the gate applies to `/files/` like everything else, + * and DownloadManager fetches in its own process with no session of ours. + */ + fun download( + context: Context, + url: String, + contentDisposition: String?, + mimeType: String?, + password: String?, + ) { + val name = URLUtil.guessFileName(url, contentDisposition, mimeType) + val request = DownloadManager.Request(url.toUri()) + .setTitle(name) + .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) + password?.let { + request.addRequestHeader( + "Authorization", + okhttp3.Credentials.basic(SkychatProfile.USER, it), + ) + } + mimeType?.takeIf { it.isNotEmpty() }?.let { request.setMimeType(it) } + // The public Downloads folder needs no permission from API 29; below + // that it would need WRITE_EXTERNAL_STORAGE, so those devices get the + // app's own external files dir instead of a permission prompt. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, name) + } else { + request.setDestinationInExternalFilesDir( + context, + Environment.DIRECTORY_DOWNLOADS, + name, + ) + } + runCatching { + (context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager) + .enqueue(request) + }.onFailure { Log.w(TAG, "download of $name failed to start", it) } + } + + /** True when the navigation was handled (i.e. must not load in-page). */ + private fun openExternally(context: Context, uri: Uri): Boolean { + val intent = Intent(Intent.ACTION_VIEW, uri).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + return try { + context.startActivity(intent) + true + } catch (e: ActivityNotFoundException) { + Log.w(TAG, "nothing handles $uri", e) + // Nothing opened it and it is not ours to render — refusing beats + // navigating the chat away to a page it cannot come back from. + true + } + } + + /** Tear-down that actually stops the page: SSE otherwise keeps polling. */ + fun release(view: WebView) { + view.stopLoading() + view.webChromeClient = null + view.loadUrl("about:blank") + view.clearHistory() + (view.parent as? ViewGroup)?.removeView(view) + view.destroy() + } +} diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/components/AppLabels.kt b/android/app/src/main/java/com/skycoin/skywire/ui/components/AppLabels.kt new file mode 100644 index 0000000000..e18ccd92cb --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/components/AppLabels.kt @@ -0,0 +1,35 @@ +package com.skycoin.skywire.ui.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.skycoin.skywire.R +import com.skycoin.skywire.core.SkychatProfile +import com.skycoin.skywire.core.SkydexProfile +import com.skycoin.skywire.ui.socks.SocksArgs +import com.skycoin.skywire.ui.vpn.VpnArgs + +/** + * The two names every client app has: the product it is (**SkyVPN**) and the + * process the visor runs it as (`vpn-client`). + * + * Everywhere else in the app only the first one is ever shown — the hub tile, + * the screen, the notification. The log surfaces are the exception, because + * there the process name is the thing you actually need: it is what the config + * calls it, what the API route is keyed on, and what a line in the log says. + * So they show both, product first, rather than dropping the user into a list + * of process names and expecting them to know which app is which. + */ + +/** Product name, or null for an app with no screen of its own. */ +@Composable +fun appProductName(app: String): String? = when (app) { + SkychatProfile.APP -> stringResource(R.string.app_skychat) + SocksArgs.APP -> stringResource(R.string.app_skysocks) + VpnArgs.APP -> stringResource(R.string.app_skyvpn) + SkydexProfile.APP -> stringResource(R.string.app_skydex) + else -> null +} + +/** `SkyVPN (vpn-client)` — for lists, which have the width for both. */ +@Composable +fun appLabel(app: String): String = appProductName(app)?.let { "$it ($app)" } ?: app diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/components/BiometricGate.kt b/android/app/src/main/java/com/skycoin/skywire/ui/components/BiometricGate.kt new file mode 100644 index 0000000000..24cbc8efbd --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/components/BiometricGate.kt @@ -0,0 +1,155 @@ +package com.skycoin.skywire.ui.components + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +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.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.platform.LocalContext +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 com.skycoin.skywire.R +import com.skycoin.skywire.core.AppLock +import com.skycoin.skywire.core.AppPreferences +import com.skycoin.skywire.core.AppVisibility +import com.skycoin.skywire.core.VoiceCalls + +/** + * The app lock: nothing is readable until the phone's own check passes. + * + * The lock is drawn *over* the app rather than replacing it. Composing the + * whole navigation tree only while unlocked would tear down the back stack and + * the embedded chat WebView on every glance at a notification, and rebuild + * them on every return — a lock the user feels as slowness is a lock they turn + * off. What keeps the content from leaking anyway is [android.view.WindowManager.LayoutParams.FLAG_SECURE], + * set for the whole session while the lock is on (see MainActivity): the + * recents preview is a black card and screenshots are refused, which is the + * part an overlay could never cover — the system takes that snapshot as the + * app leaves, before there is anything to overlay. + * + * A ringing or connected call is the one thing shown through the lock. A call + * screen holds no secrets — a peer's key, answer, hang up — and every dialer on + * Android surfaces one above the lock screen for the obvious reason: a phone + * that cannot be answered until you authenticate is not answering. + */ +@Composable +fun BiometricGate(content: @Composable () -> Unit) { + val context = LocalContext.current + val prefs = remember(context) { AppPreferences(context) } + // null until the store answers: covered, not exposed, while unknown. + val enabled by prefs.boolean(AppLock.PREF_KEY, AppLock.DEFAULT) + .collectAsState(initial = null) + val locked by AppLock.isLocked.collectAsState() + val foreground by AppVisibility.isForeground.collectAsState() + val call by VoiceCalls.state.collectAsState() + + val showLock = enabled != false && locked && !call.busy + + Box(Modifier.fillMaxSize()) { + content() + if (showLock) { + LockOverlay( + // While the preference is still loading there is nothing to + // ask about yet, so the overlay is just the splash, held. + ready = enabled == true, + foreground = foreground, + ) + } + } +} + +@Composable +private fun LockOverlay(ready: Boolean, foreground: Boolean) { + val context = LocalContext.current + var inFlight by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + // Bumped by the Unlock button. Without it a dismissed prompt could never + // be re-raised: the effect's other keys have not changed. + var attempt by remember { mutableIntStateOf(0) } + + val title = stringResource(R.string.lock_prompt_title) + val subtitle = stringResource(R.string.lock_prompt_subtitle) + + LaunchedEffect(ready, foreground, attempt) { + if (!ready || !foreground || inFlight) return@LaunchedEffect + val activity = context.findFragmentActivity() ?: return@LaunchedEffect + inFlight = true + error = null + Biometrics.prompt(activity, title = title, subtitle = subtitle) { success, message -> + inFlight = false + if (success) AppLock.unlock() else error = message + } + } + + // Back on a lock screen puts the app away — it does not walk the + // navigation stack of the screens underneath, which is what would happen + // if this were left to the NavHost behind the overlay. + BackHandler { + context.findFragmentActivity()?.moveTaskToBack(true) + } + + Box( + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + // Swallows every gesture so nothing below is reachable. + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = {}, + ), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(32.dp), + ) { + Image( + painter = painterResource(R.drawable.skywire_logo), + contentDescription = null, + modifier = Modifier.size(96.dp), + ) + if (!ready) return@Column + Spacer(Modifier.height(28.dp)) + Text( + stringResource(R.string.lock_title), + style = MaterialTheme.typography.titleMedium, + ) + error?.let { message -> + Spacer(Modifier.height(8.dp)) + Text( + message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + Spacer(Modifier.height(24.dp)) + Button(onClick = { attempt++ }, enabled = !inFlight) { + Text(stringResource(R.string.lock_unlock)) + } + } + } +} diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/components/Biometrics.kt b/android/app/src/main/java/com/skycoin/skywire/ui/components/Biometrics.kt new file mode 100644 index 0000000000..afcba5d6f7 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/components/Biometrics.kt @@ -0,0 +1,113 @@ +package com.skycoin.skywire.ui.components + +import android.content.Context +import android.content.ContextWrapper +import android.os.Build +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity + +/** + * The one biometric prompt, shared by everything that has to ask. + * + * Two callers today — the app lock on launch and return-from-background, and + * the config export, which writes the secret key to a file the user picks — + * and the wallet's seed reveal and send confirmation join them later. There is + * one prompt because there is one question: *is this the person who set this + * phone up?* The answer is the phone's own, not ours: no key material and no + * secret of ours is bound to it. + */ +object Biometrics { + + /** + * Fingerprint, face, iris — whatever the phone has — with the device PIN, + * pattern or password as the fallback, which is what makes the lock usable + * on a device with no enrolled biometric at all. + * + * The pairing is not free to choose. `BIOMETRIC_STRONG or + * DEVICE_CREDENTIAL` is rejected outright on API 28-29 (the builder throws + * "Authenticator combination is unsupported on API n"), and the weak class + * is the combination androidx's own deprecated `setDeviceCredentialAllowed` + * resolves to there. Weak means "the phone's own bar for unlocking itself" + * — which is exactly the bar this asks for. + */ + private fun allowed(): Int { + val biometric = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + BiometricManager.Authenticators.BIOMETRIC_STRONG + } else { + BiometricManager.Authenticators.BIOMETRIC_WEAK + } + return biometric or BiometricManager.Authenticators.DEVICE_CREDENTIAL + } + + /** + * Whether this phone can answer the question at all. False on a device + * with no screen lock and no enrolled biometric — there is nothing to + * check against, and no prompt to show. + */ + fun canAuthenticate(context: Context): Boolean = + BiometricManager.from(context).canAuthenticate(allowed()) == + BiometricManager.BIOMETRIC_SUCCESS + + /** + * Ask. [onResult] is called exactly once, on the main thread, with true + * only for a genuine success. + * + * A failed *attempt* (a finger the sensor did not recognise) is not a + * result — the prompt stays up and lets the user try again — so it is not + * reported here. What is reported is the terminal error, cancellation + * included, with the system's own wording where there is any: "Too many + * attempts. Try again later." says more than anything this app could + * substitute for it. + */ + fun prompt( + activity: FragmentActivity, + title: String, + subtitle: String? = null, + description: String? = null, + onResult: (success: Boolean, error: String?) -> Unit, + ) { + val info = BiometricPrompt.PromptInfo.Builder() + .setTitle(title) + .apply { + subtitle?.let(::setSubtitle) + description?.let(::setDescription) + } + .setAllowedAuthenticators(allowed()) + // No negative button: the builder rejects one when device + // credentials are allowed, because the credential fallback IS the + // second button. + .setConfirmationRequired(false) + .build() + + val prompt = BiometricPrompt( + activity, + ContextCompat.getMainExecutor(activity), + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + onResult(true, null) + } + + override fun onAuthenticationError(code: Int, message: CharSequence) { + onResult(false, message.toString()) + } + }, + ) + prompt.authenticate(info) + } +} + +/** + * The [FragmentActivity] behind a composable's context, which is what + * [BiometricPrompt] takes. Null only if a composable is ever hosted outside an + * Activity — a preview — where asking is meaningless anyway. + */ +fun Context.findFragmentActivity(): FragmentActivity? { + var context = this + while (context is ContextWrapper) { + if (context is FragmentActivity) return context + context = context.baseContext + } + return null +} diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/components/Effects.kt b/android/app/src/main/java/com/skycoin/skywire/ui/components/Effects.kt new file mode 100644 index 0000000000..b0e8f74251 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/components/Effects.kt @@ -0,0 +1,49 @@ +package com.skycoin.skywire.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.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.Dp + +/** + * The design's one piece of ambient motion: a disc that swells from a + * control and fades as it goes, over and over. Drawn *behind* whatever it + * belongs to — the caller stacks it under the control in a Box. Used by the + * bar's cloud button and Home's Connect while the phone is on the network. + */ +@Composable +fun PulseRing(size: Dp, color: Color, modifier: Modifier = Modifier) { + val pulse = rememberInfiniteTransition(label = "pulseRing") + val phase by pulse.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 2600, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "pulseRingPhase", + ) + Box( + modifier + .size(size) + .graphicsLayer { + val scale = 0.9f + 0.45f * phase + scaleX = scale + scaleY = scale + alpha = 0.45f * (1f - phase) + } + .background(color, CircleShape), + ) +} diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/components/MinHopsUi.kt b/android/app/src/main/java/com/skycoin/skywire/ui/components/MinHopsUi.kt new file mode 100644 index 0000000000..a22feb6526 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/components/MinHopsUi.kt @@ -0,0 +1,132 @@ +package com.skycoin.skywire.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.skycoin.skywire.R + +/** + * The route-length control: how many hops the visor insists on when it builds + * a route. + * + * One hop allows a direct route to the exit, which is the fastest thing the + * network can do and the default. Two or more forces the traffic through + * intermediaries, so no single node sees both who is asking and what is being + * asked — that is the property being bought, and latency is what it costs. + * The wording says that rather than showing a bare number, because "min_hops" + * means nothing to someone deciding whether they want it. + * + * Visor-wide, not SkyVPN-only: it is a router knob and every route the visor + * builds obeys it. It lives on the SkyVPN screen because that is where the + * trade-off is felt. + */ +@Composable +fun MinHopsCard( + hops: Int, + enabled: Boolean, + onSelect: (Int) -> Unit, +) { + SectionCard { + Text( + stringResource(R.string.hops_title), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(10.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + HOP_CHOICES.forEach { choice -> + HopChoice( + hops = choice, + // Anything the visor reports outside the offered set + // (an operator edited the config by hand) leaves all + // three unselected rather than silently rounding. + selected = hops == choice, + enabled = enabled, + onClick = { onSelect(choice) }, + modifier = Modifier.weight(1f), + ) + } + } + Spacer(Modifier.height(10.dp)) + Text( + stringResource( + when (hops) { + 1 -> R.string.hops_hint_direct + in 2..Int.MAX_VALUE -> R.string.hops_hint_multi + else -> R.string.hops_hint_unknown + }, + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun HopChoice( + hops: Int, + selected: Boolean, + enabled: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + .clip(MaterialTheme.shapes.medium) + .background( + if (selected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.surfaceContainerHighest + }, + ) + .clickable(enabled = enabled && !selected, onClick = onClick) + .padding(vertical = 12.dp, horizontal = 8.dp), + ) { + val content = if (selected) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.onSurface + } + Text( + text = pluralStringResource(R.plurals.hub_hops, hops, hops), + style = MaterialTheme.typography.titleMedium, + color = content, + ) + Text( + text = stringResource( + when (hops) { + 1 -> R.string.hops_label_fastest + 2 -> R.string.hops_label_balanced + else -> R.string.hops_label_private + }, + ), + style = MaterialTheme.typography.labelSmall, + color = content.copy(alpha = 0.75f), + textAlign = TextAlign.Center, + ) + } +} + +/** + * One, two, three. Above three the added latency on a phone stops being worth + * the marginal anonymity, and each extra hop is another node that has to stay + * up for the route to survive. + */ +private val HOP_CHOICES = listOf(1, 2, 3) diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/components/NetworkAddressUi.kt b/android/app/src/main/java/com/skycoin/skywire/ui/components/NetworkAddressUi.kt new file mode 100644 index 0000000000..f28d21b682 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/components/NetworkAddressUi.kt @@ -0,0 +1,99 @@ +package com.skycoin.skywire.ui.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.skycoin.skywire.R +import com.skycoin.skywire.api.Overview +import java.util.Locale + +/** + * What this phone's address actually is, and what SkyVPN does and does not + * change about it. + * + * The honest version of "your IP before and after". A tunnelled phone + * normally proves itself by fetching its own address and watching it change, + * and that cannot work here: [com.skycoin.skywire.core.SkyVpnService] excludes + * this app's UID from the tunnel — it has to, because the visor is a child of + * the same UID and its dmsg traffic is what CARRIES the tunnel — so any probe + * the phone makes leaves through the underlay whether or not SkyVPN is up. It + * would print the same address twice and look broken. + * + * So the card shows the two things that are true: the address this device + * reaches the network from, which SkyVPN does not change, and the country the + * traffic leaves from, which is the thing that does. The exit's own address is + * not knowable from here — the VPN handshake carries a public key, a TUN IP + * and a gateway, and no public address in either direction. + */ +@Composable +fun NetworkAddressCard(overview: Overview?, exitCountry: String?, connected: Boolean) { + SectionCard { + Text( + stringResource(R.string.net_addr_title), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(6.dp)) + + InfoRow( + label = stringResource(R.string.net_addr_device), + value = deviceAddressText(overview), + mono = overview?.publicIpOrNull != null, + ) + InfoRow( + label = stringResource(R.string.net_addr_exit), + value = when { + !connected -> stringResource(R.string.net_addr_exit_none) + exitCountry.isNullOrEmpty() -> stringResource(R.string.net_addr_exit_unknown) + else -> countryText(exitCountry) + }, + ) + + Spacer(Modifier.height(8.dp)) + Column { + Text( + stringResource(R.string.net_addr_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +/** + * The device's own address, or the reason there isn't one. Symmetric NAT is + * the common case on a mobile carrier and is worth naming: it is not a + * failure, it means no single public address belongs to this phone. + */ +@Composable +fun deviceAddressText(overview: Overview?): String { + val ip = overview?.publicIpOrNull + return when { + ip != null -> ip + overview == null -> stringResource(R.string.net_addr_waiting) + overview.isSymmetricNat -> stringResource(R.string.net_addr_carrier_nat) + else -> stringResource(R.string.net_addr_unknown) + } +} + +/** + * `🇨🇦 Canada` — flag plus the name in the reader's own language. + * + * runCatching because the code is not ours: service discovery hands back `??` + * when the visor's geo lookup could not place a node, and `setRegion` rejects + * anything that is not a well-formed region with an exception. The bare code + * is the honest fallback — better a literal `??` than a crash on a card. + */ +fun countryText(code: String): String { + val name = runCatching { Locale.Builder().setRegion(code).build().displayCountry } + .getOrNull() + ?.takeIf { it.isNotBlank() } + ?: code + return listOfNotNull(flagEmoji(code), name).joinToString(" ") +} diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/components/PlaceholderScreens.kt b/android/app/src/main/java/com/skycoin/skywire/ui/components/PlaceholderScreens.kt new file mode 100644 index 0000000000..bfe1f458e6 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/components/PlaceholderScreens.kt @@ -0,0 +1,36 @@ +package com.skycoin.skywire.ui.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + +/** Centered placeholder body for tabs whose real screens land later. */ +@Composable +fun PlaceholderTab(title: String, subtitle: String) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + verticalArrangement = androidx.compose.foundation.layout.Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(title, style = MaterialTheme.typography.headlineMedium) + Spacer(Modifier.height(8.dp)) + Text( + subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } +} + diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/components/RateSampler.kt b/android/app/src/main/java/com/skycoin/skywire/ui/components/RateSampler.kt new file mode 100644 index 0000000000..2fcd352958 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/components/RateSampler.kt @@ -0,0 +1,68 @@ +package com.skycoin.skywire.ui.components + +import android.os.SystemClock + +/** + * Turns a pair of monotonically-rising byte counters into a rate. + * + * The visor reports `upload_speed` / `download_speed` on an app connection, + * but those are not derived from the byte counters: the route group exchanges + * them inside its ping/pong keepalive — `handlePingPacket` stores whatever + * throughput the far side announced, and the download figure is the remote + * throughput echoed back. That needs an active route group, a cooperating + * exit and a completed ping round; on a phone the pair sits at zero and never + * moves, which is why the SkyVPN screen only ever showed its rate row behind + * a `> 0` guard. + * + * `bandwidth_sent` / `bandwidth_received` do move, so the rate is measured + * here instead: bytes gained since the previous sample over the time between + * them. Deliberately local — nothing is asked of the visor that it is not + * already being asked. + */ +class RateSampler { + + private var lastSent = 0L + private var lastReceived = 0L + private var lastAt = 0L + + /** Bytes per second since the previous [sample]; null until there are two. */ + data class Rates(val upBytesPerSec: Long, val downBytesPerSec: Long) + + /** + * Feed the current counters. Returns null on the first call and whenever + * the counters restart, since neither yields a rate anyone should read. + * + * elapsedRealtime, not wall clock: a clock correction mid-session would + * otherwise divide by a negative or absurd interval and print a rate in + * gigabytes. + */ + fun sample(sent: Long, received: Long): Rates? { + val now = SystemClock.elapsedRealtime() + val previousAt = lastAt + val previousSent = lastSent + val previousReceived = lastReceived + + lastAt = now + lastSent = sent + lastReceived = received + + if (previousAt == 0L) return null + val millis = now - previousAt + if (millis <= 0) return null + // A counter that went backwards means the app restarted its + // connection; the delta across that boundary is meaningless. + if (sent < previousSent || received < previousReceived) return null + + return Rates( + upBytesPerSec = (sent - previousSent) * 1000 / millis, + downBytesPerSec = (received - previousReceived) * 1000 / millis, + ) + } + + /** Forget the history — the connection this was measuring is gone. */ + fun reset() { + lastAt = 0 + lastSent = 0 + lastReceived = 0 + } +} diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/components/SecureWindow.kt b/android/app/src/main/java/com/skycoin/skywire/ui/components/SecureWindow.kt new file mode 100644 index 0000000000..9772bad1b7 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/components/SecureWindow.kt @@ -0,0 +1,47 @@ +package com.skycoin.skywire.ui.components + +import android.view.WindowManager +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.platform.LocalContext +import com.skycoin.skywire.core.AppLock +import com.skycoin.skywire.core.AppPreferences + +/** + * Blocks screenshots and the recents thumbnail for as long as this composable + * is in the tree. Placed on every screen that can put an unrecoverable secret + * on the display — the wallet's twelve words, and the visor's secret key. + * + * The two are the same kind of thing and get the same treatment: each is the + * whole of an identity or a balance, neither can be reissued, and both are + * read off a screen by someone who is looking at the screen. A screenshot of + * either is the secret, and the recents thumbnail is a screenshot the user did + * not ask for. + * + * On dispose the flag is cleared only if the app lock is not already holding + * it session-wide — MainActivity sets it whenever that preference is on, and + * clearing it here would quietly undo the lock's own promise. + * + * Lives in components rather than beside the wallet because it stopped being + * a wallet concern the moment Settings grew a field you paste a secret key + * into. + */ +@Composable +fun SecureWindow() { + val context = LocalContext.current + val prefs = remember(context) { AppPreferences(context) } + // initial=true errs on the safe side: never clear a flag we might need. + val lockEnabled by prefs.boolean(AppLock.PREF_KEY, AppLock.DEFAULT).collectAsState(initial = true) + val lockHeld = rememberUpdatedState(lockEnabled) + DisposableEffect(Unit) { + val window = context.findFragmentActivity()?.window + window?.addFlags(WindowManager.LayoutParams.FLAG_SECURE) + onDispose { + if (!lockHeld.value) window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + } + } +} diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/components/ServerUi.kt b/android/app/src/main/java/com/skycoin/skywire/ui/components/ServerUi.kt new file mode 100644 index 0000000000..6b42bfcf76 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/components/ServerUi.kt @@ -0,0 +1,225 @@ +package com.skycoin.skywire.ui.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.skycoin.skywire.R +import com.skycoin.skywire.api.ServiceEntry +import com.skycoin.skywire.ui.theme.SkyAccents +import kotlinx.serialization.Serializable +import java.util.Locale + +/** + * The pieces every "pick a public server and connect to it" screen is made + * of. SkySOCKS established the pattern and SkyVPN repeats it exactly — the + * two differ in which service-discovery type they ask for and what they do + * once connected, not in how a server is chosen or how a status card reads. + */ + +/** The last server the user connected to, kept across app restarts. */ +@Serializable +data class SavedServer( + val pk: String, + val country: String = "", + val version: String = "", +) { + companion object { + fun of(entry: ServiceEntry) = SavedServer( + pk = entry.pk, + country = entry.geo?.country.orEmpty(), + version = entry.version, + ) + } +} + +/** Two-letter ISO country code → flag emoji; null for anything else. */ +fun flagEmoji(country: String): String? { + if (country.length != 2 || !country.all { it.isLetter() }) return null + val code = country.uppercase() + val base = 0x1F1E6 // REGIONAL INDICATOR SYMBOL LETTER A + return String(Character.toChars(base + (code[0] - 'A'))) + + String(Character.toChars(base + (code[1] - 'A'))) +} + +fun shortPk(pk: String): String = if (pk.length <= 20) pk else pk.take(10) + "…" + pk.takeLast(8) + +fun formatBytes(bytes: Long): String { + if (bytes < 1024) return "$bytes B" + var value = bytes.toDouble() / 1024 + val units = listOf("KB", "MB", "GB", "TB") + var unit = 0 + while (value >= 1024 && unit < units.lastIndex) { + value /= 1024 + unit++ + } + return String.format(Locale.US, "%.1f %s", value, units[unit]) +} + +/** + * `2d 3h 4m 5s` — a visor's uptime, which is measured in days rather than the + * minutes a tunnel session lasts, so the days unit is worth its width and the + * seconds keep ticking visibly. + */ +fun formatUptime(seconds: Double): String { + val total = seconds.toLong() + val days = total / 86_400 + val hours = (total % 86_400) / 3_600 + val minutes = (total % 3_600) / 60 + return buildString { + if (days > 0) append("${days}d ") + if (hours > 0 || days > 0) append("${hours}h ") + if (minutes > 0 || hours > 0 || days > 0) append("${minutes}m ") + append("${total % 60}s") + } +} + +/** `1h 04m 12s`, dropping the leading units that are still zero. */ +fun formatDuration(seconds: Long): String { + val h = seconds / 3600 + val m = (seconds % 3600) / 60 + val s = seconds % 60 + return when { + h > 0 -> String.format(Locale.US, "%dh %02dm %02ds", h, m, s) + m > 0 -> String.format(Locale.US, "%dm %02ds", m, s) + else -> String.format(Locale.US, "%ds", s) + } +} + +/** Status dot colors shared by the app screens — the theme's accents. */ +val CONNECTED_GREEN = SkyAccents.success +val PENDING_AMBER = SkyAccents.warning + +/** + * The app's standard card: the palette's blue-tinted near-white on a + * hairline border, generous radius, 20dp inside. On the plain background + * the border is what makes it a card — the fill alone is too close to + * white to hold an edge. + */ +@Composable +fun SectionCard(content: @Composable ColumnScope.() -> Unit) { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + // Cards hold real prose: content must default to ink, not the + // muted onSurfaceVariant this container would otherwise imply. + contentColor = MaterialTheme.colorScheme.onSurface, + ), + shape = MaterialTheme.shapes.large, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + modifier = Modifier.fillMaxWidth(), + ) { + Column(Modifier.padding(20.dp), content = content) + } +} + +@Composable +fun InfoRow( + label: String, + value: String, + mono: Boolean = false, + valueColor: Color = MaterialTheme.colorScheme.onSurface, + modifier: Modifier = Modifier, +) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + ) { + // The label keeps its intrinsic width (softWrap off) so a long value + // can never squeeze it down to one character per line; the value takes + // the rest and wraps right-aligned. + Text( + label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + softWrap = false, + ) + Spacer(Modifier.width(16.dp)) + Text( + value, + style = MaterialTheme.typography.bodyMedium.let { + if (mono) it.copy(fontFamily = FontFamily.Monospace) else it + }, + color = valueColor, + textAlign = TextAlign.End, + modifier = Modifier.weight(1f), + ) + } +} + +/** One row of a service-discovery list: flag, location, key, version. */ +@Composable +fun ServerRow( + server: ServiceEntry, + selected: Boolean, + enabled: Boolean, + onClick: () -> Unit, +) { + Card( + colors = CardDefaults.cardColors( + containerColor = if (selected) { + MaterialTheme.colorScheme.secondaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + contentColor = MaterialTheme.colorScheme.onSurface, + ), + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = enabled, onClick = onClick), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + ) { + flagEmoji(server.geo?.country.orEmpty())?.let { flag -> + Text(flag, style = MaterialTheme.typography.titleLarge) + Spacer(Modifier.width(12.dp)) + } + Column(Modifier.weight(1f)) { + Text( + listOfNotNull( + server.geo?.country?.uppercase()?.takeIf { it.isNotEmpty() }, + server.geo?.region?.takeIf { it.isNotEmpty() }, + ).joinToString(" · ").ifEmpty { stringResource(R.string.server_location_unknown) }, + style = MaterialTheme.typography.bodyMedium, + ) + Text( + shortPk(server.pk), + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + server.version.takeIf { it.isNotEmpty() }?.let { version -> + Spacer(Modifier.width(12.dp)) + Text( + version, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/components/SkyTopBar.kt b/android/app/src/main/java/com/skycoin/skywire/ui/components/SkyTopBar.kt new file mode 100644 index 0000000000..c85cb1392e --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/components/SkyTopBar.kt @@ -0,0 +1,169 @@ +package com.skycoin.skywire.ui.components + +import androidx.annotation.StringRes +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +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.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.automirrored.rounded.HelpOutline +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.skycoin.skywire.R + +/** + * What the ? in a screen's header explains: what this screen is for, in a few + * sentences, read once and then never again. String resources rather than + * free text, so the copy lives in strings.xml with everything else the user + * reads. + * + * The screens that used to carry a `Logs` action say where the logs went in + * here instead — see [SkyTopBar] for why the action was removed. + */ +data class HelpTopic( + @StringRes val title: Int, + @StringRes val body: Int, +) + +/** + * The app's header, the same one on every screen that has a title: a rounded- + * square back button at the left, the name beside it — left-aligned, with an + * optional one-line [subtitle] under it for the screen's living fact ("6 + * installed · 1 running") — and a matching ? button at the right wherever the + * screen has something worth saying about itself. + * + * The two square buttons are the same size and weight on purpose: with a + * matched pair the title column sits still as the user moves between screens, + * whatever a screen happens to put on the right. + * + * The ? took the place of a per-screen `Logs` action on SkyChat, SkySOCKS, + * SkyVPN and SkyDEX. A log viewer is not something wanted from the screen + * being used — it is wanted when something is wrong, and then all of them are + * wanted at once, which is the list Settings ▸ Diagnostics already keeps. + * Fleet keeps its own per-visor Logs button: that is a remote machine's feed + * arriving over dmsg, and Diagnostics only knows about this phone. + */ +@Composable +fun SkyTopBar( + title: String, + subtitle: String? = null, + onBack: (() -> Unit)? = null, + help: HelpTopic? = null, + actions: @Composable RowScope.() -> Unit = {}, +) { + var helpOpen by remember { mutableStateOf(false) } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.background) + .statusBarsPadding() + .padding(horizontal = 16.dp, vertical = 10.dp), + ) { + if (onBack != null) { + SquareBarButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(R.string.back), + ) + } + } + Column(Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (subtitle != null) { + Text( + text = subtitle, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + actions() + if (help != null) { + SquareBarButton(onClick = { helpOpen = true }) { + Icon( + Icons.AutoMirrored.Rounded.HelpOutline, + contentDescription = stringResource(R.string.help_open), + ) + } + } + } + + if (help != null && helpOpen) { + HelpDialog(topic = help, onDismiss = { helpOpen = false }) + } +} + +/** The one button shape in the bar: a 42dp tonal rounded square. */ +@Composable +fun SquareBarButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + FilledTonalIconButton( + onClick = onClick, + shape = MaterialTheme.shapes.medium, + modifier = modifier.size(42.dp), + colors = IconButtonDefaults.filledTonalIconButtonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ), + content = { content() }, + ) +} + +/** + * The ? sheet. Scrolls, because the longest of these runs past a short phone + * held in landscape, and a help text that cannot be reached the end of is + * worse than no help text. + */ +@Composable +private fun HelpDialog(topic: HelpTopic, onDismiss: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(topic.title)) }, + text = { + Text( + text = stringResource(topic.body), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.verticalScroll(rememberScrollState()), + ) + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.help_close)) } + }, + ) +} diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/components/TransportPreferenceUi.kt b/android/app/src/main/java/com/skycoin/skywire/ui/components/TransportPreferenceUi.kt new file mode 100644 index 0000000000..5594019df4 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/components/TransportPreferenceUi.kt @@ -0,0 +1,178 @@ +package com.skycoin.skywire.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +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.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +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.res.stringResource +import androidx.compose.ui.unit.dp +import com.skycoin.skywire.R +import com.skycoin.skywire.core.TransportPreference + +/** + * The primary-transport control, shared by the app screens that build + * routes to a remote server — SkySOCKS today, SkyVPN next. The value is a + * visor-wide setting ([TransportPreference]), so both screens show and + * change the same thing. + */ +@Composable +fun TransportPreferenceCard( + primary: String, + enabled: Boolean, + onClick: () -> Unit, +) { + SectionCard { + Text( + stringResource(R.string.transport_title), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(6.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + transportName(primary), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f), + ) + FilledTonalButton(onClick = onClick, enabled = enabled) { + Text(stringResource(R.string.transport_change)) + } + } + Spacer(Modifier.height(4.dp)) + Text( + stringResource(R.string.transport_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** + * Picking a row applies it and closes the sheet, so there is no Save (and + * no Cancel — the scrim, the swipe and the back gesture all dismiss it + * without choosing). + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TransportPreferenceSheet( + current: String, + onDismiss: () -> Unit, + onSelect: (String) -> Unit, +) { + val sheetState = rememberModalBottomSheetState() + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column( + Modifier + // The three choices make this sheet tall enough to reach the + // gesture/button bar — without the inset the last row sits + // under it. + .navigationBarsPadding() + .padding(horizontal = 24.dp) + .padding(bottom = 24.dp), + ) { + Text( + stringResource(R.string.transport_sheet_title), + style = MaterialTheme.typography.titleMedium, + ) + Spacer(Modifier.height(8.dp)) + Text( + stringResource(R.string.transport_sheet_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(12.dp)) + TransportPreference.choices.forEach { type -> + TransportChoiceRow( + type = type, + selected = type == current, + onClick = { onSelect(type) }, + ) + } + } + } +} + +@Composable +private fun TransportChoiceRow(type: String, selected: Boolean, onClick: () -> Unit) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = 10.dp), + ) { + RadioButton(selected = selected, onClick = onClick) + Spacer(Modifier.width(8.dp)) + Column(Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(transportName(type), style = MaterialTheme.typography.bodyLarge) + if (type == TransportPreference.DEFAULT) { + Spacer(Modifier.width(8.dp)) + RecommendedBadge() + } + } + Text( + transportDescription(type), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun RecommendedBadge() { + Box( + Modifier + .clip(CircleShape) + .background(MaterialTheme.colorScheme.secondaryContainer) + .padding(horizontal = 8.dp, vertical = 2.dp), + ) { + Text( + stringResource(R.string.transport_recommended), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } +} + +@Composable +private fun transportName(type: String): String = when (type) { + TransportPreference.DMSG -> stringResource(R.string.transport_dmsg) + TransportPreference.STCPR -> stringResource(R.string.transport_stcpr) + TransportPreference.SUDPH -> stringResource(R.string.transport_sudph) + else -> type +} + +@Composable +private fun transportDescription(type: String): String = when (type) { + TransportPreference.DMSG -> stringResource(R.string.transport_dmsg_hint) + TransportPreference.STCPR -> stringResource(R.string.transport_stcpr_hint) + TransportPreference.SUDPH -> stringResource(R.string.transport_sudph_hint) + else -> "" +} diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/dex/DexModels.kt b/android/app/src/main/java/com/skycoin/skywire/ui/dex/DexModels.kt new file mode 100644 index 0000000000..02c6ecc307 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/dex/DexModels.kt @@ -0,0 +1,84 @@ +package com.skycoin.skywire.ui.dex + +import kotlinx.serialization.Serializable + +/** + * A market the user has connected to before, offered again in the recents + * dropdown. The name is the operator's, learned from the connect handshake — + * a 66-character key is not something anyone recognises in a list. + */ +@Serializable +data class SavedMarket(val pk: String, val name: String = "") { + /** What the dropdown row reads as. */ + val label: String get() = if (name.isEmpty()) shortMarketPk(pk) else "$name · ${shortMarketPk(pk)}" +} + +/** + * The one skydex-client flag this screen owns: `--market-pk`. + * + * It is written back through the visor so the *config* records which market + * this phone trades on — it survives a core restart and an app reinstall, and + * it pre-fills the page's own connect form. It does not connect anything by + * itself; see [com.skycoin.skywire.api.SkydexApi]. + * + * Everything else in that argv — the listen address, the password file — is + * the phone profile's ([com.skycoin.skywire.core.SkydexProfile]), pinned at + * config time and not this screen's to touch. + */ +object DexArgs { + + // The visor writes the double-dash form; the single-dash spelling is + // accepted too because a hand-edited config may carry it. + private val MARKET_PK = listOf("--market-pk", "-market-pk") + + fun marketPk(args: List): String? = value(args, MARKET_PK)?.takeIf { it.isNotEmpty() } + + /** + * [args] rendered back as the single string the API's `args` field takes, + * with `--market-pk` set to [pk]. Values are quoted only when they need + * it — the server parses this with shell-like rules. + */ + fun withMarketPk(args: List, pk: String): String { + val flag = args.indexOfFirst { token -> MARKET_PK.any { token == it || token.startsWith("$it=") } } + val updated = when { + flag < 0 -> args + listOf(MARKET_PK.first(), pk) + args[flag].contains('=') -> args.toMutableList() + .also { it[flag] = args[flag].substringBefore('=') + "=" + pk } + flag + 1 < args.size -> args.toMutableList().also { it[flag + 1] = pk } + // Trailing flag with no value: a broken argv the visor would + // reject anyway — complete it rather than shifting everything. + else -> args + pk + } + return updated.joinToString(" ") { token -> + if (token.any { it.isWhitespace() }) "\"" + token.replace("\"", "\\\"") + "\"" else token + } + } + + /** Accepts both `--flag value` and `--flag=value`. */ + private fun value(args: List, flags: List): String? { + args.forEachIndexed { i, token -> + flags.forEach { flag -> + if (token.startsWith("$flag=")) return token.substringAfter('=') + if (token == flag && i + 1 < args.size) return args[i + 1] + } + } + return null + } +} + +/** + * Whether [value] is shaped like a market public key: 33 bytes of hex behind + * a compressed-point prefix, which is what the client's `UnmarshalText` + * accepts. Checked here so a typo is caught in the field instead of after a + * dial that was never going to resolve. + */ +fun isMarketPk(value: String): Boolean = + value.length == MARKET_PK_LENGTH && + (value.startsWith("02") || value.startsWith("03")) && + value.all { it.isDigit() || it in 'a'..'f' || it in 'A'..'F' } + +private const val MARKET_PK_LENGTH = 66 + +/** 8…6, the same shortening the trading UI's own header uses. */ +fun shortMarketPk(pk: String): String = + if (pk.length <= 20) pk else pk.take(8) + "…" + pk.takeLast(6) diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/dex/DexScreen.kt b/android/app/src/main/java/com/skycoin/skywire/ui/dex/DexScreen.kt new file mode 100644 index 0000000000..67e8413309 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/dex/DexScreen.kt @@ -0,0 +1,396 @@ +package com.skycoin.skywire.ui.dex + +import android.webkit.WebView +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +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.imePadding +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.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.viewmodel.compose.viewModel +import com.skycoin.skywire.R +import com.skycoin.skywire.core.CoreState +import com.skycoin.skywire.core.SkydexProfile +import com.skycoin.skywire.ui.components.HelpTopic +import com.skycoin.skywire.ui.components.SkyTopBar +import com.skycoin.skywire.ui.theme.LocalDarkTheme +import com.skycoin.skywire.ui.theme.SkyAccents + +/** + * SkyDEX: pick a market, dial it over Skywire, trade in the page the desktop + * already serves. + * + * The split is deliberate. The market public key is entered *natively* — a + * 66-character key wants the phone's own field, its paste behaviour and a list + * of the markets this phone has used before — and everything past the + * handshake is the embedded trading UI, unchanged. Its phone layout is a + * separate piece of work; until then it is shown zoomed to fit and left + * zoomable rather than clipped. + */ +@Composable +fun DexScreen( + onBack: () -> Unit, + viewModel: DexViewModel = viewModel(), +) { + val state by viewModel.uiState.collectAsState() + + // The page's own failure, kept apart from the view model's: one is "the + // market never answered", the other "it did and then the page broke". + var pageError by remember { mutableStateOf(null) } + var loadedUrl by remember { mutableStateOf(null) } + var webView by remember { mutableStateOf(null) } + var canGoBack by remember { mutableStateOf(false) } + // Read by the WebView client, which is built once — these keep it looking + // at the current values instead of the ones at creation time. + val url = rememberUpdatedState(state.uiUrl) + val password = rememberUpdatedState(state.password) + // The page follows the app's theme, after the user's own Light/Dark + // override — the same resolved answer every native screen renders with. + val darkTheme = LocalDarkTheme.current + val dark = rememberUpdatedState(darkTheme) + + // A theme change while the trading page is open re-themes it in place + // instead of leaving it in the other theme until its next load. + LaunchedEffect(darkTheme) { + webView?.let { DexWebView.applyTheme(it, darkTheme) } + } + + // Back walks SkyDEX backwards rather than leaving it: a screen inside the + // trading page first, then the market picker, and only from the picker — + // where there is genuinely nothing behind — the hub. Leaving the market + // IS the disconnect; the page and the app it talks to are one step. + val goBack: () -> Unit = { + val view = webView + when { + canGoBack && view != null -> view.goBack() + state.connected -> viewModel.disconnect() + else -> onBack() + } + } + BackHandler(enabled = canGoBack || state.connected) { goBack() } + + Scaffold( + topBar = { + SkyTopBar( + title = stringResource(R.string.app_skydex), + onBack = goBack, + help = HelpTopic(R.string.help_dex_title, R.string.help_dex_body), + ) + }, + // The system bars belong to the app scaffold this route sits in and + // are consumed there — claiming them again would offset the page. + contentWindowInsets = WindowInsets(0), + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + // The page's order forms sit low on the screen; without this + // the keyboard covers the field it belongs to. + .imePadding(), + ) { + if (state.connected) { + ConnectedHeader(state, onDisconnect = viewModel::disconnect) + } else { + ConnectPanel(state, viewModel) + } + + if (state.connected && pageError == null) { + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { ctx -> + DexWebView.create(ctx).also { view -> + view.webViewClient = DexWebView.client( + baseUrl = { url.value }, + password = { password.value }, + isDark = { dark.value }, + onError = { pageError = it }, + onHistoryChanged = { canGoBack = it }, + ) + view.webChromeClient = DexWebView.chromeClient(isDark = { dark.value }) + webView = view + } + }, + update = { view -> + val target = state.uiUrl + if (target != null && target != loadedUrl) { + loadedUrl = target + view.loadUrl(target) + } + }, + onRelease = { view: WebView -> + // The page polls its market on a timer; it would keep + // going after the composable is gone. + DexWebView.release(view) + loadedUrl = null + webView = null + canGoBack = false + }, + ) + } else if (pageError != null) { + PageError(pageError) { + pageError = null + loadedUrl = null + } + } + } + } +} + +// --- the native half: pick a market and dial it --- + +@Composable +private fun ConnectPanel(state: DexUiState, viewModel: DexViewModel) { + var recentsOpen by remember { mutableStateOf(false) } + val entry = state.entry.trim() + val malformed = entry.isNotEmpty() && !state.entryValid + + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + // Cards hold real prose: content must default to ink, not the + // muted onSurfaceVariant this container would otherwise imply. + contentColor = MaterialTheme.colorScheme.onSurface, + ), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 16.dp), + ) { + Column(Modifier.padding(20.dp)) { + Text( + stringResource(R.string.dex_market_title), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(10.dp)) + + Box { + OutlinedTextField( + value = state.entry, + onValueChange = viewModel::setEntry, + singleLine = true, + isError = malformed, + enabled = !state.busy, + label = { Text(stringResource(R.string.dex_market_label)) }, + textStyle = MaterialTheme.typography.bodyMedium + .copy(fontFamily = FontFamily.Monospace), + trailingIcon = { + if (state.recents.isNotEmpty()) { + IconButton(onClick = { recentsOpen = true }, enabled = !state.busy) { + Icon( + Icons.Default.ArrowDropDown, + contentDescription = stringResource(R.string.dex_recents), + ) + } + } + }, + modifier = Modifier.fillMaxWidth(), + ) + DropdownMenu(expanded = recentsOpen, onDismissRequest = { recentsOpen = false }) { + state.recents.forEach { market -> + DropdownMenuItem( + text = { + Text( + market.label, + style = MaterialTheme.typography.bodyMedium, + ) + }, + onClick = { + recentsOpen = false + viewModel.pickRecent(market) + }, + ) + } + } + } + + if (malformed) { + Spacer(Modifier.height(6.dp)) + Text( + stringResource(R.string.dex_market_invalid), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + + state.error?.let { error -> + Spacer(Modifier.height(8.dp)) + Text( + error, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + + Spacer(Modifier.height(16.dp)) + ConnectControl(state, viewModel) + } + } +} + +@Composable +private fun ConnectControl(state: DexUiState, viewModel: DexViewModel) { + if (!state.coreReady) { + Text( + stringResource( + if (state.coreState is CoreState.Stopped) { + R.string.dex_core_offline + } else { + R.string.dex_core_starting + }, + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + return + } + + Button( + onClick = viewModel::connect, + enabled = !state.busy && state.entryValid, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.connect)) + } + Spacer(Modifier.height(8.dp)) + if (state.busy) { + Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp) + Spacer(Modifier.width(10.dp)) + Text( + stringResource(R.string.dex_connecting), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + Text( + stringResource(R.string.dex_market_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** + * Once there is a market, the native half shrinks to one line: the page below + * is the screen. It keeps Disconnect because that is the action the phone + * owns — the page's own Disconnect only drops the session and leaves the app + * running, which the poll then reflects here. + */ +@Composable +private fun ConnectedHeader(state: DexUiState, onDisconnect: () -> Unit) { + val clipboard = LocalClipboardManager.current + val context = LocalContext.current + val copied = stringResource(R.string.copied_to_clipboard) + val pk = state.market?.marketPk.orEmpty() + val name = state.market?.marketName.orEmpty() + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(start = 20.dp, end = 8.dp, top = 4.dp, bottom = 4.dp), + ) { + Box( + Modifier + .size(10.dp) + .clip(CircleShape) + .background(CONNECTED_GREEN), + ) + Spacer(Modifier.width(10.dp)) + Column( + Modifier + .weight(1f) + .clickable { + clipboard.setText(AnnotatedString(pk)) + Toast.makeText(context, copied, Toast.LENGTH_SHORT).show() + }, + ) { + Text( + name.ifEmpty { stringResource(R.string.dex_market_title) }, + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + ) + Text( + shortMarketPk(pk), + style = MaterialTheme.typography.bodySmall + .copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + FilledTonalButton(onClick = onDisconnect, enabled = !state.busy) { + Text(stringResource(R.string.disconnect)) + } + } +} + +@Composable +private fun PageError(message: String?, onRetry: () -> Unit) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(32.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + message.orEmpty(), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(8.dp)) + FilledTonalButton(onClick = onRetry) { Text(stringResource(R.string.socks_retry)) } + } +} + +private val CONNECTED_GREEN = SkyAccents.success diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/dex/DexViewModel.kt b/android/app/src/main/java/com/skycoin/skywire/ui/dex/DexViewModel.kt new file mode 100644 index 0000000000..821fdf6d27 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/dex/DexViewModel.kt @@ -0,0 +1,263 @@ +package com.skycoin.skywire.ui.dex + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.skycoin.skywire.api.AppState +import com.skycoin.skywire.api.MarketStatus +import com.skycoin.skywire.api.SkydexApi +import com.skycoin.skywire.api.VisorApi +import com.skycoin.skywire.core.AppPreferences +import com.skycoin.skywire.core.CoreServiceState +import com.skycoin.skywire.core.CoreState +import com.skycoin.skywire.core.SkydexProfile +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.Json +import java.io.IOException + +/** Everything the SkyDEX screen renders. */ +data class DexUiState( + val coreState: CoreState = CoreState.Stopped, + /** The visor's local API answered — nothing starts before it does. */ + val apiUp: Boolean = false, + val app: AppState? = null, + /** The trading UI answered here; null while it hasn't. */ + val uiUrl: String? = null, + /** Secret the WebView answers the gate's challenge with — see [SkydexProfile]. */ + val password: String? = null, + /** skydex-client's own view of its market — the page can change it too. */ + val market: MarketStatus? = null, + val recents: List = emptyList(), + /** Text in the market-key field, kept here so it survives a rotation. */ + val entry: String = "", + /** A configure/start/connect round trip is in flight. */ + val busy: Boolean = false, + val error: String? = null, +) { + val coreReady: Boolean get() = coreState is CoreState.Running && apiUp + + /** + * The WebView is worth showing exactly when there is a market behind it. + * Not merely "the app is up": the page's own screen at that point is a + * second connect form, which is the one thing the native header exists to + * replace. + */ + val connected: Boolean get() = uiUrl != null && market?.connected == true + + val entryValid: Boolean get() = isMarketPk(entry.trim()) +} + +/** + * Drives the SkyDEX flow: point skydex-client at a market, start it, dial the + * market, and hand the screen a URL for the trading UI. + * + * Two servers are involved and they answer different questions. The visor + * (`:8000`) owns the app — its argv, and whether it runs. skydex-client's own + * control API (`:8051`) owns the market connection, because the engine dials + * only when asked: `--market-pk` alone would leave the user on the page's + * connect form with the key already filled in, which is two steps for what the + * desktop flow does in one. + * + * The market state is polled rather than remembered — the embedded page keeps + * a Disconnect button of its own, and a header claiming "connected" over a + * page that isn't would be worse than no header at all. + */ +class DexViewModel(app: Application) : AndroidViewModel(app) { + + private val visor = VisorApi.get(app) + private val skydex = SkydexApi.get(app) + private val prefs = AppPreferences(app) + private val json = Json { ignoreUnknownKeys = true } + + private val mutable = MutableStateFlow(DexUiState()) + val uiState: StateFlow = mutable.asStateFlow() + + private var actionJob: Job? = null + + /** + * The field is filled in from what the phone already knows — the last + * market, or the one in the argv — but only until the user has seen it + * once. Refilling on a later poll would fight whoever is typing. + */ + private var prefilled = false + + init { + viewModelScope.launch { + val secret = skydex.password() + mutable.update { it.copy(password = secret) } + } + viewModelScope.launch { + val saved = readRecents() + mutable.update { state -> + state.copy( + recents = saved, + entry = state.entry.ifEmpty { saved.firstOrNull()?.pk.orEmpty() }, + ) + } + if (saved.isNotEmpty()) prefilled = true + } + viewModelScope.launch { + CoreServiceState.state.collectLatest { core -> + // Everything below is derived from a live visor: a restart + // takes the app's listener with it, so the WebView must be + // torn down rather than left pointed at a dead port. + mutable.update { + it.copy(coreState = core, apiUp = false, app = null, uiUrl = null, market = null) + } + if (core !is CoreState.Running) return@collectLatest + while (!visor.ping()) delay(PING_INTERVAL_MS) + mutable.update { it.copy(apiUp = true) } + poll() + } + } + } + + fun setEntry(value: String) { + prefilled = true + mutable.update { it.copy(entry = value, error = null) } + } + + fun pickRecent(market: SavedMarket) { + prefilled = true + mutable.update { it.copy(entry = market.pk, error = null) } + } + + /** + * Point skydex-client at the key in the field, make sure it runs, and dial + * the market. + * + * The stop is unconditional and its failure ignored, for the reason the + * SkySOCKS screen found the hard way: `status: 1` on an app the visor + * already considers started is a 500, and the polled snapshot can be a + * poll behind. With the app reliably stopped the single PUT below is valid + * either way — the args just rewrite the argv (the server-side restart it + * triggers is a no-op with no proc running), then the status starts it + * with the new key in place. + */ + fun connect() = action { + val pk = mutable.value.entry.trim() + if (!isMarketPk(pk)) throw IOException("That is not a market public key.") + + val current = visor.app(SkydexProfile.APP) + runCatching { visor.updateApp(SkydexProfile.APP, status = VisorApi.APP_STOP) } + val started = visor.updateApp( + SkydexProfile.APP, + args = DexArgs.withMarketPk(current.args, pk), + status = VisorApi.APP_START, + ) + mutable.update { it.copy(app = started) } + + val url = SkydexProfile.baseUrl(SkydexProfile.listenPort(started.args)) + if (!awaitUi(url)) throw IOException("SkyDEX did not answer on $url") + + val market = skydex.connect(url, pk) + saveRecent(SavedMarket(pk, market.marketName)) + mutable.update { it.copy(uiUrl = url, market = market) } + } + + /** + * Drop the market and stop the app. Stopping is deliberate: SkyDEX has no + * background duty — the engine holds no connection while idle — so leaving + * a trading UI listening on the phone would be a surface kept open for + * nothing. + */ + fun disconnect() = action { + mutable.value.uiUrl?.let { skydex.disconnect(it) } + val stopped = runCatching { + visor.updateApp(SkydexProfile.APP, status = VisorApi.APP_STOP) + }.getOrNull() + mutable.update { it.copy(app = stopped ?: it.app, uiUrl = null, market = null) } + } + + // --- internals --- + + /** + * One user action at a time, with its failure surfaced on the screen. + * Launched on the view-model scope rather than inside the state collector, + * which is cancelled the moment the core state changes. + */ + private fun action(block: suspend () -> Unit) { + actionJob?.cancel() + actionJob = viewModelScope.launch { + mutable.update { it.copy(busy = true, error = null) } + try { + block() + } catch (e: Exception) { + mutable.update { it.copy(error = e.message) } + } finally { + mutable.update { it.copy(busy = false) } + } + } + } + + /** Runs until the core-state collector cancels it. */ + private suspend fun poll() { + while (true) { + try { + val app = visor.app(SkydexProfile.APP) + // One call answers both questions the screen has: an app that + // reports its market is by definition an app whose UI is up. + val url = SkydexProfile.baseUrl(SkydexProfile.listenPort(app.args)) + val market = if (app.running) skydex.status(url) else null + mutable.update { + it.copy(app = app, uiUrl = if (market != null) url else null, market = market) + } + val configured = DexArgs.marketPk(app.args) + if (!prefilled && configured != null) { + prefilled = true + mutable.update { it.copy(entry = configured) } + } + } catch (e: Exception) { + mutable.update { it.copy(error = e.message) } + } + delay(POLL_INTERVAL_MS) + } + } + + /** Wait for the trading UI's listener; ~15 s, as skychat's screen does. */ + private suspend fun awaitUi(url: String): Boolean { + repeat(READY_ATTEMPTS) { + if (skydex.probe(url)) return true + delay(READY_INTERVAL_MS) + } + return false + } + + private suspend fun readRecents(): List = + prefs.string(KEY_RECENTS).first()?.let { stored -> + runCatching { + json.decodeFromString(ListSerializer(SavedMarket.serializer()), stored) + }.getOrNull() + }.orEmpty() + + /** Most recent first, deduplicated by key, oldest beyond [MAX_RECENTS] dropped. */ + private suspend fun saveRecent(market: SavedMarket) { + val updated = (listOf(market) + mutable.value.recents.filterNot { it.pk == market.pk }) + .take(MAX_RECENTS) + prefs.putString( + KEY_RECENTS, + json.encodeToString(ListSerializer(SavedMarket.serializer()), updated), + ) + mutable.update { it.copy(recents = updated) } + } + + private companion object { + const val KEY_RECENTS = "dex_recent_markets" + const val MAX_RECENTS = 5 + const val PING_INTERVAL_MS = 700L + const val POLL_INTERVAL_MS = 2_000L + const val READY_INTERVAL_MS = 500L + + /** ~15 s: an in-proc app binds its listener in well under a second. */ + const val READY_ATTEMPTS = 30 + } +} diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/dex/DexWebView.kt b/android/app/src/main/java/com/skycoin/skywire/ui/dex/DexWebView.kt new file mode 100644 index 0000000000..6da1474f81 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/dex/DexWebView.kt @@ -0,0 +1,917 @@ +package com.skycoin.skywire.ui.dex + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.content.pm.ApplicationInfo +import android.net.Uri +import android.util.Log +import android.view.ViewGroup +import android.app.AlertDialog +import android.webkit.ConsoleMessage +import android.webkit.JsResult +import android.webkit.WebChromeClient +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.core.net.toUri +import com.skycoin.skywire.core.SkydexProfile +import org.json.JSONObject + +/** + * The Android glue around skydex-client's embedded trading UI. Much smaller + * than the chat's: this page has no gate to answer, no uploads and no media — + * it is a single-page app talking to its own loopback API. What it does need + * is the same rule about what may leave the page, and a console pipe, because + * a trading screen that renders blank has to be diagnosable from logcat. + */ +internal object DexWebView { + + private const val TAG = "SkydexWebView" + + /** + * A WebView configured for the trading UI. JavaScript is the app itself + * and DOM storage holds its client-side state (the last market, the open + * tab). File and content access stay off — nothing in the page loads from + * either, and they are the two settings that turn a rendering bug into a + * file read. + */ + fun create(context: Context): WebView = WebView(context).apply { + // MATCH_PARENT is load-bearing: a WebView left at wrap_content is + // measured with an AT_MOST height, and Chromium then treats the layout + // viewport as indefinite, collapsing every `height: 100%` in the page. + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.allowFileAccess = false + settings.allowContentAccess = false + // The page asks for `width=device-width`, and [applyPhoneStyles] gives + // it the breakpoint it never shipped — so it is laid out at the real + // width rather than zoomed out to a desktop one. Zoom stays available: + // this is a screen full of hex addresses and prices. + settings.useWideViewPort = true + settings.loadWithOverviewMode = false + settings.setSupportZoom(true) + settings.builtInZoomControls = true + settings.displayZoomControls = false + // chrome://inspect on a debug build; never on a release APK. + val debuggable = + (context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0 + WebView.setWebContentsDebuggingEnabled(debuggable) + setBackgroundColor(android.graphics.Color.TRANSPARENT) + } + + /** + * Client for the page itself. The UI is one document driven by React + * state, so any main-frame navigation off its own origin is a link the + * page is offering (a block explorer, a help page) and belongs in the + * browser — not loaded over the trading screen, which cannot navigate + * back to itself. + */ + fun client( + baseUrl: () -> String?, + password: () -> String?, + isDark: () -> Boolean, + onError: (String) -> Unit, + onHistoryChanged: (Boolean) -> Unit = {}, + ): WebViewClient = object : WebViewClient() { + + private var authAttempts = 0 + + // The trading UI is a React app that routes with pushState, so its + // own screens are history entries — which is what lets back walk them + // before it gives up and leaves SkyDEX. + override fun doUpdateVisitedHistory(view: WebView, url: String, isReload: Boolean) { + onHistoryChanged(view.canGoBack()) + } + + override fun onReceivedHttpAuthRequest( + view: WebView, + handler: android.webkit.HttpAuthHandler, + host: String, + realm: String, + ) { + val secret = password() + // Re-challenged with the same credential means the credential is + // wrong; proceeding again would spin forever. + if (secret == null || authAttempts++ > 0) { + handler.cancel() + onError("SkyDEX rejected the stored password") + return + } + handler.proceed(SkydexProfile.USER, secret) + } + + override fun shouldOverrideUrlLoading( + view: WebView, + request: WebResourceRequest, + ): Boolean { + if (!request.isForMainFrame) return false + val base = baseUrl()?.toUri() + val target = request.url + if (base != null && target.host == base.host && target.port == base.port) return false + return openExternally(view.context, target) + } + + // The earliest moment a script provably runs in the NEW document — + // onPageStarted can still evaluate against the old one. Committing + // the theme here keeps a light-mode load from flashing the page's + // built-in navy while its stylesheet arrives. + override fun onPageCommitVisible(view: WebView, url: String) { + if (url != "about:blank") applyTheme(view, isDark()) + } + + override fun onPageFinished(view: WebView, url: String) { + authAttempts = 0 + onHistoryChanged(view.canGoBack()) + if (url != "about:blank") { + applyTheme(view, isDark()) + applyPhoneStyles(view) + } + } + + override fun onReceivedError( + view: WebView, + request: WebResourceRequest, + error: android.webkit.WebResourceError, + ) { + // Subresource failures are the page's own business (its market + // polling 409s the moment a connection drops); only a dead main + // frame is a screen state. + if (request.isForMainFrame) onError(error.description.toString()) + } + } + + /** + * Put the page in the app's theme. The trading UI ships one theme — dark + * navy, declared as six custom properties on `:root` — so following the + * app is a matter of re-pointing those tokens under a class this side + * controls. Called from [WebViewClient.onPageCommitVisible] so a light + * load never paints navy first, again at finish (belt and braces on a + * page we do not own), and live from the screen when the app's theme + * changes with the page open — same mechanism as the chat's. + * + * Idempotent like the other injections: the stylesheet lands once per + * document, only the class toggles. + */ + fun applyTheme(view: WebView, dark: Boolean) { + view.evaluateJavascript( + """ + (function () { + var root = document.documentElement; + root.classList.toggle('sky-light', ${!dark}); + root.classList.toggle('sky-dark', $dark); + var id = 'skywire-theme-styles'; + if (document.getElementById(id)) return; + var style = document.createElement('style'); + style.id = id; + style.textContent = ${JSONObject.quote(THEME_CSS)}; + (document.head || root).appendChild(style); + })(); + """.trimIndent(), + null, + ) + } + + /** + * The two themes, written against the page's own tokens. + * + * Dark is the page's design on the app's ground: one token moves so the + * document behind the native header row is the same navy as the rest of + * the app, and every border, panel and accent stays as shipped. Light is + * the app's light palette from `ui/theme/Theme.kt` — white ground, + * near-black ink, the brand blue — plus overrides for each hard-coded + * translucent dark in the page's CSS, which are all white-on-navy + * arithmetic that turns to mud on white. `color-scheme` flips with it so + * the engine draws scrollbars and select popups to match. + */ + private val THEME_CSS = """ + html.sky-dark { + --sky-navy: #0A101C; + } + + html.sky-light { + color-scheme: light; + --sky-navy: #FFFFFF; + --sky-white: #0B1526; + --sky-blue: #0F7BF4; + --panel: #FAFCFF; + --border: rgba(15, 123, 244, .28); + --muted: #44536B; + --bs-emphasis-color: #0B1526; + --bs-emphasis-color-rgb: 11, 21, 38; + } + + /* Inputs: the page fills them with translucent black over navy. */ + html.sky-light .form-control, + html.sky-light .form-select { background-color: #FFFFFF; } + html.sky-light .form-control:focus, + html.sky-light .form-select:focus { background-color: #FFFFFF; } + html.sky-light .form-control::placeholder { color: rgba(11, 21, 38, .4); } + /* — except the trade builder's amount, which the page deliberately + leaves transparent so it reads as a figure sitting on the leg panel + rather than a field. Re-stating it keeps the rule above from + printing a white box inside a tinted one. */ + html.sky-light .trade-leg .leg-amount, + html.sky-light .trade-leg .leg-amount:focus { background-color: transparent; } + + /* The one place re-pointing --sky-white is wrong: it is the *ink* token, + and the primary button uses it on a fill of brand blue. Ink on blue + is what the dark page means by it; on light it has to stay white. */ + html.sky-light .btn-primary, + html.sky-light .btn-connect, + html.sky-light .btn-primary:hover, + html.sky-light .btn-connect:hover:not(:disabled) { color: #FFFFFF; } + + /* The other translucent darks, each re-based on ink or the brand. */ + html.sky-light .trade-builder .trade-leg { background: #F6F9FD; } + html.sky-light .addr-box { background: #F0F5FC; } + html.sky-light .recent-connect { background: #F6F9FD; } + html.sky-light .card.product-card:hover { background-color: rgba(15, 123, 244, .06); } + html.sky-light .progress { background-color: rgba(11, 21, 38, .1); } + html.sky-light .deposit-close:hover { background: rgba(11, 21, 38, .08); } + + /* A red readable on white, not the page's salmon-on-navy. */ + html.sky-light .req, + html.sky-light .recent-del:hover:not(:disabled) { color: #C62828; } + + /* Shadows tuned for a dark ground read as smears on a light one. */ + html.sky-light .qr-modal { box-shadow: 0 12px 40px rgba(11, 21, 38, .2); } + html.sky-light .connect-card { box-shadow: 0 8px 30px rgba(15, 123, 244, .12); } + + /* The card each table row becomes on a phone is this side's own + translucent black (PHONE_CSS) — same scope, light fill. */ + @media (max-width: 600px) { + html.sky-light .table tbody tr.sky-card { background: #FAFCFF; } + } + """.trimIndent() + + /** + * Give the page the phone layout it does not ship with. + * + * The trading UI is built from Bootstrap plus about 10 kB of its own CSS, + * and that CSS contains **no media query at all** — every padding, grid + * minimum and flex basis in it was measured on a desktop. It is a built + * bundle from another repo, so the only lever this side has is a + * stylesheet layered on top; it is written as one, scoped to phone widths + * so a tablet in landscape keeps the layout the page intends. + * + * A stylesheet rather than DOM surgery wherever it can be, because the + * page is React: a rule in `` survives every re-render and disturbs + * no state, while an element changed underneath React is restored on the + * next one. The tables are the exception and [TABLE_CARDS] explains why. + * Both injections are idempotent and run once per page load. + */ + private fun applyPhoneStyles(view: WebView) { + view.evaluateJavascript( + """ + (function () { + var id = 'skywire-phone-styles'; + if (document.getElementById(id)) return; + var style = document.createElement('style'); + style.id = id; + style.textContent = ${JSONObject.quote(PHONE_CSS)}; + document.head.appendChild(style); + })(); + """.trimIndent(), + null, + ) + view.evaluateJavascript(TABLE_CARDS, null) + } + + /** + * What CSS alone cannot do for the tables: name each cell, and decide which + * cells a closed card shows. + * + * A `` carries no clue what column it is in, so the labels are copied + * off the `` into `data-label` for the stylesheet to print. Which + * cells stay visible is chosen by column *name* rather than position — + * "Amount" and "Price" are what identify a row across all three tables, + * while "ID", the escrow address and the transaction hashes are reference + * data — and any cell holding a control is always kept, because a Cancel + * button behind a toggle is a Cancel button nobody presses. + * + * Everything here is re-applied by a MutationObserver, and the open cards + * are remembered in a Set outside the DOM. Both are load-bearing: the page + * re-renders its tables every eight seconds while it polls, which would + * otherwise strip the labels and snap every open card shut mid-read. + */ + private val TABLE_CARDS = """ + (function () { + if (window.__skywirePhoneTables) return; + window.__skywirePhoneTables = true; + + var KEEP = ['type', 'amount', 'price', 'status', 'lifecycle']; + var MAX_VISIBLE = 4; + var open = {}; + + /* Two ways to read every section: the labelled cards, or a compact + list whose rows hold only the primary pair until tapped open. + One choice for the whole page, kept across visits — a reader who + prefers the list prefers it on every tab. */ + var mode = 'card'; + try { mode = localStorage.getItem('skywire-dex-view') || 'card'; } catch (e) {} + + function setMode(m) { + mode = m === 'list' ? 'list' : 'card'; + try { localStorage.setItem('skywire-dex-view', mode); } catch (e) {} + document.documentElement.classList.toggle('sky-list', mode === 'list'); + syncViewbar(); + } + + function syncViewbar() { + var btns = document.querySelectorAll('.sky-viewbar button'); + for (var i = 0; i < btns.length; i++) { + btns[i].classList.toggle('on', btns[i].dataset.view === mode); + } + } + + /* The switch rides in the heading of whatever it switches, which + React rebuilds on every tab change — so it is (re)inserted from + enhance(), the same way the data-labels are re-applied. + It only appears where it does something: Settings is a form, and a + Cards/List choice above a form is a control with nothing to act on. + The test is the DOM's, not a list of tab names — whatever the page + adds later, the switch follows the rows. + + Which heading: the market names its grid ("Available products", + an .section-title) and its .page-head already carries New Sell + Order; every other tab's rows ARE the tab, so the page title is + the heading. Both are one line with the switch on the right — + a bar of its own above the rows is a row of chrome that says + nothing. */ + function ensureViewbar(applies) { + var content = document.querySelector('.content'); + if (!content) return; + var existing = content.querySelector('.sky-viewbar'); + if (!applies) { + if (existing) existing.remove(); + return; + } + var host = content.querySelector('.section-title') || + content.querySelector('.page-head'); + if (!host) return; + if (existing && existing.parentNode === host) { syncViewbar(); return; } + if (existing) existing.remove(); + var bar = document.createElement('div'); + bar.className = 'sky-viewbar'; + bar.innerHTML = + '' + + ''; + bar.addEventListener('click', function (e) { + var b = e.target.closest('button[data-view]'); + if (b) setMode(b.dataset.view); + }); + if (host.classList.contains('page-head')) { + // Straight after the title rather than at the end, so when the + // row is too narrow for three items it is the page's own button + // (Clear history) that wraps and never the switch. + var title = host.querySelector('h2'); + host.insertBefore(bar, title ? title.nextSibling : host.firstChild); + } else { + host.classList.add('sky-titlerow'); + host.appendChild(bar); + } + syncViewbar(); + } + + /* "Clear history" belongs with the other things you set once, not + beside the list it wipes — a destructive control in a heading row + is one mis-tap from the tab you just opened. The page's own button + is hidden in CSS (no flash) and re-offered here. + + It is re-implemented rather than moved because the two tabs are + separate React screens: History's button does not exist in the DOM + while Settings is on screen. What it does is entirely local — + `localStorage.removeItem('exchange:history')` — so the same key, + behind the same confirm, is the same action. Like the page's own, + a later poll can re-save trades the market still reports as + finished; that is the page's behaviour, not a difference. */ + var HISTORY_KEY = 'exchange:history'; + + function historyCount() { + try { + var raw = JSON.parse(localStorage.getItem(HISTORY_KEY)); + return Array.isArray(raw) ? raw.length : 0; + } catch (e) { return 0; } + } + + function ensureHistoryClear() { + var content = document.querySelector('.content'); + if (!content) return; + var head = content.querySelector('.page-head h2'); + var onSettings = !!head && head.textContent.trim() === 'Settings'; + var panel = content.querySelector('.sky-history-panel'); + // Nothing saved is nothing to clear — the same condition the + // History tab put on the button. + if (!onSettings || historyCount() === 0) { + if (panel) panel.remove(); + return; + } + if (panel) return; + panel = document.createElement('div'); + panel.className = 'panel sky-history-panel'; + panel.innerHTML = + '

Trade history

' + + '

Completed, cancelled and expired trades are ' + + 'kept on this device so History can show them after the market ' + + 'forgets. Clearing removes that local copy.

' + + ''; + panel.querySelector('.sky-clear-history').addEventListener('click', function (e) { + var btn = e.currentTarget; + if (!window.confirm( + 'Clear all locally saved trade history on this device?' + )) return; + try { localStorage.removeItem(HISTORY_KEY); } catch (err) {} + btn.textContent = 'History cleared'; + btn.disabled = true; + }); + content.appendChild(panel); + } + + function keyOf(tr) { + var first = tr.cells[0]; + return (first ? first.textContent.trim() : '') + '#' + tr.rowIndex; + } + + /* A product card has no row index; its own text identifies it well + enough to keep it open across the page's 8-second re-renders. */ + function productKey(card) { + return 'p#' + card.textContent.trim().slice(0, 80); + } + + function visibleColumns(heads) { + var picked = []; + for (var k = 0; k < KEEP.length && picked.length < MAX_VISIBLE; k++) { + for (var i = 0; i < heads.length && picked.length < MAX_VISIBLE; i++) { + var head = heads[i].toLowerCase(); + if (picked.indexOf(i) < 0 && head.indexOf(KEEP[k]) === 0) picked.push(i); + } + } + return picked; + } + + /** + * A lifecycle cell is a chain of badges: the completed steps, the + * one it is on, and the ones ahead, with arrows between. Everything + * that is not the current step is marked so the closed card can drop + * it. Returns whether this cell is such a chain. + * + * The current step is the one the page paints `bg-info`; the last + * badge is the fallback, for a chain that has run to its end. + */ + function markChain(td) { + var badges = td.querySelectorAll('.badge'); + if (badges.length < 2) return false; + td.classList.add('sky-chain'); + var current = td.querySelector('.badge.bg-info') || badges[badges.length - 1]; + var parts = td.querySelectorAll('span, div'); + for (var i = 0; i < parts.length; i++) { + var part = parts[i]; + if (part === current || part.contains(current)) { + part.classList.remove('sky-past'); + } else { + part.classList.add('sky-past'); + } + } + // The arrow trailing the current badge is inside its own step. + var siblings = current.parentNode ? current.parentNode.children : []; + for (var s = 0; s < siblings.length; s++) { + if (siblings[s] !== current) siblings[s].classList.add('sky-past'); + } + return true; + } + + function enhance() { + // Rows to read either way — a table with a header, or the market's + // product grid. Neither means this section is a form. + ensureViewbar(!!( + document.querySelector('table.table thead th') || + document.querySelector('.card.product-card') + )); + ensureHistoryClear(); + var tables = document.querySelectorAll('table.table'); + for (var t = 0; t < tables.length; t++) { + var table = tables[t]; + var ths = table.querySelectorAll('thead th'); + if (!ths.length) continue; + var heads = []; + for (var h = 0; h < ths.length; h++) heads.push(ths[h].textContent.trim()); + var keep = visibleColumns(heads); + // The list's closed row shows only the first two kept columns — + // the pair that identifies the row (Amount and Price wherever + // the table has them). + var primary = keep.slice(0, 2); + + var rows = table.querySelectorAll('tbody tr'); + for (var r = 0; r < rows.length; r++) { + var tr = rows[r]; + // A one-cell row is the table's own "nothing here yet" line. + if (tr.cells.length < 2) continue; + tr.classList.add('sky-card'); + tr.classList.toggle('sky-open', open[keyOf(tr)] === true); + for (var c = 0; c < tr.cells.length; c++) { + var td = tr.cells[c]; + var head = heads[c] || ''; + // The Actions column is named, not guessed at: every other + // column can hold a link-styled button too (the id copier), + // and those are reference data, not actions. + // An Actions cell on a finished row holds a placeholder dash + // and nothing else. Its label is suppressed, so left in it + // is a bare "—" on a line of its own. + var action = head.toLowerCase() === 'actions'; + var control = td.querySelector('button, input, select'); + td.classList.toggle('sky-hide', action && !control); + var chain = markChain(td); + // A lifecycle chain shows one badge when closed, so it is + // "Status" then — which is also what it reads as. + td.setAttribute('data-label', chain ? 'Status' : head); + td.classList.toggle('sky-actions', action && !!control); + td.classList.toggle('sky-detail', !action && keep.indexOf(c) < 0); + td.classList.toggle('sky-primary', primary.indexOf(c) >= 0); + // A hash or an address needs the full width; a number, a + // single badge and a button do not. + td.classList.toggle( + 'sky-wide', + !action && !chain && + (td.children.length > 1 || td.textContent.trim().length > 24) + ); + } + } + } + // The market's product grid gets the same two readings: its cards + // are already cards, and the list rows open on tap for the seller + // and the Buy button. + var cards = document.querySelectorAll('.card.product-card'); + for (var p = 0; p < cards.length; p++) { + cards[p].classList.toggle('sky-open', open[productKey(cards[p])] === true); + } + } + + document.addEventListener('click', function (e) { + if (!e.target || !e.target.closest) return; + // A control inside the card is the control, not the card. + if (e.target.closest('button, a, input, select, label')) return; + var tr = e.target.closest('tr.sky-card'); + if (tr) { + var key = keyOf(tr); + open[key] = !open[key]; + tr.classList.toggle('sky-open', open[key]); + return; + } + // Product rows expand only in list mode — the card shows + // everything already. + var pc = e.target.closest('.card.product-card'); + if (pc && document.documentElement.classList.contains('sky-list')) { + var pk = productKey(pc); + open[pk] = !open[pk]; + pc.classList.toggle('sky-open', open[pk]); + } + }); + + var queued = false; + new MutationObserver(function () { + if (queued) return; + queued = true; + requestAnimationFrame(function () { queued = false; enhance(); }); + }).observe(document.body, { childList: true, subtree: true }); + + document.documentElement.classList.toggle('sky-list', mode === 'list'); + enhance(); + })(); + """.trimIndent() + + /** + * The rules, in order: drop the duplicated header; give the page back the + * width its desktop padding spends; make the tab strip show all five tabs; + * collapse the grids and the trade builder, whose column minimums assume a + * screen this size does not have; let tables scroll sideways instead of + * shredding every cell into a column of single words; and make the small + * buttons thumb-sized. + */ + private val PHONE_CSS = """ + /* The native row above this page already says all of this. */ + .app-container > header.header { display: none !important; } + + /* The Cards/List switch only means something where the phone layout + below applies; on wider screens it stays out of the way — and so + does the Settings panel that takes over Clear history, which is the + same trade: on a desktop the page's own heading row has the room. */ + .sky-viewbar { display: none; } + .sky-history-panel { display: none; } + + @media (max-width: 600px) { + /* Cards or a compact list — the reader's choice, page-wide. It sits + in the heading row it belongs to, pushed to the right of the + title; .page-head is already such a row, .section-title is made + into one. */ + .sky-viewbar { display: flex; margin-left: auto; } + .section-title.sky-titlerow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + } + + /* Clear history moves out of History's heading row and into + Settings, where the rest of the once-and-done lives. Hiding the + original in CSS rather than script keeps it from flashing in on + every one of the page's 8-second re-renders. Only History puts a + link-button in its page-head. */ + .page-head .link-btn { display: none; } + .sky-history-panel { display: block; } + .sky-history-panel .text-muted { font-size: 0.85rem; } + .sky-viewbar button { + border: 1px solid var(--border); + background: transparent; + color: var(--muted); + font-size: 0.78rem; + padding: 0.3rem 0.85rem; + min-height: 34px; + } + .sky-viewbar button:first-child { border-radius: 8px 0 0 8px; } + .sky-viewbar button:last-child { border-radius: 0 8px 8px 0; margin-left: -1px; } + .sky-viewbar button.on { + background: var(--sky-blue); + border-color: var(--sky-blue); + color: #fff; + } + .content { padding: 0.9rem 0.85rem 1.5rem; } + .panel, .card { padding: 0.9rem; margin-bottom: 0.9rem; } + .content h2 { font-size: 1.2rem; } + + /* Five tabs do not fit on one line, and a strip that scrolls hides + the one holding the wallet addresses. Wrap instead. */ + .tabbar { padding: 0 0.35rem; overflow-x: visible; } + .tabbar-inner { flex-wrap: wrap; } + .tab-link { padding: 0.65rem 0.6rem; font-size: 0.85rem; } + + /* auto-fit at a 240px minimum is one column here anyway; saying so + stops the last row stretching a lone card across the screen. */ + .field-grid, .card-grid { grid-template-columns: 1fr; } + + /* 84px label + 140px amount + 140px unit cannot sit on one line. */ + .trade-builder .trade-leg { flex-direction: column; align-items: stretch; gap: 0.35rem; } + .trade-leg .leg-label { flex: none; } + .trade-leg .leg-amount { flex: none; width: 100%; font-size: 1.15rem; } + .trade-leg .leg-coin { flex: none; width: 100%; } + + /* Tables become cards. Ten columns of listing cannot be read on a + phone at any font size, and sideways scrolling puts the Actions + column — the one holding Cancel — off the edge where nobody finds + it. Each row becomes a labelled card instead: the four fields that + identify it plus its buttons, and the rest a tap away. Which cells + those are is decided in script, by column name. */ + /* The table's box is one element carrying both classes + (`
`) — a rounded panel around what + are now rounded cards. Let the cards be the only boxes. */ + .panel.table-wrap { + background: transparent; + border: 0; + padding: 0; + } + .table-wrap { overflow-x: visible; } + .table, .table tbody, .table tr, .table td { display: block; } + .table thead { display: none; } + + .table tbody tr.sky-card { + border: 1px solid var(--border); + border-radius: 12px; + background: #00000038; + padding: 0.85rem 0.9rem 0.15rem; + margin-bottom: 0.7rem; + } + .table tbody tr.sky-card > td { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.75rem; + border: 0; + padding: 0.25rem 0; + white-space: normal; + /* Bootstrap paints cell backgrounds with an inset shadow, which + inside a card reads as a second panel behind the fields. */ + box-shadow: none; + background: transparent; + } + .table tbody tr.sky-card > td::before { + content: attr(data-label); + color: var(--muted); + font-size: 0.78rem; + flex: 0 0 auto; + } + + /* The two numbers that say what a row IS, weighted the way the + market's own product card weights them: the amount plain, the + price in the accent. */ + .table tbody tr.sky-card > td[data-label="Amount"] { + font-size: 1.3rem; + font-weight: 700; + } + .table tbody tr.sky-card > td[data-label="Price"] { + font-size: 1.3rem; + font-weight: 700; + color: var(--sky-blue); + } + + /* A badge chain, an address or a hash gets the whole width rather + than the sliver left over beside its label. */ + .table tbody tr.sky-card > td.sky-wide { + flex-direction: column; + align-items: flex-start; + gap: 0.35rem; + word-break: break-all; + } + + /* A closed card shows where the listing IS, not the three steps it + took to get there. The completed and future badges — and the + arrows between them — come back when the card is opened, which is + where "why is my deposit still pending" gets answered. */ + .table tbody tr.sky-card:not(.sky-open) > td.sky-chain .sky-past { display: none; } + + /* "Actions" is not a word anyone needs above a button. */ + .table tbody tr.sky-card > td.sky-actions { + display: block; + padding: 0.6rem 0 0.15rem; + } + .table tbody tr.sky-card > td.sky-actions::before { content: none; } + .table tbody tr.sky-card > td.sky-actions .btn { width: 100%; } + .table tbody tr.sky-card > td.sky-hide { display: none; } + + .table tbody tr.sky-card:not(.sky-open) > td.sky-detail { display: none; } + .table tbody tr.sky-card::after { + content: 'Details'; + display: block; + text-align: center; + color: var(--sky-blue); + font-size: 0.78rem; + padding: 0.55rem 0 0.5rem; + border-top: 1px solid var(--border); + margin-top: 0.5rem; + } + .table tbody tr.sky-card.sky-open::after { content: 'Hide details'; } + + /* A banner's action reads as an action, not a word in a corner. */ + .banner { align-items: flex-start; } + .banner .btn { width: 100%; } + + /* 0.3rem of padding is a 28px target; a fingertip is 40. */ + .btn, .btn-sm, .link-btn { min-height: 40px; } + .btn.btn-sm.qr-btn { min-width: 40px; } + + /* Long hex breaks rather than pushing the page sideways. */ + .addr-box.addr-sm { max-width: 100%; } + .qr-modal { max-height: 85vh; } + + /* ---- List mode. Same DOM the card rules build on, read tighter: + a row is its primary pair on one line, everything else arrives + when the row is opened. */ + html.sky-list .table tbody tr.sky-card { + border: 0; + border-bottom: 1px solid var(--border); + border-radius: 0; + background: transparent; + padding: 0.55rem 1.4rem 0.5rem 0.1rem; + margin-bottom: 0; + position: relative; + } + html.sky-list .table tbody tr.sky-card:not(.sky-open) > td:not(.sky-primary) { + display: none; + } + html.sky-list .table tbody tr.sky-card:not(.sky-open) > td.sky-primary { + display: inline-flex; + width: auto; + font-size: 1rem; + gap: 0.4rem; + margin-right: 1.1rem; + } + /* The chevron replaces the Details footer: the row itself is the + tap target either way, and a footer per row defeats a list. */ + html.sky-list .table tbody tr.sky-card::after { + content: '\25BE'; + position: absolute; + right: 0.35rem; + top: 0.55rem; + border: 0; + margin: 0; + padding: 0; + color: var(--muted); + } + html.sky-list .table tbody tr.sky-card.sky-open::after { content: '\25B4'; } + + /* The market grid, as the same list: amount and price on the line, + seller and Buy behind the tap. */ + html.sky-list .card-grid { display: block; } + html.sky-list .card.product-card { + flex-direction: row; + flex-wrap: wrap; + align-items: baseline; + gap: 0.6rem; + border: 0; + border-bottom: 1px solid var(--border); + border-radius: 0; + background: transparent; + padding: 0.55rem 0.1rem 0.5rem; + margin-bottom: 0; + } + html.sky-list .card.product-card .product-price { color: var(--sky-blue); font-weight: 700; } + html.sky-list .card.product-card:not(.sky-open) .product-seller { display: none; } + html.sky-list .card.product-card:not(.sky-open) .btn { display: none; } + html.sky-list .card.product-card:not(.sky-open)::after { + content: '\25BE'; + margin-left: auto; + color: var(--muted); + } + html.sky-list .card.product-card.sky-open { padding-bottom: 0.75rem; } + html.sky-list .card.product-card.sky-open .product-seller { flex-basis: 100%; } + html.sky-list .card.product-card.sky-open .btn { flex-basis: 100%; margin-top: 0.2rem; } + } + """.trimIndent() + + /** + * Chrome client: the page's console next to the app's own logcat, and its + * JavaScript dialogs. + * + * The dialogs are not a nicety. A WebView with no `onJsConfirm` suppresses + * `window.confirm()` and hands the page `false`, and the page guards + * cancelling a listing or an order behind exactly that call — so without + * this, **Cancel silently does nothing**, which on a screen holding + * escrowed coins is the worst possible way to fail. + */ + fun chromeClient(isDark: () -> Boolean = { true }): WebChromeClient = object : WebChromeClient() { + + // These dialogs are drawn by the platform, not by Compose, so they do + // not inherit the app's light/dark the way every other surface does — + // an Activity theme cannot see a choice that lives in a composition + // local. Naming the half explicitly is what keeps a confirm from + // arriving as a dark slab over the light trading page. + private fun builder(context: Context) = AlertDialog.Builder( + context, + if (isDark()) android.R.style.Theme_DeviceDefault_Dialog_Alert + else android.R.style.Theme_DeviceDefault_Light_Dialog_Alert, + ) + + override fun onJsConfirm( + view: WebView, + url: String, + message: String, + result: JsResult, + ): Boolean { + builder(view.context) + .setMessage(message) + .setPositiveButton(android.R.string.ok) { _, _ -> result.confirm() } + .setNegativeButton(android.R.string.cancel) { _, _ -> result.cancel() } + .setOnCancelListener { result.cancel() } + .show() + return true + } + + override fun onJsAlert( + view: WebView, + url: String, + message: String, + result: JsResult, + ): Boolean { + builder(view.context) + .setMessage(message) + .setPositiveButton(android.R.string.ok) { _, _ -> result.confirm() } + .setOnCancelListener { result.confirm() } + .show() + return true + } + + override fun onConsoleMessage(message: ConsoleMessage): Boolean { + Log.d(TAG, "${message.sourceId()}:${message.lineNumber()} ${message.message()}") + return true + } + } + + /** True when the navigation was handled (i.e. must not load in-page). */ + private fun openExternally(context: Context, uri: Uri): Boolean { + val intent = Intent(Intent.ACTION_VIEW, uri).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + return try { + context.startActivity(intent) + true + } catch (e: ActivityNotFoundException) { + Log.w(TAG, "nothing handles $uri", e) + // Nothing opened it and it is not ours to render — refusing beats + // navigating the trading screen away to a page it cannot come + // back from. + true + } + } + + /** Tear-down that actually stops the page: it polls the market on a timer. */ + fun release(view: WebView) { + view.stopLoading() + view.webChromeClient = null + view.loadUrl("about:blank") + view.clearHistory() + (view.parent as? ViewGroup)?.removeView(view) + view.destroy() + } +} diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/fleet/FleetScreen.kt b/android/app/src/main/java/com/skycoin/skywire/ui/fleet/FleetScreen.kt new file mode 100644 index 0000000000..b297b72005 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/fleet/FleetScreen.kt @@ -0,0 +1,716 @@ +package com.skycoin.skywire.ui.fleet + +import android.widget.Toast +import androidx.compose.foundation.background +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.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.navigationBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.HelpOutline +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +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.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.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.skycoin.skywire.R +import com.skycoin.skywire.api.VisorSummary +import com.skycoin.skywire.core.CoreState +import com.skycoin.skywire.ui.components.CONNECTED_GREEN +import com.skycoin.skywire.ui.components.InfoRow +import com.skycoin.skywire.ui.components.SectionCard +import com.skycoin.skywire.ui.components.SkyTopBar +import com.skycoin.skywire.ui.components.formatDuration +import com.skycoin.skywire.ui.components.formatUptime +import com.skycoin.skywire.ui.components.shortPk +import com.skycoin.skywire.ui.logs.LogSources +import java.time.Instant + +/** + * Fleet: the visors this user runs elsewhere, seen from the phone. + * + * Off until asked for. With the toggle off the phone serves no dmsg ingest at + * all and there is nothing to list; turning it on makes this key reachable as + * a status endpoint, and any visor carrying it in its own `hypervisors` list + * connects in. What arrives is status — and one action, Restart. Everything + * else the hypervisor API can do to a visor is deliberately not here. + */ +@Composable +fun FleetScreen( + onBack: () -> Unit, + onOpenLogs: (String) -> Unit, + viewModel: FleetViewModel = viewModel(), +) { + val state by viewModel.uiState.collectAsState() + var confirmToggle by remember { mutableStateOf(null) } + var confirmRestart by remember { mutableStateOf(null) } + var addVisorOpen by remember { mutableStateOf(false) } + var renaming by remember { mutableStateOf(null) } + val snackbar = remember { SnackbarHostState() } + + LaunchedEffect(state.message) { + state.message?.let { message -> + snackbar.showSnackbar(message) + viewModel.messageShown() + } + } + + Scaffold( + snackbarHost = { SnackbarHost(snackbar) }, + topBar = { + SkyTopBar( + title = stringResource(R.string.app_fleet), + onBack = onBack, + // Fleet's help is a sheet of its own (see AddVisorSheet): it + // ends in a command with a copy button, which a plain help + // dialog has nowhere to put. + actions = { + IconButton(onClick = { addVisorOpen = true }) { + Icon( + Icons.AutoMirrored.Outlined.HelpOutline, + contentDescription = stringResource(R.string.help_open), + ) + } + }, + ) + }, + ) { padding -> + LazyColumn( + modifier = Modifier.padding(padding), + contentPadding = PaddingValues(horizontal = 20.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + item { + EnableCard( + state = state, + onToggle = { wanted -> + // A running core has to go down and come back for the + // change to land, and that drops any SkySOCKS or SkyVPN + // connection with it — worth a question first. + if (state.coreState is CoreState.Stopped || + state.coreState is CoreState.Failed + ) { + viewModel.setEnabled(wanted) + } else { + confirmToggle = wanted + } + }, + ) + } + + if (!state.enabled) { + item { WhatFleetIsCard() } + return@LazyColumn + } + + if (!state.coreReady) { + item { CoreNotReadyNote(state) } + return@LazyColumn + } + + item { VisorsHeader(state, viewModel) } + items(state.visors, key = { it.overview.localPk }) { visor -> + VisorCard( + visor = visor, + name = state.names[visor.overview.localPk].orEmpty(), + restarting = state.restarting == visor.overview.localPk, + onRename = { renaming = visor.overview.localPk }, + onRestart = { confirmRestart = visor.overview.localPk }, + onOpenLogs = { onOpenLogs(LogSources.visor(visor.overview.localPk)) }, + ) + } + item { VisorsFooter(state) } + } + } + + if (addVisorOpen) { + AddVisorSheet(state, onDismiss = { addVisorOpen = false }) + } + + renaming?.let { pk -> + RenameDialog( + pk = pk, + current = state.names[pk].orEmpty(), + onDismiss = { renaming = null }, + onSave = { name -> + viewModel.rename(pk, name) + renaming = null + }, + ) + } + + confirmToggle?.let { wanted -> + ConfirmDialog( + title = stringResource( + if (wanted) R.string.fleet_confirm_on_title else R.string.fleet_confirm_off_title, + ), + body = stringResource(R.string.fleet_confirm_restart_body), + confirm = stringResource(R.string.fleet_confirm_restart_action), + onDismiss = { confirmToggle = null }, + onConfirm = { + viewModel.setEnabled(wanted) + confirmToggle = null + }, + ) + } + + confirmRestart?.let { pk -> + // Ask about the machine by the name the user gave it — a dialog that + // quotes a key back at someone who named it "home server" is asking + // them to re-do the identification they already did. + val label = state.names[pk]?.takeIf { it.isNotEmpty() } ?: shortPk(pk) + ConfirmDialog( + title = stringResource(R.string.fleet_restart), + body = stringResource(R.string.fleet_restart_confirm, label), + confirm = stringResource(R.string.fleet_restart), + onDismiss = { confirmRestart = null }, + onConfirm = { + viewModel.restartVisor(pk, label) + confirmRestart = null + }, + ) + } +} + +// --- the toggle --- + +@Composable +private fun EnableCard(state: FleetUiState, onToggle: (Boolean) -> Unit) { + SectionCard { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Column(Modifier.weight(1f)) { + Text( + stringResource(R.string.fleet_enable), + style = MaterialTheme.typography.titleMedium, + ) + Spacer(Modifier.height(4.dp)) + Text( + stringResource(R.string.fleet_enable_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.width(12.dp)) + Switch( + checked = state.enabled, + onCheckedChange = onToggle, + // The switch writes a config field and restarts the core; both + // are meaningless while the core is already on its way up or + // down, and a second flip mid-restart would queue another one. + enabled = !state.coreCycling, + ) + } + if (state.coreCycling) { + Spacer(Modifier.height(10.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator(Modifier.size(14.dp), strokeWidth = 2.dp) + Spacer(Modifier.width(10.dp)) + Text( + stringResource(R.string.fleet_core_restarting), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + state.error?.let { error -> + Spacer(Modifier.height(8.dp)) + Text( + error, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } +} + +/** The off state: what this is for, and what turning it on actually does. */ +@Composable +private fun WhatFleetIsCard() { + SectionCard { + Text( + stringResource(R.string.fleet_off_title), + style = MaterialTheme.typography.titleMedium, + ) + Spacer(Modifier.height(8.dp)) + Text( + stringResource(R.string.fleet_off_body), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(12.dp)) + Text( + stringResource(R.string.fleet_off_reachable), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +// --- adding a visor --- + +/** + * How to point a visor at this phone. Instructions read once and then never + * again, so they live behind the app bar's ? instead of holding a card open + * above the list forever. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AddVisorSheet(state: FleetUiState, onDismiss: () -> Unit) { + val clipboard = LocalClipboardManager.current + val context = LocalContext.current + val copied = stringResource(R.string.copied_to_clipboard) + val pk = state.localPk + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(), + ) { + Column( + Modifier + .navigationBarsPadding() + .padding(horizontal = 24.dp) + .padding(bottom = 24.dp), + ) { + // Fleet's ? is the only one that opens a sheet rather than a + // dialog, because unlike the other tabs its guidance ends in a + // command the user has to copy. It answers the same question + // first — what is this tab — before getting to the command. + Text( + stringResource(R.string.help_fleet_title), + style = MaterialTheme.typography.titleMedium, + ) + Spacer(Modifier.height(8.dp)) + Text( + stringResource(R.string.help_fleet_body), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(20.dp)) + Text( + stringResource(R.string.fleet_add_title), + style = MaterialTheme.typography.titleMedium, + ) + Spacer(Modifier.height(8.dp)) + Text( + stringResource(R.string.fleet_add_body), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(16.dp)) + + if (pk.isEmpty()) { + Text( + stringResource(R.string.fleet_add_pk_pending), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + return@Column + } + + InfoRow( + label = stringResource(R.string.fleet_this_phone), + value = shortPk(pk), + mono = true, + modifier = Modifier.clickable { + clipboard.setText(AnnotatedString(pk)) + Toast.makeText(context, copied, Toast.LENGTH_SHORT).show() + }, + ) + Spacer(Modifier.height(10.dp)) + // The whole command with the key already in it — the point is that + // it can be copied once and pasted on the other machine, not read. + val command = stringResource(R.string.fleet_add_command, pk) + Text( + command, + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .clickable { + clipboard.setText(AnnotatedString(command)) + Toast.makeText(context, copied, Toast.LENGTH_SHORT).show() + } + .padding(horizontal = 12.dp, vertical = 10.dp), + ) + Spacer(Modifier.height(6.dp)) + Text( + stringResource(R.string.fleet_add_tap_to_copy), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(12.dp)) + Text( + stringResource(R.string.fleet_add_then_restart), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +// --- the list --- + +@Composable +private fun CoreNotReadyNote(state: FleetUiState) { + SectionCard { + Text( + stringResource( + if (state.coreState is CoreState.Stopped || state.coreState is CoreState.Failed) { + R.string.fleet_core_offline + } else { + R.string.fleet_core_starting + }, + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun VisorsHeader(state: FleetUiState, viewModel: FleetViewModel) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + stringResource(R.string.fleet_visors, state.visors.size), + style = MaterialTheme.typography.titleMedium, + ) + if (state.loading) { + CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) + } else { + IconButton(onClick = viewModel::refresh) { + Icon( + Icons.Default.Refresh, + contentDescription = stringResource(R.string.fleet_refresh), + ) + } + } + } +} + +@Composable +private fun VisorsFooter(state: FleetUiState) { + if (state.visors.isNotEmpty() || state.loading) return + Text( + stringResource(R.string.fleet_visors_none), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) +} + +@Composable +private fun VisorCard( + visor: VisorSummary, + name: String, + restarting: Boolean, + onRename: () -> Unit, + onRestart: () -> Unit, + onOpenLogs: () -> Unit, +) { + val clipboard = LocalClipboardManager.current + val context = LocalContext.current + val copied = stringResource(R.string.copied_to_clipboard) + val pk = visor.overview.localPk + + SectionCard { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + Modifier + .size(10.dp) + .clip(CircleShape) + .background( + if (visor.online) CONNECTED_GREEN + else MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) + Spacer(Modifier.width(8.dp)) + val copyPk = Modifier.clickable { + clipboard.setText(AnnotatedString(pk)) + Toast.makeText(context, copied, Toast.LENGTH_SHORT).show() + } + Column(Modifier.weight(1f)) { + // The name the user gave this machine leads; an unnamed one is + // its key, which is all there is to call it by. + Text( + name.ifEmpty { shortPk(pk) }, + style = if (name.isEmpty()) { + MaterialTheme.typography.titleMedium.copy(fontFamily = FontFamily.Monospace) + } else { + MaterialTheme.typography.titleMedium + }, + modifier = if (name.isEmpty()) copyPk else Modifier, + ) + Text( + stringResource( + if (visor.online) R.string.state_connected else R.string.fleet_state_offline, + ), + style = MaterialTheme.typography.bodySmall, + color = if (visor.online) CONNECTED_GREEN + else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (restarting) { + CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp) + Spacer(Modifier.width(8.dp)) + } + IconButton(onClick = onRename, modifier = Modifier.size(32.dp)) { + Icon( + Icons.Outlined.Edit, + contentDescription = stringResource(R.string.fleet_rename), + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + // A named row still has to show the key somewhere — it is what the + // other machine's config holds, and what a support question quotes. + // An unnamed row already IS the key, above. + if (name.isNotEmpty()) { + Spacer(Modifier.height(4.dp)) + Text( + shortPk(pk), + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.clickable { + clipboard.setText(AnnotatedString(pk)) + Toast.makeText(context, copied, Toast.LENGTH_SHORT).show() + }, + ) + } + + Spacer(Modifier.height(8.dp)) + InfoRow( + label = stringResource(R.string.visor_version), + value = listOfNotNull( + visor.overview.buildInfo?.version?.takeIf { it.isNotEmpty() }, + visor.buildTag.takeIf { it.isNotEmpty() }, + ).joinToString(" · ").ifEmpty { "—" }, + ) + InfoRow( + label = stringResource(R.string.visor_uptime), + value = formatUptime(visor.uptime), + ) + TransportRows(visor) + InfoRow( + label = stringResource(R.string.fleet_health), + value = healthLabel(visor), + valueColor = healthColor(visor), + ) + // How old the numbers above actually are. Shown whenever they are not + // current — which includes rows that say Connected: the server keeps + // serving a visor from its last snapshot for three minutes after the + // RPC connection drops, on the reasoning that a peer redialing is not + // a peer that is down. True, but it means an uptime can be minutes + // stale under a green dot, and it is the same gap in which Restart + // answers "currently disconnected". Saying the age costs one line. + val staleFor = visor.lastSeenAt?.let(::secondsSince) + if (staleFor != null && (!visor.online || staleFor >= STALE_AFTER_S)) { + Spacer(Modifier.height(4.dp)) + Text( + stringResource(R.string.fleet_last_seen, formatDuration(staleFor)), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Spacer(Modifier.height(8.dp)) + Row( + horizontalArrangement = Arrangement.End, + modifier = Modifier.fillMaxWidth(), + ) { + FilledTonalButton(onClick = onOpenLogs, enabled = visor.online) { + Text(stringResource(R.string.logs_title)) + } + FilledTonalButton(onClick = onRestart, enabled = visor.online && !restarting) { + Text(stringResource(R.string.fleet_restart)) + } + } + } +} + +/** + * Transports per carrier, not one total. Which types came up is the whole + * diagnosis on a visor that is reachable but unroutable — three dmsg relays + * and no stcpr says something a bare "3" does not — so each type gets its own + * row, with the total under them. + */ +@Composable +private fun TransportRows(visor: VisorSummary) { + val transports = visor.overview.transports + if (transports.isEmpty()) { + InfoRow(label = stringResource(R.string.visor_transports), value = "—") + return + } + Spacer(Modifier.height(4.dp)) + Text( + stringResource(R.string.visor_transports), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + transports + .groupBy { it.type.ifEmpty { "?" } } + .toSortedMap() + .forEach { (type, entries) -> + InfoRow(label = " $type", value = entries.size.toString()) + } + InfoRow( + label = " " + stringResource(R.string.visor_transports_total), + value = transports.size.toString(), + ) + Spacer(Modifier.height(4.dp)) +} + +// --- small shared pieces --- + +/** Name this visor on this phone, or clear the name by emptying the field. */ +@Composable +private fun RenameDialog( + pk: String, + current: String, + onDismiss: () -> Unit, + onSave: (String) -> Unit, +) { + var text by remember { mutableStateOf(current) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.fleet_rename)) }, + text = { + Column { + Text( + stringResource(R.string.fleet_rename_hint, shortPk(pk)), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(12.dp)) + OutlinedTextField( + value = text, + onValueChange = { text = it.take(MAX_NAME) }, + singleLine = true, + label = { Text(stringResource(R.string.fleet_rename_label)) }, + modifier = Modifier.fillMaxWidth(), + ) + } + }, + confirmButton = { + TextButton(onClick = { onSave(text) }) { Text(stringResource(R.string.save)) } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } + }, + ) +} + +@Composable +private fun ConfirmDialog( + title: String, + body: String, + confirm: String, + onDismiss: () -> Unit, + onConfirm: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { Text(body) }, + confirmButton = { TextButton(onClick = onConfirm) { Text(confirm) } }, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } }, + ) +} + +/** + * One word for the visor's health, since the card has one row for it: the + * services check is the one that says whether the visor can reach the + * deployment at all, and it is the only field always populated. + */ +@Composable +private fun healthLabel(visor: VisorSummary): String { + val health = visor.health?.servicesHealth.orEmpty() + return when { + !visor.online -> "—" + health.isEmpty() -> stringResource(R.string.fleet_health_unknown) + else -> health + } +} + +@Composable +private fun healthColor(visor: VisorSummary): Color = when { + !visor.online -> MaterialTheme.colorScheme.onSurfaceVariant + visor.health?.servicesHealth.equals(HEALTHY, ignoreCase = true) -> CONNECTED_GREEN + else -> MaterialTheme.colorScheme.onSurfaceVariant +} + +/** + * Seconds since an RFC 3339 instant, or null if it will not parse. The + * timestamp is stamped by this phone's own visor, not the remote one, so the + * phone's clock is the right thing to measure it against — no skew to worry + * about. Clamped at zero rather than rendering a negative age. + */ +private fun secondsSince(rfc3339: String): Long? = runCatching { + (System.currentTimeMillis() - Instant.parse(rfc3339).toEpochMilli()).coerceAtLeast(0) / 1000 +}.getOrNull() + +private const val HEALTHY = "healthy" + +/** + * How old a snapshot has to be before the card says so. Comfortably past one + * poll interval, so a row that is simply between refreshes stays quiet. + */ +private const val STALE_AFTER_S = 45L + +/** Matches [com.skycoin.skywire.core.VisorNames]'s own cap. */ +private const val MAX_NAME = 40 diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/fleet/FleetViewModel.kt b/android/app/src/main/java/com/skycoin/skywire/ui/fleet/FleetViewModel.kt new file mode 100644 index 0000000000..bd013812aa --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/fleet/FleetViewModel.kt @@ -0,0 +1,255 @@ +package com.skycoin.skywire.ui.fleet + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.skycoin.skywire.R +import com.skycoin.skywire.api.VisorApi +import com.skycoin.skywire.api.VisorSummary +import com.skycoin.skywire.core.AppPreferences +import com.skycoin.skywire.core.CoreServiceState +import com.skycoin.skywire.core.CoreState +import com.skycoin.skywire.core.Fleet +import com.skycoin.skywire.core.SkywireCoreService +import com.skycoin.skywire.core.VisorNames +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** Everything the Fleet screen renders. */ +data class FleetUiState( + val coreState: CoreState = CoreState.Stopped, + /** The local API answered — without it nothing on this screen works. */ + val apiUp: Boolean = false, + /** The opt-in itself, as stored on the phone. */ + val enabled: Boolean = Fleet.DEFAULT, + /** This phone's key — what goes into a remote visor's config. */ + val localPk: String = "", + /** Remote visors only; this phone is filtered out of the API's list. */ + val visors: List = emptyList(), + /** Public key → the name the user gave that visor, on this phone. */ + val names: Map = emptyMap(), + /** True until the first list lands, so an empty list isn't shown as "none". */ + val loading: Boolean = false, + /** Key of the visor whose restart is in flight. */ + val restarting: String? = null, + /** A standing problem with the screen itself — the toggle, or the list. */ + val error: String? = null, + /** + * One-shot feedback for an action, for the snackbar. A restart is fired + * from a card that can be anywhere in a scrolling list, and its outcome + * arrives seconds later — putting that in the card would say it where the + * user is no longer looking, or not at all. + */ + val message: String? = null, +) { + val coreReady: Boolean get() = coreState is CoreState.Running && apiUp + + /** + * The core is between lives — the toggle just restarted it, or it + * crash-looped. Not a state to act in, but not an error either. + */ + val coreCycling: Boolean + get() = coreState is CoreState.Starting || + coreState is CoreState.Stopping || + coreState is CoreState.Restarting || + (coreState is CoreState.Running && !apiUp) +} + +/** + * Drives the Fleet screen: the opt-in toggle, and — once it is on — the list + * of visors that have connected in. + * + * The toggle is the whole feature. It writes one preference, which + * `ConfigManager` turns into `hypervisor.dmsg_ingest` on the next launch, and + * restarts the core, because the visor reads that field once while it builds + * its module graph. Nothing here can be applied live. + * + * Reads only, with one exception: Restart. Everything else the hypervisor API + * could do to these visors is deliberately not wired. + */ +class FleetViewModel(app: Application) : AndroidViewModel(app) { + + private val api = VisorApi.get(app) + private val prefs = AppPreferences(app) + private val visorNames = VisorNames(app) + + private val mutable = MutableStateFlow(FleetUiState()) + val uiState: StateFlow = mutable.asStateFlow() + + private var actionJob: Job? = null + + init { + viewModelScope.launch { + prefs.boolean(Fleet.PREF_KEY, Fleet.DEFAULT).collectLatest { enabled -> + mutable.update { + it.copy(enabled = enabled, visors = if (enabled) it.visors else emptyList()) + } + } + } + viewModelScope.launch { + visorNames.names().collectLatest { names -> + mutable.update { it.copy(names = names) } + } + } + viewModelScope.launch { + CoreServiceState.state.collectLatest { core -> + mutable.update { + it.copy(coreState = core, apiUp = false, visors = emptyList()) + } + if (core !is CoreState.Running) return@collectLatest + while (!api.ping()) delay(PING_INTERVAL_MS) + mutable.update { it.copy(apiUp = true) } + // The key is worth showing whether or not Fleet is on — it is + // what the user has to put into the other visor's config to + // make turning it on mean anything. + runCatching { api.localPk() }.onSuccess { pk -> + mutable.update { it.copy(localPk = pk) } + } + pollVisors() + } + } + } + + /** + * Turn the ingest on or off. Saved first, so the choice survives even when + * the core is down and there is nothing to restart; when it is up, the + * restart is what actually applies it. + */ + fun setEnabled(enabled: Boolean) = action { + prefs.putBoolean(Fleet.PREF_KEY, enabled) + mutable.update { + it.copy( + enabled = enabled, + visors = if (enabled) it.visors else emptyList(), + loading = enabled, + ) + } + if (mutable.value.coreState is CoreState.Stopped || + mutable.value.coreState is CoreState.Failed + ) { + return@action + } + // Detached on purpose: leaving this screen must not cancel a restart + // halfway and leave the phone with no core. + SkywireCoreService.restart(getApplication()) + } + + /** + * Restart one visor. "Sent" is all this can honestly report: the visor + * cannot answer a request that tears down the connection carrying it, so + * the real outcome arrives as the row going offline and coming back. + * [label] is what to call it in that message — its name, or its key. + */ + fun restartVisor(pk: String, label: String) = action { + mutable.update { it.copy(restarting = pk) } + try { + api.restartVisor(pk) + mutable.update { state -> + state.copy( + message = getApplication() + .getString(R.string.fleet_restart_sent, label), + // Marked offline now rather than at the next poll: the + // visor IS going down, and a row still reading "connected" + // right after the tap looks like the button did nothing. + visors = state.visors.map { v -> + if (v.overview.localPk == pk) v.copy(online = false) else v + }, + ) + } + } catch (e: Exception) { + // The server's own words — "currently disconnected (last seen 40s + // ago) — retrying" says more than any wording here could. + mutable.update { it.copy(message = e.message) } + } finally { + mutable.update { it.copy(restarting = null) } + } + } + + /** The snackbar has shown [FleetUiState.message]; do not show it again. */ + fun messageShown() { + mutable.update { it.copy(message = null) } + } + + fun refresh() { + viewModelScope.launch { loadVisors() } + } + + /** + * Name a visor, or clear the name with a blank one. Its own view-model job: + * a rename must not cancel a restart that is in flight, and vice versa. + */ + fun rename(pk: String, name: String) { + viewModelScope.launch { visorNames.setName(pk, name) } + } + + // --- internals --- + + /** Runs until the core-state collector cancels it. */ + private suspend fun pollVisors() { + while (true) { + // Nothing to poll with the ingest off: the list would hold this + // phone alone, which the screen does not show. + if (mutable.value.enabled) loadVisors() + delay(POLL_INTERVAL_MS) + } + } + + private suspend fun loadVisors() { + if (!mutable.value.coreReady) return + mutable.update { it.copy(loading = it.visors.isEmpty()) } + try { + val local = mutable.value.localPk + val visors = api.visorsSummary() + // The API's list opens with the visor serving it — this phone. + // It is not part of anyone's fleet, and it already has a whole + // screen of its own on the Home tab. + .filterNot { it.isHypervisor || it.overview.localPk == local } + mutable.update { it.copy(visors = visors, loading = false, error = null) } + } catch (e: Exception) { + mutable.update { it.copy(loading = false, error = e.message) } + } + } + + /** + * One user action at a time, with its failure surfaced on the screen. + * Launched on the view-model scope rather than inside the state collector, + * which is cancelled the moment the core state changes — which flipping + * the toggle is guaranteed to do. + */ + private fun action(block: suspend () -> Unit) { + actionJob?.cancel() + actionJob = viewModelScope.launch { + mutable.update { it.copy(error = null) } + try { + block() + } catch (e: Exception) { + mutable.update { it.copy(error = e.message) } + } + } + } + + private companion object { + const val PING_INTERVAL_MS = 700L + + /** + * Deliberately slow. Each poll makes the visor fire a Summary RPC to + * every remote over dmsg, and from a phone those routinely take longer + * than the server's own 5-second budget for them. Polling faster than + * they complete stacks calls onto one dmsg stream until it breaks: + * measured on the emulator at a 5-second cadence, the peer cycled + * "summary RPC slow (>5s)" → "connection is shut down" → evicted → + * redialed, roughly once a minute, forever. While it is evicted the + * row still renders (the server keeps serving it from a fresh cache) + * but every action against it answers 503 — so the screen looked fine + * and Restart did not work. Nothing here is second-by-second data. + */ + const val POLL_INTERVAL_MS = 15_000L + } +} diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/home/HomeScreen.kt b/android/app/src/main/java/com/skycoin/skywire/ui/home/HomeScreen.kt new file mode 100644 index 0000000000..21cdfefab6 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/home/HomeScreen.kt @@ -0,0 +1,488 @@ +package com.skycoin.skywire.ui.home + +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +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.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +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.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.lifecycle.viewmodel.compose.viewModel +import com.skycoin.skywire.R +import com.skycoin.skywire.api.VisorSummary +import com.skycoin.skywire.core.CoreState +import com.skycoin.skywire.ui.components.InfoRow +import com.skycoin.skywire.ui.components.PulseRing +import com.skycoin.skywire.ui.components.SectionCard +import com.skycoin.skywire.ui.components.formatUptime +import com.skycoin.skywire.ui.components.shortPk +import com.skycoin.skywire.ui.logs.LogSources +import com.skycoin.skywire.ui.theme.SkyAccents +import com.skycoin.skywire.ui.theme.SkyButtonGradient + +/** + * Home tab: the big Connect control plus the live visor-info card. Connect + * starts the core foreground service; the card appears once the local API + * answers and a session is established. + */ +@Composable +fun HomeScreen( + onOpenLogs: (String) -> Unit, + viewModel: HomeViewModel = viewModel(), +) { + val state by viewModel.uiState.collectAsState() + val context = LocalContext.current + + // The FGS notification needs POST_NOTIFICATIONS on 33+; the service runs + // either way, so Connect proceeds whatever the user answers. + val permissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { viewModel.connect() } + val connect = { + if (Build.VERSION.SDK_INT >= 33 && + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != + PackageManager.PERMISSION_GRANTED + ) { + permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } else { + viewModel.connect() + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(Modifier.height(24.dp)) + StatusLine(state) + Spacer(Modifier.height(20.dp)) + ConnectButton(state, onConnect = connect, onDisconnect = viewModel::disconnect) + Spacer(Modifier.height(16.dp)) + StatusCaption(state, onOpenLogs) + if (state.offerBatteryExemption) { + Spacer(Modifier.height(20.dp)) + BatteryPrompt( + onAllow = viewModel::requestBatteryExemption, + onDismiss = viewModel::dismissBatteryPrompt, + ) + } + state.summary?.let { summary -> + Spacer(Modifier.height(24.dp)) + VisorInfoCard(state, summary, onOpenLogs) + } + Spacer(Modifier.height(24.dp)) + } +} + +/** + * The one place the exemption is put in front of someone who never opens + * Settings. It appears under the Connect button *after* the core is running, + * because that is the first moment the problem it describes is real, and it + * appears once — "Not now" is remembered for good. The same card lives in + * Settings permanently for anyone who changes their mind. + */ +@Composable +private fun BatteryPrompt(onAllow: () -> Unit, onDismiss: () -> Unit) { + SectionCard { + Text( + stringResource(R.string.home_battery_title), + style = MaterialTheme.typography.titleMedium, + ) + Spacer(Modifier.height(6.dp)) + Text( + stringResource(R.string.home_battery_body), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(12.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilledTonalButton(onClick = onAllow) { + Text(stringResource(R.string.settings_battery_allow)) + } + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.settings_battery_not_now)) + } + } + } +} + +@Composable +private fun StatusLine(state: HomeUiState) { + val core = state.coreState + val (label, color) = when { + state.connected -> stringResource(R.string.state_connected) to SkyAccents.success + core is CoreState.Starting || core is CoreState.Running -> + stringResource(R.string.state_starting) to SkyAccents.warning + core is CoreState.Restarting -> + stringResource(R.string.state_restarting, core.nextAttempt) to SkyAccents.warning + core is CoreState.Stopping -> + stringResource(R.string.state_stopping) to MaterialTheme.colorScheme.onSurfaceVariant + core is CoreState.Failed -> + stringResource(R.string.home_error_start) to MaterialTheme.colorScheme.error + else -> + stringResource(R.string.state_disconnected) to MaterialTheme.colorScheme.onSurfaceVariant + } + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + Modifier + .size(10.dp) + .clip(CircleShape) + .background(color), + ) + Spacer(Modifier.width(8.dp)) + Text(label, style = MaterialTheme.typography.titleMedium) + } +} + +@Composable +private fun ConnectButton( + state: HomeUiState, + onConnect: () -> Unit, + onDisconnect: () -> Unit, +) { + val core = state.coreState + // Disabled only for the brief transitional phases. While the core runs + // (even before the API answers) or crash-loops, the button stays live as + // Disconnect — the user must always be able to stop the core. + val busy = core is CoreState.Starting || core is CoreState.Stopping + val showDisconnect = core is CoreState.Running || core is CoreState.Restarting + + // Idle: the brand gradient asking to be pressed. Running: a quiet tonal + // disc — stopping should never look like the loud action — with the + // pulse breathing behind it once the network is really there. + Box(contentAlignment = Alignment.Center) { + if (state.connected) { + PulseRing(size = 180.dp, color = MaterialTheme.colorScheme.primary) + } + Surface( + onClick = { if (showDisconnect) onDisconnect() else onConnect() }, + enabled = !busy, + shape = CircleShape, + color = if (showDisconnect) { + MaterialTheme.colorScheme.secondaryContainer + } else { + Color.Transparent + }, + shadowElevation = if (showDisconnect) 0.dp else 10.dp, + modifier = Modifier.size(180.dp), + ) { + Box( + contentAlignment = Alignment.Center, + modifier = if (showDisconnect) { + Modifier.fillMaxSize() + } else { + Modifier + .fillMaxSize() + .background(SkyButtonGradient) + }, + ) { + val content = if (showDisconnect) { + MaterialTheme.colorScheme.onSecondaryContainer + } else { + Color.White + } + when { + busy -> CircularProgressIndicator( + modifier = Modifier.size(44.dp), + color = content, + strokeWidth = 4.dp, + ) + showDisconnect && !state.connected -> Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + CircularProgressIndicator( + modifier = Modifier.size(28.dp), + color = MaterialTheme.colorScheme.primary, + strokeWidth = 3.dp, + ) + Spacer(Modifier.height(10.dp)) + Text( + stringResource(R.string.disconnect), + style = MaterialTheme.typography.titleMedium, + color = content, + textAlign = TextAlign.Center, + ) + } + else -> Text( + stringResource(if (showDisconnect) R.string.disconnect else R.string.connect), + style = MaterialTheme.typography.titleLarge, + color = content, + textAlign = TextAlign.Center, + ) + } + } + } + } +} + +@Composable +private fun StatusCaption(state: HomeUiState, onOpenLogs: (String) -> Unit) { + val core = state.coreState + val error = state.error + when { + core is CoreState.Failed -> { + Text( + core.message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center, + ) + FilledTonalButton(onClick = { onOpenLogs(LogSources.PROCESS) }) { + Text(stringResource(R.string.view_logs)) + } + } + core is CoreState.Stopped -> + Text( + stringResource(R.string.home_hint_disconnected), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + core is CoreState.Stopping -> Unit + // Startup can take minutes on a slow network (dmsg discovery retries), + // so give the user something to watch instead of a bare spinner. + !state.connected -> { + Text( + stringResource(R.string.home_hint_starting), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + FilledTonalButton(onClick = { onOpenLogs(LogSources.PROCESS) }) { + Text(stringResource(R.string.view_logs)) + } + } + error != null -> + Text( + error, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center, + ) + } +} + +@Composable +private fun VisorInfoCard( + state: HomeUiState, + summary: VisorSummary, + onOpenLogs: (String) -> Unit, +) { + val clipboard = LocalClipboardManager.current + val context = LocalContext.current + val copiedText = stringResource(R.string.copied_to_clipboard) + val pk = summary.overview.localPk + var expanded by rememberSaveable { mutableStateOf(false) } + + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurface, + ), + modifier = Modifier.fillMaxWidth(), + ) { + Column(Modifier.padding(20.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + stringResource(R.string.visor_info_title), + style = MaterialTheme.typography.titleMedium, + ) + FilledTonalButton(onClick = { onOpenLogs(LogSources.CORE) }) { + Text(stringResource(R.string.view_logs)) + } + } + Spacer(Modifier.height(4.dp)) + + InfoRow( + label = stringResource(R.string.visor_public_key), + value = shortPk(pk), + mono = true, + modifier = Modifier.clickable { + clipboard.setText(AnnotatedString(pk)) + Toast.makeText(context, copiedText, Toast.LENGTH_SHORT).show() + }, + ) + InfoRow( + label = stringResource(R.string.visor_version), + value = listOfNotNull( + summary.overview.buildInfo?.version?.takeIf { it.isNotEmpty() }, + summary.buildTag.takeIf { it.isNotEmpty() }, + ).joinToString(" · ").ifEmpty { "—" }, + ) + InfoRow( + label = stringResource(R.string.visor_uptime), + value = formatUptime(summary.uptime), + ) + // Everything above is what someone checks at a glance — am I who I + // think I am, on what build, for how long. What follows is + // diagnostics: real when you need it, and a wall of keys and health + // rows when you don't. Collapsed by default, one tap away. + if (expanded) { + SectionDivider() + Text( + stringResource(R.string.visor_transports), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(4.dp)) + val transports = summary.overview.transports + if (transports.isEmpty()) { + Text("—", style = MaterialTheme.typography.bodyMedium) + } else { + // Grouped by carrier rather than listed one per remote: on + // a phone the interesting fact is WHICH carriers came up + // (dmsg always, stcpr/sudph almost never behind carrier + // NAT), and a bare total said nothing about that. + transports + .groupBy { it.type.ifEmpty { "?" } } + .toSortedMap() + .forEach { (type, entries) -> + InfoRow( + label = type, + value = entries.size.toString(), + ) + } + InfoRow( + label = stringResource(R.string.visor_transports_total), + value = transports.size.toString(), + ) + } + + SectionDivider() + Text( + stringResource(R.string.visor_dmsg_servers), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(4.dp)) + val dmsgServers = summary.dmsgServers + if (dmsgServers.isEmpty()) { + Text("—", style = MaterialTheme.typography.bodyMedium) + } else { + dmsgServers.take(MAX_DMSG_ROWS).forEach { server -> + // How the session is carried, always — plus the latency + // when there is one. The visor measures that with a + // self-ping through each server at startup and then + // hourly, so a server that joined since the last pass + // (or whose ping did not come back) has none, and every + // row would otherwise read a bare, false "0 ms". + InfoRow( + label = shortPk(server.pk), + value = listOfNotNull( + server.protocol.ifEmpty { server.carrier } + .takeIf { it.isNotEmpty() }, + server.latencyNs.takeIf { it > 0 } + ?.let { "${it / 1_000_000} ms" }, + ).joinToString(" · ").ifEmpty { "—" }, + mono = true, + ) + } + } + + SectionDivider() + Text( + stringResource(R.string.visor_service_health), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(4.dp)) + if (state.serviceHealth.isEmpty()) { + Text("—", style = MaterialTheme.typography.bodyMedium) + } else { + state.serviceHealth.forEach { entry -> + InfoRow( + label = entry.name, + value = entry.status.ifEmpty { entry.error.ifEmpty { "?" } }, + valueColor = if (entry.status.equals("healthy", ignoreCase = true)) { + SkyAccents.success + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + } + + Spacer(Modifier.height(4.dp)) + TextButton( + onClick = { expanded = !expanded }, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) { + Text( + stringResource( + if (expanded) R.string.visor_show_less else R.string.visor_show_more, + ), + ) + Icon( + imageVector = if (expanded) Icons.Default.ExpandLess + else Icons.Default.ExpandMore, + contentDescription = null, + ) + } + } + } +} + +@Composable +private fun SectionDivider() { + HorizontalDivider( + modifier = Modifier.padding(vertical = 10.dp), + color = MaterialTheme.colorScheme.surfaceContainerHighest, + ) +} + +/** The visor keeps a handful of dmsg sessions; the card shows the first few. */ +private const val MAX_DMSG_ROWS = 4 diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/home/HomeViewModel.kt b/android/app/src/main/java/com/skycoin/skywire/ui/home/HomeViewModel.kt new file mode 100644 index 0000000000..5911a92275 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/home/HomeViewModel.kt @@ -0,0 +1,187 @@ +package com.skycoin.skywire.ui.home + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.skycoin.skywire.api.AuthFailedException +import com.skycoin.skywire.api.ServiceHealthEntry +import com.skycoin.skywire.api.VisorApi +import com.skycoin.skywire.api.VisorSummary +import com.skycoin.skywire.core.AppPreferences +import com.skycoin.skywire.core.AppVisibility +import com.skycoin.skywire.core.BatteryOptimization +import com.skycoin.skywire.core.ConfigManager +import com.skycoin.skywire.core.CoreServiceState +import com.skycoin.skywire.core.CoreState +import com.skycoin.skywire.core.SecretStore +import com.skycoin.skywire.core.SkywireCoreService +import com.skycoin.skywire.core.SkywirePaths +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +/** Everything the Home tab renders. */ +data class HomeUiState( + val coreState: CoreState = CoreState.Stopped, + /** The local API answered /api/ping. */ + val apiUp: Boolean = false, + val summary: VisorSummary? = null, + val serviceHealth: List = emptyList(), + val error: String? = null, + /** Doze will pause this app's network; the user has not been asked yet. */ + val offerBatteryExemption: Boolean = false, +) { + val connected: Boolean get() = coreState is CoreState.Running && apiUp +} + +/** + * Drives Connect/Disconnect and, while the core runs, polls the local API + * for the visor-info card. Polling is tied to the core state: the collector + * restarts (and the card clears) whenever the service reports a new phase. + */ +class HomeViewModel(app: Application) : AndroidViewModel(app) { + + private val api = VisorApi.get(app) + private val prefs = AppPreferences(app) + private val live = MutableStateFlow(LiveData()) + private var authResetAttempted = false + + /** + * Whether to offer the battery-optimisation exemption on this screen. + * Re-evaluated on every return to the foreground, because the answer is + * held by the system and can be changed in a screen that is not ours. + */ + private val batteryOffer = MutableStateFlow(false) + + private data class LiveData( + val apiUp: Boolean = false, + val summary: VisorSummary? = null, + val serviceHealth: List = emptyList(), + val error: String? = null, + ) + + val uiState: StateFlow = + combine( + CoreServiceState.state, + live.asStateFlow(), + batteryOffer.asStateFlow(), + ) { core, data, offerBattery -> + HomeUiState( + coreState = core, + apiUp = data.apiUp, + summary = data.summary, + serviceHealth = data.serviceHealth, + error = data.error, + // Only once the core is actually up: before that the question + // is about a background problem the user has not got yet. + offerBatteryExemption = offerBattery && core is CoreState.Running, + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), HomeUiState()) + + init { + // The exemption is granted in a system screen and the prompt can be + // silenced from Settings, so neither answer is ours to cache. Both are + // re-read on every resume — the system's grant dialog covers the + // Activity without stopping it, so the foreground flag alone would + // miss the moment the answer changed. + viewModelScope.launch { + combine( + AppVisibility.isForeground, + AppVisibility.resumes, + prefs.boolean(BatteryOptimization.PREF_DISMISSED, false), + ) { foreground, _, dismissed -> foreground && !dismissed } + .collectLatest { askable -> + batteryOffer.value = + askable && !BatteryOptimization.isExempt(getApplication()) + } + } + viewModelScope.launch { + CoreServiceState.state.collectLatest { core -> + live.value = LiveData() + if (core is CoreState.Running) pollWhileRunning() + } + } + } + + /** "Not now" on the Home prompt — silences it here and in Settings. */ + fun dismissBatteryPrompt() { + viewModelScope.launch { prefs.putBoolean(BatteryOptimization.PREF_DISMISSED, true) } + } + + fun requestBatteryExemption() { + BatteryOptimization.openRequest(getApplication()) + } + + fun connect() { + live.value = LiveData() + SkywireCoreService.start(getApplication()) + } + + fun disconnect() { + SkywireCoreService.stop(getApplication()) + } + + /** + * Runs until the collector above cancels it (core left Running). Session + * bootstrap is implicit: the API client re-logins on any 401, so a + * transient failure here just retries on the next tick instead of + * disabling the card for the rest of the run. + */ + private suspend fun pollWhileRunning() { + while (!api.ping()) delay(PING_INTERVAL_MS) + live.value = live.value.copy(apiUp = true) + while (true) { + try { + // The dmsg-server list rides along in the summary + // (`dmsg_servers`) — see DmsgServerInfo for why the + // dedicated /api/dmsg route is not the source here. + val summary = api.summary() + val health = runCatching { api.serviceHealth() }.getOrDefault(emptyList()) + live.value = LiveData( + apiUp = true, + summary = summary, + serviceHealth = health, + ) + } catch (e: AuthFailedException) { + launchAuthRecovery(e) + return + } catch (e: Exception) { + live.value = live.value.copy(error = e.message) + } + delay(REFRESH_INTERVAL_MS) + } + } + + /** + * The stored password no longer opens the visor's account DB (device + * keystore rotated under a surviving data dir). Recovery: stop the core, + * drop users.db, start again — the bootstrap then re-creates the account + * with the current password. Tried once per process. + * + * The restart runs on the service's own process-scoped job, so stopping + * the core — which cancels [pollWhileRunning] and everything called from + * it — cannot strand the phone between the stop and the start. + */ + private fun launchAuthRecovery(cause: AuthFailedException) { + if (authResetAttempted) { + live.value = live.value.copy(error = cause.message) + return + } + authResetAttempted = true + val app = getApplication() + SkywireCoreService.restart(app) { + ConfigManager(SkywirePaths(app), SecretStore(app)).deleteUsersDb() + } + } + + private companion object { + const val PING_INTERVAL_MS = 700L + const val REFRESH_INTERVAL_MS = 4_000L + } +} diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/hub/HubScreen.kt b/android/app/src/main/java/com/skycoin/skywire/ui/hub/HubScreen.kt new file mode 100644 index 0000000000..78f8ae507d --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/hub/HubScreen.kt @@ -0,0 +1,753 @@ +package com.skycoin.skywire.ui.hub + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.AccountBalanceWallet +import androidx.compose.material.icons.rounded.AltRoute +import androidx.compose.material.icons.rounded.CandlestickChart +import androidx.compose.material.icons.rounded.ChevronRight +import androidx.compose.material.icons.rounded.Forum +import androidx.compose.material.icons.rounded.Hub +import androidx.compose.material.icons.rounded.Route +import androidx.compose.material.icons.rounded.Videocam +import androidx.compose.material.icons.rounded.VpnLock +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +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.drawBehind +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.skycoin.skywire.R +import com.skycoin.skywire.api.AppState +import com.skycoin.skywire.core.SkychatProfile +import com.skycoin.skywire.core.SkydexProfile +import com.skycoin.skywire.ui.components.HelpTopic +import com.skycoin.skywire.ui.components.SkyTopBar +import com.skycoin.skywire.ui.components.countryText +import com.skycoin.skywire.ui.components.formatBytes +import com.skycoin.skywire.ui.components.formatDuration +import com.skycoin.skywire.ui.navigation.Routes +import com.skycoin.skywire.ui.socks.SocksArgs +import com.skycoin.skywire.ui.theme.SkyAccents +import com.skycoin.skywire.ui.theme.SkyHeroGradient +import com.skycoin.skywire.ui.vpn.VpnStatus + +/** + * The apps hub behind the raised cloud: category chips, a live SkyVPN hero + * card while the tunnel is up (or on its way up), and a grid of app cards + * whose status dots come from the visor's own app list. + * + * At most ONE greyed "coming soon" card at a time — currently SkyMeet. A hub + * with several of them reads as an unfinished app; a single one reads as the + * next thing being built. The rest arrive as they ship. + */ +private enum class HubCategory { NETWORK, FINANCE, SOCIAL } + +private data class HubTile( + val name: String, + val subtitle: String, + val icon: ImageVector, + val category: HubCategory, + /** Visor process whose status drives the card's dot; null = no dot. */ + val statusApp: String? = null, + /** A count worth interrupting for — SkyChat's unread messages. */ + val badgeCount: Int? = null, + val wide: Boolean = false, + val comingSoon: Boolean = false, + val onClick: (() -> Unit)? = null, +) + +@Composable +fun HubScreen( + onBack: () -> Unit, + onOpenRoute: (String) -> Unit, + onOpenTab: (String) -> Unit, + viewModel: HubViewModel = viewModel(), +) { + val state by viewModel.uiState.collectAsState() + var filter by rememberSaveable { mutableStateOf(null) } + + // SkyVPN is not in this list: it is the hero card, on or off. + val tiles = buildList { + add( + HubTile( + name = stringResource(R.string.app_skysocks), + subtitle = stringResource(R.string.hub_socks_sub), + icon = Icons.Rounded.AltRoute, + category = HubCategory.NETWORK, + statusApp = SocksArgs.APP, + onClick = { onOpenRoute(Routes.SOCKS) }, + ), + ) + add( + HubTile( + name = stringResource(R.string.app_skydex), + subtitle = stringResource(R.string.hub_dex_sub), + icon = Icons.Rounded.CandlestickChart, + category = HubCategory.FINANCE, + statusApp = SkydexProfile.APP, + onClick = { onOpenRoute(Routes.DEX) }, + ), + ) + add( + HubTile( + name = stringResource(R.string.app_skychat), + subtitle = stringResource(R.string.hub_chat_sub), + icon = Icons.Rounded.Forum, + category = HubCategory.SOCIAL, + statusApp = SkychatProfile.APP, + badgeCount = state.unreadMessages.takeIf { it > 0 }, + // Same destination as the Chat tab (one screen, two entries). + onClick = { onOpenTab(Routes.CHAT) }, + ), + ) + add( + HubTile( + name = stringResource(R.string.app_wallet), + // The one number a wallet is for, when there is one to show. + subtitle = state.skyBalance + ?.let { stringResource(R.string.hub_wallet_balance, it) } + ?: stringResource(R.string.hub_wallet_sub), + icon = Icons.Rounded.AccountBalanceWallet, + category = HubCategory.FINANCE, + onClick = { onOpenTab(Routes.WALLET) }, + ), + ) + add( + HubTile( + name = stringResource(R.string.app_fleet), + // With the ingest on, the subtitle is the fleet's headcount. + subtitle = state.fleetOnline + ?.takeIf { state.fleetEnabled } + ?.let { pluralStringResource(R.plurals.hub_fleet_connected, it, it) } + ?: stringResource(R.string.hub_fleet_sub), + icon = Icons.Rounded.Hub, + category = HubCategory.NETWORK, + wide = true, + onClick = { onOpenRoute(Routes.FLEET) }, + ), + ) + add( + HubTile( + name = stringResource(R.string.app_skymeet), + subtitle = stringResource(R.string.hub_meet_sub), + icon = Icons.Rounded.Videocam, + category = HubCategory.SOCIAL, + wide = true, + comingSoon = true, + ), + ) + } + // +1: SkyVPN, which lives in the hero card rather than the grid. + val installed = tiles.count { !it.comingSoon } + 1 + val shown = tiles.filter { filter == null || it.category == filter } + + Scaffold( + topBar = { + SkyTopBar( + title = stringResource(R.string.tab_hub_description), + subtitle = stringResource(R.string.hub_subtitle, installed, state.runningCount), + onBack = onBack, + help = HelpTopic(R.string.help_hub_title, R.string.help_hub_body), + ) + }, + ) { padding -> + LazyVerticalGrid( + columns = GridCells.Fixed(2), + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(11.dp), + horizontalArrangement = Arrangement.spacedBy(11.dp), + ) { + item(span = { GridItemSpan(maxLineSpan) }) { + CategoryChips(filter, onSelect = { filter = it }) + } + if (filter == null || filter == HubCategory.NETWORK) { + item(span = { GridItemSpan(maxLineSpan) }) { + VpnHeroCard( + state = state, + onOpen = { onOpenRoute(Routes.VPN) }, + // Turning on needs an exit and the system consent; + // when the hub has neither, the SkyVPN screen does. + onTurnOn = { if (!viewModel.startVpn()) onOpenRoute(Routes.VPN) }, + onTurnOff = viewModel::stopVpn, + ) + } + } + item(span = { GridItemSpan(maxLineSpan) }) { + Text( + text = stringResource(R.string.hub_section_apps), + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(start = 2.dp, top = 8.dp), + ) + } + items( + items = shown, + span = { tile -> GridItemSpan(if (tile.wide) maxLineSpan else 1) }, + ) { tile -> + if (tile.comingSoon) { + ComingSoonCard(tile) + } else { + AppCard(tile, status = tile.statusApp?.let { state.apps[it] }) + } + } + } + } +} + +@Composable +private fun CategoryChips(filter: HubCategory?, onSelect: (HubCategory?) -> Unit) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(vertical = 2.dp), + ) { + CategoryChip(stringResource(R.string.hub_filter_all), filter == null) { onSelect(null) } + CategoryChip(stringResource(R.string.hub_filter_network), filter == HubCategory.NETWORK) { + onSelect(HubCategory.NETWORK) + } + CategoryChip(stringResource(R.string.hub_filter_finance), filter == HubCategory.FINANCE) { + onSelect(HubCategory.FINANCE) + } + CategoryChip(stringResource(R.string.hub_filter_social), filter == HubCategory.SOCIAL) { + onSelect(HubCategory.SOCIAL) + } + } +} + +@Composable +private fun CategoryChip(label: String, selected: Boolean, onClick: () -> Unit) { + Surface( + onClick = onClick, + shape = MaterialTheme.shapes.small, + color = if (selected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.surfaceContainerLowest + }, + border = if (selected) { + null + } else { + BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) + }, + shadowElevation = if (selected) 4.dp else 0.dp, + ) { + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + color = if (selected) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.padding(horizontal = 15.dp, vertical = 8.dp), + ) + } +} + +/** + * SkyVPN's card, always at hero size, on or off: status and session length, + * the exit it points at — flag and country by name — live rates while it + * carries, and the switch. The card body opens the SkyVPN screen. The switch + * turns the tunnel off directly; turning it *on* works from here only when + * nothing needs a screen (exit chosen, consent granted) — otherwise the + * SkyVPN screen takes over. + */ +@Composable +private fun VpnHeroCard( + state: HubUiState, + onOpen: () -> Unit, + onTurnOn: () -> Unit, + onTurnOff: () -> Unit, +) { + val vpn = state.vpn + val on = state.vpnOn + val starting = on && ( + vpn?.status == AppState.STATUS_STARTING || + vpn?.detailedStatus == VpnStatus.CONNECTING + ) + val reconnecting = on && vpn?.detailedStatus == VpnStatus.RECONNECTING + + val statusText = when { + !on -> stringResource(R.string.state_disconnected) + starting -> stringResource(R.string.hub_hero_starting) + reconnecting -> stringResource(R.string.hub_hero_reconnecting) + else -> { + val duration = state.vpnConnection?.connectionSeconds + ?.takeIf { it > 0 }?.let { " · " + formatDuration(it) }.orEmpty() + stringResource(R.string.state_connected) + duration + } + } + val statusDot = when { + !on -> Color.White.copy(alpha = 0.45f) + starting || reconnecting -> SkyAccents.warning + else -> SkyAccents.successBright + } + + // The exit as flag, country name and enough of the key to tell two + // exits in one country apart. The exit's own IP is not knowable from + // this phone — the handshake carries a public key, a TUN IP and a + // gateway, no public address in either direction (NetworkAddressCard + // explains) — so the country is the "where" and the prefix the "which". + val exit = state.vpnExitPk?.let { pk -> + listOfNotNull( + state.vpnCountry?.let(::countryText), + pk.take(EXIT_PK_PREFIX), + ).joinToString(" · ") + } ?: stringResource(R.string.hub_hero_no_exit) + + Surface( + onClick = onOpen, + shape = MaterialTheme.shapes.large, + color = Color.Transparent, + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier + .background(SkyHeroGradient) + .drawBehind { + // The design's two soft discs in the card's corner. + drawCircle( + color = Color.White.copy(alpha = 0.10f), + radius = 85.dp.toPx(), + center = Offset(size.width + 15.dp.toPx(), (-15).dp.toPx()), + ) + drawCircle( + color = Color.White.copy(alpha = 0.07f), + radius = 60.dp.toPx(), + center = Offset(size.width - 44.dp.toPx(), size.height + 30.dp.toPx()), + ) + } + .padding(18.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(48.dp) + .clip(MaterialTheme.shapes.medium) + .background(Color.White.copy(alpha = 0.18f)), + ) { + Icon( + Icons.Rounded.VpnLock, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(26.dp), + ) + } + Spacer(Modifier.width(13.dp)) + Column(Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + Modifier + .size(7.dp) + .background(statusDot, CircleShape), + ) + Spacer(Modifier.width(7.dp)) + Text( + text = statusText.uppercase(), + style = MaterialTheme.typography.labelSmall.copy(letterSpacing = 1.2.sp), + color = Color.White.copy(alpha = 0.75f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Text( + text = stringResource(R.string.app_skyvpn), + style = MaterialTheme.typography.titleMedium, + color = Color.White, + ) + Text( + text = exit, + style = MaterialTheme.typography.bodySmall, + color = Color.White.copy(alpha = 0.8f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Spacer(Modifier.width(8.dp)) + val toggleLabel = stringResource(R.string.hub_vpn_toggle) + Switch( + checked = on, + onCheckedChange = { checked -> if (checked) onTurnOn() else onTurnOff() }, + enabled = !state.vpnBusy, + colors = SwitchDefaults.colors( + checkedThumbColor = Color.White, + checkedTrackColor = SkyAccents.successBright.copy(alpha = 0.9f), + uncheckedThumbColor = Color.White, + uncheckedTrackColor = Color.White.copy(alpha = 0.28f), + uncheckedBorderColor = Color.Transparent, + ), + modifier = Modifier.semantics { contentDescription = toggleLabel }, + ) + } + // The stats row is part of the card's shape, not of its state: + // the card never changes size, the numbers become — when there + // is nothing to count. + // + // Down/Up come from HubViewModel's own sampling of the byte + // counters, not from the connection's speed fields, which the + // visor only fills from a route-group ping exchange and which + // stay at zero on a phone. + val conn = state.vpnConnection + val rates = state.vpnRates + Spacer(Modifier.height(15.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + HeroStat( + label = stringResource(R.string.hub_stat_down), + value = rates?.let { formatBytes(it.downBytesPerSec) + "/s" } ?: "—", + modifier = Modifier.weight(1f), + ) + HeroStat( + label = stringResource(R.string.hub_stat_up), + value = rates?.let { formatBytes(it.upBytesPerSec) + "/s" } ?: "—", + modifier = Modifier.weight(1f), + ) + HeroStat( + label = stringResource(R.string.hub_stat_data), + value = conn?.let { formatBytes(it.bandwidthSent + it.bandwidthReceived) } + ?: "—", + modifier = Modifier.weight(1f), + ) + } + + // Two facts that hold whether or not the tunnel is up, so they + // sit outside the stats row: how many hops the visor dials + // through, and whether the killswitch would catch a drop. + Spacer(Modifier.height(10.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + HeroChip( + icon = Icons.Rounded.Route, + text = if (state.minHops > 0) { + pluralStringResource(R.plurals.hub_hops, state.minHops, state.minHops) + } else { + stringResource(R.string.hub_hops_unknown) + }, + ) + KillswitchChip(on = state.killswitch) + } + } + } +} + +/** + * The killswitch spelled out, color-coded: green words when a dropped + * tunnel would be caught, red when it would not. A glyph alone read as + * decoration here — the state is worth a sentence fragment. + */ +@Composable +private fun KillswitchChip(on: Boolean) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .clip(MaterialTheme.shapes.small) + .background(Color.White.copy(alpha = 0.13f)) + .padding(horizontal = 10.dp, vertical = 6.dp), + ) { + Text( + text = stringResource( + if (on) R.string.hub_killswitch_on else R.string.hub_killswitch_off, + ), + style = MaterialTheme.typography.labelMedium, + color = if (on) SkyAccents.successBright else SkyAccents.dangerBright, + maxLines = 1, + ) + } +} + +/** A small fact on the hero's gradient: an icon and a word. */ +@Composable +private fun HeroChip(icon: ImageVector, text: String) { + val tint = Color.White.copy(alpha = 0.95f) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clip(MaterialTheme.shapes.small) + .background(Color.White.copy(alpha = 0.13f)) + .padding(horizontal = 10.dp, vertical = 6.dp), + ) { + Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(15.dp)) + Spacer(Modifier.width(6.dp)) + Text( + text = text, + style = MaterialTheme.typography.labelMedium, + color = tint, + maxLines = 1, + ) + } +} + +@Composable +private fun HeroStat(label: String, value: String, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .clip(MaterialTheme.shapes.medium) + .background(Color.White.copy(alpha = 0.13f)) + .padding(horizontal = 12.dp, vertical = 9.dp), + ) { + Text( + text = label.uppercase(), + style = MaterialTheme.typography.labelSmall.copy(letterSpacing = 0.8.sp), + color = Color.White.copy(alpha = 0.72f), + ) + Text( + text = value, + style = MaterialTheme.typography.titleMedium, + color = Color.White, + maxLines = 1, + ) + } +} + +@Composable +private fun AppCard(tile: HubTile, status: AppState?) { + Surface( + onClick = tile.onClick ?: {}, + enabled = tile.onClick != null, + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surfaceVariant, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + modifier = Modifier.fillMaxWidth(), + ) { + if (tile.wide) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(14.dp), + ) { + IconTile(tile.icon) + Spacer(Modifier.width(13.dp)) + Column(Modifier.weight(1f)) { + Text(tile.name, style = MaterialTheme.typography.titleMedium) + CardSubtitle(tile.subtitle) + } + Icon( + Icons.Rounded.ChevronRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.outline, + modifier = Modifier.size(20.dp), + ) + } + } else { + Column(modifier = Modifier.padding(14.dp)) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + modifier = Modifier.fillMaxWidth(), + ) { + IconTile(tile.icon) + Row(verticalAlignment = Alignment.CenterVertically) { + tile.badgeCount?.let { + CountBadge(it) + Spacer(Modifier.width(6.dp)) + } + if (tile.statusApp != null) StatusDot(status) + } + } + Spacer(Modifier.height(9.dp)) + Text( + text = tile.name, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + CardSubtitle(tile.subtitle) + } + } + } +} + +/** + * The single greyed card: dashed border, muted throughout, and the one + * "coming soon" badge in the app. + */ +@Composable +private fun ComingSoonCard(tile: HubTile) { + val border = MaterialTheme.colorScheme.outlineVariant + val corner = 22.dp + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clip(MaterialTheme.shapes.large) + .background(MaterialTheme.colorScheme.surfaceContainerLow) + .drawBehind { + drawRoundRect( + color = border, + style = Stroke( + width = 1.5.dp.toPx(), + pathEffect = PathEffect.dashPathEffect(floatArrayOf(12f, 9f)), + ), + cornerRadius = CornerRadius(corner.toPx()), + ) + } + .padding(14.dp), + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(44.dp) + .clip(MaterialTheme.shapes.medium) + .background(MaterialTheme.colorScheme.surfaceContainerHigh), + ) { + Icon( + tile.icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.outline, + modifier = Modifier.size(25.dp), + ) + } + Spacer(Modifier.width(13.dp)) + Column(Modifier.weight(1f)) { + Text( + text = tile.name, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + CardSubtitle(tile.subtitle) + } + Text( + text = stringResource(R.string.coming_soon).uppercase(), + style = MaterialTheme.typography.labelSmall.copy(letterSpacing = 0.5.sp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .clip(MaterialTheme.shapes.extraSmall) + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + .padding(horizontal = 10.dp, vertical = 6.dp), + ) + } +} + +/** 44dp icon plate: the app's mark on the palest blue in the palette. */ +@Composable +private fun IconTile(icon: ImageVector) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(44.dp) + .clip(MaterialTheme.shapes.medium) + .background(MaterialTheme.colorScheme.primaryContainer), + ) { + Icon( + icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(25.dp), + ) + } +} + +/** Unread messages on the SkyChat tile: a filled pill beside the dot. */ +@Composable +private fun CountBadge(count: Int) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary) + .padding(horizontal = 7.dp, vertical = 2.dp), + ) { + Text( + text = if (count > MAX_BADGE_COUNT) "$MAX_BADGE_COUNT+" else count.toString(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimary, + ) + } +} + +/** + * The card's status dot with a soft halo: green running, amber starting, + * error red errored, and the border grey of "not running" otherwise — + * including whenever the core itself is down and no status exists. + */ +@Composable +private fun StatusDot(status: AppState?) { + val color = when { + status == null -> MaterialTheme.colorScheme.outlineVariant + status.running -> SkyAccents.success + status.status == AppState.STATUS_STARTING -> SkyAccents.warning + status.status == AppState.STATUS_ERRORED -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.outlineVariant + } + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(16.dp) + .background(color.copy(alpha = 0.18f), CircleShape), + ) { + Box( + Modifier + .size(8.dp) + .background(color, CircleShape), + ) + } +} + +@Composable +private fun CardSubtitle(text: String) { + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 2.dp), + ) +} + +/** Enough of an exit key to tell two exits in one country apart. */ +private const val EXIT_PK_PREFIX = 6 + +/** Past this the badge reads "99+" — the message is "a lot", not a number. */ +private const val MAX_BADGE_COUNT = 99 diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/hub/HubViewModel.kt b/android/app/src/main/java/com/skycoin/skywire/ui/hub/HubViewModel.kt new file mode 100644 index 0000000000..71b19eae0c --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/hub/HubViewModel.kt @@ -0,0 +1,314 @@ +package com.skycoin.skywire.ui.hub + +import android.app.Application +import android.net.VpnService +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.skycoin.skywire.api.AppConnection +import com.skycoin.skywire.api.AppState +import com.skycoin.skywire.api.Overview +import com.skycoin.skywire.api.SkychatApi +import com.skycoin.skywire.api.VisorApi +import com.skycoin.skywire.core.AppPreferences +import com.skycoin.skywire.core.CoreServiceState +import com.skycoin.skywire.core.CoreState +import com.skycoin.skywire.core.Fleet +import com.skycoin.skywire.core.SkyVpnService +import com.skycoin.skywire.core.SkychatProfile +import com.skycoin.skywire.ui.components.RateSampler +import com.skycoin.skywire.ui.components.SavedServer +import com.skycoin.skywire.ui.vpn.VpnArgs +import com.skycoin.skywire.wallet.CoinSpec +import com.skycoin.skywire.wallet.WalletRepository +import com.skycoin.wallet.Amounts +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json + +/** Everything the apps hub renders that is alive rather than declared. */ +data class HubUiState( + val coreState: CoreState = CoreState.Stopped, + /** The local API answered — before that every status below is unknown. */ + val apiUp: Boolean = false, + /** The visor's client apps by process name, from the local summary. */ + val apps: Map = emptyMap(), + /** SkyVPN's live connection while it runs — the byte totals ride on it. */ + val vpnConnection: AppConnection? = null, + /** + * Measured here from the connection's byte counters rather than read off + * the visor's own speed fields, which never leave zero — see [RateSampler]. + * Null until two samples exist. + */ + val vpnRates: RateSampler.Rates? = null, + /** The exit SkyVPN points at (or last pointed at), if any. */ + val vpnExitPk: String? = null, + /** [vpnExitPk]'s country code, when the phone knows it. */ + val vpnCountry: String? = null, + /** Last summary overview — the device's own address comes off this. */ + val overview: Overview? = null, + /** The phone's killswitch preference — armed or not. */ + val killswitch: Boolean = false, + /** Minimum route hops the visor is dialling with; 1 allows direct. */ + val minHops: Int = 0, + /** The hero card's toggle is mid-flight. */ + val vpnBusy: Boolean = false, + /** Messages waiting in SkyChat, as skychat itself counts them. */ + val unreadMessages: Int = 0, + /** The active SKY wallet's cached balance, formatted; null = no wallet. */ + val skyBalance: String? = null, + /** The Fleet opt-in, so the tile says nothing extra while it is off. */ + val fleetEnabled: Boolean = false, + /** Remote visors currently connected in; null until the first count. */ + val fleetOnline: Int? = null, +) { + val coreReady: Boolean get() = coreState is CoreState.Running && apiUp + + val runningCount: Int get() = apps.values.count { it.running } + + val vpn: AppState? get() = apps[VpnArgs.APP] + + /** The hero's switch state: the tunnel is up or on its way up. */ + val vpnOn: Boolean + get() = vpn?.let { it.running || it.status == AppState.STATUS_STARTING } == true +} + +/** + * Feeds the apps hub: one local-summary poll for every app's status (the + * subtitle's "· N running" and the tiles' dots), plus SkyVPN's connection + * while it runs, for the hero card's rates. All local API — nothing here + * crosses dmsg, so the poll is cheap. + */ +class HubViewModel(app: Application) : AndroidViewModel(app) { + + private val api = VisorApi.get(app) + private val prefs = AppPreferences(app) + private val skychat = SkychatApi.get(app) + private val wallet = WalletRepository.get(app) + private val json = Json { ignoreUnknownKeys = true } + + private val mutable = MutableStateFlow(HubUiState()) + val uiState: StateFlow = mutable.asStateFlow() + + /** Last exit the SkyVPN screen saved — country label and re-dial ride on it. */ + private var lastServer: SavedServer? = null + + private val rates = RateSampler() + + init { + // The killswitch is the phone's preference, so it reads the same with + // the core down — which is exactly when someone checks whether they + // are still protected. + viewModelScope.launch { + prefs.boolean(VpnArgs.PREF_KILLSWITCH).collect { on -> + mutable.update { it.copy(killswitch = on) } + } + } + viewModelScope.launch { + prefs.boolean(Fleet.PREF_KEY, Fleet.DEFAULT).collect { on -> + mutable.update { + it.copy(fleetEnabled = on, fleetOnline = if (on) it.fleetOnline else null) + } + } + } + viewModelScope.launch { + lastServer = prefs.string(VpnArgs.PREF_LAST_SERVER).first()?.let { stored -> + runCatching { json.decodeFromString(SavedServer.serializer(), stored) }.getOrNull() + } + mutable.update { withExit(it) } + CoreServiceState.state.collectLatest { core -> + rates.reset() + mutable.update { + withExit( + it.copy( + coreState = core, + apiUp = false, + apps = emptyMap(), + vpnConnection = null, + vpnRates = null, + ), + ) + } + if (core is CoreState.Running) { + while (!api.ping()) delay(PING_INTERVAL_MS) + mutable.update { it.copy(apiUp = true) } + // Visor-wide and rarely changed, so it is read once per + // core lifetime rather than on every poll. + runCatching { api.routerSettings().minHops }.getOrNull()?.let { hops -> + mutable.update { it.copy(minHops = hops) } + } + poll() + } + } + } + } + + /** + * Turn SkyVPN on from the hero card — but only when nothing about it + * needs a screen: the system consent already granted, an exit already + * chosen. Returns false otherwise, and the caller opens the SkyVPN + * screen, which owns both of those conversations. + */ + fun startVpn(): Boolean { + val state = mutable.value + if (!state.coreReady || state.vpnBusy) return false + val pk = state.vpnExitPk ?: return false + if (VpnService.prepare(getApplication()) != null) return false + + viewModelScope.launch { + mutable.update { it.copy(vpnBusy = true) } + runCatching { + val killswitch = prefs.boolean(VpnArgs.PREF_KILLSWITCH).first() + // The service first: vpn-client asks it for a descriptor + // mid-handshake and must find it listening. Then the + // unconditional stop — a stale proc would race the new one. + SkyVpnService.start(getApplication(), killswitch) + runCatching { api.updateApp(VpnArgs.APP, status = VisorApi.APP_STOP) } + val updated = api.updateApp( + VpnArgs.APP, + pk = pk, + killswitch = killswitch, + status = VisorApi.APP_START, + ) + mutable.update { withExit(it.copy(apps = it.apps + (VpnArgs.APP to updated))) } + } + mutable.update { it.copy(vpnBusy = false) } + } + return true + } + + /** + * Turn SkyVPN off from the hero card. The mirror of the SkyVPN screen's + * own disconnect: stop the app, then take the interface away — deliberate, + * so the killswitch does not trap the phone afterwards. + */ + fun stopVpn() { + viewModelScope.launch { + mutable.update { it.copy(vpnBusy = true) } + val stopped = runCatching { + api.updateApp(VpnArgs.APP, status = VisorApi.APP_STOP) + }.getOrNull() + SkyVpnService.stop(getApplication()) + mutable.update { state -> + withExit( + state.copy( + vpnBusy = false, + vpnConnection = null, + apps = stopped?.let { state.apps + (VpnArgs.APP to it) } ?: state.apps, + ), + ) + } + } + } + + /** Runs until the core-state collector cancels it. */ + private suspend fun poll() { + // Counting the fleet fires a Summary RPC to every remote over dmsg, + // which routinely outlasts this loop's own cadence — so it runs on + // FleetViewModel's slower one, folded in as every Nth pass. + var fleetCountdown = 0 + while (true) { + runCatching { + val overview = api.summary().overview + val apps = overview.apps.associateBy { it.name } + val vpn = apps[VpnArgs.APP] + val connection = if (vpn?.running == true) { + runCatching { api.appConnections(VpnArgs.APP) } + .getOrDefault(emptyList()) + .firstOrNull() + } else { + null + } + // A gone connection ends the series: the next one starts its + // counters from zero and a delta across that is nonsense. + val sampled = if (connection == null) { + rates.reset() + null + } else { + rates.sample(connection.bandwidthSent, connection.bandwidthReceived) + } + mutable.update { + withExit( + it.copy( + overview = overview, + apps = apps, + vpnConnection = connection, + // Keep the last good reading between samples + // rather than blinking back to "—". + vpnRates = sampled ?: it.vpnRates.takeIf { connection != null }, + ), + ) + } + } + // The tiles' own numbers, each behind its own failure boundary — + // a chat probe failing must not cost the hero card its rates. + runCatching { pollBadges() } + if (mutable.value.fleetEnabled && fleetCountdown-- <= 0) { + fleetCountdown = FLEET_EVERY_N_POLLS - 1 + runCatching { pollFleetCount() } + } + delay(POLL_INTERVAL_MS) + } + } + + /** + * The SkyChat badge and the Wallet tile's balance. The unread number is + * skychat's own — the page reports what it shows, the server carries it + * while no page is open — and the balance is the wallet cache's, because + * the wallet tab owns talking to the node. + */ + private suspend fun pollBadges() { + val chat = mutable.value.apps[SkychatProfile.APP] + val unread = if (chat?.running == true) { + skychat.unread(SkychatProfile.baseUrl(SkychatProfile.listenPort(chat.args))) + ?: mutable.value.unreadMessages + } else { + 0 + } + val balance = withContext(Dispatchers.IO) { + wallet.activeWalletId(CoinSpec.SKY.id).first() + ?.let { wallet.cachedSnapshot(it) } + ?.let { Amounts.format(it.confirmed, CoinSpec.SKY.exponent, 0) } + } + mutable.update { it.copy(unreadMessages = unread, skyBalance = balance) } + } + + /** Remote visors currently connected in, the Fleet screen's own way. */ + private suspend fun pollFleetCount() { + val local = api.localPk() + val online = api.visorsSummary() + .filterNot { it.isHypervisor || it.overview.localPk == local } + .count { it.online } + mutable.update { it.copy(fleetOnline = online) } + } + + /** + * Recompute the exit line from wherever the truth currently is: the + * app's own args once the visor has them, the saved server before that. + * The country only ever belongs to the same key it was saved with — the + * flag must never belong to a different exit than the one shown. + */ + private fun withExit(state: HubUiState): HubUiState { + val pk = state.vpn?.let { VpnArgs.serverPk(it.args) } ?: lastServer?.pk + val country = lastServer?.takeIf { it.pk == pk }?.country?.takeIf { it.isNotEmpty() } + return state.copy(vpnExitPk = pk, vpnCountry = country) + } + + private companion object { + const val PING_INTERVAL_MS = 700L + const val POLL_INTERVAL_MS = 5_000L + + /** + * Fleet count cadence, in polls: 3 × 5 s matches FleetViewModel's + * 15 s, which is what the Summary-RPC-per-remote fan-out tolerates. + */ + const val FLEET_EVERY_N_POLLS = 3 + } +} diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/logs/LogModels.kt b/android/app/src/main/java/com/skycoin/skywire/ui/logs/LogModels.kt new file mode 100644 index 0000000000..1baecd6dca --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/logs/LogModels.kt @@ -0,0 +1,126 @@ +package com.skycoin.skywire.ui.logs + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +/** Sources the viewer can tail. [APP_PREFIX] + app name selects one app. */ +object LogSources { + /** Visor runtime ring buffer over the local API. */ + const val CORE = "core" + + /** Captured child-process output — works when the visor won't start. */ + const val PROCESS = "process" + + // Dash, not colon: the value travels inside a navigation route path. + const val APP_PREFIX = "app-" + + /** + * [VISOR_PREFIX] + public key: the runtime buffer of a *remote* visor, the + * one Fleet reads. Same route and same shape as [CORE] — the hypervisor + * mux resolves the key and fetches over dmsg — so the viewer needs nothing + * beyond knowing whose logs to ask for. + */ + const val VISOR_PREFIX = "visor-" + + fun app(name: String) = APP_PREFIX + name + fun appName(source: String) = source.removePrefix(APP_PREFIX) + fun isApp(source: String) = source.startsWith(APP_PREFIX) + + fun visor(pk: String) = VISOR_PREFIX + pk + fun visorPk(source: String) = source.removePrefix(VISOR_PREFIX) + fun isVisor(source: String) = source.startsWith(VISOR_PREFIX) +} + +enum class LogLevel { TRACE, DEBUG, INFO, WARN, ERROR, FATAL, UNKNOWN } + +data class LogEntry( + val timestamp: String, + val level: LogLevel, + val module: String, + val message: String, + /** Rendered line for copy/share. */ + val raw: String, +) + +/** + * Parsers for the three log formats the core emits. + * + * Runtime-logs entries are logrus JSON; app logs and the captured process + * output are the text formatter's `[ts] LEVEL [module]: msg k="v"` lines — + * and app lines carry ANSI color because in-proc apps force colors on a + * non-TTY stream, so stripping is mandatory before parsing. + */ +object LogParser { + + private val json = Json { ignoreUnknownKeys = true; isLenient = true } + + private val ansi = Regex( + "[\\u001B\\u009B][\\[\\]()#;?]*" + + "(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\\u0007)" + + "|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))", + ) + + // The formatter sometimes inserts a caller-context token between the + // level and the [module] (e.g. "DEBUG ClientSession.DialStream [dmsgC]:") + // — matched lazily and dropped so the module still parses. + private val textLine = Regex( + "^\\[(?[^\\]]+)]\\s+(?TRACE|DEBUG|INFO|WARN|WARNING|ERROR|FATAL|PANIC)" + + "(?:\\s+(?[^\\[\\s]\\S*))??\\s*(?:\\[(?[^\\]]*)]:)?\\s?(?.*)$", + ) + + fun level(name: String): LogLevel = when (name.uppercase()) { + "TRACE" -> LogLevel.TRACE + "DEBUG" -> LogLevel.DEBUG + "INFO" -> LogLevel.INFO + "WARN", "WARNING" -> LogLevel.WARN + "ERROR" -> LogLevel.ERROR + "FATAL", "PANIC" -> LogLevel.FATAL + else -> LogLevel.UNKNOWN + } + + /** One logrus-JSON entry from the runtime-logs ring buffer. */ + fun parseJsonEntry(line: String): LogEntry { + val trimmed = line.trim() + val obj = runCatching { json.parseToJsonElement(trimmed) as? JsonObject }.getOrNull() + ?: return raw(trimmed) + // Log fields are usually primitives but not always — a structured + // value (object/array) must render, not throw. + fun str(key: String) = obj[key]?.asText().orEmpty() + val message = str("msg") + if (message.isEmpty() && obj["level"] == null) return raw(trimmed) + val extras = obj.entries + .filter { it.key !in JSON_CORE_KEYS } + .joinToString(" ") { (k, v) -> "$k=${v.asText()}" } + return LogEntry( + timestamp = str("time"), + level = level(str("level")), + module = str("_module"), + message = if (extras.isEmpty()) message else "$message $extras", + raw = trimmed, + ) + } + + /** One text-formatter line (app logs, captured process output). */ + fun parseTextLine(line: String): LogEntry { + val clean = ansi.replace(line, "").trimEnd() + if (clean.isBlank()) return raw(clean) + val match = textLine.find(clean) ?: return raw(clean) + return LogEntry( + timestamp = match.groups["ts"]?.value.orEmpty(), + level = level(match.groups["level"]?.value.orEmpty()), + module = match.groups["module"]?.value.orEmpty(), + message = match.groups["msg"]?.value.orEmpty(), + raw = clean, + ) + } + + private fun raw(line: String) = + LogEntry(timestamp = "", level = LogLevel.UNKNOWN, module = "", message = line, raw = line) + + /** Primitive content without quotes; anything structured as its JSON. */ + private fun JsonElement.asText(): String = (this as? JsonPrimitive)?.content ?: toString() + + private val JSON_CORE_KEYS = setOf("time", "level", "msg", "_module", "log_line") +} diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/logs/LogViewModel.kt b/android/app/src/main/java/com/skycoin/skywire/ui/logs/LogViewModel.kt new file mode 100644 index 0000000000..d7ec71ac4d --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/logs/LogViewModel.kt @@ -0,0 +1,211 @@ +package com.skycoin.skywire.ui.logs + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.skycoin.skywire.api.VisorApi +import com.skycoin.skywire.core.SkywirePaths +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.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.RandomAccessFile + +data class LogUiState( + val entries: List = emptyList(), + /** + * The first fetch for this source has not come back yet. Worth its own + * state because one source is slow enough to matter: a remote visor's + * buffer travels the whole ring over dmsg and takes several seconds, and + * "No log entries yet." during that wait reads as "this is broken". + */ + val loading: Boolean = false, + val following: Boolean = true, + val levels: Set = emptySet(), + val query: String = "", + val dropped: Long = 0, + /** Bumped on every append — a scroll-to-bottom key that keeps working + * after [entries] hits its size cap and the list size stops changing. */ + val revision: Long = 0, + val error: String? = null, +) { + /** + * Level chips are additive; empty selection means "everything". ERROR + * implies FATAL (no separate chip), and unparsed UNKNOWN lines always + * pass — they are often the crash text a filter must not hide. + */ + val visible: List + get() = entries.filter { entry -> + val levelOk = levels.isEmpty() || + entry.level in levels || + entry.level == LogLevel.UNKNOWN || + (entry.level == LogLevel.FATAL && LogLevel.ERROR in levels) + levelOk && (query.isBlank() || entry.raw.contains(query, ignoreCase = true)) + } +} + +/** + * Tails one log source. Polling stops while paused and resumes from the + * kept cursor, so pausing never loses lines the source still holds. + */ +class LogViewModel(app: Application) : AndroidViewModel(app) { + + private val api = VisorApi.get(app) + private val paths = SkywirePaths(app) + private val mutable = MutableStateFlow(LogUiState()) + val uiState: StateFlow = mutable.asStateFlow() + + private var pump: Job? = null + private var source: String = LogSources.CORE + + // Per-source cursors. + private var runtimeSince = 0L + private var appSince: String? = null + private var fileOffset = 0L + private var filePendingTail = "" + + fun start(source: String) { + if (pump?.isActive == true && source == this.source) return + pump?.cancel() + this.source = source + runtimeSince = 0L + appSince = null + fileOffset = 0L + filePendingTail = "" + mutable.value = LogUiState(loading = true, following = mutable.value.following) + pump = viewModelScope.launch { + while (true) { + if (mutable.value.following) { + runCatching { poll() } + .onFailure { e -> mutable.value = mutable.value.copy(error = e.message) } + // Whatever the first round did, the wait is over — an empty + // list from here on really is an empty list. + if (mutable.value.loading) { + mutable.value = mutable.value.copy(loading = false) + } + } + delay(POLL_INTERVAL_MS) + } + } + } + + fun setFollowing(following: Boolean) { + mutable.value = mutable.value.copy(following = following) + } + + fun toggleLevel(level: LogLevel) { + val levels = mutable.value.levels.toMutableSet() + if (!levels.add(level)) levels.remove(level) + mutable.value = mutable.value.copy(levels = levels) + } + + fun setQuery(query: String) { + mutable.value = mutable.value.copy(query = query) + } + + private suspend fun poll() = when { + source == LogSources.PROCESS -> pollProcessFile() + LogSources.isApp(source) -> pollAppLogs(LogSources.appName(source)) + LogSources.isVisor(source) -> pollRuntimeLogs(LogSources.visorPk(source)) + else -> pollRuntimeLogs() + } + + /** [pk] null tails this phone's visor; Fleet passes a remote one. */ + private suspend fun pollRuntimeLogs(pk: String? = null) { + // No explicit session bootstrap: the client re-logins on 401 by + // itself, and probing /api/user each poll would spam the very ring + // buffer this feed displays. + val firstPoll = runtimeSince == 0L + val delta = api.runtimeLogs(runtimeSince, pk) + if (delta.latest < runtimeSince) { + // The visor restarted (its line counter reset) — start over so + // the new process's startup lines aren't silently skipped. + runtimeSince = 0 + return + } + val fresh = delta.entries.orEmpty().map(LogParser::parseJsonEntry) + runtimeSince = delta.latest + // On the opening poll the whole ring is "dropped" relative to cursor + // 0 — that is the buffer's age, not lines this viewer missed. + append(fresh, dropped = if (firstPoll) 0 else delta.dropped) + } + + private suspend fun pollAppLogs(appName: String) { + val page = api.appLogs(appName, appSince) + if (page.logs.isEmpty()) { + mutable.value = mutable.value.copy(error = null) + return + } + val previous = appSince + // The server re-delivers the boundary line whenever its 4-digit + // fraction ends in zero (RFC3339Nano drops trailing zeros), so drop + // anything at or before the cursor — but keep unparsable lines + // (blank timestamp), which would otherwise vanish. + val fresh = page.logs + .map(LogParser::parseTextLine) + .filter { previous == null || it.timestamp.isEmpty() || it.timestamp > previous } + appSince = page.lastLogTimestamp.ifEmpty { appSince } + append(fresh) + } + + /** + * Tail of the captured child output. Reads only the bytes appended since + * the last poll (decoded as UTF-8, partial trailing line carried over); + * a shrinking file means the log rotated, so restart from the beginning. + */ + private suspend fun pollProcessFile() = withContext(Dispatchers.IO) { + val file = paths.processLogFile + if (!file.exists()) return@withContext + val length = file.length() + if (length < fileOffset) { + fileOffset = 0 + filePendingTail = "" + } + if (length == fileOffset) return@withContext + var chunk: ByteArray? = null + RandomAccessFile(file, "r").use { raf -> + raf.seek(fileOffset) + val buf = ByteArray((length - fileOffset).coerceAtMost(MAX_READ_BYTES).toInt()) + val n = raf.read(buf) + if (n > 0) { + fileOffset += n + chunk = if (n == buf.size) buf else buf.copyOf(n) + } + } + val bytes = chunk ?: return@withContext + val text = filePendingTail + String(bytes, Charsets.UTF_8) + val parts = text.split('\n') + filePendingTail = parts.last() + append(parts.dropLast(1).filter { it.isNotBlank() }.map(LogParser::parseTextLine)) + } + + /** Everything the share sheet gets — capped: a full 2000-line buffer in + * an Intent extra risks TransactionTooLargeException. */ + fun shareText(): String = + mutable.value.visible.takeLast(MAX_SHARE_LINES).joinToString("\n") { it.raw } + + private fun append(fresh: List, dropped: Long = 0) { + if (fresh.isEmpty() && dropped == 0L) { + if (mutable.value.error != null) mutable.value = mutable.value.copy(error = null) + return + } + val current = mutable.value + mutable.value = current.copy( + entries = (current.entries + fresh).takeLast(MAX_ENTRIES), + dropped = current.dropped + dropped, + revision = current.revision + 1, + error = null, + ) + } + + private companion object { + const val POLL_INTERVAL_MS = 1_200L + const val MAX_ENTRIES = 2_000 + const val MAX_SHARE_LINES = 500 + const val MAX_READ_BYTES = 512_000L + } +} diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/logs/LogViewerScreen.kt b/android/app/src/main/java/com/skycoin/skywire/ui/logs/LogViewerScreen.kt new file mode 100644 index 0000000000..3c36c7addb --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/logs/LogViewerScreen.kt @@ -0,0 +1,311 @@ +package com.skycoin.skywire.ui.logs + +import android.content.Intent +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +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.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.skycoin.skywire.R +import com.skycoin.skywire.core.VisorNames +import com.skycoin.skywire.ui.components.SkyTopBar +import com.skycoin.skywire.ui.components.appProductName +import com.skycoin.skywire.ui.components.shortPk +import com.skycoin.skywire.ui.theme.SkyAccents +import com.skycoin.skywire.ui.theme.SkywireBlue + +/** + * The one log viewer, used by every screen that has a log feed: the core + * runtime feed, any app's feed, and the captured process output (the only + * source that survives a visor that won't start). + */ +@Composable +fun LogViewerScreen( + source: String, + onBack: () -> Unit, + viewModel: LogViewModel = viewModel(), +) { + val state by viewModel.uiState.collectAsState() + val context = LocalContext.current + val clipboard = LocalClipboardManager.current + val copied = stringResource(R.string.copied_to_clipboard) + val listState = rememberLazyListState() + + LaunchedEffect(source) { viewModel.start(source) } + + // Keyed on the append revision, not the list size — the size stops + // changing once the buffer hits its cap, but the tail keeps moving. + val visible = state.visible + LaunchedEffect(state.revision, state.following) { + if (state.following && visible.isNotEmpty()) { + listState.animateScrollToItem(visible.lastIndex) + } + } + + Scaffold( + topBar = { + SkyTopBar( + title = titleFor(source), + onBack = onBack, + actions = { + IconButton(onClick = { viewModel.setFollowing(!state.following) }) { + Icon( + if (state.following) Icons.Default.Pause else Icons.Default.PlayArrow, + contentDescription = stringResource( + if (state.following) R.string.logs_pause else R.string.logs_follow, + ), + ) + } + IconButton( + onClick = { + val text = viewModel.shareText() + context.startActivity( + Intent.createChooser( + Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, text) + }, + context.getString(R.string.logs_share), + ), + ) + }, + ) { + Icon(Icons.Default.Share, contentDescription = stringResource(R.string.logs_share)) + } + }, + ) + }, + ) { padding -> + Column( + Modifier + .fillMaxSize() + .padding(padding), + ) { + FilterBar(state, viewModel) + state.dropped.takeIf { it > 0 }?.let { dropped -> + Text( + stringResource(R.string.logs_dropped, dropped), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + } + state.error?.let { error -> + Text( + error, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + } + + if (visible.isEmpty()) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + if (state.loading) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator(Modifier.size(24.dp), strokeWidth = 2.dp) + Spacer(Modifier.height(12.dp)) + Text( + stringResource(R.string.logs_loading), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + Text( + stringResource(R.string.logs_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } else { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = androidx.compose.foundation.layout.PaddingValues( + horizontal = 12.dp, + vertical = 8.dp, + ), + ) { + items(visible) { entry -> + LogRow(entry) { + clipboard.setText(AnnotatedString(entry.raw)) + Toast.makeText(context, copied, Toast.LENGTH_SHORT).show() + } + } + } + } + } + } +} + +@Composable +private fun FilterBar(state: LogUiState, viewModel: LogViewModel) { + Column(Modifier.padding(horizontal = 12.dp)) { + Row( + Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + FILTER_LEVELS.forEach { level -> + FilterChip( + selected = level in state.levels, + onClick = { viewModel.toggleLevel(level) }, + label = { Text(level.name, style = MaterialTheme.typography.labelSmall) }, + ) + } + } + Box( + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + .background( + MaterialTheme.colorScheme.surfaceVariant, + RoundedCornerShape(12.dp), + ) + .padding(horizontal = 12.dp, vertical = 10.dp), + ) { + if (state.query.isEmpty()) { + Text( + stringResource(R.string.logs_search_hint), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + BasicTextField( + value = state.query, + onValueChange = viewModel::setQuery, + singleLine = true, + textStyle = MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.onSurface, + ), + cursorBrush = androidx.compose.ui.graphics.SolidColor( + MaterialTheme.colorScheme.primary, + ), + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + +@Composable +private fun LogRow(entry: LogEntry, onClick: () -> Unit) { + Row( + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = 3.dp), + ) { + Text( + entry.level.name.take(1), + style = MaterialTheme.typography.labelSmall, + color = levelColor(entry.level), + modifier = Modifier.padding(end = 8.dp, top = 2.dp), + ) + Column { + if (entry.module.isNotEmpty() || entry.timestamp.isNotEmpty()) { + Text( + listOf(entry.timestamp.takeLast(TIME_TAIL), entry.module) + .filter { it.isNotEmpty() } + .joinToString(" · "), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Text( + entry.message, + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + color = if (entry.level == LogLevel.ERROR || entry.level == LogLevel.FATAL) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + } + } +} + +@Composable +private fun titleFor(source: String): String = when { + source == LogSources.PROCESS -> stringResource(R.string.logs_source_process) + // The product name alone here, unlike the diagnostics list: a centered + // app-bar title has no room for "SkySOCKS (skysocks-client)", and the row + // that opened this screen already showed both. + LogSources.isApp(source) -> LogSources.appName(source) + .let { name -> appProductName(name) ?: name } + // A remote visor's feed. Titled with the name the user gave it in Fleet — + // the same store, read straight from here rather than threaded through the + // navigation route — and its key when they have not named it. + LogSources.isVisor(source) -> { + val pk = LogSources.visorPk(source) + val context = LocalContext.current + val names by remember(context) { VisorNames(context).names() } + .collectAsState(initial = emptyMap()) + names[pk]?.takeIf { it.isNotEmpty() } ?: shortPk(pk) + } + else -> stringResource(R.string.logs_source_core) +} + +private fun levelColor(level: LogLevel): Color = when (level) { + LogLevel.ERROR, LogLevel.FATAL -> Color(0xFFDC2626) + LogLevel.WARN -> SkyAccents.warning + LogLevel.INFO -> SkywireBlue + else -> Color(0xFF9CA3AF) +} + +private val FILTER_LEVELS = listOf( + LogLevel.ERROR, + LogLevel.WARN, + LogLevel.INFO, + LogLevel.DEBUG, + LogLevel.TRACE, +) + +private const val TIME_TAIL = 12 diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/navigation/Routes.kt b/android/app/src/main/java/com/skycoin/skywire/ui/navigation/Routes.kt new file mode 100644 index 0000000000..340b4cf4d3 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/navigation/Routes.kt @@ -0,0 +1,60 @@ +package com.skycoin.skywire.ui.navigation + +/** + * Navigation routes. Five top-level bottom-bar destinations; SkySOCKS / + * SkyVPN / SkyDEX / Fleet are full-screen routes pushed from the hub — + * back returns to the hub. + */ +object Routes { + const val HOME = "home" + const val CHAT = "chat" + const val HUB = "hub" + const val WALLET = "wallet" + const val SETTINGS = "settings" + + const val SOCKS = "socks" + const val VPN = "vpn" + const val DEX = "dex" + const val FLEET = "fleet" + + /** Logs & diagnostics — pushed from Settings. */ + const val DIAGNOSTICS = "diagnostics" + + // Wallet flow — pushed from the Wallet tab root; the bar keeps the + // Wallet slot highlighted throughout. + const val WALLET_CREATE = "wallet/create" + const val WALLET_VERIFY = "wallet/verify" + const val WALLET_RESTORE = "wallet/restore" + const val WALLET_RECEIVE = "wallet/receive" + const val WALLET_SEND = "wallet/send" + const val WALLET_RESULT = "wallet/result" + const val WALLET_HISTORY = "wallet/history" + const val WALLET_TX = "wallet/tx/{txid}" + const val WALLET_WALLETS = "wallet/wallets" + const val WALLET_REVEAL = "wallet/reveal/{walletId}" + const val WALLET_ADD_COIN = "wallet/addcoin" + + fun walletTx(txid: String) = "wallet/tx/$txid" + fun walletReveal(walletId: String) = "wallet/reveal/$walletId" + + /** + * Shared log viewer; {source} is core, process, `app-`, or + * `visor-` for a remote visor's feed (Fleet). + */ + const val LOGS = "logs/{source}" + + fun logs(source: String) = "logs/$source" + + /** Routes pushed from the hub; the bar keeps the hub slot highlighted. */ + val hubPushed = setOf(SOCKS, VPN, DEX, FLEET) + + /** Same, for the Settings tab. */ + val settingsPushed = setOf(DIAGNOSTICS) + + /** Same, for the Wallet tab. */ + val walletPushed = setOf( + WALLET_CREATE, WALLET_VERIFY, WALLET_RESTORE, WALLET_RECEIVE, + WALLET_SEND, WALLET_RESULT, WALLET_HISTORY, WALLET_TX, + WALLET_WALLETS, WALLET_REVEAL, WALLET_ADD_COIN, + ) +} diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/settings/DiagnosticsScreen.kt b/android/app/src/main/java/com/skycoin/skywire/ui/settings/DiagnosticsScreen.kt new file mode 100644 index 0000000000..c2e24aff9b --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/settings/DiagnosticsScreen.kt @@ -0,0 +1,277 @@ +package com.skycoin.skywire.ui.settings + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.skycoin.skywire.R +import com.skycoin.skywire.core.CoreLogLevel +import com.skycoin.skywire.core.CoreState +import com.skycoin.skywire.ui.components.SectionCard +import com.skycoin.skywire.ui.components.SkyTopBar +import com.skycoin.skywire.ui.components.appLabel +import com.skycoin.skywire.ui.logs.LogSources +import java.util.Locale + +/** + * Logs & diagnostics — where the logs are *collected*, not where they are + * read. + * + * The per-screen `Logs` buttons are the ones you reach for while something is + * failing, because you are already on the screen that is failing. This is the + * aggregate: every source the phone has, whether or not its screen is open; + * one file with all of them in it, for handing to someone else; and the one + * setting that decides how much there is to hand over. + */ +@Composable +fun DiagnosticsScreen( + onBack: () -> Unit, + onOpenLogs: (String) -> Unit, + viewModel: DiagnosticsViewModel = viewModel(), +) { + val state by viewModel.uiState.collectAsState() + val snackbar = remember { SnackbarHostState() } + var confirmLevel by remember { mutableStateOf(null) } + + LaunchedEffect(state.message) { + state.message?.let { message -> + snackbar.showSnackbar(message) + viewModel.messageShown() + } + } + + val exportPicker = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument("application/zip"), + ) { uri -> uri?.let(viewModel::export) } + + Scaffold( + snackbarHost = { SnackbarHost(snackbar) }, + topBar = { SkyTopBar(title = stringResource(R.string.diag_title), onBack = onBack) }, + ) { padding -> + LazyColumn( + modifier = Modifier.padding(padding), + contentPadding = PaddingValues(horizontal = 20.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + item { SourcesCard(viewModel.apps, onOpenLogs) } + item { + ExportCard( + exporting = state.exporting, + onExport = { exportPicker.launch(exportFileName()) }, + ) + } + item { + LogLevelCard( + state = state, + onPick = { level -> + // A level change restarts the core, and that drops any + // SkySOCKS or SkyVPN connection with it. + if (state.coreState is CoreState.Stopped || + state.coreState is CoreState.Failed + ) { + viewModel.setLogLevel(level) + } else { + confirmLevel = level + } + }, + ) + } + } + } + + confirmLevel?.let { level -> + AlertDialog( + onDismissRequest = { confirmLevel = null }, + title = { Text(stringResource(R.string.diag_level_confirm_title, levelLabel(level))) }, + text = { Text(stringResource(R.string.diag_level_confirm_body)) }, + confirmButton = { + TextButton( + onClick = { + viewModel.setLogLevel(level) + confirmLevel = null + }, + ) { + Text(stringResource(R.string.fleet_confirm_restart_action)) + } + }, + dismissButton = { + TextButton(onClick = { confirmLevel = null }) { + Text(stringResource(R.string.cancel)) + } + }, + ) + } +} + +@Composable +private fun SourcesCard(apps: List, onOpenLogs: (String) -> Unit) { + SectionCard { + Text(stringResource(R.string.diag_sources), style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(4.dp)) + Text( + stringResource(R.string.diag_sources_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(8.dp)) + + SourceRow( + title = stringResource(R.string.logs_source_core), + subtitle = stringResource(R.string.diag_source_core_hint), + onClick = { onOpenLogs(LogSources.CORE) }, + ) + HorizontalDivider() + SourceRow( + title = stringResource(R.string.logs_source_process), + subtitle = stringResource(R.string.diag_source_process_hint), + onClick = { onOpenLogs(LogSources.PROCESS) }, + ) + apps.forEach { app -> + HorizontalDivider() + SourceRow( + // Both names: the product the user opened, and the process the + // log lines and the API route are keyed on. See [appLabel]. + title = appLabel(app), + subtitle = stringResource(R.string.diag_source_app_hint), + onClick = { onOpenLogs(LogSources.app(app)) }, + ) + } + } +} + +@Composable +private fun SourceRow(title: String, subtitle: String, onClick: () -> Unit) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = 12.dp), + ) { + Column(Modifier.weight(1f)) { + Text(title, style = MaterialTheme.typography.bodyLarge) + Text( + subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun ExportCard(exporting: Boolean, onExport: () -> Unit) { + SectionCard { + Text(stringResource(R.string.diag_export), style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(4.dp)) + Text( + stringResource(R.string.diag_export_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(12.dp)) + if (exporting) { + Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator(Modifier.size(14.dp), strokeWidth = 2.dp) + Spacer(Modifier.width(10.dp)) + Text( + stringResource(R.string.diag_exporting), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + FilledTonalButton(onClick = onExport) { + Text(stringResource(R.string.diag_export_action)) + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun LogLevelCard(state: DiagnosticsUiState, onPick: (String) -> Unit) { + SectionCard { + Text(stringResource(R.string.diag_level), style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(4.dp)) + Text( + stringResource(R.string.diag_level_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(12.dp)) + // Wrapping, not scrolling: five chips do not fit a phone's width, and + // a level the user cannot see is a level they will not find. + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + CoreLogLevel.LEVELS.forEach { level -> + FilterChip( + selected = state.logLevel == level, + onClick = { onPick(level) }, + enabled = !state.coreCycling, + label = { + Text(levelLabel(level), style = MaterialTheme.typography.labelSmall) + }, + ) + } + } + } +} + +private fun levelLabel(level: String): String = level.uppercase(Locale.US) + +/** + * A name that sorts and identifies: several bundles from the same phone end up + * in the same Downloads folder, and "which one was the failing run" is the + * question they are collected to answer. + */ +private fun exportFileName(): String { + val stamp = java.text.SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US) + .format(java.util.Date()) + return "skywire-diagnostics-$stamp.zip" +} diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/settings/DiagnosticsViewModel.kt b/android/app/src/main/java/com/skycoin/skywire/ui/settings/DiagnosticsViewModel.kt new file mode 100644 index 0000000000..b1fd2ea211 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/settings/DiagnosticsViewModel.kt @@ -0,0 +1,128 @@ +package com.skycoin.skywire.ui.settings + +import android.app.Application +import android.net.Uri +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.skycoin.skywire.R +import com.skycoin.skywire.core.AppPreferences +import com.skycoin.skywire.core.CoreLogLevel +import com.skycoin.skywire.core.CoreServiceState +import com.skycoin.skywire.core.CoreState +import com.skycoin.skywire.core.DiagnosticsExport +import com.skycoin.skywire.core.SkychatProfile +import com.skycoin.skywire.core.SkydexProfile +import com.skycoin.skywire.core.SkywireCoreService +import com.skycoin.skywire.ui.socks.SocksArgs +import com.skycoin.skywire.ui.vpn.VpnArgs +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +data class DiagnosticsUiState( + val coreState: CoreState = CoreState.Stopped, + val logLevel: String = CoreLogLevel.DEFAULT, + /** An export is being written to the document the user picked. */ + val exporting: Boolean = false, + val message: String? = null, +) { + /** The level change needs a restart, and a restart in flight is not a moment to ask for another. */ + val coreCycling: Boolean + get() = coreState is CoreState.Starting || + coreState is CoreState.Stopping || + coreState is CoreState.Restarting +} + +/** + * Logs & diagnostics: every source in one place, the whole set as one file, + * and the one setting that decides how much of it there is. + */ +class DiagnosticsViewModel(app: Application) : AndroidViewModel(app) { + + private val prefs = AppPreferences(app) + private val mutable = MutableStateFlow(DiagnosticsUiState()) + val uiState: StateFlow = mutable.asStateFlow() + + private var actionJob: Job? = null + + /** + * The four client apps, in the order the hub lists them. Named from each + * screen's own constant rather than a second list here — a diagnostics + * bundle that collects a log for an app name nothing else uses would be + * silently empty. + */ + val apps = listOf( + SkychatProfile.APP, + SocksArgs.APP, + VpnArgs.APP, + SkydexProfile.APP, + ) + + init { + viewModelScope.launch { + CoreServiceState.state.collectLatest { core -> + mutable.update { it.copy(coreState = core) } + } + } + viewModelScope.launch { + prefs.string(CoreLogLevel.PREF_KEY).collectLatest { stored -> + mutable.update { it.copy(logLevel = CoreLogLevel.sanitize(stored)) } + } + } + } + + /** + * Set how much the visor logs. Saved first so the choice survives a core + * that is down; when it is up, the restart is what applies it — the visor + * reads `log_level` once, while it builds its module graph. + */ + fun setLogLevel(level: String) = action { + prefs.putString(CoreLogLevel.PREF_KEY, CoreLogLevel.sanitize(level)) + val core = mutable.value.coreState + if (core is CoreState.Stopped || core is CoreState.Failed) return@action + // Detached on purpose: leaving this screen must not cancel a restart + // halfway and leave the phone with no core. + SkywireCoreService.restart(getApplication()) + } + + /** Write the whole bundle into the document the user picked. */ + fun export(uri: Uri) = action { + mutable.update { it.copy(exporting = true) } + try { + withContext(Dispatchers.IO) { + val resolver = getApplication().contentResolver + resolver.openOutputStream(uri, "wt")?.use { out -> + DiagnosticsExport.writeTo(getApplication(), out, apps) + } ?: error("could not open the chosen file for writing") + } + mutable.update { + it.copy(message = getApplication().getString(R.string.diag_export_done)) + } + } finally { + mutable.update { it.copy(exporting = false) } + } + } + + fun messageShown() { + mutable.update { it.copy(message = null) } + } + + private fun action(block: suspend () -> Unit) { + actionJob?.cancel() + actionJob = viewModelScope.launch { + try { + block() + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + mutable.update { it.copy(message = e.message ?: e::class.java.simpleName) } + } + } + } +} diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/settings/SettingsScreen.kt b/android/app/src/main/java/com/skycoin/skywire/ui/settings/SettingsScreen.kt new file mode 100644 index 0000000000..f87e65e6d4 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/settings/SettingsScreen.kt @@ -0,0 +1,660 @@ +package com.skycoin.skywire.ui.settings + +import android.content.Intent +import android.provider.Settings +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.lazy.LazyColumn +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.skycoin.skywire.R +import com.skycoin.skywire.core.ThemeMode +import com.skycoin.skywire.ui.components.Biometrics +import com.skycoin.skywire.ui.components.InfoRow +import com.skycoin.skywire.ui.components.SecureWindow +import com.skycoin.skywire.ui.components.SectionCard +import com.skycoin.skywire.ui.components.HelpTopic +import com.skycoin.skywire.ui.components.SkyTopBar +import com.skycoin.skywire.ui.components.findFragmentActivity +import com.skycoin.skywire.ui.components.shortPk +import kotlinx.coroutines.launch + +/** + * Settings: who this visor is, how to get its config off the phone, what + * guards the app, and where the logs are collected. + * + * The identity operations are the reason this screen is careful. Both of them + * end the identity this phone has — replacing the key is not "editing a + * setting", it is becoming a different visor — so both are asked twice, with + * the consequence spelled out rather than implied by the word *destructive*. + * The key handling itself is never done here: the core binary validates the + * key and derives its public half, and the confirmation quotes what it said. + */ +@Composable +fun SettingsScreen( + onBack: () -> Unit, + onOpenDiagnostics: () -> Unit, + viewModel: SettingsViewModel = viewModel(), +) { + val state by viewModel.uiState.collectAsState() + val context = LocalContext.current + val scope = rememberCoroutineScope() + val snackbar = remember { SnackbarHostState() } + var dialog by remember { mutableStateOf(null) } + + // What the phone can actually check against. Re-read on every composition + // of this screen, because the answer changes outside the app: enrolling a + // fingerprint or setting a PIN happens in system settings. + LaunchedEffect(Unit) { + viewModel.setBiometricsAvailable(Biometrics.canAuthenticate(context)) + } + + LaunchedEffect(state.message) { + state.message?.let { message -> + snackbar.showSnackbar(message) + viewModel.messageShown() + } + } + + val exportPicker = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument("application/json"), + ) { uri -> uri?.let(viewModel::exportConfig) } + + /** Ask, then run — or run anyway on a phone with nothing to ask with. */ + val confirmBiometrically: (Int, () -> Unit) -> Unit = { titleRes, onConfirmed -> + val activity = context.findFragmentActivity() + if (activity == null || !Biometrics.canAuthenticate(context)) { + // A device with no screen lock and no enrolled biometric has no + // check to offer. Refusing the action would be a lockout with no + // security gained — the warning that precedes it is the gate. + onConfirmed() + } else { + Biometrics.prompt( + activity, + title = context.getString(titleRes), + subtitle = context.getString(R.string.settings_biometric_subtitle), + ) { success, error -> + if (success) onConfirmed() else error?.let(viewModel::report) + } + } + } + + Scaffold( + snackbarHost = { SnackbarHost(snackbar) }, + topBar = { + SkyTopBar( + title = stringResource(R.string.tab_settings), + onBack = onBack, + help = HelpTopic(R.string.help_settings_title, R.string.help_settings_body), + ) + }, + ) { padding -> + LazyColumn( + modifier = Modifier.padding(padding), + contentPadding = PaddingValues(horizontal = 20.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + item { + IdentityCard( + state = state, + onReplace = { dialog = SettingsDialog.EnterSecretKey }, + onReset = { dialog = SettingsDialog.NewIdentityWarning }, + ) + } + item { + ConfigCard( + state = state, + onExport = { dialog = SettingsDialog.ExportWarning }, + onToggleEncryption = { wanted -> + // Turning it ON is not destructive and needs no + // ceremony. Turning it OFF puts the secret key back on + // the disk in the clear, which is a security decision + // being reversed — so it is confirmed the same way the + // app lock's own reversal is. + if (wanted) { + viewModel.setConfigEncrypted(true) + } else { + confirmBiometrically(R.string.settings_encrypt_disable_prompt) { + viewModel.setConfigEncrypted(false) + } + } + }, + ) + } + item { + AppLockCard( + state = state, + onToggle = { wanted -> + confirmBiometrically( + if (wanted) R.string.settings_lock_enable_prompt + else R.string.settings_lock_disable_prompt, + ) { viewModel.setAppLock(wanted) } + }, + onOpenSecuritySettings = { + runCatching { + context.startActivity(Intent(Settings.ACTION_SECURITY_SETTINGS)) + }.onFailure { viewModel.report(context.getString(R.string.settings_no_security_screen)) } + }, + ) + } + item { + BatteryCard( + state = state, + onGrant = viewModel::requestBatteryExemption, + onDismiss = viewModel::dismissBatteryPrompt, + ) + } + item { ThemeCard(state, viewModel::setThemeMode) } + item { DiagnosticsRow(onOpenDiagnostics) } + item { AboutCard(state) } + } + } + + // --- the dialogs, in the order each flow walks them --- + + when (val open = dialog) { + null -> Unit + + SettingsDialog.EnterSecretKey -> SecretKeyDialog( + busy = state.busy, + onDismiss = { dialog = null }, + onSubmit = { entered -> + scope.launch { + val pk = viewModel.publicKeyOf(entered) + when { + pk == null -> Unit // the failure is already on its way to the snackbar + viewModel.isCurrentKey(pk) -> { + viewModel.report(context.getString(R.string.settings_sk_unchanged)) + dialog = null + } + // Nothing to lose yet: with no config on disk this is + // not replacing an identity, it is choosing the first + // one. The final confirmation still stands. + !state.hasIdentity -> dialog = SettingsDialog.ReplaceFinal(entered, pk) + else -> dialog = SettingsDialog.ReplaceWarning(entered, pk) + } + } + }, + ) + + is SettingsDialog.ReplaceWarning -> DestructiveDialog( + title = stringResource(R.string.settings_replace_title), + body = stringResource(R.string.settings_identity_loss, shortPk(state.publicKey)), + extra = stringResource(R.string.settings_replace_new_pk, shortPk(open.publicKey)), + confirm = stringResource(R.string.settings_continue), + onDismiss = { dialog = null }, + onConfirm = { dialog = SettingsDialog.ReplaceFinal(open.secretKey, open.publicKey) }, + ) + + is SettingsDialog.ReplaceFinal -> DestructiveDialog( + title = stringResource(R.string.settings_replace_final_title), + body = stringResource(R.string.settings_replace_final_body, shortPk(open.publicKey)), + confirm = stringResource(R.string.settings_replace_action), + onDismiss = { dialog = null }, + onConfirm = { + viewModel.replaceSecretKey(open.secretKey) + dialog = null + }, + ) + + SettingsDialog.NewIdentityWarning -> DestructiveDialog( + title = stringResource(R.string.settings_new_title), + body = stringResource(R.string.settings_identity_loss, shortPk(state.publicKey)), + extra = stringResource(R.string.settings_new_extra), + confirm = stringResource(R.string.settings_continue), + onDismiss = { dialog = null }, + onConfirm = { dialog = SettingsDialog.NewIdentityFinal }, + ) + + SettingsDialog.NewIdentityFinal -> DestructiveDialog( + title = stringResource(R.string.settings_new_final_title), + body = stringResource(R.string.settings_new_final_body), + confirm = stringResource(R.string.settings_new_action), + onDismiss = { dialog = null }, + onConfirm = { + viewModel.newIdentity() + dialog = null + }, + ) + + SettingsDialog.ExportWarning -> DestructiveDialog( + title = stringResource(R.string.settings_export_title), + body = stringResource(R.string.settings_export_warning), + confirm = stringResource(R.string.settings_continue), + onDismiss = { dialog = null }, + onConfirm = { + dialog = null + confirmBiometrically(R.string.settings_export_prompt) { + exportPicker.launch(EXPORT_FILE_NAME) + } + }, + ) + } +} + +/** Which dialog is open, and what it is carrying. */ +private sealed interface SettingsDialog { + data object EnterSecretKey : SettingsDialog + data class ReplaceWarning(val secretKey: String, val publicKey: String) : SettingsDialog + data class ReplaceFinal(val secretKey: String, val publicKey: String) : SettingsDialog + data object NewIdentityWarning : SettingsDialog + data object NewIdentityFinal : SettingsDialog + data object ExportWarning : SettingsDialog +} + +// --- cards --- + +@Composable +private fun IdentityCard( + state: SettingsUiState, + onReplace: () -> Unit, + onReset: () -> Unit, +) { + val clipboard = LocalClipboardManager.current + val context = LocalContext.current + val copied = stringResource(R.string.copied_to_clipboard) + + SectionCard { + Text(stringResource(R.string.settings_identity), style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(4.dp)) + Text( + stringResource(R.string.settings_identity_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(12.dp)) + + if (state.hasIdentity) { + InfoRow( + label = stringResource(R.string.visor_public_key), + value = shortPk(state.publicKey), + mono = true, + modifier = Modifier.clickable { + clipboard.setText(AnnotatedString(state.publicKey)) + Toast.makeText(context, copied, Toast.LENGTH_SHORT).show() + }, + ) + } else { + Text( + stringResource(R.string.settings_identity_none), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Spacer(Modifier.height(12.dp)) + if (state.busy) { + Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator(Modifier.size(14.dp), strokeWidth = 2.dp) + Spacer(Modifier.width(10.dp)) + Text( + stringResource(R.string.settings_identity_working), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilledTonalButton(onClick = onReplace) { + Text(stringResource(R.string.settings_replace_sk)) + } + FilledTonalButton(onClick = onReset, enabled = state.hasIdentity) { + Text(stringResource(R.string.settings_new_config)) + } + } + } + } +} + +@Composable +private fun ConfigCard( + state: SettingsUiState, + onExport: () -> Unit, + onToggleEncryption: (Boolean) -> Unit, +) { + SectionCard { + Text(stringResource(R.string.settings_config), style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(4.dp)) + Text( + stringResource(R.string.settings_config_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(12.dp)) + FilledTonalButton(onClick = onExport, enabled = state.hasIdentity && !state.busy) { + Text(stringResource(R.string.settings_export)) + } + + // Encryption at rest sits in this card rather than its own, because it + // is a property of the file the card is already about. + Spacer(Modifier.height(16.dp)) + HorizontalDivider() + Spacer(Modifier.height(16.dp)) + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Column(Modifier.weight(1f)) { + Text( + stringResource(R.string.settings_encrypt), + style = MaterialTheme.typography.titleSmall, + ) + Spacer(Modifier.height(4.dp)) + Text( + stringResource(R.string.settings_encrypt_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.width(12.dp)) + Switch( + checked = state.configEncrypted, + onCheckedChange = onToggleEncryption, + enabled = !state.busy, + ) + } + if (state.configEncrypted) { + Spacer(Modifier.height(10.dp)) + Text( + stringResource(R.string.settings_encrypt_warning), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun AppLockCard( + state: SettingsUiState, + onToggle: (Boolean) -> Unit, + onOpenSecuritySettings: () -> Unit, +) { + SectionCard { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Column(Modifier.weight(1f)) { + Text( + stringResource(R.string.settings_app_lock), + style = MaterialTheme.typography.titleMedium, + ) + Spacer(Modifier.height(4.dp)) + Text( + stringResource(R.string.settings_app_lock_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.width(12.dp)) + Switch( + checked = state.appLockEnabled, + onCheckedChange = onToggle, + enabled = state.biometricsAvailable, + ) + } + if (!state.biometricsAvailable) { + Spacer(Modifier.height(10.dp)) + Text( + stringResource(R.string.settings_app_lock_unavailable), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(4.dp)) + TextButton(onClick = onOpenSecuritySettings, contentPadding = PaddingValues(0.dp)) { + Text(stringResource(R.string.settings_open_security)) + } + } + } +} + +@Composable +private fun ThemeCard(state: SettingsUiState, onPick: (ThemeMode) -> Unit) { + SectionCard { + Text(stringResource(R.string.settings_theme), style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(12.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + ThemeMode.entries.forEach { mode -> + FilterChip( + selected = state.themeMode == mode, + onClick = { onPick(mode) }, + label = { Text(stringResource(themeLabel(mode))) }, + ) + } + } + } +} + +private fun themeLabel(mode: ThemeMode): Int = when (mode) { + ThemeMode.SYSTEM -> R.string.settings_theme_system + ThemeMode.LIGHT -> R.string.settings_theme_light + ThemeMode.DARK -> R.string.settings_theme_dark +} + +@Composable +private fun DiagnosticsRow(onOpen: () -> Unit) { + SectionCard { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onOpen), + ) { + Column(Modifier.weight(1f)) { + Text( + stringResource(R.string.settings_diagnostics), + style = MaterialTheme.typography.titleMedium, + ) + Spacer(Modifier.height(4.dp)) + Text( + stringResource(R.string.settings_diagnostics_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun AboutCard(state: SettingsUiState) { + SectionCard { + Text(stringResource(R.string.settings_about), style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(8.dp)) + InfoRow( + label = stringResource(R.string.settings_app_version), + value = state.appVersion.ifEmpty { "—" }, + ) + InfoRow( + label = stringResource(R.string.settings_core_version), + value = state.coreVersion.ifEmpty { "—" }, + ) + } +} + +// --- dialogs --- + +/** Paste the key. Nothing is validated here — the core binary is asked. */ +/** + * Doze, and what to do about it. Shown in both states rather than only when + * something is wrong: "the system may pause Skywire in the background" is + * worth knowing even once it has been dealt with, and a card that vanishes on + * success leaves a user who granted it wondering whether it took. + * + * The "Not now" button is what stops this being a nag — it silences the Home + * prompt too. The card itself stays, because Settings is where you go looking. + */ +@Composable +private fun BatteryCard( + state: SettingsUiState, + onGrant: () -> Unit, + onDismiss: () -> Unit, +) { + SectionCard { + Text(stringResource(R.string.settings_battery), style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(4.dp)) + Text( + stringResource( + if (state.batteryExempt) R.string.settings_battery_exempt_hint + else R.string.settings_battery_hint, + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (!state.batteryExempt) { + Spacer(Modifier.height(12.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilledTonalButton(onClick = onGrant) { + Text(stringResource(R.string.settings_battery_allow)) + } + if (!state.batteryPromptDismissed) { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.settings_battery_not_now)) + } + } + } + } + } +} + +@Composable +private fun SecretKeyDialog( + busy: Boolean, + onDismiss: () -> Unit, + onSubmit: (String) -> Unit, +) { + // The field below holds a visor secret key in the clear. That is the same + // class of secret as the wallet's twelve words — it *is* the identity, + // it cannot be reissued, and anyone who reads it owns this visor — so it + // gets the same screenshot and recents-thumbnail block the seed screens + // have always had. It did not, until this audit. + SecureWindow() + var text by remember { mutableStateOf("") } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.settings_replace_sk)) }, + text = { + Column { + Text( + stringResource(R.string.settings_sk_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(12.dp)) + OutlinedTextField( + value = text, + onValueChange = { text = it.trim() }, + singleLine = false, + maxLines = 3, + label = { Text(stringResource(R.string.settings_sk_label)) }, + textStyle = MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace, + ), + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Done, + ), + modifier = Modifier.fillMaxWidth(), + ) + } + }, + confirmButton = { + TextButton( + onClick = { onSubmit(text) }, + enabled = text.isNotEmpty() && !busy, + ) { + Text(stringResource(R.string.settings_continue)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } + }, + ) +} + +/** + * One step of a two-step confirmation. Both steps look the same on purpose — + * the second is not a formality to click through, it is the same question + * asked once the user has read what the first one said. + */ +@Composable +private fun DestructiveDialog( + title: String, + body: String, + confirm: String, + onDismiss: () -> Unit, + onConfirm: () -> Unit, + extra: String? = null, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + Column { + Text(body, style = MaterialTheme.typography.bodyMedium) + extra?.let { + Spacer(Modifier.height(12.dp)) + Text( + it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(confirm, color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } + }, + ) +} + +private const val EXPORT_FILE_NAME = "skywire-config.json" diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/settings/SettingsViewModel.kt b/android/app/src/main/java/com/skycoin/skywire/ui/settings/SettingsViewModel.kt new file mode 100644 index 0000000000..d9a9ead6f8 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/settings/SettingsViewModel.kt @@ -0,0 +1,360 @@ +package com.skycoin.skywire.ui.settings + +import android.app.Application +import android.net.Uri +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.skycoin.skywire.R +import com.skycoin.skywire.api.VisorApi +import com.skycoin.skywire.core.AppLock +import com.skycoin.skywire.core.AppPreferences +import com.skycoin.skywire.core.AppVisibility +import com.skycoin.skywire.core.BatteryOptimization +import com.skycoin.skywire.core.ConfigManager +import com.skycoin.skywire.core.ConfigVault +import com.skycoin.skywire.core.CoreServiceState +import com.skycoin.skywire.core.CoreState +import com.skycoin.skywire.core.SecretStore +import com.skycoin.skywire.core.SkywireCoreService +import com.skycoin.skywire.core.SkywirePaths +import com.skycoin.skywire.core.ThemeMode +import kotlinx.coroutines.CompletableDeferred +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.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** Everything the Settings tab renders. */ +data class SettingsUiState( + val coreState: CoreState = CoreState.Stopped, + /** This visor's key, from the config on disk — shown with the core down too. */ + val publicKey: String = "", + val appLockEnabled: Boolean = AppLock.DEFAULT, + /** False when the phone has no screen lock and no enrolled biometric. */ + val biometricsAvailable: Boolean = true, + val themeMode: ThemeMode = ThemeMode.SYSTEM, + /** The visor config is encrypted at rest while the core is down. */ + val configEncrypted: Boolean = ConfigVault.DEFAULT, + /** Whether Doze has been told to leave this app's network alone. */ + val batteryExempt: Boolean = true, + /** The user has already declined the exemption once; stop offering. */ + val batteryPromptDismissed: Boolean = false, + val appVersion: String = "", + /** From the running visor's own summary; empty while the core is down. */ + val coreVersion: String = "", + /** An identity operation is in flight; the core is coming down and back. */ + val busy: Boolean = false, + /** One-shot feedback for the snackbar. */ + val message: String? = null, +) { + /** No config yet — the core has never successfully generated one. */ + val hasIdentity: Boolean get() = publicKey.isNotEmpty() +} + +/** + * Settings: identity, config export, the app lock, and the small preferences + * that had nowhere else to live. + * + * Every identity operation runs the same way — stop the core, change the + * config, start it again — and every one of them runs through + * [SkywireCoreService.restart], on the service's own process-scoped job. + * That is not a convenience: replacing a key while the visor holds the config + * open would have it write its own copy back over the new one, and doing the + * work on this view model's scope would let a back press cancel it between the + * stop and the start, leaving the phone with no core at all. + */ +class SettingsViewModel(app: Application) : AndroidViewModel(app) { + + private val prefs = AppPreferences(app) + private val paths = SkywirePaths(app) + private val config = ConfigManager(paths, SecretStore(app)) + private val vault = ConfigVault(paths) + private val api = VisorApi.get(app) + + private val mutable = MutableStateFlow(SettingsUiState()) + val uiState: StateFlow = mutable.asStateFlow() + + private var actionJob: Job? = null + + init { + viewModelScope.launch { + CoreServiceState.state.collectLatest { core -> + mutable.update { it.copy(coreState = core) } + // The key is read from disk on every core transition rather + // than once: an identity operation ends in one of these, and + // this is what makes the new key appear without a refresh. + loadIdentity() + if (core is CoreState.Running) loadCoreVersion() + } + } + viewModelScope.launch { + prefs.boolean(AppLock.PREF_KEY, AppLock.DEFAULT).collectLatest { enabled -> + mutable.update { it.copy(appLockEnabled = enabled) } + } + } + viewModelScope.launch { + prefs.string(ThemeMode.PREF_KEY).collectLatest { stored -> + mutable.update { it.copy(themeMode = ThemeMode.of(stored)) } + } + } + viewModelScope.launch { + prefs.boolean(ConfigVault.PREF_KEY, ConfigVault.DEFAULT).collectLatest { on -> + mutable.update { it.copy(configEncrypted = on) } + } + } + viewModelScope.launch { + prefs.boolean(BatteryOptimization.PREF_DISMISSED, false).collectLatest { dismissed -> + mutable.update { it.copy(batteryPromptDismissed = dismissed) } + } + } + // The exemption is granted in a system screen, not in this app, so the + // only reliable moment to re-read it is when the user comes back from + // there. That has to be every resume, not every return to the + // foreground: the system's own dialog covers the Activity without + // stopping it, so a grant made there never changes the foreground + // flag and the card would keep offering until the app restarts. + viewModelScope.launch { + AppVisibility.resumes.collectLatest { refreshBatteryExemption() } + } + refreshBatteryExemption() + viewModelScope.launch { loadVersions() } + } + + // --- config at rest --- + + /** + * Turn encryption of the config on or off. + * + * Turning it **on** with the core running only records the choice: the + * visor holds that file open and rewrites it, so the sealing happens when + * it next exits (see [SkywireCoreService]). Turning it **off** unseals + * straight away — a user who just switched encryption off should not be + * left with an encrypted config until the next disconnect. + */ + fun setConfigEncrypted(enabled: Boolean) = action { + val running = CoreServiceState.state.value is CoreState.Running + vault.applyPreference(enabled, running).getOrThrow() + prefs.putBoolean(ConfigVault.PREF_KEY, enabled) + val message = when { + !enabled -> R.string.settings_encrypt_off_done + running -> R.string.settings_encrypt_on_pending + else -> R.string.settings_encrypt_on_done + } + mutable.update { it.copy(message = getApplication().getString(message)) } + } + + // --- battery --- + + fun refreshBatteryExemption() { + val exempt = BatteryOptimization.isExempt(getApplication()) + mutable.update { it.copy(batteryExempt = exempt) } + } + + /** + * Hand the user to the system's own dialog. Nothing is recorded here: the + * answer lives in the platform, and [refreshBatteryExemption] reads it back + * when they return. + */ + fun requestBatteryExemption() { + val context = getApplication() + if (!BatteryOptimization.openRequest(context)) { + report(context.getString(R.string.settings_battery_no_screen)) + } + } + + /** "Not now" — stop offering, in Settings and on Home. */ + fun dismissBatteryPrompt() { + viewModelScope.launch { prefs.putBoolean(BatteryOptimization.PREF_DISMISSED, true) } + } + + // --- identity --- + + /** + * Install [secretKey] as this visor's identity. The key is validated (and + * its public half derived) by the core binary before anything is touched — + * see [ConfigManager.derivePublicKey] — so the confirmation the user saw + * already knew the answer. + */ + fun replaceSecretKey(secretKey: String) = identityAction(R.string.settings_sk_replaced) { + config.replaceSecretKey(secretKey).getOrThrow() + } + + /** Throw the identity away and let the first-run pipeline make a new one. */ + fun newIdentity() = identityAction(R.string.settings_identity_reset) { + config.resetIdentity() + "" + } + + /** + * Derive [secretKey]'s public key, for the confirmation dialog. Returns + * null with the failure in [SettingsUiState.message] when the core will + * not read it. + */ + suspend fun publicKeyOf(secretKey: String): String? = + config.derivePublicKey(secretKey) + .onFailure { e -> mutable.update { it.copy(message = e.message) } } + .getOrNull() + + /** The pasted key is the one already installed — nothing to do. */ + fun isCurrentKey(publicKey: String): Boolean = + publicKey.isNotEmpty() && publicKey == mutable.value.publicKey + + // --- export --- + + /** + * Write the complete config — secret key included — to the document the + * user picked. Deliberately the whole file: a config that has had anything + * removed is not a backup, and this is the only way the key leaves the + * phone at all. + */ + fun exportConfig(uri: Uri) = action { + withContext(Dispatchers.IO) { + val json = config.configJson() + val resolver = getApplication().contentResolver + // "wt" truncates: the picker will happily hand back an existing + // document, and a shorter config written into a longer one leaves + // the tail of the old file behind. + resolver.openOutputStream(uri, "wt")?.use { out -> + out.write(json.toByteArray(Charsets.UTF_8)) + } ?: error("could not open the chosen file for writing") + } + mutable.update { + it.copy(message = getApplication().getString(R.string.settings_export_done)) + } + } + + // --- preferences --- + + /** + * The lock is turned on only after a check has passed (the screen asks + * first), so the app is unlocked by definition at that moment — say so, + * rather than dropping the user onto a lock screen they just satisfied. + */ + fun setAppLock(enabled: Boolean) { + viewModelScope.launch { + prefs.putBoolean(AppLock.PREF_KEY, enabled) + if (enabled) AppLock.unlock() + } + } + + fun setBiometricsAvailable(available: Boolean) { + mutable.update { it.copy(biometricsAvailable = available) } + } + + fun setThemeMode(mode: ThemeMode) { + viewModelScope.launch { prefs.putString(ThemeMode.PREF_KEY, mode.name) } + } + + fun messageShown() { + mutable.update { it.copy(message = null) } + } + + fun report(message: String) { + mutable.update { it.copy(message = message) } + } + + // --- internals --- + + private suspend fun loadIdentity() { + val pk = withContext(Dispatchers.IO) { config.publicKey().orEmpty() } + mutable.update { it.copy(publicKey = pk) } + } + + private suspend fun loadVersions() { + val app = getApplication() + val appVersion = withContext(Dispatchers.IO) { + runCatching { + app.packageManager.getPackageInfo(app.packageName, 0).versionName.orEmpty() + }.getOrDefault("") + } + mutable.update { it.copy(appVersion = appVersion) } + } + + /** + * The core's version, asked of the running visor rather than of the binary. + * + * `libskywire-mobile.so --version` would answer with the core down too, + * which is tempting — but the CLI writes to stdout before any command + * runs, so scraping it means parsing whatever else happened to be printed + * that launch. The visor's summary is the same binary reporting itself, + * over a typed API. With the core down the row simply reads "—". + */ + private suspend fun loadCoreVersion() { + // The API is not up the instant the process is. Waiting here is safe: + // this runs inside the core-state collector, which cancels it the + // moment the core moves on. + while (!api.ping()) delay(PING_INTERVAL_MS) + val version = runCatching { + api.summary().let { summary -> + listOfNotNull( + summary.overview.buildInfo?.version?.takeIf { it.isNotEmpty() }, + summary.buildTag.takeIf { it.isNotEmpty() }, + ).joinToString(" · ") + } + }.getOrDefault("") + mutable.update { it.copy(coreVersion = version) } + } + + /** + * The shape every identity change takes: the work happens with the core + * down, and the caller follows it through [CoreServiceState] like any other + * lifecycle change. + * + * The result comes back through a [CompletableDeferred] rather than being + * returned, because [SkywireCoreService.restart] runs [block] on a + * process-scoped job on purpose. Awaiting it here is safe in the other + * direction too: if this view model is cleared mid-restart the await is + * cancelled, but the work is not — only the message is lost. + */ + private fun identityAction(successMessage: Int, block: suspend () -> String) = action { + mutable.update { it.copy(busy = true) } + try { + val outcome = CompletableDeferred>() + val running = mutable.value.coreState !is CoreState.Stopped && + mutable.value.coreState !is CoreState.Failed + if (running) { + SkywireCoreService.restart(getApplication()) { + outcome.complete(runCatching { block() }) + } + } else { + // Nothing to restart. The next Connect picks the new identity + // up through the ordinary first-run path. + outcome.complete(runCatching { withContext(Dispatchers.IO) { block() } }) + } + outcome.await().getOrThrow() + // The API client caches this visor's key for the life of the + // process, and it is now the key of a visor that no longer exists. + api.forgetIdentity() + loadIdentity() + mutable.update { + it.copy(message = getApplication().getString(successMessage)) + } + } finally { + mutable.update { it.copy(busy = false) } + } + } + + private companion object { + const val PING_INTERVAL_MS = 700L + } + + /** One user action at a time, with its failure surfaced on the screen. */ + private fun action(block: suspend () -> Unit) { + actionJob?.cancel() + actionJob = viewModelScope.launch { + try { + block() + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + mutable.update { it.copy(message = e.message ?: e::class.java.simpleName) } + } + } + } +} diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/socks/SocksModels.kt b/android/app/src/main/java/com/skycoin/skywire/ui/socks/SocksModels.kt new file mode 100644 index 0000000000..51b51c957b --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/socks/SocksModels.kt @@ -0,0 +1,63 @@ +package com.skycoin.skywire.ui.socks + +/** + * The two skysocks-client flags this screen owns. The visor exposes the + * app's argv over the API, so the flags are read from there and written + * back as a whole — the server key stays put when only the port changes, + * and vice versa. + * + * The visor writes `--srv` itself (a PUT with a `pk` field), so only the + * listen address is ever rewritten from here. + */ +object SocksArgs { + + const val APP = "skysocks-client" + + /** Loopback-only by design — see ConfigManager's address pin. */ + const val HOST = "127.0.0.1" + const val DEFAULT_PORT = 1080 + + // The visor writes the double-dash form; the single-dash spelling is + // accepted too because a hand-edited config may carry it. + private val SRV = listOf("--srv", "-srv") + private val ADDR = listOf("--addr", "-addr") + + fun serverPk(args: List): String? = value(args, SRV)?.takeIf { it.isNotEmpty() } + + fun listenPort(args: List): Int = + value(args, ADDR)?.substringAfterLast(':')?.toIntOrNull() ?: DEFAULT_PORT + + /** + * [args] rendered back as the single string the API's `args` field + * takes, with the listen address re-pointed at [port]. Values are + * quoted only when they need it — the server parses this with + * shell-like rules. + */ + fun withPort(args: List, port: Int): String { + val addr = "$HOST:$port" + val flag = args.indexOfFirst { token -> ADDR.any { token == it || token.startsWith("$it=") } } + val updated = when { + flag < 0 -> args + listOf(ADDR.first(), addr) + args[flag].contains('=') -> args.toMutableList() + .also { it[flag] = args[flag].substringBefore('=') + "=" + addr } + flag + 1 < args.size -> args.toMutableList().also { it[flag + 1] = addr } + // Trailing flag with no value: a broken argv the visor would + // reject anyway — complete it rather than shifting everything. + else -> args + addr + } + return updated.joinToString(" ") { token -> + if (token.any { it.isWhitespace() }) "\"" + token.replace("\"", "\\\"") + "\"" else token + } + } + + /** Accepts both `--flag value` and `--flag=value`. */ + private fun value(args: List, flags: List): String? { + args.forEachIndexed { i, token -> + flags.forEach { flag -> + if (token.startsWith("$flag=")) return token.substringAfter('=') + if (token == flag && i + 1 < args.size) return args[i + 1] + } + } + return null + } +} diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/socks/SocksScreen.kt b/android/app/src/main/java/com/skycoin/skywire/ui/socks/SocksScreen.kt new file mode 100644 index 0000000000..17b21f2b31 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/socks/SocksScreen.kt @@ -0,0 +1,468 @@ +package com.skycoin.skywire.ui.socks + +import android.widget.Toast +import androidx.compose.foundation.background +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.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +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.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.skycoin.skywire.R +import com.skycoin.skywire.api.AppConnection +import com.skycoin.skywire.core.CoreState +import com.skycoin.skywire.ui.components.CONNECTED_GREEN +import com.skycoin.skywire.ui.components.InfoRow +import com.skycoin.skywire.ui.components.PENDING_AMBER +import com.skycoin.skywire.ui.components.SavedServer +import com.skycoin.skywire.ui.components.SectionCard +import com.skycoin.skywire.ui.components.ServerRow +import com.skycoin.skywire.ui.components.HelpTopic +import com.skycoin.skywire.ui.components.SkyTopBar +import com.skycoin.skywire.ui.components.TransportPreferenceCard +import com.skycoin.skywire.ui.components.TransportPreferenceSheet +import com.skycoin.skywire.ui.components.flagEmoji +import com.skycoin.skywire.ui.components.formatBytes +import com.skycoin.skywire.ui.components.shortPk + +/** + * SkySOCKS: pick a public proxy server, point skysocks-client at it, and + * watch what it does. The pattern every app screen repeats — list from + * service discovery, configure, start, observe, plus a per-app `Logs` + * action in the bar. + */ +@Composable +fun SocksScreen( + onBack: () -> Unit, + viewModel: SocksViewModel = viewModel(), +) { + val state by viewModel.uiState.collectAsState() + var portSheetOpen by remember { mutableStateOf(false) } + var transportSheetOpen by remember { mutableStateOf(false) } + + Scaffold( + topBar = { + SkyTopBar( + title = stringResource(R.string.app_skysocks), + onBack = onBack, + help = HelpTopic(R.string.help_socks_title, R.string.help_socks_body), + ) + }, + ) { padding -> + LazyColumn( + modifier = Modifier.padding(padding), + contentPadding = PaddingValues(horizontal = 20.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + item { StatusCard(state, viewModel) } + item { ProxyAddressCard(state, onChangePort = { portSheetOpen = true }) } + item { + TransportPreferenceCard( + primary = state.transportPrimary, + // Changeable with the core down too — it is stored on the + // phone and applied when the visor next starts. + enabled = !state.busy, + onClick = { transportSheetOpen = true }, + ) + } + + if (state.coreReady) { + item { ServersHeader(state, viewModel) } + items(state.filteredServers, key = { it.address }) { server -> + ServerRow( + server = server, + selected = server.pk == state.selectedPk, + enabled = !state.busy, + onClick = { viewModel.connect(SavedServer.of(server)) }, + ) + } + item { ServersFooter(state, viewModel) } + } + } + } + + if (portSheetOpen) { + PortSheet( + current = state.listenPort, + onDismiss = { portSheetOpen = false }, + onSave = { port -> + viewModel.setListenPort(port) + portSheetOpen = false + }, + ) + } + + if (transportSheetOpen) { + TransportPreferenceSheet( + current = state.transportPrimary, + onDismiss = { transportSheetOpen = false }, + onSelect = { type -> + viewModel.setTransportPrimary(type) + transportSheetOpen = false + }, + ) + } +} + +// --- status --- + +@Composable +private fun StatusCard(state: SocksUiState, viewModel: SocksViewModel) { + val clipboard = LocalClipboardManager.current + val context = LocalContext.current + val copied = stringResource(R.string.copied_to_clipboard) + + SectionCard { + Row(verticalAlignment = Alignment.CenterVertically) { + val (label, color) = statusLabel(state) + Box( + Modifier + .size(10.dp) + .clip(CircleShape) + .background(color), + ) + Spacer(Modifier.width(8.dp)) + Text(label, style = MaterialTheme.typography.titleMedium) + if (state.busy) { + Spacer(Modifier.width(10.dp)) + CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp) + } + } + + // The visor's own wording for what the app is doing ("Starting", + // "Connection failed, reconnecting", …) — more useful than the + // numeric status whenever it disagrees with the label above. + state.app?.detailedStatus + ?.takeIf { it.isNotEmpty() && !it.equals(RUNNING_STATUS, ignoreCase = true) } + ?.let { detail -> + Spacer(Modifier.height(4.dp)) + Text( + detail, + style = MaterialTheme.typography.bodySmall, + color = if (state.errored) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + + state.selectedPk?.let { pk -> + Spacer(Modifier.height(12.dp)) + InfoRow( + label = stringResource(R.string.socks_server), + value = listOfNotNull( + state.selectedCountry?.let(::flagEmoji), + shortPk(pk), + ).joinToString(" "), + mono = true, + modifier = Modifier.clickable { + clipboard.setText(AnnotatedString(pk)) + Toast.makeText(context, copied, Toast.LENGTH_SHORT).show() + }, + ) + } + + state.connection?.let { connection -> + ConnectionRows(connection) + } + + state.error?.let { error -> + Spacer(Modifier.height(8.dp)) + Text( + error, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + + Spacer(Modifier.height(16.dp)) + ConnectControl(state, viewModel) + } +} + +@Composable +private fun ConnectionRows(connection: AppConnection) { + // skysocks-client never measures round trips, so its latency is a + // constant zero — showing "0 ms" would just be wrong. + connection.latencyMs.takeIf { it > 0 }?.let { latency -> + InfoRow( + label = stringResource(R.string.socks_latency), + value = "$latency ms", + ) + } + InfoRow( + label = stringResource(R.string.socks_transferred), + value = "↑ ${formatBytes(connection.bandwidthSent)} ↓ ${formatBytes(connection.bandwidthReceived)}", + ) + connection.error.takeIf { it.isNotEmpty() }?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) + } +} + +@Composable +private fun ConnectControl(state: SocksUiState, viewModel: SocksViewModel) { + if (!state.coreReady) { + Text( + stringResource( + if (state.coreState is CoreState.Stopped) { + R.string.socks_core_offline + } else { + R.string.socks_core_starting + }, + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + return + } + + val active = state.running || state.starting + Button( + onClick = { if (active) viewModel.disconnect() else viewModel.reconnect() }, + enabled = !state.busy && (active || state.selectedPk != null), + modifier = Modifier.fillMaxWidth(), + colors = if (active) { + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } else { + ButtonDefaults.buttonColors() + }, + ) { + Text( + stringResource( + when { + active -> R.string.disconnect + state.selectedPk != null -> R.string.socks_reconnect + else -> R.string.connect + }, + ), + ) + } + if (!active && state.selectedPk == null) { + Spacer(Modifier.height(8.dp)) + Text( + stringResource(R.string.socks_pick_server), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +// --- the proxy address other apps use --- + +@Composable +private fun ProxyAddressCard(state: SocksUiState, onChangePort: () -> Unit) { + val clipboard = LocalClipboardManager.current + val context = LocalContext.current + val copied = stringResource(R.string.copied_to_clipboard) + + SectionCard { + Text( + stringResource(R.string.socks_proxy_title), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(6.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + state.listenAddress, + style = MaterialTheme.typography.titleMedium.copy(fontFamily = FontFamily.Monospace), + modifier = Modifier + .weight(1f) + .clickable { + clipboard.setText(AnnotatedString(state.listenAddress)) + Toast.makeText(context, copied, Toast.LENGTH_SHORT).show() + }, + ) + FilledTonalButton(onClick = onChangePort, enabled = state.coreReady && !state.busy) { + Text(stringResource(R.string.socks_change_port)) + } + } + Spacer(Modifier.height(4.dp)) + Text( + stringResource(R.string.socks_proxy_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun PortSheet(current: Int, onDismiss: () -> Unit, onSave: (Int) -> Unit) { + val sheetState = rememberModalBottomSheetState() + var text by remember { mutableStateOf(current.toString()) } + val port = text.toIntOrNull() + val valid = port != null && port in MIN_PORT..MAX_PORT + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column(Modifier.padding(horizontal = 24.dp).padding(bottom = 32.dp)) { + Text( + stringResource(R.string.socks_port_title), + style = MaterialTheme.typography.titleMedium, + ) + Spacer(Modifier.height(8.dp)) + Text( + stringResource(R.string.socks_port_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(16.dp)) + OutlinedTextField( + value = text, + onValueChange = { text = it.filter(Char::isDigit).take(5) }, + singleLine = true, + isError = text.isNotEmpty() && !valid, + label = { Text(stringResource(R.string.socks_port_label)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + ) + if (text.isNotEmpty() && !valid) { + Spacer(Modifier.height(6.dp)) + Text( + stringResource(R.string.socks_port_invalid), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + Spacer(Modifier.height(20.dp)) + Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } + Spacer(Modifier.width(8.dp)) + Button(onClick = { onSave(port!!) }, enabled = valid) { + Text(stringResource(R.string.save)) + } + } + } + } +} + +// --- server list --- + +@Composable +private fun ServersHeader(state: SocksUiState, viewModel: SocksViewModel) { + Column { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + stringResource(R.string.socks_servers, state.servers.size), + style = MaterialTheme.typography.titleMedium, + ) + if (state.serversLoading) { + CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) + } else { + IconButton(onClick = viewModel::refreshServers) { + Icon( + Icons.Default.Refresh, + contentDescription = stringResource(R.string.socks_refresh), + ) + } + } + } + OutlinedTextField( + value = state.query, + onValueChange = viewModel::setQuery, + singleLine = true, + placeholder = { Text(stringResource(R.string.socks_search_hint)) }, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Composable +private fun ServersFooter(state: SocksUiState, viewModel: SocksViewModel) { + val serversError = state.serversError + when { + serversError != null -> Column { + Text( + serversError, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + FilledTonalButton(onClick = viewModel::refreshServers) { + Text(stringResource(R.string.socks_retry)) + } + } + state.serversLoading && state.servers.isEmpty() -> Unit + state.filteredServers.isEmpty() -> Text( + stringResource( + if (state.servers.isEmpty()) R.string.socks_servers_none else R.string.socks_servers_empty, + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +// --- small shared pieces --- + +@Composable +private fun statusLabel(state: SocksUiState): Pair = when { + !state.coreReady -> stringResource(R.string.state_disconnected) to + MaterialTheme.colorScheme.onSurfaceVariant + state.running -> stringResource(R.string.state_connected) to CONNECTED_GREEN + state.starting -> stringResource(R.string.socks_state_connecting) to PENDING_AMBER + state.errored -> stringResource(R.string.socks_state_error) to MaterialTheme.colorScheme.error + else -> stringResource(R.string.state_disconnected) to + MaterialTheme.colorScheme.onSurfaceVariant +} + +private const val RUNNING_STATUS = "Running" +private const val MIN_PORT = 1024 +private const val MAX_PORT = 65535 diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/socks/SocksViewModel.kt b/android/app/src/main/java/com/skycoin/skywire/ui/socks/SocksViewModel.kt new file mode 100644 index 0000000000..36bbda7b58 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/socks/SocksViewModel.kt @@ -0,0 +1,324 @@ +package com.skycoin.skywire.ui.socks + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.skycoin.skywire.api.AppConnection +import com.skycoin.skywire.api.AppState +import com.skycoin.skywire.api.ServiceEntry +import com.skycoin.skywire.api.VisorApi +import com.skycoin.skywire.core.AppPreferences +import com.skycoin.skywire.core.CoreServiceState +import com.skycoin.skywire.core.CoreState +import com.skycoin.skywire.core.TransportPreference +import com.skycoin.skywire.ui.components.SavedServer +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json + +/** Everything the SkySOCKS screen renders. */ +data class SocksUiState( + val coreState: CoreState = CoreState.Stopped, + /** The local API answered — without it nothing on this screen works. */ + val apiUp: Boolean = false, + val app: AppState? = null, + val connection: AppConnection? = null, + val servers: List = emptyList(), + val serversLoading: Boolean = false, + val serversError: String? = null, + val query: String = "", + val listenPort: Int = SocksArgs.DEFAULT_PORT, + /** Transport type the visor tries first — see [TransportPreference]. */ + val transportPrimary: String = TransportPreference.DEFAULT, + val lastServer: SavedServer? = null, + /** A start/stop/settings call is in flight. */ + val busy: Boolean = false, + val error: String? = null, +) { + val coreReady: Boolean get() = coreState is CoreState.Running && apiUp + + /** The server the app is configured for, whether or not it runs. */ + val selectedPk: String? get() = app?.args?.let(SocksArgs::serverPk) ?: lastServer?.pk + + /** + * Country of [selectedPk], from the discovery list when it is loaded + * and otherwise from the saved server — but only when that is the + * same key, so the flag can never belong to a different server than + * the one shown next to it. + */ + val selectedCountry: String? + get() = selectedPk?.let { pk -> + servers.firstOrNull { it.pk == pk }?.geo?.country + ?: lastServer?.takeIf { it.pk == pk }?.country + }?.takeIf { it.isNotEmpty() } + + val running: Boolean get() = app?.running == true + val starting: Boolean get() = app?.status == AppState.STATUS_STARTING + val errored: Boolean get() = app?.status == AppState.STATUS_ERRORED + + val listenAddress: String get() = "${SocksArgs.HOST}:$listenPort" + + val filteredServers: List + get() = if (query.isBlank()) { + servers + } else { + servers.filter { + it.pk.contains(query, true) || + it.geo?.country.orEmpty().contains(query, true) || + it.geo?.region.orEmpty().contains(query, true) || + it.version.contains(query, true) + } + } +} + +/** + * Drives the whole SkySOCKS flow: list servers from service discovery, + * point skysocks-client at one, start/stop it, and watch what it reports. + * + * Everything hangs off the core's state — the local API only exists while + * the visor runs, so polling starts when it comes up and the screen falls + * back to an explanatory state when it doesn't. Every write goes through + * [update]: these loaders suspend, and a read-modify-write spanning a + * suspension point silently drops whatever landed in the meantime. + */ +class SocksViewModel(app: Application) : AndroidViewModel(app) { + + private val api = VisorApi.get(app) + private val prefs = AppPreferences(app) + private val json = Json { ignoreUnknownKeys = true } + + private val mutable = MutableStateFlow(SocksUiState()) + val uiState: StateFlow = mutable.asStateFlow() + + private var actionJob: Job? = null + + init { + viewModelScope.launch { + val saved = readLastServer() + mutable.update { it.copy(lastServer = saved) } + } + viewModelScope.launch { + val primary = TransportPreference.sanitize( + prefs.string(TransportPreference.PREF_KEY).first(), + ) + mutable.update { it.copy(transportPrimary = primary) } + } + viewModelScope.launch { + CoreServiceState.state.collectLatest { core -> + mutable.update { + it.copy(coreState = core, apiUp = false, app = null, connection = null) + } + if (core is CoreState.Running) { + while (!api.ping()) delay(PING_INTERVAL_MS) + mutable.update { it.copy(apiUp = true) } + // The API answers well before dmsg has a session, and + // the server list rides dmsg — so the opening fetch + // gets a few attempts before it becomes the user's + // problem to press Retry. + loadServers(attempts = INITIAL_LOAD_ATTEMPTS) + pollAppState() + } + } + } + } + + fun setQuery(query: String) { + mutable.update { it.copy(query = query) } + } + + fun refreshServers() { + viewModelScope.launch { loadServers() } + } + + /** Point the app at [server] and make sure it runs. */ + fun connect(server: SavedServer) = action { startWith(server) } + + /** + * The one-tap shortcut: reconnect to whatever the app is already + * pointed at — the saved server, or (after an app reinstall that kept + * the visor's config) the key still in its argv. + */ + fun reconnect() { + val state = mutable.value + val pk = state.selectedPk ?: return + connect(state.lastServer?.takeIf { it.pk == pk } ?: SavedServer(pk)) + } + + fun disconnect() = action { + val updated = api.updateApp(SocksArgs.APP, status = VisorApi.APP_STOP) + mutable.update { it.copy(app = updated, connection = null) } + } + + /** + * Change the port the SOCKS5 listener binds on loopback. The whole + * argv is rewritten (that is the API's only shape for it), so it is + * read fresh here rather than from the polled snapshot. + * + * A running app is stopped around the change instead of leaning on the + * server's restart-on-args-change: that restart races the old proc, + * whose blocked `accept` returns "use of closed network connection" as + * the app's error and leaves it stuck in Errored. Stop, rewrite, start + * lands on the new port every time. + */ + fun setListenPort(port: Int) = action { + // Read the app rather than trusting the polled snapshot — this + // decides both the new argv and whether to put the app back up. + val current = api.app(SocksArgs.APP) + if (current.running) runCatching { api.updateApp(SocksArgs.APP, status = VisorApi.APP_STOP) } + var updated = api.updateApp(SocksArgs.APP, args = SocksArgs.withPort(current.args, port)) + if (current.running) updated = api.updateApp(SocksArgs.APP, status = VisorApi.APP_START) + mutable.update { + it.copy(app = updated, listenPort = SocksArgs.listenPort(updated.args)) + } + } + + /** + * Change the transport type the visor tries first. Visor-wide, not + * SkySOCKS-only — SkyVPN will read the same value. + * + * Saved locally first: the phone profile writes it into the config on + * every launch ([TransportPreference]), so the choice holds even when + * it is made with the core down and there is nothing to PUT. When the + * core *is* up the visor takes it live, and a running client is + * re-dialed so the new route is actually built over the new primary — + * the setting only steers route setup, so an established route keeps + * whatever transport it already rides. + */ + fun setTransportPrimary(type: String) = action { + prefs.putString(TransportPreference.PREF_KEY, type) + mutable.update { it.copy(transportPrimary = type) } + if (!mutable.value.coreReady) return@action + api.setTransportPreference(TransportPreference.order(type)) + val state = mutable.value + if (state.running || state.starting) { + val pk = state.selectedPk ?: return@action + startWith(state.lastServer?.takeIf { it.pk == pk } ?: SavedServer(pk)) + } + } + + // --- internals --- + + /** + * Point the app at [server] and start it. + * + * The stop is unconditional and its failure ignored: stopping an + * already-stopped app is the harmless half of the trade, while + * *skipping* a needed stop is not — starting a running app is a + * server-side error, and the polled snapshot can be a poll behind + * what the visor actually has. With the app reliably stopped, the + * single PUT below is valid in every case: the key just rewrites the + * argv, then the status starts it with that key in place. + * + * A plain suspend function, not another [action]: the callers already + * run inside one, and re-entering `action` from within would cancel + * the very job making the call. + */ + private suspend fun startWith(server: SavedServer) { + runCatching { api.updateApp(SocksArgs.APP, status = VisorApi.APP_STOP) } + val updated = api.updateApp( + SocksArgs.APP, + pk = server.pk, + status = VisorApi.APP_START, + ) + saveLastServer(server) + mutable.update { it.copy(app = updated, lastServer = server) } + } + + /** + * One user action at a time, with its failure surfaced on the screen. + * Launched on the view-model scope rather than inside the state + * collector, which is cancelled the moment the core state changes. + */ + private fun action(block: suspend () -> Unit) { + actionJob?.cancel() + actionJob = viewModelScope.launch { + mutable.update { it.copy(busy = true, error = null) } + try { + block() + } catch (e: Exception) { + mutable.update { it.copy(error = e.message) } + } finally { + mutable.update { it.copy(busy = false) } + } + } + } + + /** + * Fetch the server list, keeping the spinner up across [attempts] + * tries. Only the last failure is shown — an intermediate one just + * means dmsg wasn't ready yet. + */ + private suspend fun loadServers(attempts: Int = 1) { + mutable.update { it.copy(serversLoading = true, serversError = null) } + repeat(attempts) { attempt -> + try { + // Service discovery is reached over dmsg; the list is then + // held in state and only refetched on demand. + val servers = api.services(PROXY_TYPE) + mutable.update { it.copy(servers = servers, serversLoading = false) } + return + } catch (e: Exception) { + if (attempt == attempts - 1) { + mutable.update { it.copy(serversLoading = false, serversError = e.message) } + } else { + delay(RETRY_DELAY_MS) + } + } + } + } + + /** Runs until the core-state collector cancels it. */ + private suspend fun pollAppState() { + while (true) { + try { + val state = api.app(SocksArgs.APP) + // Only a running app has connections, and that summary is + // best-effort — never let it mask the app state itself. + val connection = if (state.running) { + runCatching { api.appConnections(SocksArgs.APP) } + .getOrDefault(emptyList()) + .firstOrNull() + } else { + null + } + mutable.update { + it.copy( + app = state, + connection = connection, + listenPort = SocksArgs.listenPort(state.args), + error = null, + ) + } + } catch (e: Exception) { + mutable.update { it.copy(error = e.message) } + } + delay(POLL_INTERVAL_MS) + } + } + + private suspend fun readLastServer(): SavedServer? = + prefs.string(KEY_LAST_SERVER).first()?.let { stored -> + runCatching { json.decodeFromString(SavedServer.serializer(), stored) }.getOrNull() + } + + private suspend fun saveLastServer(server: SavedServer) { + prefs.putString(KEY_LAST_SERVER, json.encodeToString(SavedServer.serializer(), server)) + } + + private companion object { + /** SD's own filter value; "proxy" and "skysocks" are the same family. */ + const val PROXY_TYPE = "proxy" + const val KEY_LAST_SERVER = "socks_last_server" + const val PING_INTERVAL_MS = 700L + const val POLL_INTERVAL_MS = 2_000L + const val INITIAL_LOAD_ATTEMPTS = 3 + const val RETRY_DELAY_MS = 5_000L + } +} diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/theme/Theme.kt b/android/app/src/main/java/com/skycoin/skywire/ui/theme/Theme.kt new file mode 100644 index 0000000000..2b44708430 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/theme/Theme.kt @@ -0,0 +1,163 @@ +package com.skycoin.skywire.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Shapes +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +/** + * Brand tokens: blue-dominant, no dynamic (Material You) color — the palette + * is brand-locked on both themes. Light is the design's native theme (white + * surfaces, faint blue-tinted cards with hairline borders); dark is the same + * hue family moved onto deep navy rather than grey or black, so the blue + * still reads as the brand and not as decoration. + */ +val SkywireBlue = Color(0xFF0F7BF4) + +/** The gradient's deep stops — hero cards and the raised nav button. */ +val SkywireBlueDeep = Color(0xFF0B57C9) +val SkywireNavy = Color(0xFF0A3F97) + +/** + * Accents with no Material role of their own, identical on both themes: the + * greens hold up on white and on navy alike. + */ +object SkyAccents { + /** Running / healthy / gains. */ + val success = Color(0xFF22C275) + + /** The success green's bright form — dots and toggle tracks ON blue. */ + val successBright = Color(0xFF5CF2A8) + + /** Starting / restarting / degraded. */ + val warning = Color(0xFFF59E0B) + + /** Off / unprotected, where Material's error is too dark for blue. */ + val dangerBright = Color(0xFFFF7B6E) +} + +/** Hero surface fill: the deep three-stop diagonal from the design. */ +val SkyHeroGradient = Brush.linearGradient( + colors = listOf(Color(0xFF1685FA), SkywireBlueDeep, SkywireNavy), + start = Offset.Zero, + end = Offset.Infinite, +) + +/** Primary round action (Connect, the raised nav cloud): lighter, punchier. */ +val SkyButtonGradient = Brush.linearGradient( + colors = listOf(Color(0xFF3D9BFF), SkywireBlue, Color(0xFF0B4FBC)), + start = Offset.Zero, + end = Offset.Infinite, +) + +private val LightColors = lightColorScheme( + primary = SkywireBlue, + onPrimary = Color.White, + secondary = SkywireBlue, + onSecondary = Color.White, + background = Color.White, + onBackground = Color(0xFF0B1526), + surface = Color.White, + onSurface = Color(0xFF0B1526), + // Cards: a blue-tinted near-white, kept just off pure white — the + // hairline border from outlineVariant is what draws the card's edge, + // so the fill can stay bright and let the ink carry the contrast. + surfaceVariant = Color(0xFFFAFCFF), + // Darker than the mock's caption grey on purpose: this role carries + // real sentences (Settings bodies, subtitles), and at the mock's value + // they read faint on white. The mock keeps its washier greys for + // decoration (outline below), not for prose. + onSurfaceVariant = Color(0xFF44536B), + // Darker than a decorative grey, for the same reason as the role above: + // this one is not only hairlines. It tints the bottom bar's resting + // icons and every "off" glyph in the hub, and at the mock's value those + // read as switched-off rather than as not-currently-selected. ~4.9:1 on + // white, which is the floor for something you are meant to aim at. + outline = Color(0xFF56657C), + outlineVariant = Color(0xFFE7EEF9), + primaryContainer = Color(0xFFE4EEFD), + onPrimaryContainer = SkywireBlueDeep, + // Container roles drive tonal buttons and selection pills — without + // these, M3 baseline purple leaks in. + secondaryContainer = Color(0xFFEEF3FB), + onSecondaryContainer = Color(0xFF101A2B), + surfaceContainerLowest = Color.White, + surfaceContainerLow = Color(0xFFFBFCFE), + surfaceContainer = Color(0xFFF6F9FD), + surfaceContainerHigh = Color(0xFFF0F5FC), + surfaceContainerHighest = Color(0xFFE9EFF8), +) + +private val DarkColors = darkColorScheme( + // Brighter than the brand blue: on navy, #0F7BF4 sinks. Dark ink on it + // rather than white — the button should read as a light source. + primary = Color(0xFF4AA3FF), + onPrimary = Color(0xFF04264D), + secondary = Color(0xFF4AA3FF), + onSecondary = Color(0xFF04264D), + background = Color(0xFF0A101C), + onBackground = Color(0xFFECF2FB), + surface = Color(0xFF0A101C), + onSurface = Color(0xFFECF2FB), + surfaceVariant = Color(0xFF121C2F), + onSurfaceVariant = Color(0xFF93A3BD), + outline = Color(0xFF64758F), + outlineVariant = Color(0xFF1E2B45), + primaryContainer = Color(0xFF0E3D77), + onPrimaryContainer = Color(0xFFCFE4FF), + secondaryContainer = Color(0xFF16233B), + onSecondaryContainer = Color(0xFFD7E4F6), + surfaceContainerLowest = Color(0xFF070D17), + surfaceContainerLow = Color(0xFF0C1424), + surfaceContainer = Color(0xFF101A2E), + surfaceContainerHigh = Color(0xFF16223A), + surfaceContainerHighest = Color(0xFF1C2A46), +) + +/** + * The design's radius scale. Generous on purpose: chips at 12, buttons and + * icon tiles at 16, cards at 22, sheets and the nav shell at 28. Material + * pulls small/medium/large for its own components, so most of the app picks + * these up without asking. + */ +private val SkywireShapes = Shapes( + extraSmall = RoundedCornerShape(8.dp), + small = RoundedCornerShape(12.dp), + medium = RoundedCornerShape(16.dp), + large = RoundedCornerShape(22.dp), + extraLarge = RoundedCornerShape(28.dp), +) + +/** + * Whether the app resolved to its dark half. `isSystemInDarkTheme()` is not + * the same question — the user's own Light/Dark override sits on top of it — + * and screens that hand a colour scheme to something outside Compose need the + * answer after that override, not before. The embedded pages are the callers: + * SkyChat picks between its own theme pair with it, and SkyDEX's injected + * stylesheet re-tokens the trading UI by it. + */ +val LocalDarkTheme = staticCompositionLocalOf { true } + +@Composable +fun SkywireTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit, +) { + CompositionLocalProvider(LocalDarkTheme provides darkTheme) { + MaterialTheme( + colorScheme = if (darkTheme) DarkColors else LightColors, + typography = SkywireTypography, + shapes = SkywireShapes, + content = content, + ) + } +} diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/theme/Type.kt b/android/app/src/main/java/com/skycoin/skywire/ui/theme/Type.kt new file mode 100644 index 0000000000..97618892aa --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/theme/Type.kt @@ -0,0 +1,58 @@ +package com.skycoin.skywire.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import com.skycoin.skywire.R + +/** + * Two families, both variable-weight single files (minSdk 26, so the wght + * axis is always honoured and each [Font] entry below is a real instance, + * not a faux weight): + * + * - **Quicksand** carries every display/headline/title role — the rounded + * geometric voice of the redesign. Titles are Bold throughout; the family + * has no meaningful hierarchy below 600 at title sizes. + * - **Nunito** carries body and label text: the same roundness, but drawn + * for small sizes and long lines, which Quicksand is not. + */ +val QuicksandFamily = FontFamily( + Font(R.font.quicksand_variable, FontWeight.Medium), + Font(R.font.quicksand_variable, FontWeight.SemiBold), + Font(R.font.quicksand_variable, FontWeight.Bold), +) + +val NunitoFamily = FontFamily( + Font(R.font.nunito_variable, FontWeight.Normal), + Font(R.font.nunito_variable, FontWeight.Medium), + Font(R.font.nunito_variable, FontWeight.SemiBold), + Font(R.font.nunito_variable, FontWeight.Bold), +) + +private val Base = Typography() + +/** + * Quicksand for the roles that name things (display/headline/title), Nunito + * for the roles that explain them (body) and operate them (label). Body sits + * at SemiBold and labels at Bold — the design's text is deliberately chunky, + * and anything lighter reads faint the moment it lands on the blue-tinted + * cards. + */ +val SkywireTypography = Base.copy( + displayLarge = Base.displayLarge.copy(fontFamily = QuicksandFamily, fontWeight = FontWeight.Bold), + displayMedium = Base.displayMedium.copy(fontFamily = QuicksandFamily, fontWeight = FontWeight.Bold), + displaySmall = Base.displaySmall.copy(fontFamily = QuicksandFamily, fontWeight = FontWeight.Bold), + headlineLarge = Base.headlineLarge.copy(fontFamily = QuicksandFamily, fontWeight = FontWeight.Bold), + headlineMedium = Base.headlineMedium.copy(fontFamily = QuicksandFamily, fontWeight = FontWeight.Bold), + headlineSmall = Base.headlineSmall.copy(fontFamily = QuicksandFamily, fontWeight = FontWeight.Bold), + titleLarge = Base.titleLarge.copy(fontFamily = QuicksandFamily, fontWeight = FontWeight.Bold), + titleMedium = Base.titleMedium.copy(fontFamily = QuicksandFamily, fontWeight = FontWeight.Bold), + titleSmall = Base.titleSmall.copy(fontFamily = QuicksandFamily, fontWeight = FontWeight.Bold), + bodyLarge = Base.bodyLarge.copy(fontFamily = NunitoFamily, fontWeight = FontWeight.SemiBold), + bodyMedium = Base.bodyMedium.copy(fontFamily = NunitoFamily, fontWeight = FontWeight.SemiBold), + bodySmall = Base.bodySmall.copy(fontFamily = NunitoFamily, fontWeight = FontWeight.SemiBold), + labelLarge = Base.labelLarge.copy(fontFamily = NunitoFamily, fontWeight = FontWeight.Bold), + labelMedium = Base.labelMedium.copy(fontFamily = NunitoFamily, fontWeight = FontWeight.Bold), + labelSmall = Base.labelSmall.copy(fontFamily = NunitoFamily, fontWeight = FontWeight.Bold), +) diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/vpn/VpnModels.kt b/android/app/src/main/java/com/skycoin/skywire/ui/vpn/VpnModels.kt new file mode 100644 index 0000000000..3461f9538e --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/vpn/VpnModels.kt @@ -0,0 +1,53 @@ +package com.skycoin.skywire.ui.vpn + +import com.skycoin.skywire.core.argValue + +/** + * The two vpn-client flags this screen reads. + * + * Neither is ever written as raw argv: the visor owns both spellings — `--srv` + * through the PUT's `pk` field and `--killswitch` through its `killswitch` + * field, which adds the bare flag or strips it. This side only has to + * recognise what came back, including the `--flag=value` form a hand-edited + * config can carry. + */ +object VpnArgs { + + const val APP = "vpn-client" + + /** + * Where the last-used exit lives in [com.skycoin.skywire.core.AppPreferences] + * (a [com.skycoin.skywire.ui.components.SavedServer] as JSON). Owned by the + * SkyVPN screen; the apps hub reads it for the exit's country and to turn + * the tunnel back on from the hero card. + */ + const val PREF_LAST_SERVER = "vpn_last_server" + + /** The phone's killswitch preference — same arrangement, same owners. */ + const val PREF_KILLSWITCH = "vpn_killswitch" + + private val SRV = listOf("--srv", "-srv") + + fun serverPk(args: List): String? = + argValue(args, SRV)?.takeIf { it.isNotEmpty() } + + fun killswitch(args: List): Boolean = args.any { token -> + val bare = token.trimStart('-') + bare == KILLSWITCH || ( + bare.startsWith("$KILLSWITCH=") && bare.substringAfter('=') != "false" + ) + } + + private const val KILLSWITCH = "killswitch" +} + +/** + * The visor's own wording for what vpn-client is doing, as it reports it in + * `detailed_status`. Matched as strings because that is what the API sends — + * the constants live in pkg/app/appserver/app_state.go. + */ +object VpnStatus { + const val RUNNING = "Running" + const val CONNECTING = "Connecting" + const val RECONNECTING = "Connection failed, reconnecting" +} diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/vpn/VpnScreen.kt b/android/app/src/main/java/com/skycoin/skywire/ui/vpn/VpnScreen.kt new file mode 100644 index 0000000000..c33ec33414 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/vpn/VpnScreen.kt @@ -0,0 +1,531 @@ +package com.skycoin.skywire.ui.vpn + +import android.app.Activity +import android.content.Intent +import android.net.VpnService +import android.provider.Settings +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +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.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +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.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.skycoin.skywire.R +import com.skycoin.skywire.core.CoreState +import com.skycoin.skywire.ui.components.CONNECTED_GREEN +import com.skycoin.skywire.ui.components.InfoRow +import com.skycoin.skywire.ui.components.PENDING_AMBER +import com.skycoin.skywire.ui.components.SavedServer +import com.skycoin.skywire.ui.components.SectionCard +import com.skycoin.skywire.ui.components.ServerRow +import com.skycoin.skywire.ui.components.HelpTopic +import com.skycoin.skywire.ui.components.SkyTopBar +import com.skycoin.skywire.ui.components.MinHopsCard +import com.skycoin.skywire.ui.components.NetworkAddressCard +import com.skycoin.skywire.ui.components.TransportPreferenceCard +import com.skycoin.skywire.ui.components.TransportPreferenceSheet +import com.skycoin.skywire.ui.components.flagEmoji +import com.skycoin.skywire.ui.components.formatBytes +import com.skycoin.skywire.ui.components.formatDuration +import com.skycoin.skywire.ui.components.shortPk + +/** + * SkyVPN: pick an exit, route the whole phone through it. + * + * The same list-and-connect shape as SkySOCKS, with the two things a + * whole-phone tunnel adds — the killswitch, and stats worth watching while it + * carries your traffic. What makes it whole-phone is not on this screen: it + * is [com.skycoin.skywire.core.SkyVpnService], which owns the network + * interface and hands its descriptor to the visor. + */ +@Composable +fun VpnScreen( + onBack: () -> Unit, + viewModel: VpnViewModel = viewModel(), +) { + val state by viewModel.uiState.collectAsState() + val context = LocalContext.current + var transportSheetOpen by remember { mutableStateOf(false) } + + // Android will not create a TUN until the user has agreed to it, in the + // system's own dialog. prepare() returns the intent that asks; a null + // means consent is already given and we can go straight to connecting. + var pendingServer by remember { mutableStateOf(null) } + val consentDenied = stringResource(R.string.vpn_error_consent_denied) + val consent = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult(), + ) { result -> + val server = pendingServer + pendingServer = null + if (result.resultCode == Activity.RESULT_OK && server != null) { + viewModel.connect(server) + } else { + viewModel.fail(consentDenied) + } + } + val connect: (SavedServer) -> Unit = { server -> + val ask = VpnService.prepare(context) + if (ask == null) { + viewModel.connect(server) + } else { + pendingServer = server + consent.launch(ask) + } + } + + Scaffold( + topBar = { + SkyTopBar( + title = stringResource(R.string.app_skyvpn), + onBack = onBack, + help = HelpTopic(R.string.help_vpn_title, R.string.help_vpn_body), + ) + }, + ) { padding -> + LazyColumn( + modifier = Modifier.padding(padding), + contentPadding = PaddingValues(horizontal = 20.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + item { StatusCard(state, viewModel, connect) } + if (state.tunnel.established || state.running) { + item { StatsCard(state) } + } + item { + NetworkAddressCard( + overview = state.overview, + exitCountry = state.selectedCountry, + connected = state.carrying, + ) + } + item { + KillswitchCard( + on = state.killswitch, + // Changeable with the core down too — it is stored on the + // phone and applied when the visor next starts. + enabled = !state.busy, + onChange = viewModel::setKillswitch, + ) + } + item { + TransportPreferenceCard( + primary = state.transportPrimary, + enabled = !state.busy, + onClick = { transportSheetOpen = true }, + ) + } + item { + MinHopsCard( + hops = state.minHops, + // Needs the core: unlike the killswitch and the transport + // order, this is not a phone preference applied later — + // it is a live PUT to the visor's router settings. + enabled = state.coreReady && !state.busy, + onSelect = viewModel::setMinHops, + ) + } + + if (state.coreReady) { + item { ServersHeader(state, viewModel) } + items(state.filteredServers, key = { it.address }) { server -> + ServerRow( + server = server, + selected = server.pk == state.selectedPk, + enabled = !state.busy, + onClick = { connect(SavedServer.of(server)) }, + ) + } + item { ServersFooter(state, viewModel) } + } + } + } + + if (transportSheetOpen) { + TransportPreferenceSheet( + current = state.transportPrimary, + onDismiss = { transportSheetOpen = false }, + onSelect = { type -> + viewModel.setTransportPrimary(type) + transportSheetOpen = false + }, + ) + } +} + +// --- status --- + +@Composable +private fun StatusCard( + state: VpnUiState, + viewModel: VpnViewModel, + onConnect: (SavedServer) -> Unit, +) { + val clipboard = LocalClipboardManager.current + val context = LocalContext.current + val copied = stringResource(R.string.copied_to_clipboard) + + SectionCard { + Row(verticalAlignment = Alignment.CenterVertically) { + val (label, color) = statusLabel(state) + Box( + Modifier + .size(10.dp) + .clip(CircleShape) + .background(color), + ) + Spacer(Modifier.width(8.dp)) + Text(label, style = MaterialTheme.typography.titleMedium) + if (state.busy) { + Spacer(Modifier.width(10.dp)) + CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp) + } + } + + // The visor's own wording for what the app is doing ("Connecting", + // "Connection failed, reconnecting") — more useful than the numeric + // status whenever it disagrees with the label above. + state.app?.detailedStatus + ?.takeIf { it.isNotEmpty() && !it.equals(VpnStatus.RUNNING, ignoreCase = true) } + ?.let { detail -> + Spacer(Modifier.height(4.dp)) + Text( + detail, + style = MaterialTheme.typography.bodySmall, + color = if (state.errored) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + + state.selectedPk?.let { pk -> + Spacer(Modifier.height(12.dp)) + InfoRow( + label = stringResource(R.string.vpn_server), + value = listOfNotNull( + state.selectedCountry?.let(::flagEmoji), + shortPk(pk), + ).joinToString(" "), + mono = true, + modifier = Modifier.clickable { + clipboard.setText(AnnotatedString(pk)) + Toast.makeText(context, copied, Toast.LENGTH_SHORT).show() + }, + ) + } + + if (state.blocked && state.killswitch) { + Spacer(Modifier.height(8.dp)) + Text( + stringResource(R.string.vpn_hint_blocked), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + state.connection?.error?.takeIf { it.isNotEmpty() }?.let { + Spacer(Modifier.height(8.dp)) + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) + } + + listOfNotNull(state.error, state.tunnel.error).forEach { error -> + Spacer(Modifier.height(8.dp)) + Text( + error, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + + Spacer(Modifier.height(16.dp)) + ConnectControl(state, viewModel, onConnect) + } +} + +@Composable +private fun ConnectControl( + state: VpnUiState, + viewModel: VpnViewModel, + onConnect: (SavedServer) -> Unit, +) { + if (!state.coreReady) { + Text( + stringResource( + if (state.coreState is CoreState.Stopped) { + R.string.vpn_core_offline + } else { + R.string.vpn_core_starting + }, + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + return + } + + val active = state.running || state.starting || state.tunnel.established + val pk = state.selectedPk + Button( + onClick = { + if (active) { + viewModel.disconnect() + } else if (pk != null) { + onConnect(state.lastServer?.takeIf { it.pk == pk } ?: SavedServer(pk)) + } + }, + enabled = !state.busy && (active || pk != null), + modifier = Modifier.fillMaxWidth(), + colors = if (active) { + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } else { + ButtonDefaults.buttonColors() + }, + ) { + Text( + stringResource( + when { + active -> R.string.disconnect + pk != null -> R.string.vpn_reconnect + else -> R.string.connect + }, + ), + ) + } + if (!active && pk == null) { + Spacer(Modifier.height(8.dp)) + Text( + stringResource(R.string.vpn_pick_server), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +// --- stats --- + +@Composable +private fun StatsCard(state: VpnUiState) { + val connection = state.connection + + SectionCard { + Text( + stringResource(R.string.vpn_stats_title), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(6.dp)) + + // Round-trip to the exit, as the route group measures it. Zero until + // the first measurement lands, and showing "0 ms" would be a claim. + connection?.latencyMs?.takeIf { it > 0 }?.let { latency -> + InfoRow(label = stringResource(R.string.vpn_stats_ping), value = "$latency ms") + } + InfoRow( + label = stringResource(R.string.vpn_stats_transferred), + value = "↑ ${formatBytes(connection?.bandwidthSent ?: 0)}" + + " ↓ ${formatBytes(connection?.bandwidthReceived ?: 0)}", + ) + connection?.takeIf { it.uploadSpeed > 0 || it.downloadSpeed > 0 }?.let { + InfoRow( + label = stringResource(R.string.vpn_stats_speed), + value = "↑ ${formatBytes(it.uploadSpeed)}/s ↓ ${formatBytes(it.downloadSpeed)}/s", + ) + } + connection?.connectionSeconds?.takeIf { it > 0 }?.let { seconds -> + InfoRow( + label = stringResource(R.string.vpn_stats_session), + value = formatDuration(seconds), + ) + } + InfoRow( + label = stringResource(R.string.vpn_stats_total), + value = formatBytes(state.lifetimeBytes), + ) + state.tunnel.address.takeIf { it.isNotEmpty() }?.let { address -> + InfoRow( + label = stringResource(R.string.vpn_stats_interface), + value = address, + mono = true, + ) + } + + Spacer(Modifier.height(8.dp)) + Text( + stringResource(R.string.vpn_hint_excluded), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +// --- killswitch --- + +@Composable +private fun KillswitchCard(on: Boolean, enabled: Boolean, onChange: (Boolean) -> Unit) { + val context = LocalContext.current + val noSettings = stringResource(R.string.vpn_error_settings) + + SectionCard { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Text( + stringResource(R.string.vpn_killswitch_title), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f), + ) + Switch(checked = on, onCheckedChange = onChange, enabled = enabled) + } + Spacer(Modifier.height(4.dp)) + Text( + stringResource(R.string.vpn_killswitch_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(8.dp)) + Text( + stringResource(R.string.vpn_killswitch_always_on_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + FilledTonalButton( + onClick = { + // Not every build ships the VPN settings screen; a phone + // without it says so instead of crashing. + val opened = runCatching { + context.startActivity( + Intent(Settings.ACTION_VPN_SETTINGS) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), + ) + }.isSuccess + if (!opened) Toast.makeText(context, noSettings, Toast.LENGTH_SHORT).show() + }, + ) { + Text(stringResource(R.string.vpn_killswitch_always_on)) + } + } +} + +// --- exit list --- + +@Composable +private fun ServersHeader(state: VpnUiState, viewModel: VpnViewModel) { + Column { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + stringResource(R.string.vpn_servers, state.servers.size), + style = MaterialTheme.typography.titleMedium, + ) + if (state.serversLoading) { + CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) + } else { + IconButton(onClick = viewModel::refreshServers) { + Icon( + Icons.Default.Refresh, + contentDescription = stringResource(R.string.socks_refresh), + ) + } + } + } + OutlinedTextField( + value = state.query, + onValueChange = viewModel::setQuery, + singleLine = true, + placeholder = { Text(stringResource(R.string.vpn_search_hint)) }, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Composable +private fun ServersFooter(state: VpnUiState, viewModel: VpnViewModel) { + val serversError = state.serversError + when { + serversError != null -> Column { + Text( + serversError, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + FilledTonalButton(onClick = viewModel::refreshServers) { + Text(stringResource(R.string.socks_retry)) + } + } + state.serversLoading && state.servers.isEmpty() -> Unit + state.filteredServers.isEmpty() -> Text( + stringResource( + if (state.servers.isEmpty()) R.string.vpn_servers_none else R.string.vpn_servers_empty, + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Composable +private fun statusLabel(state: VpnUiState): Pair = when { + !state.coreReady -> stringResource(R.string.state_disconnected) to + MaterialTheme.colorScheme.onSurfaceVariant + // Named only when the setting is what is holding it — the same state with + // the killswitch off is a tunnel about to come back, not a block. + state.blocked && state.killswitch -> + stringResource(R.string.vpn_state_blocked) to PENDING_AMBER + state.carrying -> stringResource(R.string.state_connected) to CONNECTED_GREEN + state.running || state.starting -> + stringResource(R.string.vpn_state_connecting) to PENDING_AMBER + state.errored -> stringResource(R.string.vpn_state_error) to MaterialTheme.colorScheme.error + else -> stringResource(R.string.state_disconnected) to + MaterialTheme.colorScheme.onSurfaceVariant +} diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/vpn/VpnViewModel.kt b/android/app/src/main/java/com/skycoin/skywire/ui/vpn/VpnViewModel.kt new file mode 100644 index 0000000000..7e6ef42f6a --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/vpn/VpnViewModel.kt @@ -0,0 +1,463 @@ +package com.skycoin.skywire.ui.vpn + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.skycoin.skywire.api.AppConnection +import com.skycoin.skywire.api.AppState +import com.skycoin.skywire.api.Overview +import com.skycoin.skywire.api.ServiceEntry +import com.skycoin.skywire.api.VisorApi +import com.skycoin.skywire.core.AppPreferences +import com.skycoin.skywire.core.CoreServiceState +import com.skycoin.skywire.core.CoreState +import com.skycoin.skywire.core.SkyVpnService +import com.skycoin.skywire.core.TransportPreference +import com.skycoin.skywire.core.VpnTunnel +import com.skycoin.skywire.core.VpnTunnelState +import com.skycoin.skywire.ui.components.SavedServer +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json + +/** Everything the SkyVPN screen renders. */ +data class VpnUiState( + val coreState: CoreState = CoreState.Stopped, + /** The local API answered — without it nothing on this screen works. */ + val apiUp: Boolean = false, + val app: AppState? = null, + val connection: AppConnection? = null, + /** What [SkyVpnService] has done with the phone's network interface. */ + val tunnel: VpnTunnelState = VpnTunnelState(), + val servers: List = emptyList(), + val serversLoading: Boolean = false, + val serversError: String? = null, + val query: String = "", + /** Transport type the visor tries first — see [TransportPreference]. */ + val transportPrimary: String = TransportPreference.DEFAULT, + /** + * Minimum route hops. Unlike [transportPrimary], which the phone owns and + * re-pins into the config on every launch, this one is the visor's: it is + * read from and written to the router settings, the visor persists it, and + * the phone profile's routing edit preserves whatever it finds. 0 means + * "not read yet" — the visor's own floor is 1. + */ + val minHops: Int = 0, + val killswitch: Boolean = false, + /** Last summary overview — the device's own address comes off this. */ + val overview: Overview? = null, + val lastServer: SavedServer? = null, + /** Bytes this phone has moved through SkyVPN, across all sessions. */ + val lifetimeBytes: Long = 0, + /** A start/stop/settings call is in flight. */ + val busy: Boolean = false, + val error: String? = null, +) { + val coreReady: Boolean get() = coreState is CoreState.Running && apiUp + + /** The server the app is configured for, whether or not it runs. */ + val selectedPk: String? get() = app?.args?.let(VpnArgs::serverPk) ?: lastServer?.pk + + /** + * Country of [selectedPk], from the discovery list when it is loaded and + * otherwise from the saved server — but only when that is the same key, + * so the flag can never belong to a different exit than the one shown. + */ + val selectedCountry: String? + get() = selectedPk?.let { pk -> + servers.firstOrNull { it.pk == pk }?.geo?.country + ?: lastServer?.takeIf { it.pk == pk }?.country + }?.takeIf { it.isNotEmpty() } + + val running: Boolean get() = app?.running == true + val starting: Boolean get() = app?.status == AppState.STATUS_STARTING + val errored: Boolean get() = app?.status == AppState.STATUS_ERRORED + + /** The tunnel is up and carrying: the visor says so and traffic is in it. */ + val carrying: Boolean + get() = running && tunnel.established && + app?.detailedStatus != VpnStatus.RECONNECTING + + /** + * The interface is in place with nothing carrying traffic through it — + * the killswitch doing its job. Every other app on the phone is offline + * until the tunnel comes back or the user disconnects. + */ + val blocked: Boolean get() = tunnel.established && !carrying + + val sessionBytes: Long + get() = connection?.let { it.bandwidthSent + it.bandwidthReceived } ?: 0 + + val filteredServers: List + get() = if (query.isBlank()) { + servers + } else { + servers.filter { + it.pk.contains(query, true) || + it.geo?.country.orEmpty().contains(query, true) || + it.geo?.region.orEmpty().contains(query, true) || + it.version.contains(query, true) + } + } +} + +/** + * Drives SkyVPN: list exits from service discovery, put the phone's network + * interface in place, point vpn-client at an exit and watch what it carries. + * + * Two things run in step here and neither works alone. [SkyVpnService] owns + * the interface — it is the only thing on Android allowed to — and vpn-client + * owns the tunnel the interface drains into. So the service goes up first + * (the core asks it for a descriptor mid-handshake and must find it + * listening) and comes down last. + */ +class VpnViewModel(app: Application) : AndroidViewModel(app) { + + private val api = VisorApi.get(app) + private val prefs = AppPreferences(app) + private val json = Json { ignoreUnknownKeys = true } + + private val mutable = MutableStateFlow(VpnUiState()) + val uiState: StateFlow = mutable.asStateFlow() + + private var actionJob: Job? = null + + /** Session total at the last accrual, to turn the counter into a delta. */ + private var lastSessionBytes = 0L + + /** Accrued but not yet written to disk — see [accrue]. */ + private var unsavedBytes = 0L + + init { + viewModelScope.launch { + mutable.update { + it.copy( + lastServer = readLastServer(), + killswitch = prefs.boolean(VpnArgs.PREF_KILLSWITCH).first(), + lifetimeBytes = prefs.long(KEY_LIFETIME_BYTES).first(), + transportPrimary = TransportPreference.sanitize( + prefs.string(TransportPreference.PREF_KEY).first(), + ), + ) + } + } + viewModelScope.launch { + VpnTunnel.state.collect { tunnel -> mutable.update { it.copy(tunnel = tunnel) } } + } + viewModelScope.launch { + CoreServiceState.state.collectLatest { core -> + mutable.update { + it.copy(coreState = core, apiUp = false, app = null, connection = null) + } + if (core is CoreState.Running) { + while (!api.ping()) delay(PING_INTERVAL_MS) + mutable.update { it.copy(apiUp = true) } + applyStoredKillswitch() + runCatching { api.routerSettings().minHops }.getOrNull()?.let { hops -> + mutable.update { it.copy(minHops = hops) } + } + // Once, not per poll: public_ip is the visor's startup + // STUN result and STUN is not re-run in steady state, so + // re-reading it every two seconds would cost a summary + // call to watch a value that cannot change. + runCatching { api.summary().overview }.getOrNull()?.let { ov -> + mutable.update { it.copy(overview = ov) } + } + // The API answers well before dmsg has a session, and the + // exit list rides dmsg — so the opening fetch gets a few + // attempts before it becomes the user's problem. + loadServers(attempts = INITIAL_LOAD_ATTEMPTS) + pollAppState() + } + } + } + } + + fun setQuery(query: String) { + mutable.update { it.copy(query = query) } + } + + fun refreshServers() { + viewModelScope.launch { loadServers() } + } + + /** Route the phone through [server]. Consent is the screen's business. */ + fun connect(server: SavedServer) = action { startWith(server) } + + /** Reconnect to whatever the app is already pointed at. */ + fun reconnect() { + val state = mutable.value + val pk = state.selectedPk ?: return + connect(state.lastServer?.takeIf { it.pk == pk } ?: SavedServer(pk)) + } + + /** + * Stop carrying, then take the interface away. Deliberate, so the + * killswitch does not apply: it exists to survive a dropped tunnel, not + * to trap the phone after the user asked to be let out. + */ + fun disconnect() = action { + val stopped = runCatching { + api.updateApp(VpnArgs.APP, status = VisorApi.APP_STOP) + }.getOrNull() + SkyVpnService.stop(getApplication()) + flushLifetime() + lastSessionBytes = 0 + mutable.update { it.copy(app = stopped ?: it.app, connection = null) } + } + + /** + * Turn the killswitch on or off. + * + * Stored on the phone first: it is the phone's setting, applied to the + * visor's config whenever the core comes up ([applyStoredKillswitch]), so + * the choice holds even when it is made with nothing running. Both halves + * then need it — vpn-client stops restoring traffic between reconnect + * attempts, and the service stops releasing the interface when the core + * goes away without a word. + * + * A running tunnel is re-dialed rather than PUT in place: the visor + * restarts the app itself on a killswitch change, and that restart races + * the old proc exactly as it does on SkySOCKS' port change. + */ + fun setKillswitch(on: Boolean) = action { + prefs.putBoolean(VpnArgs.PREF_KILLSWITCH, on) + mutable.update { it.copy(killswitch = on) } + if (mutable.value.tunnel.serviceUp) { + SkyVpnService.setKillswitch(getApplication(), on) + } + if (!mutable.value.coreReady) return@action + + val state = mutable.value + if (!state.running && !state.starting) { + api.updateApp(VpnArgs.APP, killswitch = on) + return@action + } + val pk = state.selectedPk ?: return@action + startWith(state.lastServer?.takeIf { it.pk == pk } ?: SavedServer(pk)) + } + + /** + * Change the minimum number of route hops. Visor-wide, like the transport + * preference, and applied to the running tunnel by re-dialling it: the + * setting only takes effect when a route is built, so an established + * tunnel would otherwise keep the hop count it was dialled with and the + * screen would claim a privacy property the traffic does not have. + */ + fun setMinHops(hops: Int) = action { + val settings = api.setMinHops(hops) + mutable.update { it.copy(minHops = settings.minHops) } + val state = mutable.value + if (state.running || state.starting) { + val pk = state.selectedPk ?: return@action + startWith(state.lastServer?.takeIf { it.pk == pk } ?: SavedServer(pk)) + } + } + + /** + * Change the transport type the visor tries first. Visor-wide, not + * SkyVPN-only — SkySOCKS shows and changes the same value. + */ + fun setTransportPrimary(type: String) = action { + prefs.putString(TransportPreference.PREF_KEY, type) + mutable.update { it.copy(transportPrimary = type) } + if (!mutable.value.coreReady) return@action + api.setTransportPreference(TransportPreference.order(type)) + val state = mutable.value + if (state.running || state.starting) { + val pk = state.selectedPk ?: return@action + startWith(state.lastServer?.takeIf { it.pk == pk } ?: SavedServer(pk)) + } + } + + /** Surface a failure the screen caused (a denied consent, say). */ + fun fail(message: String) { + mutable.update { it.copy(error = message) } + } + + override fun onCleared() { + // Whatever accrued since the last write would otherwise be lost when + // the screen is left with the tunnel still up. + viewModelScope.launch { flushLifetime() } + super.onCleared() + } + + // --- internals --- + + /** + * Put the interface in place, point the app at [server], start it. + * + * The service goes first and unconditionally: vpn-client asks it for a + * descriptor while it sets the TUN up, which is a moment after the + * handshake with the exit — if nothing is listening by then, the dial + * fails and the retrier backs off for nothing. + * + * The stop is unconditional and its failure ignored, for the reason + * SkySOCKS documents: stopping a stopped app is harmless, while skipping + * a needed stop is not. + */ + private suspend fun startWith(server: SavedServer) { + val killswitch = mutable.value.killswitch + SkyVpnService.start(getApplication(), killswitch) + runCatching { api.updateApp(VpnArgs.APP, status = VisorApi.APP_STOP) } + val updated = api.updateApp( + VpnArgs.APP, + pk = server.pk, + killswitch = killswitch, + status = VisorApi.APP_START, + ) + saveLastServer(server) + lastSessionBytes = 0 + mutable.update { it.copy(app = updated, lastServer = server) } + } + + /** + * The phone's killswitch preference is authoritative over whatever the + * visor's config carries — the same deal [TransportPreference] has. The + * app is not running yet at this point, so this only rewrites config. + */ + private suspend fun applyStoredKillswitch() { + val wanted = mutable.value.killswitch + runCatching { + val current = api.app(VpnArgs.APP) + if (VpnArgs.killswitch(current.args) != wanted) { + api.updateApp(VpnArgs.APP, killswitch = wanted) + } + } + } + + /** + * One user action at a time, with its failure surfaced on the screen. + * Launched on the view-model scope rather than inside the state + * collector, which is cancelled the moment the core state changes. + */ + private fun action(block: suspend () -> Unit) { + actionJob?.cancel() + actionJob = viewModelScope.launch { + mutable.update { it.copy(busy = true, error = null) } + try { + block() + } catch (e: Exception) { + mutable.update { it.copy(error = e.message) } + } finally { + mutable.update { it.copy(busy = false) } + } + } + } + + /** + * Fetch the exit list, keeping the spinner up across [attempts] tries. + * Only the last failure is shown — an intermediate one just means dmsg + * wasn't ready yet. + */ + private suspend fun loadServers(attempts: Int = 1) { + mutable.update { it.copy(serversLoading = true, serversError = null) } + repeat(attempts) { attempt -> + try { + val servers = api.services(VPN_TYPE) + mutable.update { it.copy(servers = servers, serversLoading = false) } + return + } catch (e: Exception) { + if (attempt == attempts - 1) { + mutable.update { it.copy(serversLoading = false, serversError = e.message) } + } else { + delay(RETRY_DELAY_MS) + } + } + } + } + + /** Runs until the core-state collector cancels it. */ + private suspend fun pollAppState() { + while (true) { + try { + val state = api.app(VpnArgs.APP) + // Only a running app has connections or stats, and both are + // best-effort — never let them mask the app state itself. + val connection = if (state.running) { + runCatching { api.appConnections(VpnArgs.APP) } + .getOrDefault(emptyList()) + .firstOrNull() + } else { + null + } + mutable.update { + it.copy( + app = state, + connection = connection, + killswitch = VpnArgs.killswitch(state.args), + error = null, + ) + } + accrue(connection) + } catch (e: Exception) { + mutable.update { it.copy(error = e.message) } + } + delay(POLL_INTERVAL_MS) + } + } + + /** + * Fold this poll's traffic into the lifetime counter. + * + * The app's counters restart with every connection, so what is added is + * the rise since the last poll; a value that went *down* means a new + * connection started and the whole of it is new. Written to disk in + * chunks rather than every two seconds — the number is a curiosity, not + * an accounting record, and losing the last megabyte to a kill is a + * better trade than a DataStore write per poll for as long as the phone + * is connected. + */ + private suspend fun accrue(connection: AppConnection?) { + if (connection == null) { + flushLifetime() + lastSessionBytes = 0 + return + } + val session = connection.bandwidthSent + connection.bandwidthReceived + val delta = if (session >= lastSessionBytes) session - lastSessionBytes else session + lastSessionBytes = session + if (delta <= 0) return + + unsavedBytes += delta + val total = mutable.value.lifetimeBytes + delta + mutable.update { it.copy(lifetimeBytes = total) } + if (unsavedBytes >= PERSIST_EVERY_BYTES) flushLifetime() + } + + private suspend fun flushLifetime() { + if (unsavedBytes <= 0) return + unsavedBytes = 0 + runCatching { prefs.putLong(KEY_LIFETIME_BYTES, mutable.value.lifetimeBytes) } + } + + private suspend fun readLastServer(): SavedServer? = + prefs.string(VpnArgs.PREF_LAST_SERVER).first()?.let { stored -> + runCatching { json.decodeFromString(SavedServer.serializer(), stored) }.getOrNull() + } + + private suspend fun saveLastServer(server: SavedServer) { + prefs.putString( + VpnArgs.PREF_LAST_SERVER, + json.encodeToString(SavedServer.serializer(), server), + ) + } + + private companion object { + /** SD's own filter value for the SkyVPN server family. */ + const val VPN_TYPE = "vpn" + const val KEY_LIFETIME_BYTES = "vpn_lifetime_bytes" + const val PING_INTERVAL_MS = 700L + const val POLL_INTERVAL_MS = 2_000L + const val INITIAL_LOAD_ATTEMPTS = 3 + const val RETRY_DELAY_MS = 5_000L + const val PERSIST_EVERY_BYTES = 8L * 1024 * 1024 + } +} diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/wallet/CoinIcons.kt b/android/app/src/main/java/com/skycoin/skywire/ui/wallet/CoinIcons.kt new file mode 100644 index 0000000000..d564c21e78 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/wallet/CoinIcons.kt @@ -0,0 +1,65 @@ +package com.skycoin.skywire.ui.wallet + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import androidx.annotation.DrawableRes +import com.skycoin.skywire.R +import com.skycoin.skywire.wallet.CoinSpec +import java.io.File +import java.util.UUID + +/** + * Badge artwork for the coin list. The four built-ins ship with their real + * logos (bundled PNGs from the CC0 cryptocurrency-icons set — no network + * fetch at render time); a user-added coin carries an image its owner + * picked from the phone at creation, stored under [CoinSpec.icon], and + * ticker letters when they picked none. + */ +@DrawableRes +fun builtInCoinLogo(coinId: String): Int? = when (coinId) { + CoinSpec.SKY.id -> R.drawable.coin_sky + CoinSpec.BTC.id -> R.drawable.coin_btc + CoinSpec.ETH.id -> R.drawable.coin_eth + CoinSpec.USDT.id -> R.drawable.coin_usdt + else -> null +} + +/** Where picked coin icons live: app-private, survives across sessions. */ +fun coinIconFile(context: Context, name: String): File = + File(File(context.filesDir, "coin_icons"), name) + +/** + * Copy a picked image into app storage as the coin's badge: center-cropped + * square, scaled down to badge size, PNG. Copying matters — the photo + * picker's grant on the original Uri does not outlive this process, so the + * badge has to be our own file. Returns the stored file's name. + */ +fun importCoinIconFrom(context: Context, uri: Uri): String { + val source = context.contentResolver.openInputStream(uri)?.use { + BitmapFactory.decodeStream(it) + } ?: throw IllegalArgumentException("that image cannot be read") + val side = minOf(source.width, source.height) + val square = Bitmap.createBitmap( + source, + (source.width - side) / 2, + (source.height - side) / 2, + side, + side, + ) + val scaled = if (side > ICON_SIDE_PX) { + Bitmap.createScaledBitmap(square, ICON_SIDE_PX, ICON_SIDE_PX, true) + } else { + square + } + val dir = File(context.filesDir, "coin_icons").apply { mkdirs() } + val file = File(dir, "coin-${UUID.randomUUID().toString().take(8)}.png") + file.outputStream().use { out -> + scaled.compress(Bitmap.CompressFormat.PNG, 100, out) + } + return file.name +} + +/** 192px covers the largest badge (46dp) on up to ~4x density screens. */ +private const val ICON_SIDE_PX = 192 diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/wallet/WalletHistory.kt b/android/app/src/main/java/com/skycoin/skywire/ui/wallet/WalletHistory.kt new file mode 100644 index 0000000000..31bbdd7ba1 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/wallet/WalletHistory.kt @@ -0,0 +1,375 @@ +package com.skycoin.skywire.ui.wallet + +import android.content.Intent +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.lazy.LazyColumn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.OpenInNew +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import com.skycoin.skywire.R +import com.skycoin.skywire.ui.components.SkyTopBar +import com.skycoin.skywire.wallet.CachedTx +import com.skycoin.wallet.Amounts +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import kotlinx.coroutines.launch + +private enum class TxFilter { ALL, SENT, RECEIVED, PENDING } + +/** Full history: filter chips, transactions grouped by day. */ +@Composable +fun WalletHistoryScreen( + viewModel: WalletViewModel, + onTx: (String) -> Unit, + onReceive: () -> Unit, + onBack: () -> Unit, +) { + val state by viewModel.uiState.collectAsState() + val coin = state.coin + var filter by remember { mutableStateOf(TxFilter.ALL) } + + val txs = state.snapshot?.txs.orEmpty().filter { + when (filter) { + TxFilter.ALL -> true + TxFilter.SENT -> !it.incoming + TxFilter.RECEIVED -> it.incoming + TxFilter.PENDING -> !it.confirmed + } + } + val groups = txs.groupBy { dayLabel(it.timestamp) } + + Scaffold(topBar = { SkyTopBar(stringResource(R.string.wallet_history_title), onBack = onBack) }) { padding -> + LazyColumn( + modifier = Modifier.padding(padding), + contentPadding = PaddingValues(bottom = 24.dp), + ) { + item { + Row( + modifier = Modifier + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 20.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilterChipButton(stringResource(R.string.wallet_filter_all), filter == TxFilter.ALL) { filter = TxFilter.ALL } + FilterChipButton(stringResource(R.string.wallet_filter_sent), filter == TxFilter.SENT) { filter = TxFilter.SENT } + FilterChipButton(stringResource(R.string.wallet_filter_received), filter == TxFilter.RECEIVED) { filter = TxFilter.RECEIVED } + FilterChipButton(stringResource(R.string.wallet_filter_pending), filter == TxFilter.PENDING) { filter = TxFilter.PENDING } + } + } + + if (groups.isEmpty()) { + item { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 40.dp, vertical = 80.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + Modifier + .size(52.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant), + ) + Text( + stringResource(R.string.wallet_history_empty_title), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(top = 20.dp), + ) + Text( + stringResource( + if (filter == TxFilter.ALL) R.string.wallet_history_empty_all + else R.string.wallet_history_empty_filter, + coin.ticker, + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp), + textAlign = TextAlign.Center, + ) + FilledTonalButton(onClick = onReceive, modifier = Modifier.padding(top = 20.dp)) { + Text(stringResource(R.string.wallet_history_show_address), fontWeight = FontWeight.Bold) + } + } + } + } else { + groups.forEach { (day, dayTxs) -> + item { + Text( + day.uppercase(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 20.dp, end = 20.dp, top = 22.dp, bottom = 10.dp), + ) + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + // Cards hold real prose: content must default to ink, not the + // muted onSurfaceVariant this container would otherwise imply. + contentColor = MaterialTheme.colorScheme.onSurface, + ), + shape = RoundedCornerShape(16.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + ) { + Column { + dayTxs.forEachIndexed { i, tx -> + TxRow( + coin = coin, + tx = tx, + showStatus = true, + topDivider = i > 0, + onClick = { onTx(tx.txid) }, + ) + } + } + } + } + } + } + } + } +} + +@Composable +private fun FilterChipButton(label: String, selected: Boolean, onClick: () -> Unit) { + Text( + label, + style = MaterialTheme.typography.labelLarge, + color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .clip(RoundedCornerShape(18.dp)) + .background( + if (selected) MaterialTheme.colorScheme.secondaryContainer + else MaterialTheme.colorScheme.surfaceVariant, + ) + .clickable(onClick = onClick) + .padding(horizontal = 15.dp, vertical = 9.dp), + ) +} + +/** One transaction, in full. */ +@Composable +fun WalletTxScreen( + viewModel: WalletViewModel, + txid: String, + onBack: () -> Unit, +) { + val state by viewModel.uiState.collectAsState() + val coin = state.coin + val tx = state.snapshot?.txs?.firstOrNull { it.txid == txid } + val clipboard = LocalClipboardManager.current + val context = LocalContext.current + val snackbar = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + val copied = stringResource(R.string.wallet_txid_copied) + + Scaffold( + snackbarHost = { SnackbarHost(snackbar) }, + topBar = { SkyTopBar(stringResource(R.string.wallet_tx_title), onBack = onBack) }, + ) { padding -> + if (tx == null) { + Box(Modifier.padding(padding).fillMaxWidth().padding(40.dp), contentAlignment = Alignment.Center) { + Text( + stringResource(R.string.wallet_history_empty_title), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + return@Scaffold + } + Column( + modifier = Modifier + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp), + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + (if (tx.incoming) "+" else "−") + coin.amountText(tx.amount) + " " + coin.ticker, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = txColor(tx), + ) + Row( + modifier = Modifier + .padding(top = 12.dp) + .clip(RoundedCornerShape(16.dp)) + .background(txColor(tx).copy(alpha = 0.12f)) + .padding(horizontal = 13.dp, vertical = 7.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(7.dp), + ) { + Box( + Modifier + .size(6.dp) + .clip(CircleShape) + .background(txColor(tx)), + ) + Text( + stringResource( + if (tx.confirmed) R.string.wallet_tx_confirmed + else R.string.wallet_tx_pending_long, + ), + style = MaterialTheme.typography.labelLarge, + color = txColor(tx), + ) + } + } + + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + // Cards hold real prose: content must default to ink, not the + // muted onSurfaceVariant this container would otherwise imply. + contentColor = MaterialTheme.colorScheme.onSurface, + ), + shape = RoundedCornerShape(16.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Column(Modifier.padding(horizontal = 16.dp)) { + DetailRow( + stringResource(if (tx.incoming) R.string.wallet_tx_from else R.string.wallet_tx_to), + tx.party?.let { shortAddress(it) } ?: "—", + first = true, + ) + DetailRow(stringResource(R.string.wallet_tx_wallet), state.active?.name ?: "—") + DetailRow( + stringResource(R.string.wallet_tx_date), + Instant.ofEpochSecond(tx.timestamp).atZone(ZoneId.systemDefault()) + .format(DateTimeFormatter.ofPattern("d MMMM, HH:mm")), + ) + DetailRow(stringResource(R.string.wallet_tx_fee), feeText(coin, tx.fee)) + DetailRow( + stringResource(R.string.wallet_tx_confirmations), + if (tx.confirmed) Amounts.groupThousands(tx.confirmations.toString()) else "0 of 1", + ) + HorizontalDivider(color = MaterialTheme.colorScheme.surfaceContainerHighest) + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + clipboard.setText(AnnotatedString(tx.txid)) + scope.launch { snackbar.showSnackbar(copied) } + } + .padding(vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + stringResource(R.string.wallet_tx_id), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.weight(1f)) + Text( + shortAddress(tx.txid, 8, 6), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + ) + Icon( + Icons.Outlined.ContentCopy, null, + Modifier.padding(start = 8.dp).size(15.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + coin.explorerTxUrl?.let { template -> + FilledTonalButton( + onClick = { + val url = template.format(tx.txid) + context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri())) + }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 14.dp) + .height(50.dp), + ) { + Text(stringResource(R.string.wallet_tx_explorer), fontWeight = FontWeight.Bold) + Icon( + Icons.AutoMirrored.Outlined.OpenInNew, null, + Modifier.padding(start = 9.dp).size(15.dp), + ) + } + Text( + stringResource( + R.string.wallet_tx_explorer_note, + template.format("").removeSuffix("/").toUri().host ?: "the explorer", + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp, bottom = 24.dp), + textAlign = TextAlign.Center, + ) + } + } + } +} + +@Composable +private fun DetailRow(label: String, value: String, first: Boolean = false) { + if (!first) { + HorizontalDivider(color = MaterialTheme.colorScheme.surfaceContainerHighest) + } + Row(Modifier.padding(vertical = 14.dp), verticalAlignment = Alignment.CenterVertically) { + Text( + label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.weight(1f)) + Text(value, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold) + } +} diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/wallet/WalletManage.kt b/android/app/src/main/java/com/skycoin/skywire/ui/wallet/WalletManage.kt new file mode 100644 index 0000000000..e30267fa01 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/wallet/WalletManage.kt @@ -0,0 +1,687 @@ +package com.skycoin.skywire.ui.wallet + +import android.graphics.BitmapFactory +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material.icons.outlined.DeleteOutline +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material.icons.outlined.ErrorOutline +import androidx.compose.material.icons.outlined.MoreVert +import androidx.compose.material.icons.outlined.Visibility +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +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.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +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 com.skycoin.skywire.R +import com.skycoin.skywire.ui.components.SecureWindow +import com.skycoin.skywire.ui.components.SkyTopBar +import com.skycoin.skywire.wallet.WalletMeta +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** Wallets of the selected coin: switch, rename, reveal, remove, add. */ +@Composable +fun WalletManageScreen( + viewModel: WalletViewModel, + onAddWallet: () -> Unit, + onRestoreWallet: () -> Unit, + onReveal: (String) -> Unit, + onBack: () -> Unit, +) { + val state by viewModel.uiState.collectAsState() + val snackbar = remember { SnackbarHostState() } + val context = LocalContext.current + val scope = androidx.compose.runtime.rememberCoroutineScope() + var sheetWallet by remember { mutableStateOf(null) } + var removeTarget by remember { mutableStateOf(null) } + var renameTarget by remember { mutableStateOf(null) } + + LaunchedEffect(state.message) { + state.message?.let { snackbar.showSnackbar(it); viewModel.messageShown() } + } + + Scaffold( + snackbarHost = { SnackbarHost(snackbar) }, + topBar = { SkyTopBar(stringResource(R.string.wallet_wallets_title), onBack = onBack) }, + ) { padding -> + Column( + modifier = Modifier + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + state.coinWallets.forEach { wallet -> + val isActive = wallet.id == state.active?.id + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .clickable { sheetWallet = wallet } + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(13.dp), + ) { + Box( + modifier = Modifier + .size(38.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.secondaryContainer), + contentAlignment = Alignment.Center, + ) { + Text( + wallet.name.take(2).uppercase(), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + Column(Modifier.weight(1f)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + wallet.name, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + ) + if (isActive) { + Text( + stringResource(R.string.wallet_active_badge), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.secondaryContainer) + .padding(horizontal = 7.dp, vertical = 2.dp), + ) + } + } + Text( + "${shortAddress(wallet.receiveAddresses.first(), 6, 3)} · " + + addressCount(wallet), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 3.dp), + ) + } + Icon( + Icons.Outlined.MoreVert, null, + Modifier.size(17.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Row(Modifier.fillMaxWidth().padding(top = 6.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + FilledTonalButton( + onClick = { + viewModel.startCreate() + onAddWallet() + }, + modifier = Modifier.weight(1f).height(48.dp), + ) { + Text(stringResource(R.string.wallet_manage_create), fontWeight = FontWeight.Bold, maxLines = 1) + } + FilledTonalButton( + onClick = { + viewModel.startRestore() + onRestoreWallet() + }, + modifier = Modifier.weight(1f).height(48.dp), + ) { + Text(stringResource(R.string.wallet_intro_restore), fontWeight = FontWeight.Bold, maxLines = 1) + } + } + Spacer(Modifier.height(14.dp)) + } + } + + sheetWallet?.let { wallet -> + WalletActionSheet( + wallet = wallet, + isActive = wallet.id == state.active?.id, + onUse = { + viewModel.useWallet(wallet.id) + sheetWallet = null + }, + onRename = { + sheetWallet = null + renameTarget = wallet + }, + onReveal = { + sheetWallet = null + confirmWithBiometrics( + context = context, + title = context.getString(R.string.wallet_bio_reveal_title), + subtitle = context.getString(R.string.wallet_bio_reveal_subtitle, wallet.name), + ) { + onReveal(wallet.id) + } + }, + onRemove = { + sheetWallet = null + removeTarget = wallet + }, + onDismiss = { sheetWallet = null }, + ) + } + + renameTarget?.let { wallet -> + RenameDialog( + wallet = wallet, + onRename = { name -> + viewModel.renameWallet(wallet.id, name) + renameTarget = null + }, + onDismiss = { renameTarget = null }, + ) + } + + removeTarget?.let { wallet -> + val removedText = stringResource(R.string.wallet_removed, wallet.name) + AlertDialog( + onDismissRequest = { removeTarget = null }, + title = { Text(stringResource(R.string.wallet_remove_title, wallet.name)) }, + text = { Text(stringResource(R.string.wallet_remove_body)) }, + confirmButton = { + TextButton(onClick = { + removeTarget = null + viewModel.removeWallet(wallet.id) { + scope.launch { snackbar.showSnackbar(removedText) } + } + }) { + Text( + stringResource(R.string.wallet_remove_confirm), + color = MaterialTheme.colorScheme.error, + fontWeight = FontWeight.Bold, + ) + } + }, + dismissButton = { + TextButton(onClick = { removeTarget = null }) { + Text(stringResource(R.string.wallet_remove_keep), fontWeight = FontWeight.Bold) + } + }, + ) + } +} + +@Composable +private fun addressCount(wallet: WalletMeta): String { + val n = wallet.receiveAddresses.size + return if (n == 1) stringResource(R.string.wallet_address_count_one) + else stringResource(R.string.wallet_address_count_many, n) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun WalletActionSheet( + wallet: WalletMeta, + isActive: Boolean, + onUse: () -> Unit, + onRename: () -> Unit, + onReveal: () -> Unit, + onRemove: () -> Unit, + onDismiss: () -> Unit, +) { + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = rememberModalBottomSheetState()) { + Column(Modifier.padding(bottom = 20.dp)) { + Text( + wallet.name, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(horizontal = 20.dp), + ) + Text( + stringResource( + R.string.wallet_created_on, + addressCount(wallet), + Instant.ofEpochMilli(wallet.createdAtMs).atZone(ZoneId.systemDefault()) + .format(DateTimeFormatter.ofPattern("d MMMM yyyy")), + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 5.dp), + ) + Spacer(Modifier.height(10.dp)) + if (!isActive) { + SheetAction( + icon = { Icon(Icons.Outlined.CheckCircle, null, Modifier.size(19.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) }, + title = stringResource(R.string.wallet_use), + onClick = onUse, + ) + } + SheetAction( + icon = { Icon(Icons.Outlined.Edit, null, Modifier.size(19.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) }, + title = stringResource(R.string.wallet_rename), + onClick = onRename, + ) + SheetAction( + icon = { Icon(Icons.Outlined.Visibility, null, Modifier.size(19.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) }, + title = stringResource(R.string.wallet_reveal_action), + subtitle = stringResource(R.string.wallet_reveal_action_sub), + onClick = onReveal, + ) + SheetAction( + icon = { Icon(Icons.Outlined.DeleteOutline, null, Modifier.size(19.dp), tint = MaterialTheme.colorScheme.error) }, + title = stringResource(R.string.wallet_remove_action), + subtitle = stringResource(R.string.wallet_remove_action_sub), + titleColor = MaterialTheme.colorScheme.error, + subtitleColor = MaterialTheme.colorScheme.error.copy(alpha = 0.8f), + onClick = onRemove, + ) + } + } +} + +@Composable +private fun SheetAction( + icon: @Composable () -> Unit, + title: String, + subtitle: String? = null, + titleColor: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.onSurface, + subtitleColor: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.onSurfaceVariant, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 20.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + icon() + Column { + Text(title, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Bold, color = titleColor) + subtitle?.let { + Text( + it, + style = MaterialTheme.typography.bodySmall, + color = subtitleColor, + modifier = Modifier.padding(top = 3.dp), + ) + } + } + } +} + +@Composable +private fun RenameDialog( + wallet: WalletMeta, + onRename: (String) -> Unit, + onDismiss: () -> Unit, +) { + var name by remember { mutableStateOf(wallet.name) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.wallet_rename_title)) }, + text = { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + }, + confirmButton = { + TextButton( + onClick = { if (name.isNotBlank()) onRename(name) }, + enabled = name.isNotBlank(), + ) { + Text(stringResource(R.string.save), fontWeight = FontWeight.Bold) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel), fontWeight = FontWeight.Bold) + } + }, + ) +} + +/** The phrase in the clear — reached only through the biometric confirm. */ +@Composable +fun WalletRevealScreen( + viewModel: WalletViewModel, + walletId: String, + onBack: () -> Unit, +) { + SecureWindow() + val state by viewModel.uiState.collectAsState() + val wallet = state.allWallets.firstOrNull { it.id == walletId } + var seed by remember { mutableStateOf(null) } + var unavailable by remember { mutableStateOf(false) } + var secondsLeft by remember { mutableStateOf(120) } + + LaunchedEffect(walletId) { + val s = viewModel.revealSeed(walletId) + if (s == null) unavailable = true else seed = s + } + LaunchedEffect(seed) { + if (seed == null) return@LaunchedEffect + while (secondsLeft > 0) { + delay(1000) + secondsLeft-- + } + onBack() + } + + Scaffold(topBar = { SkyTopBar(stringResource(R.string.wallet_seed_title), onBack = onBack) }) { padding -> + Column( + modifier = Modifier + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background(MaterialTheme.colorScheme.errorContainer) + .padding(horizontal = 15.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon( + Icons.Outlined.ErrorOutline, null, + Modifier.size(17.dp), + tint = MaterialTheme.colorScheme.onErrorContainer, + ) + Text( + stringResource(R.string.wallet_reveal_warning, wallet?.name ?: ""), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + } + Spacer(Modifier.height(20.dp)) + when { + unavailable -> Text( + stringResource(R.string.wallet_seed_unavailable), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.error, + ) + seed != null -> SeedGrid(seed!!.split(" ")) + } + if (seed != null) { + Text( + stringResource( + R.string.wallet_reveal_autohide, + "%d:%02d".format(secondsLeft / 60, secondsLeft % 60), + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth().padding(top = 16.dp), + textAlign = TextAlign.Center, + ) + } + FilledTonalButton( + onClick = onBack, + modifier = Modifier + .fillMaxWidth() + .padding(top = 22.dp, bottom = 24.dp) + .height(52.dp), + ) { + Text(stringResource(R.string.wallet_reveal_hide), fontWeight = FontWeight.Bold) + } + } + } +} + +/** What the add screen can add: a fiber chain, or an ERC-20 on Ethereum. */ +private enum class AddCoinKind { FIBER, ERC20 } + +/** Add a Fibercoin (name + ticker + node URL) or an ERC-20 token + * (name + ticker + contract + decimals). */ +@Composable +fun WalletAddCoinScreen( + viewModel: WalletViewModel, + onAdded: () -> Unit, + onBack: () -> Unit, +) { + val state by viewModel.uiState.collectAsState() + var kind by remember { mutableStateOf(AddCoinKind.FIBER) } + var name by remember { mutableStateOf("") } + var ticker by remember { mutableStateOf("") } + var icon by remember { mutableStateOf(null) } + var node by remember { mutableStateOf("") } + var contract by remember { mutableStateOf("") } + var decimals by remember { mutableStateOf("18") } + val snackbar = remember { SnackbarHostState() } + + LaunchedEffect(state.message) { + state.message?.let { snackbar.showSnackbar(it); viewModel.messageShown() } + } + + Scaffold( + snackbarHost = { SnackbarHost(snackbar) }, + topBar = { SkyTopBar(stringResource(R.string.wallet_add_coin_title), onBack = onBack) }, + ) { padding -> + Column( + modifier = Modifier + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp), + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + AddKindChip( + stringResource(R.string.wallet_add_kind_fiber), + selected = kind == AddCoinKind.FIBER, + ) { kind = AddCoinKind.FIBER } + AddKindChip( + stringResource(R.string.wallet_add_kind_erc20), + selected = kind == AddCoinKind.ERC20, + ) { kind = AddCoinKind.ERC20 } + } + Spacer(Modifier.height(18.dp)) + Text( + stringResource( + if (kind == AddCoinKind.FIBER) R.string.wallet_add_coin_body + else R.string.wallet_add_token_body, + ), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(24.dp)) + LabeledField(stringResource(R.string.wallet_add_coin_name), name, stringResource(R.string.wallet_add_coin_name_hint)) { name = it } + LabeledField(stringResource(R.string.wallet_add_coin_ticker), ticker, stringResource(R.string.wallet_add_coin_ticker_hint)) { ticker = it } + IconPickerRow( + ticker = ticker, + iconName = icon, + onPick = { uri -> viewModel.importCoinIcon(uri) { icon = it } }, + onClear = { icon = null }, + ) + if (kind == AddCoinKind.FIBER) { + LabeledField(stringResource(R.string.wallet_add_coin_node), node, stringResource(R.string.wallet_add_coin_node_hint)) { node = it } + } else { + LabeledField(stringResource(R.string.wallet_add_token_contract), contract, stringResource(R.string.wallet_add_token_contract_hint)) { contract = it } + LabeledField(stringResource(R.string.wallet_add_token_decimals), decimals, stringResource(R.string.wallet_add_token_decimals_hint)) { decimals = it } + } + Button( + onClick = { + if (kind == AddCoinKind.FIBER) { + viewModel.addFiberCoin(name, ticker, node, icon, onDone = onAdded) + } else { + viewModel.addErc20Token(name, ticker, contract, decimals, icon, onDone = onAdded) + } + }, + enabled = name.isNotBlank() && ticker.isNotBlank() && + if (kind == AddCoinKind.FIBER) node.isNotBlank() + else contract.isNotBlank() && decimals.isNotBlank(), + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp, bottom = 24.dp) + .height(52.dp), + ) { + Text(stringResource(R.string.wallet_add_coin_save), fontWeight = FontWeight.Bold) + } + } + } +} + +/** + * The badge the new coin will wear: an image picked from the phone, or the + * ticker letters when none is chosen. The circle previews whichever will + * apply — live off the ticker field while it is still letters. The picked + * file is copied into app storage at import, so the badge never depends on + * the gallery grant again. + */ +@Composable +private fun IconPickerRow( + ticker: String, + iconName: String?, + onPick: (Uri) -> Unit, + onClear: () -> Unit, +) { + val context = LocalContext.current + val launcher = rememberLauncherForActivityResult( + ActivityResultContracts.PickVisualMedia(), + ) { uri -> uri?.let(onPick) } + val pick: () -> Unit = { + launcher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly), + ) + } + Column(Modifier.padding(bottom = 16.dp)) { + Text( + stringResource(R.string.wallet_add_coin_icon), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 7.dp), + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + val preview = remember(iconName) { + iconName?.let { name -> + val file = coinIconFile(context, name) + if (file.exists()) BitmapFactory.decodeFile(file.path)?.asImageBitmap() else null + } + } + Box( + modifier = Modifier + .size(46.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceContainerHighest) + .border(1.dp, MaterialTheme.colorScheme.outlineVariant, CircleShape) + .clickable(onClick = pick), + contentAlignment = Alignment.Center, + ) { + if (preview != null) { + Image( + bitmap = preview, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(46.dp) + .clip(CircleShape), + ) + } else { + Text( + ticker.trim().uppercase().take(3).ifEmpty { "ABC" }, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + } + } + FilledTonalButton(onClick = pick) { + Text(stringResource(R.string.wallet_add_coin_icon_pick)) + } + if (iconName != null) { + TextButton(onClick = onClear) { + Text(stringResource(R.string.wallet_add_coin_icon_clear)) + } + } + } + } +} + +@Composable +private fun AddKindChip(label: String, selected: Boolean, onClick: () -> Unit) { + Surface( + onClick = onClick, + shape = RoundedCornerShape(10.dp), + color = if (selected) MaterialTheme.colorScheme.secondaryContainer + else MaterialTheme.colorScheme.surfaceContainerHighest, + ) { + Text( + label, + style = MaterialTheme.typography.labelLarge, + color = if (selected) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 15.dp, vertical = 9.dp), + ) + } +} + +@Composable +private fun LabeledField(label: String, value: String, hint: String, onChange: (String) -> Unit) { + Column(Modifier.padding(bottom = 16.dp)) { + Text( + label, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 7.dp), + ) + OutlinedTextField( + value = value, + onValueChange = onChange, + placeholder = { Text(hint) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + ) + } +} diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/wallet/WalletOnboarding.kt b/android/app/src/main/java/com/skycoin/skywire/ui/wallet/WalletOnboarding.kt new file mode 100644 index 0000000000..fea48ffecb --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/wallet/WalletOnboarding.kt @@ -0,0 +1,446 @@ +package com.skycoin.skywire.ui.wallet + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.lazy.LazyColumn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ErrorOutline +import androidx.compose.material.icons.outlined.ScreenshotMonitor +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.toMutableStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.skycoin.skywire.R +import com.skycoin.skywire.ui.components.SecureWindow +import com.skycoin.skywire.ui.components.SkyTopBar +import com.skycoin.wallet.Bip39 + +/** Seed backup: the numbered grid, screenshots off, no copy anywhere. */ +@Composable +fun WalletSeedScreen( + viewModel: WalletViewModel, + onContinue: () -> Unit, + onBack: () -> Unit, +) { + SecureWindow() + val state by viewModel.uiState.collectAsState() + val words = state.draft.seed?.split(" ") ?: emptyList() + + Scaffold(topBar = { SkyTopBar(stringResource(R.string.wallet_seed_title), onBack = onBack) }) { padding -> + Column( + modifier = Modifier + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp), + ) { + Text( + stringResource(R.string.wallet_seed_body), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHighest) + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + Icons.Outlined.ScreenshotMonitor, null, + Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + stringResource(R.string.wallet_seed_secure), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.height(20.dp)) + SeedGrid(words) + Button( + onClick = onContinue, + modifier = Modifier + .fillMaxWidth() + .padding(top = 28.dp, bottom = 24.dp) + .height(52.dp), + ) { + Text(stringResource(R.string.wallet_seed_continue), fontWeight = FontWeight.Bold) + } + } + } +} + +/** The two-column numbered word grid — backup and reveal share it. */ +@Composable +fun SeedGrid(words: List) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + words.chunked(2).forEachIndexed { rowIndex, pair -> + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + pair.forEachIndexed { colIndex, word -> + Row( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(horizontal = 14.dp, vertical = 13.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + "${rowIndex * 2 + colIndex + 1}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.width(16.dp), + ) + Text(word, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Bold) + } + } + if (pair.size == 1) Spacer(Modifier.weight(1f)) + } + } + } +} + +/** The three-word quiz. Wrong fields name their position; matching activates. */ +@Composable +fun WalletVerifyScreen( + viewModel: WalletViewModel, + onActivated: () -> Unit, + onShowSeed: () -> Unit, + onBack: () -> Unit, +) { + SecureWindow() + val state by viewModel.uiState.collectAsState() + val positions = state.draft.quizPositions + val answers = remember { mutableStateMapOf() } + val errors = remember { mutableStateMapOf() } + val snackbar = remember { SnackbarHostState() } + + LaunchedEffect(state.message) { + state.message?.let { snackbar.showSnackbar(it); viewModel.messageShown() } + } + + Scaffold( + snackbarHost = { SnackbarHost(snackbar) }, + topBar = { SkyTopBar(stringResource(R.string.wallet_verify_title), onBack = onBack) }, + ) { padding -> + val context = androidx.compose.ui.platform.LocalContext.current + Column( + modifier = Modifier + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp), + ) { + Text( + stringResource(R.string.wallet_verify_body), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(26.dp)) + positions.forEach { pos -> + Column(Modifier.padding(bottom = 14.dp)) { + Text( + stringResource(R.string.wallet_verify_word, pos), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 7.dp), + ) + OutlinedTextField( + value = answers[pos] ?: "", + onValueChange = { + answers[pos] = it + errors.remove(pos) + }, + placeholder = { Text(stringResource(R.string.wallet_verify_hint)) }, + singleLine = true, + isError = errors.containsKey(pos), + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + ) + errors[pos]?.let { err -> + Row( + modifier = Modifier.padding(top = 7.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon( + Icons.Outlined.ErrorOutline, null, + Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.error, + ) + Text(err, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) + } + } + } + } + Button( + onClick = { + errors.clear() + val wrong = viewModel.submitQuiz(answers, onActivated = onActivated) + wrong.keys.forEach { pos -> + val typed = answers[pos]?.trim().orEmpty() + errors[pos] = if (typed.isEmpty()) { + context.getString(R.string.wallet_verify_missing, pos) + } else { + context.getString(R.string.wallet_verify_wrong, pos) + } + } + }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp) + .height(52.dp), + ) { + Text(stringResource(R.string.wallet_verify_activate), fontWeight = FontWeight.Bold) + } + TextButton( + onClick = onShowSeed, + modifier = Modifier + .fillMaxWidth() + .padding(top = 6.dp, bottom = 24.dp), + ) { + Text(stringResource(R.string.wallet_verify_again), fontWeight = FontWeight.Bold) + } + } + } +} + +/** Restore: word-by-word with wordlist suggestions, or one paste of the phrase. */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun WalletRestoreScreen( + viewModel: WalletViewModel, + onRestored: () -> Unit, + onBack: () -> Unit, +) { + SecureWindow() + val state by viewModel.uiState.collectAsState() + val words = remember { mutableListOf().toMutableStateList() } + var input by remember { mutableStateOf("") } + var error by remember { mutableStateOf(null) } + val clipboard = LocalClipboardManager.current + val context = androidx.compose.ui.platform.LocalContext.current + val snackbar = remember { SnackbarHostState() } + + LaunchedEffect(state.message) { + state.message?.let { snackbar.showSnackbar(it); viewModel.messageShown() } + } + + val target = if (words.size > 12) 24 else 12 + + fun addWord(word: String) { + val w = word.trim().lowercase() + if (w.isEmpty()) return + if (words.size < 24) { + words.add(w) + input = "" + error = null + } + } + + fun finish() { + if (words.size != 12 && words.size != 24) { + error = context.getString(R.string.wallet_restore_short, words.size) + return + } + val phrase = words.joinToString(" ") + if (!Bip39.validate(phrase)) { + error = context.getString(R.string.wallet_restore_checksum) + return + } + viewModel.restoreWallet(phrase, onDone = onRestored) + } + + Scaffold( + snackbarHost = { SnackbarHost(snackbar) }, + topBar = { SkyTopBar(stringResource(R.string.wallet_restore_title), onBack = onBack) }, + ) { padding -> + LazyColumn( + modifier = Modifier.padding(padding), + contentPadding = PaddingValues(start = 20.dp, end = 20.dp, bottom = 24.dp), + ) { + item { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + stringResource(R.string.wallet_restore_progress, (words.size + 1).coerceAtMost(target), target), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.weight(1f)) + TextButton(onClick = { + val text = clipboard.getText()?.text.orEmpty() + val pasted = text.trim().lowercase().split(Regex("\\s+")).filter { it.isNotEmpty() } + if (pasted.size in listOf(12, 24)) { + words.clear() + words.addAll(pasted) + error = null + } else if (pasted.isNotEmpty()) { + error = context.getString(R.string.wallet_restore_short, pasted.size) + } + }) { + Text(stringResource(R.string.wallet_restore_paste), fontWeight = FontWeight.Bold) + } + } + LinearProgressIndicator( + progress = { words.size / target.toFloat() }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 6.dp) + .height(3.dp), + ) + } + + error?.let { err -> + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.errorContainer) + .padding(horizontal = 14.dp, vertical = 13.dp), + horizontalArrangement = Arrangement.spacedBy(9.dp), + ) { + Icon( + Icons.Outlined.ErrorOutline, null, + Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onErrorContainer, + ) + Text( + err, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + } + } + } + + item { + // Entered words as numbered chips; tapping one takes it out. + FlowRow( + modifier = Modifier.padding(top = 18.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + words.forEachIndexed { i, w -> + Row( + modifier = Modifier + .clip(RoundedCornerShape(20.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .clickable { words.removeAt(i) } + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(7.dp), + ) { + Text( + "${i + 1}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text(w, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold) + } + } + } + } + + item { + OutlinedTextField( + value = input, + onValueChange = { new -> + if (new.endsWith(" ") && new.isNotBlank()) addWord(new) + else { input = new; error = null } + }, + placeholder = { Text(stringResource(R.string.wallet_restore_hint)) }, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), + shape = RoundedCornerShape(12.dp), + ) + val suggestions = if (input.isNotBlank()) Bip39.suggestions(input, 4) else emptyList() + Row( + modifier = Modifier.padding(top = 12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + suggestions.forEach { s -> + Text( + s, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier + .clip(RoundedCornerShape(18.dp)) + .background(MaterialTheme.colorScheme.secondaryContainer) + .clickable { addWord(s) } + .padding(horizontal = 14.dp, vertical = 9.dp), + ) + } + } + Text( + stringResource(R.string.wallet_restore_wordlist_note), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 14.dp), + ) + Button( + onClick = { finish() }, + enabled = !state.restoring, + modifier = Modifier + .fillMaxWidth() + .padding(top = 26.dp) + .height(52.dp), + ) { + if (state.restoring) { + CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp) + Spacer(Modifier.width(10.dp)) + Text(stringResource(R.string.wallet_restore_scanning), fontWeight = FontWeight.Bold) + } else { + Text(stringResource(R.string.wallet_restore_action), fontWeight = FontWeight.Bold) + } + } + } + } + } +} diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/wallet/WalletReceive.kt b/android/app/src/main/java/com/skycoin/skywire/ui/wallet/WalletReceive.kt new file mode 100644 index 0000000000..adf01f87f5 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/wallet/WalletReceive.kt @@ -0,0 +1,282 @@ +package com.skycoin.skywire.ui.wallet + +import android.content.Intent +import androidx.compose.foundation.background +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.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.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowRight +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +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.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.skycoin.skywire.R +import com.skycoin.skywire.ui.components.SkyTopBar +import kotlinx.coroutines.launch + +/** Receive: the QR, tap-to-copy, share, and the other-addresses sheet. */ +@Composable +fun WalletReceiveScreen( + viewModel: WalletViewModel, + onBack: () -> Unit, +) { + val state by viewModel.uiState.collectAsState() + val coin = state.coin + val wallet = state.active + val snackbar = remember { SnackbarHostState() } + val clipboard = LocalClipboardManager.current + val context = LocalContext.current + val scope = rememberCoroutineScope() + var addressSheet by remember { mutableStateOf(false) } + + val address = wallet?.receiveAddresses?.getOrNull(state.receiveIndex) + ?: wallet?.receiveAddresses?.firstOrNull() + + LaunchedEffect(state.message) { + state.message?.let { snackbar.showSnackbar(it); viewModel.messageShown() } + } + LaunchedEffect(wallet) { if (wallet == null) onBack() } + if (wallet == null || address == null) return + + val copiedText = stringResource(R.string.wallet_address_copied) + fun copy() { + clipboard.setText(AnnotatedString(address)) + scope.launch { snackbar.showSnackbar(copiedText) } + } + + Scaffold( + snackbarHost = { SnackbarHost(snackbar) }, + topBar = { SkyTopBar(stringResource(R.string.wallet_receive_title), onBack = onBack) }, + ) { padding -> + Column( + modifier = Modifier + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + stringResource(R.string.wallet_receive_only, coin.ticker), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Box( + modifier = Modifier + .padding(top = 20.dp) + .clip(RoundedCornerShape(20.dp)) + .background(Color.White) + .padding(16.dp), + ) { + QrImage(address) + } + Row( + modifier = Modifier + .padding(top = 20.dp) + .clip(RoundedCornerShape(24.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .clickable { copy() } + .padding(horizontal = 18.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + shortAddress(address), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + ) + Icon( + Icons.Outlined.ContentCopy, null, + Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + stringResource(R.string.wallet_receive_tap_copy), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp), + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 26.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Button(onClick = { copy() }, modifier = Modifier.weight(1f).height(50.dp)) { + Text(stringResource(R.string.wallet_copy), fontWeight = FontWeight.Bold) + } + FilledTonalButton( + onClick = { + val send = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, address) + } + context.startActivity(Intent.createChooser(send, null)) + }, + modifier = Modifier.weight(1f).height(50.dp), + ) { + Text(stringResource(R.string.wallet_share), fontWeight = FontWeight.Bold) + } + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp, bottom = 24.dp) + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .clickable { addressSheet = true } + .padding(horizontal = 16.dp, vertical = 15.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + stringResource(R.string.wallet_other_addresses), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + ) + Text( + wallet.receiveAddresses.size.toString(), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Icon( + Icons.AutoMirrored.Outlined.KeyboardArrowRight, null, + Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + if (addressSheet) { + AddressSheet( + viewModel = viewModel, + onDismiss = { addressSheet = false }, + onPicked = { addressSheet = false }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AddressSheet( + viewModel: WalletViewModel, + onDismiss: () -> Unit, + onPicked: () -> Unit, +) { + val state by viewModel.uiState.collectAsState() + val wallet = state.active ?: return + val generated = stringResource(R.string.wallet_address_generated, wallet.name) + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = rememberModalBottomSheetState()) { + Column(Modifier.padding(bottom = 24.dp)) { + Text( + stringResource(R.string.wallet_addresses_in, wallet.name), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(horizontal = 20.dp), + ) + Text( + stringResource(R.string.wallet_addresses_note), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 6.dp), + ) + Spacer(Modifier.height(8.dp)) + wallet.receiveAddresses.forEachIndexed { i, addr -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + viewModel.pickReceiveIndex(i) + onPicked() + } + .padding(horizontal = 20.dp, vertical = 13.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + "${i + 1}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.width(16.dp), + ) + Column(Modifier.weight(1f)) { + Text( + shortAddress(addr), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + ) + Text( + stringResource( + if (i == 0) R.string.wallet_address_default + else R.string.wallet_address_unused, + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp), + ) + } + if (i == state.receiveIndex) { + Box( + Modifier + .size(8.dp) + .clip(androidx.compose.foundation.shape.CircleShape) + .background(MaterialTheme.colorScheme.primary), + ) + } + } + } + FilledTonalButton( + onClick = { + viewModel.generateNewAddress { } + }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 8.dp) + .height(48.dp), + ) { + Text(stringResource(R.string.wallet_address_new), fontWeight = FontWeight.Bold) + } + } + } +} diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/wallet/WalletScreen.kt b/android/app/src/main/java/com/skycoin/skywire/ui/wallet/WalletScreen.kt new file mode 100644 index 0000000000..ce473e94ce --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/wallet/WalletScreen.kt @@ -0,0 +1,616 @@ +package com.skycoin.skywire.ui.wallet + +import android.graphics.BitmapFactory +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +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.PaddingValues +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.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowRight +import androidx.compose.material.icons.outlined.AccountBalanceWallet +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.ArrowDownward +import androidx.compose.material.icons.outlined.ArrowUpward +import androidx.compose.material.icons.outlined.KeyboardArrowDown +import androidx.compose.material.icons.outlined.Schedule +import androidx.compose.material.icons.outlined.Search +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +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.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.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.skycoin.skywire.R +import com.skycoin.skywire.ui.components.HelpTopic +import com.skycoin.skywire.ui.components.PENDING_AMBER +import com.skycoin.skywire.ui.components.SkyTopBar +import com.skycoin.skywire.wallet.CoinKind +import com.skycoin.skywire.wallet.CoinSpec +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +/** + * Wallet tab root. With no wallet for the selected coin it opens straight + * into setup; with one it is the balance screen. Either way the coin chip + * sits on top — the tab is one surface for every coin the user holds. + */ +@Composable +fun WalletScreen( + viewModel: WalletViewModel, + onBack: () -> Unit, + onCreate: () -> Unit, + onRestore: () -> Unit, + onReceive: () -> Unit, + onSend: () -> Unit, + onHistory: () -> Unit, + onTx: (String) -> Unit, + onWallets: () -> Unit, + onAddCoin: () -> Unit, +) { + val state by viewModel.uiState.collectAsState() + val snackbar = remember { SnackbarHostState() } + var coinSheet by remember { mutableStateOf(false) } + + LaunchedEffect(state.message) { + state.message?.let { + snackbar.showSnackbar(it) + viewModel.messageShown() + } + } + + Scaffold( + snackbarHost = { SnackbarHost(snackbar) }, + // The wallet design left the tab root bare and opened straight into + // the balance. It carries the shared header now, so every tab but + // Home is topped by the same three things in the same places. + topBar = { + SkyTopBar( + title = stringResource(R.string.tab_wallet), + onBack = onBack, + help = HelpTopic(R.string.help_wallet_title, R.string.help_wallet_body), + ) + }, + ) { padding -> + if (!state.ready) { + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { + CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) + } + return@Scaffold + } + + LazyColumn( + modifier = Modifier.padding(padding), + contentPadding = PaddingValues(start = 20.dp, end = 20.dp, top = 8.dp, bottom = 16.dp), + ) { + item { + CoinChip(coin = state.coin) { coinSheet = true } + } + + if (state.active == null) { + item { + IntroContent( + coin = state.coin, + onCreate = { + viewModel.startCreate() + onCreate() + }, + onRestore = { + viewModel.startRestore() + onRestore() + }, + ) + } + } else { + item { + BalanceHeader(state) + if (state.stale) StaleBanner(state) + Row( + modifier = Modifier.fillMaxWidth().padding(top = 22.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + FilledTonalButton( + onClick = onReceive, + modifier = Modifier.weight(1f).height(50.dp), + ) { + Icon(Icons.Outlined.ArrowDownward, null, Modifier.size(17.dp)) + Spacer(Modifier.size(8.dp)) + Text(stringResource(R.string.wallet_receive), fontWeight = FontWeight.Bold) + } + Button( + onClick = onSend, + enabled = !state.stale, + modifier = Modifier.weight(1f).height(50.dp), + ) { + Icon(Icons.Outlined.ArrowUpward, null, Modifier.size(17.dp)) + Spacer(Modifier.size(8.dp)) + Text(stringResource(R.string.wallet_send), fontWeight = FontWeight.Bold) + } + } + if (state.stale) { + Text( + stringResource(R.string.wallet_stale_send_note), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth().padding(top = 10.dp), + textAlign = TextAlign.Center, + ) + } + Row( + modifier = Modifier.fillMaxWidth().padding(top = 32.dp, bottom = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + stringResource(R.string.wallet_recent).uppercase(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.weight(1f)) + Text( + stringResource(R.string.wallet_see_all), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.clickable(onClick = onHistory), + ) + } + } + + item { + val recent = state.snapshot?.txs?.take(3) ?: emptyList() + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + // Cards hold real prose: content must default to ink, not the + // muted onSurfaceVariant this container would otherwise imply. + contentColor = MaterialTheme.colorScheme.onSurface, + ), + shape = RoundedCornerShape(16.dp), + modifier = Modifier.fillMaxWidth(), + ) { + if (recent.isEmpty()) { + Column( + Modifier.fillMaxWidth().padding(vertical = 34.dp, horizontal = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + stringResource(R.string.wallet_no_activity_title), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + ) + Text( + stringResource(R.string.wallet_no_activity_body), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 6.dp), + textAlign = TextAlign.Center, + ) + } + } else { + Column { + recent.forEachIndexed { i, tx -> + TxRow( + coin = state.coin, + tx = tx, + showStatus = false, + topDivider = i > 0, + onClick = { onTx(tx.txid) }, + ) + } + } + } + } + } + + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 14.dp) + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .clickable(onClick = onWallets) + .padding(horizontal = 16.dp, vertical = 15.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + Icons.Outlined.AccountBalanceWallet, + null, + Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + stringResource(R.string.wallet_wallets_row), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + ) + Text( + state.coinWallets.size.toString(), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Icon( + Icons.AutoMirrored.Outlined.KeyboardArrowRight, + null, + Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + + if (coinSheet) { + CoinSheet( + state = state, + onPick = { + viewModel.selectCoin(it) + coinSheet = false + }, + onAddCoin = { + coinSheet = false + onAddCoin() + }, + onDismiss = { coinSheet = false }, + ) + } +} + +@Composable +private fun CoinChip(coin: CoinSpec, onClick: () -> Unit) { + Row( + modifier = Modifier + .clip(RoundedCornerShape(22.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .clickable(onClick = onClick) + .padding(start = 7.dp, end = 12.dp, top = 7.dp, bottom = 7.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + CoinBadge(coin, 30) + Text(coin.name, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Bold) + Icon( + Icons.Outlined.KeyboardArrowDown, + contentDescription = stringResource(R.string.wallet_coin_chip_description), + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** + * Small circular badge — the coin's logo everywhere in the tab: bundled + * artwork for the built-ins, the owner's picked symbol for user-added + * coins, ticker letters when there is neither. + */ +@Composable +fun CoinBadge(coin: CoinSpec, sizeDp: Int) { + val logo = builtInCoinLogo(coin.id) + if (logo != null) { + Image( + painter = painterResource(logo), + contentDescription = null, + modifier = Modifier + .size(sizeDp.dp) + .clip(CircleShape), + ) + return + } + val context = LocalContext.current + val custom = remember(coin.icon) { + coin.icon?.let { name -> + val file = coinIconFile(context, name) + if (file.exists()) BitmapFactory.decodeFile(file.path)?.asImageBitmap() else null + } + } + if (custom != null) { + Image( + bitmap = custom, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(sizeDp.dp) + .clip(CircleShape), + ) + return + } + Box( + modifier = Modifier + .size(sizeDp.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.secondaryContainer), + contentAlignment = Alignment.Center, + ) { + Text( + coin.ticker.take(4), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + } +} + +@Composable +private fun BalanceHeader(state: WalletUiState) { + val coin = state.coin + val snapshot = state.snapshot + Column(Modifier.padding(top = 22.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + Text( + coin.amountText(snapshot?.confirmed ?: 0uL), + style = MaterialTheme.typography.displaySmall, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.alignByBaseline(), + ) + Text( + coin.ticker, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.alignByBaseline(), + ) + if (state.refreshing) { + CircularProgressIndicator(Modifier.size(14.dp), strokeWidth = 2.dp) + } + } + Text( + text = when (coin.kind) { + CoinKind.BTC -> stringResource(R.string.wallet_btc_sub, snapshot?.spendableOutputs ?: 0) + CoinKind.ETH -> stringResource(R.string.wallet_eth_sub) + CoinKind.ERC20 -> stringResource(R.string.wallet_erc20_sub, coin.ticker) + else -> stringResource(R.string.wallet_hours_sub, hoursText(snapshot?.hours ?: 0uL)) + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 9.dp), + ) + } +} + +@Composable +private fun StaleBanner(state: WalletUiState) { + val snapshot = state.snapshot + val text = if (snapshot == null || snapshot.fetchedAtMs == 0L) { + stringResource(R.string.wallet_stale_never) + } else { + val at = Instant.ofEpochMilli(snapshot.fetchedAtMs).atZone(ZoneId.systemDefault()) + .format(DateTimeFormatter.ofPattern("HH:mm")) + val minutes = ((System.currentTimeMillis() - snapshot.fetchedAtMs) / 60000L).coerceAtLeast(1) + val age = when { + minutes >= 60 -> "${minutes / 60} h ${minutes % 60} min" + minutes == 1L -> "1 minute" + else -> "$minutes minutes" + } + stringResource(R.string.wallet_stale_banner, at, age) + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 18.dp) + .clip(RoundedCornerShape(12.dp)) + .background(PENDING_AMBER.copy(alpha = 0.12f)) + .padding(horizontal = 14.dp, vertical = 13.dp), + horizontalArrangement = Arrangement.spacedBy(9.dp), + ) { + Icon(Icons.Outlined.Schedule, null, Modifier.size(16.dp), tint = PENDING_AMBER) + Text(text, style = MaterialTheme.typography.bodySmall, color = PENDING_AMBER) + } +} + +@Composable +private fun IntroContent(coin: CoinSpec, onCreate: () -> Unit, onRestore: () -> Unit) { + Column(Modifier.padding(top = 38.dp)) { + Text( + stringResource(R.string.wallet_intro_title, coin.name), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + ) + Text( + stringResource(R.string.wallet_intro_body), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 12.dp), + ) + Spacer(Modifier.height(32.dp)) + IntroCard( + icon = { Icon(Icons.Outlined.Add, null, Modifier.size(20.dp), tint = MaterialTheme.colorScheme.primary) }, + title = stringResource(R.string.wallet_intro_create), + subtitle = stringResource(R.string.wallet_intro_create_sub), + onClick = onCreate, + ) + Spacer(Modifier.height(12.dp)) + IntroCard( + icon = { Icon(Icons.Outlined.ArrowDownward, null, Modifier.size(20.dp), tint = MaterialTheme.colorScheme.primary) }, + title = stringResource(R.string.wallet_intro_restore), + subtitle = stringResource(R.string.wallet_intro_restore_sub), + onClick = onRestore, + ) + } +} + +@Composable +private fun IntroCard( + icon: @Composable () -> Unit, + title: String, + subtitle: String, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .clickable(onClick = onClick) + .padding(20.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + Box( + modifier = Modifier + .size(44.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.secondaryContainer), + contentAlignment = Alignment.Center, + ) { icon() } + Column(Modifier.weight(1f)) { + Text(title, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Bold) + Text( + subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 3.dp), + ) + } + Icon( + Icons.AutoMirrored.Outlined.KeyboardArrowRight, + null, + Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CoinSheet( + state: WalletUiState, + onPick: (String) -> Unit, + onAddCoin: () -> Unit, + onDismiss: () -> Unit, +) { + var query by remember { mutableStateOf("") } + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = rememberModalBottomSheetState()) { + Column(Modifier.padding(bottom = 28.dp)) { + Text( + stringResource(R.string.wallet_coins_title), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 4.dp), + ) + OutlinedTextField( + value = query, + onValueChange = { query = it }, + leadingIcon = { Icon(Icons.Outlined.Search, null, Modifier.size(16.dp)) }, + placeholder = { Text(stringResource(R.string.wallet_coins_search, state.coins.size)) }, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 12.dp), + shape = RoundedCornerShape(12.dp), + ) + val q = query.trim().lowercase() + val filtered = state.coins.filter { + q.isEmpty() || it.name.lowercase().contains(q) || it.ticker.lowercase().contains(q) + } + filtered.forEach { coin -> + val walletCount = state.allWallets.count { it.coinId == coin.id } + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onPick(coin.id) } + .background( + if (coin.id == state.coin.id) MaterialTheme.colorScheme.surfaceVariant + else Color.Transparent, + ) + .padding(horizontal = 20.dp, vertical = 13.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(13.dp), + ) { + CoinBadge(coin, 36) + Column(Modifier.weight(1f)) { + Text(coin.name, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Bold) + Text( + when { + coin.id == CoinSpec.SKY.id -> stringResource(R.string.wallet_coin_native) + coin.kind == CoinKind.BTC -> stringResource(R.string.wallet_coin_btc) + coin.kind == CoinKind.ETH -> stringResource(R.string.wallet_coin_eth) + coin.kind == CoinKind.ERC20 -> stringResource(R.string.wallet_coin_erc20) + else -> stringResource(R.string.wallet_coin_fiber) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp), + ) + } + Text( + when (walletCount) { + 0 -> "—" + 1 -> stringResource(R.string.wallet_count_one) + else -> stringResource(R.string.wallet_count_many, walletCount) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onAddCoin) + .padding(horizontal = 20.dp, vertical = 15.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(13.dp), + ) { + Box( + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceContainerHighest), + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Outlined.Add, null, Modifier.size(18.dp), tint = MaterialTheme.colorScheme.primary) + } + Text( + stringResource(R.string.wallet_coins_add), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } +} diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/wallet/WalletSend.kt b/android/app/src/main/java/com/skycoin/skywire/ui/wallet/WalletSend.kt new file mode 100644 index 0000000000..7fc54a32b0 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/wallet/WalletSend.kt @@ -0,0 +1,763 @@ +package com.skycoin.skywire.ui.wallet + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.compose.foundation.background +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.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.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material.icons.outlined.QrCodeScanner +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Slider +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.rememberModalBottomSheetState +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.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.journeyapps.barcodescanner.ScanContract +import com.journeyapps.barcodescanner.ScanOptions +import com.skycoin.skywire.R +import com.skycoin.skywire.ui.components.CONNECTED_GREEN +import com.skycoin.skywire.ui.components.SkyTopBar +import com.skycoin.skywire.wallet.CoinKind +import com.skycoin.wallet.Amounts +import kotlinx.coroutines.launch + +/** Send: recipient, amount, the per-chain fee card, then review → sign. */ +@Composable +fun WalletSendScreen( + viewModel: WalletViewModel, + onSent: () -> Unit, + onBack: () -> Unit, +) { + val state by viewModel.uiState.collectAsState() + val coin = state.coin + val send = state.send + val snackbar = remember { SnackbarHostState() } + val clipboard = LocalClipboardManager.current + val context = LocalContext.current + var reviewSheet by remember { mutableStateOf(false) } + + val scanLauncher = rememberLauncherForActivityResult(ScanContract()) { result -> + result.contents?.let { raw -> + // Accept plain addresses and bitcoin:/skycoin: URIs. + val addr = raw.trim().substringAfterLast(":").substringBefore("?") + viewModel.updateSend { it.copy(to = addr) } + } + } + + LaunchedEffect(Unit) { viewModel.loadFeePresets() } + LaunchedEffect(state.message) { + state.message?.let { snackbar.showSnackbar(it); viewModel.messageShown() } + } + LaunchedEffect(state.active) { if (state.active == null) onBack() } + + Scaffold( + snackbarHost = { SnackbarHost(snackbar) }, + topBar = { SkyTopBar(stringResource(R.string.wallet_send_title), onBack = onBack) }, + ) { padding -> + Column( + modifier = Modifier + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp), + ) { + SectionLabel(stringResource(R.string.wallet_send_to)) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 9.dp) + .clip(RoundedCornerShape(14.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(start = 4.dp, end = 6.dp, top = 4.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + TextField( + value = send.to, + onValueChange = { new -> viewModel.updateSend { it.copy(to = new, planError = null) } }, + placeholder = { + Text( + when (coin.kind) { + CoinKind.BTC -> stringResource(R.string.wallet_send_to_hint_btc) + CoinKind.ETH, CoinKind.ERC20 -> + stringResource(R.string.wallet_send_to_hint_eth) + else -> stringResource(R.string.wallet_send_to_hint_fiber, coin.name) + }, + ) + }, + singleLine = true, + colors = transparentFieldColors(), + modifier = Modifier.weight(1f), + ) + TextButton(onClick = { + clipboard.getText()?.text?.trim()?.let { pasted -> + viewModel.updateSend { it.copy(to = pasted, planError = null) } + } + }) { + Text(stringResource(R.string.wallet_paste), fontWeight = FontWeight.Bold) + } + IconButton(onClick = { + scanLauncher.launch( + ScanOptions() + .setDesiredBarcodeFormats(ScanOptions.QR_CODE) + .setBeepEnabled(false) + .setOrientationLocked(true), + ) + }) { + Icon( + Icons.Outlined.QrCodeScanner, + contentDescription = stringResource(R.string.wallet_scan_description), + modifier = Modifier.size(20.dp), + ) + } + } + + Row( + modifier = Modifier.fillMaxWidth().padding(top = 26.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + SectionLabel(stringResource(R.string.wallet_send_amount)) + Spacer(Modifier.weight(1f)) + Text( + stringResource( + R.string.wallet_send_available, + coin.amountText(state.snapshot?.confirmed ?: 0uL), + coin.ticker, + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 9.dp) + .clip(RoundedCornerShape(14.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(start = 4.dp, end = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + TextField( + value = send.amountText, + onValueChange = { new -> + viewModel.updateSend { it.copy(amountText = new, sendMax = false, planError = null) } + }, + placeholder = { Text("0") }, + singleLine = true, + textStyle = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.Bold), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + colors = transparentFieldColors(), + modifier = Modifier.weight(1f), + ) + Text( + coin.ticker, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + TextButton(onClick = { + val max = viewModel.sendMaxAmount() + viewModel.updateSend { + it.copy( + amountText = Amounts.format(max, coin.exponent, coin.displayDecimals).replace(",", ""), + sendMax = true, + planError = null, + ) + } + }) { + Text(stringResource(R.string.wallet_max), fontWeight = FontWeight.Bold) + } + } + Text( + text = when (coin.kind) { + CoinKind.BTC -> stringResource(R.string.wallet_max_note_btc) + CoinKind.ETH -> stringResource(R.string.wallet_max_note_eth) + CoinKind.ERC20 -> stringResource(R.string.wallet_max_note_erc20) + else -> stringResource(R.string.wallet_max_note_fiber, coin.ticker) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp), + ) + + SectionLabel( + stringResource(R.string.wallet_fee), + modifier = Modifier.padding(top = 26.dp), + ) + when (coin.kind) { + CoinKind.BTC -> BtcFeeCard(viewModel) + CoinKind.ETH, CoinKind.ERC20 -> EthFeeCard(viewModel) + else -> FiberFeeCard(viewModel) + } + + send.planError?.let { err -> + Text( + err, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(top = 12.dp), + ) + } + + Button( + onClick = { viewModel.buildPlan { reviewSheet = true } }, + enabled = !send.planning && !state.stale, + modifier = Modifier + .fillMaxWidth() + .padding(top = 26.dp, bottom = 24.dp) + .height(52.dp), + ) { + if (send.planning) { + CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp) + Spacer(Modifier.width(10.dp)) + } + Text(stringResource(R.string.wallet_review), fontWeight = FontWeight.Bold) + } + } + } + + if (reviewSheet && send.plan != null) { + ReviewSheet( + viewModel = viewModel, + onDismiss = { reviewSheet = false }, + onConfirm = { + reviewSheet = false + confirmWithBiometrics( + context = context, + title = context.getString(R.string.wallet_bio_send_title), + subtitle = context.getString(R.string.wallet_bio_send_subtitle), + ) { + viewModel.signAndSend(onSent = onSent) + } + }, + ) + } +} + +@Composable +private fun SectionLabel(text: String, modifier: Modifier = Modifier) { + Text( + text.uppercase(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = modifier, + ) +} + +@Composable +private fun transparentFieldColors() = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, +) + +/** The Coin Hours card: what burns now, what remains after. */ +@Composable +private fun FiberFeeCard(viewModel: WalletViewModel) { + val state by viewModel.uiState.collectAsState() + val hours = state.snapshot?.hours ?: 0uL + val plan = state.send.plan + // Before a plan exists the burn is a projection off the whole balance — + // a tenth of held hours — replaced by exact numbers once planned. + val burned = plan?.fee ?: ((hours + 9uL) / 10uL) + val after = if (hours >= burned) hours - burned else 0uL + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 9.dp) + .clip(RoundedCornerShape(14.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(16.dp), + ) { + Row { + Text(stringResource(R.string.wallet_hours_burned), style = MaterialTheme.typography.bodyLarge) + Spacer(Modifier.weight(1f)) + Text( + hoursText(burned), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + ) + } + HorizontalDivider( + modifier = Modifier.padding(vertical = 13.dp), + color = MaterialTheme.colorScheme.surfaceContainerHighest, + ) + Row { + Text( + stringResource(R.string.wallet_hours_after), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.weight(1f)) + Text( + hoursText(after), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + stringResource(R.string.wallet_fee_note_fiber, state.coin.ticker), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 12.dp), + ) + } +} + +/** + * The gas card: EIP-1559 prices itself, so this shows rather than asks — + * the worst-case fee once a plan exists, and where that money comes from + * on a token send. No slider: the one honest knob (priority tip) moves a + * mainnet fee by fractions of a cent. + */ +@Composable +private fun EthFeeCard(viewModel: WalletViewModel) { + val state by viewModel.uiState.collectAsState() + val coin = state.coin + val plan = state.send.plan + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 9.dp) + .clip(RoundedCornerShape(14.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(16.dp), + ) { + Row { + Text(stringResource(R.string.wallet_fee_network), style = MaterialTheme.typography.bodyLarge) + Spacer(Modifier.weight(1f)) + Text( + plan?.let { "${Amounts.format(it.fee, 9, 6)} ETH" } + ?: stringResource(R.string.wallet_fee_at_review), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + ) + } + if (plan?.vsize != null) { + HorizontalDivider( + modifier = Modifier.padding(vertical = 13.dp), + color = MaterialTheme.colorScheme.surfaceContainerHighest, + ) + Row { + Text( + stringResource(R.string.wallet_fee_gas, plan.vsize ?: 0, plan.feeRate ?: 0), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Text( + stringResource( + if (coin.kind == CoinKind.ERC20) R.string.wallet_fee_note_erc20 + else R.string.wallet_fee_note_eth, + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 12.dp), + ) + } +} + +/** The sat/vB card: presets, slider, estimated fee. */ +@Composable +private fun BtcFeeCard(viewModel: WalletViewModel) { + val state by viewModel.uiState.collectAsState() + val send = state.send + val presets = send.presets + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 9.dp) + .clip(RoundedCornerShape(14.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(16.dp), + ) { + if (presets != null) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FeePresetButton( + stringResource(R.string.wallet_fee_economy), + stringResource(R.string.wallet_fee_eta_economy), + presets.economy, send.feeRate, viewModel, + Modifier.weight(1f), + ) + FeePresetButton( + stringResource(R.string.wallet_fee_normal), + stringResource(R.string.wallet_fee_eta_normal), + presets.normal, send.feeRate, viewModel, + Modifier.weight(1f), + ) + FeePresetButton( + stringResource(R.string.wallet_fee_priority), + stringResource(R.string.wallet_fee_eta_priority), + presets.priority, send.feeRate, viewModel, + Modifier.weight(1f), + ) + } + } + Row(modifier = Modifier.padding(top = 18.dp)) { + Text(stringResource(R.string.wallet_fee_rate), style = MaterialTheme.typography.bodyLarge) + Spacer(Modifier.weight(1f)) + Text( + stringResource(R.string.wallet_fee_rate_value, send.feeRate), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + ) + } + Slider( + value = send.feeRate.toFloat(), + onValueChange = { v -> + viewModel.updateSend { it.copy(feeRate = v.toInt().coerceAtLeast(1), plan = null) } + }, + valueRange = 1f..60f, + modifier = Modifier.padding(top = 4.dp), + ) + val vsize = send.plan?.vsize + if (vsize != null) { + HorizontalDivider( + modifier = Modifier.padding(vertical = 13.dp), + color = MaterialTheme.colorScheme.surfaceContainerHighest, + ) + Row { + Text( + stringResource(R.string.wallet_fee_estimate, vsize), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.weight(1f)) + Text( + "${Amounts.format(send.plan?.fee ?: 0uL, 8, 8)} BTC", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +private fun FeePresetButton( + name: String, + eta: String, + rate: Int, + currentRate: Int, + viewModel: WalletViewModel, + modifier: Modifier = Modifier, +) { + val selected = rate == currentRate + Column( + modifier = modifier + .clip(RoundedCornerShape(11.dp)) + .background( + if (selected) MaterialTheme.colorScheme.secondaryContainer + else MaterialTheme.colorScheme.surfaceContainerHighest, + ) + .clickable { viewModel.updateSend { it.copy(feeRate = rate, plan = null) } } + .padding(vertical = 11.dp, horizontal = 6.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + name, + style = MaterialTheme.typography.labelLarge, + color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + eta, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ReviewSheet( + viewModel: WalletViewModel, + onDismiss: () -> Unit, + onConfirm: () -> Unit, +) { + val state by viewModel.uiState.collectAsState() + val coin = state.coin + val send = state.send + val plan = send.plan ?: return + val snapshot = state.snapshot + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = rememberModalBottomSheetState()) { + Column(Modifier.padding(horizontal = 20.dp).padding(bottom = 26.dp)) { + Text(stringResource(R.string.wallet_review_title), style = MaterialTheme.typography.titleMedium) + Text( + "${coin.amountText(plan.amount)} ${coin.ticker}", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(top = 16.dp), + ) + Text( + // The network is the coin's own chain — Skycoin, this fiber + // coin's, or Bitcoin. Skycoin is not a Fibercoin. + stringResource( + R.string.wallet_review_dest, + shortAddress(plan.toAddress), + coin.name, + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp), + ) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 18.dp) + .clip(RoundedCornerShape(14.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(horizontal = 15.dp, vertical = 2.dp), + ) { + if (coin.kind == CoinKind.BTC) { + val total = plan.amount + plan.fee + val after = (snapshot?.confirmed ?: 0uL).let { if (it >= total) it - total else 0uL } + ReviewRow(stringResource(R.string.wallet_review_amount), "${coin.amountText(plan.amount)} BTC", first = true) + ReviewRow( + stringResource(R.string.wallet_review_miner_fee, plan.feeRate ?: 0), + "${Amounts.format(plan.fee, 8, 8)} BTC", + ) + ReviewRow(stringResource(R.string.wallet_review_total), "${Amounts.format(total, 8, 8)} BTC") + ReviewRow(stringResource(R.string.wallet_review_balance_after, "BTC"), Amounts.format(after, 8, 8)) + } else if (coin.kind == CoinKind.ETH) { + val total = plan.amount + plan.fee + val after = (snapshot?.confirmed ?: 0uL).let { if (it >= total) it - total else 0uL } + ReviewRow(stringResource(R.string.wallet_review_amount), "${coin.amountText(plan.amount)} ETH", first = true) + // The worst case, not a quote: unspent gas is never charged. + ReviewRow( + stringResource(R.string.wallet_review_network_fee), + "≤ ${Amounts.format(plan.fee, 9, 6)} ETH", + ) + ReviewRow(stringResource(R.string.wallet_review_total), "≤ ${Amounts.format(total, 9, 6)} ETH") + ReviewRow(stringResource(R.string.wallet_review_balance_after, "ETH"), coin.amountText(after)) + } else if (coin.kind == CoinKind.ERC20) { + // The amount and the fee live in different currencies, so + // there is no total row to add them into. + val after = (snapshot?.confirmed ?: 0uL).let { if (it >= plan.amount) it - plan.amount else 0uL } + ReviewRow( + stringResource(R.string.wallet_review_amount), + "${coin.amountText(plan.amount)} ${coin.ticker}", + first = true, + ) + ReviewRow( + stringResource(R.string.wallet_review_network_fee), + "≤ ${Amounts.format(plan.fee, 9, 6)} ETH", + ) + ReviewRow( + stringResource(R.string.wallet_review_balance_after, coin.ticker), + coin.amountText(after), + ) + } else { + val afterCoins = (snapshot?.confirmed ?: 0uL).let { if (it >= plan.amount) it - plan.amount else 0uL } + val hoursNow = snapshot?.hours ?: 0uL + val hoursAfter = (plan.hoursToRecipient ?: 0uL).let { toDest -> + val kept = hoursNow - minOf(hoursNow, plan.fee + toDest) + kept + } + ReviewRow( + stringResource(R.string.wallet_review_amount), + "${coin.amountText(plan.amount)} ${coin.ticker}", + first = true, + ) + ReviewRow(stringResource(R.string.wallet_hours_burned), hoursText(plan.fee)) + ReviewRow( + stringResource(R.string.wallet_review_balance_after, coin.ticker), + coin.amountText(afterCoins), + ) + ReviewRow(stringResource(R.string.wallet_review_hours_after), hoursText(hoursAfter)) + } + } + Row( + modifier = Modifier.fillMaxWidth().padding(top = 18.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + FilledTonalButton(onClick = onDismiss, modifier = Modifier.weight(1f).height(50.dp)) { + Text(stringResource(R.string.wallet_review_back), fontWeight = FontWeight.Bold) + } + Button( + onClick = onConfirm, + enabled = !send.sending, + modifier = Modifier.weight(1.4f).height(50.dp), + ) { + if (send.sending) { + CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp) + Spacer(Modifier.width(10.dp)) + } + Text(stringResource(R.string.wallet_review_sign), fontWeight = FontWeight.Bold) + } + } + } + } +} + +@Composable +private fun ReviewRow(label: String, value: String, first: Boolean = false) { + if (!first) { + HorizontalDivider(color = MaterialTheme.colorScheme.surfaceContainerHighest) + } + Row(Modifier.padding(vertical = 13.dp), verticalAlignment = Alignment.CenterVertically) { + Text( + label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.weight(1f)) + Text(value, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold) + } +} + +/** The broadcast confirmation — a full screen, not a toast. */ +@Composable +fun WalletResultScreen( + viewModel: WalletViewModel, + onDone: () -> Unit, + onHistory: () -> Unit, +) { + val state by viewModel.uiState.collectAsState() + val coin = state.coin + val send = state.send + val clipboard = LocalClipboardManager.current + val snackbar = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + val copied = stringResource(R.string.wallet_txid_copied) + + Scaffold(snackbarHost = { SnackbarHost(snackbar) }) { padding -> + Column( + modifier = Modifier + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(Modifier.height(60.dp)) + Box( + modifier = Modifier + .size(64.dp) + .clip(CircleShape) + .background(CONNECTED_GREEN.copy(alpha = 0.15f)), + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Outlined.CheckCircle, null, + Modifier.size(32.dp), + tint = CONNECTED_GREEN, + ) + } + Text( + stringResource(R.string.wallet_result_title), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(top = 22.dp), + textAlign = TextAlign.Center, + ) + Text( + stringResource( + R.string.wallet_result_body, + coin.amountText(send.sentAmount), + coin.ticker, + shortAddress(send.sentTo, 6, 4), + ), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 10.dp), + textAlign = TextAlign.Center, + ) + send.sentTxid?.let { txid -> + Row( + modifier = Modifier + .padding(top = 22.dp) + .clip(RoundedCornerShape(22.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .clickable { + clipboard.setText(AnnotatedString(txid)) + scope.launch { snackbar.showSnackbar(copied) } + } + .padding(horizontal = 16.dp, vertical = 11.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(9.dp), + ) { + Text( + shortAddress(txid, 10, 8), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + ) + Icon( + Icons.Outlined.ContentCopy, null, + Modifier.size(15.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Spacer(Modifier.height(38.dp)) + Button( + onClick = { + viewModel.resetSend() + onDone() + }, + modifier = Modifier.fillMaxWidth().height(52.dp), + ) { + Text(stringResource(R.string.wallet_result_done), fontWeight = FontWeight.Bold) + } + TextButton( + onClick = { + viewModel.resetSend() + onHistory() + }, + modifier = Modifier.fillMaxWidth().padding(top = 6.dp, bottom = 24.dp), + ) { + Text(stringResource(R.string.wallet_result_history), fontWeight = FontWeight.Bold) + } + } + } +} diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/wallet/WalletUi.kt b/android/app/src/main/java/com/skycoin/skywire/ui/wallet/WalletUi.kt new file mode 100644 index 0000000000..923bcc330f --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/wallet/WalletUi.kt @@ -0,0 +1,231 @@ +package com.skycoin.skywire.ui.wallet + +import android.graphics.Bitmap +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +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.fillMaxWidth +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.outlined.ArrowDownward +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +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.rotate +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +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.TextOverflow +import androidx.compose.ui.unit.dp +import com.google.zxing.BarcodeFormat +import com.google.zxing.EncodeHintType +import com.google.zxing.qrcode.QRCodeWriter +import com.skycoin.skywire.R +import com.skycoin.skywire.ui.components.Biometrics +import com.skycoin.skywire.ui.components.CONNECTED_GREEN +import com.skycoin.skywire.ui.components.PENDING_AMBER +import com.skycoin.skywire.ui.components.findFragmentActivity +import com.skycoin.skywire.wallet.CachedTx +import com.skycoin.skywire.wallet.CoinSpec +import com.skycoin.wallet.Amounts +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +/** "2GgFvq…7uQ" — how every address is shown outside copy/share. */ +fun shortAddress(address: String, head: Int = 8, tail: Int = 6): String = + if (address.length <= head + tail + 1) address + else address.take(head) + "…" + address.takeLast(tail) + +fun CoinSpec.amountText(units: ULong): String = + Amounts.format(units, exponent, displayDecimals) + +fun hoursText(hours: ULong): String = Amounts.groupThousands(hours.toString()) + +/** Fee line for a history row / detail: burned hours, BTC, or ETH gas. */ +@Composable +fun feeText(coin: CoinSpec, fee: ULong?): String = when { + fee == null -> "—" + coin.kind == com.skycoin.skywire.wallet.CoinKind.BTC -> + "${Amounts.format(fee, 8, 8)} BTC" + // Always the gas, always in ETH — a token has no fee of its own. + coin.kind == com.skycoin.skywire.wallet.CoinKind.ETH || + coin.kind == com.skycoin.skywire.wallet.CoinKind.ERC20 -> + "${Amounts.format(fee, 9, 6)} ETH" + else -> stringResource(R.string.wallet_fee_hours_value, hoursText(fee)) +} + +/** The status color triple every transaction row and pill uses. */ +@Composable +fun txColor(tx: CachedTx): Color = when { + !tx.confirmed -> PENDING_AMBER + tx.incoming -> CONNECTED_GREEN + else -> MaterialTheme.colorScheme.onSurface +} + +@Composable +fun dayLabel(timestamp: Long): String { + val date = Instant.ofEpochSecond(timestamp).atZone(ZoneId.systemDefault()).toLocalDate() + val today = LocalDate.now() + return when (date) { + today -> stringResource(R.string.wallet_day_today) + today.minusDays(1) -> stringResource(R.string.wallet_day_yesterday) + else -> date.format(DateTimeFormatter.ofPattern("d MMMM")) + } +} + +fun timeLabel(timestamp: Long): String = + Instant.ofEpochSecond(timestamp).atZone(ZoneId.systemDefault()) + .format(DateTimeFormatter.ofPattern("HH:mm")) + +/** One transaction row — recent activity and full history share it. */ +@Composable +fun TxRow( + coin: CoinSpec, + tx: CachedTx, + showStatus: Boolean, + topDivider: Boolean, + onClick: () -> Unit, +) { + Column { + if (topDivider) { + androidx.compose.material3.HorizontalDivider( + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.colorScheme.surfaceContainerHighest, + ) + } + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 13.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(13.dp), + ) { + Box( + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceContainerHighest), + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Outlined.ArrowDownward, + contentDescription = null, + modifier = Modifier + .size(16.dp) + .rotate(if (tx.incoming) 0f else 180f), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Column(Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(7.dp)) { + Text( + stringResource(if (tx.incoming) R.string.wallet_received_verb else R.string.wallet_sent_verb), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + ) + if (showStatus) { + Box( + Modifier + .size(6.dp) + .clip(CircleShape) + .background(txColor(tx)), + ) + Text( + stringResource(if (tx.confirmed) R.string.wallet_tx_confirmed else R.string.wallet_filter_pending), + style = MaterialTheme.typography.labelSmall, + color = txColor(tx), + ) + } + } + Text( + text = tx.party?.let { + stringResource( + if (tx.incoming) R.string.wallet_from_line else R.string.wallet_to_line, + shortAddress(it, 6, 4), + coin.ticker, + ) + } ?: stringResource(R.string.wallet_self_line, coin.ticker), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Column(horizontalAlignment = Alignment.End) { + Text( + text = (if (tx.incoming) "+" else "−") + coin.amountText(tx.amount), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + color = txColor(tx), + ) + Text( + timeLabel(tx.timestamp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +/** Receive-address QR. White quiet zone in both themes — scanners want it. */ +@Composable +fun QrImage(content: String, sizeDp: Int = 220) { + val bitmap = remember(content) { + val hints = mapOf(EncodeHintType.MARGIN to 1) + val px = 512 + val matrix = QRCodeWriter().encode(content, BarcodeFormat.QR_CODE, px, px, hints) + val pixels = IntArray(px * px) { i -> + if (matrix[i % px, i / px]) 0xFF000000.toInt() else 0xFFFFFFFF.toInt() + } + Bitmap.createBitmap(pixels, px, px, Bitmap.Config.ARGB_8888) + } + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = content, + modifier = Modifier + .size(sizeDp.dp) + .clip(RoundedCornerShape(12.dp)), + ) +} + +/** + * Ask for the phone's own credential, then run — or run directly on a phone + * with nothing enrolled to ask with. The Settings screen set this pattern; + * the wallet's sends and reveals follow it. + */ +fun confirmWithBiometrics( + context: android.content.Context, + title: String, + subtitle: String, + onDenied: (String?) -> Unit = {}, + onConfirmed: () -> Unit, +) { + val activity = context.findFragmentActivity() + if (activity == null || !Biometrics.canAuthenticate(context)) { + onConfirmed() + return + } + Biometrics.prompt(activity, title, subtitle) { success, error -> + if (success) onConfirmed() else onDenied(error) + } +} diff --git a/android/app/src/main/java/com/skycoin/skywire/ui/wallet/WalletViewModel.kt b/android/app/src/main/java/com/skycoin/skywire/ui/wallet/WalletViewModel.kt new file mode 100644 index 0000000000..d4f6524704 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/wallet/WalletViewModel.kt @@ -0,0 +1,402 @@ +package com.skycoin.skywire.ui.wallet + +import android.app.Application +import android.net.Uri +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.skycoin.skywire.wallet.CoinKind +import com.skycoin.skywire.wallet.CoinSpec +import com.skycoin.skywire.wallet.WalletMeta +import com.skycoin.skywire.wallet.WalletRepository +import com.skycoin.skywire.wallet.WalletSnapshot +import com.skycoin.wallet.Amounts +import com.skycoin.wallet.Bip39 +import com.skycoin.wallet.FeePresets +import com.skycoin.wallet.TxPlan +import com.skycoin.wallet.WalletException +import kotlinx.coroutines.CancellationException +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.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.IOException +import java.security.SecureRandom + +/** Where the send flow stands; the review sheet and result screen read this. */ +data class SendState( + val to: String = "", + val amountText: String = "", + val sendMax: Boolean = false, + val feeRate: Int = 12, + val presets: FeePresets? = null, + val planning: Boolean = false, + val plan: TxPlan? = null, + val planError: String? = null, + val sending: Boolean = false, + val sentTxid: String? = null, + val sentAmount: ULong = 0u, + val sentTo: String = "", +) + +/** The create/restore flow in progress — the phrase lives only here, in memory. */ +data class DraftState( + val coin: CoinSpec = CoinSpec.SKY, + val seed: String? = null, + val quizPositions: List = emptyList(), +) + +data class WalletUiState( + val ready: Boolean = false, + val coins: List = emptyList(), + val coin: CoinSpec = CoinSpec.SKY, + val allWallets: List = emptyList(), + /** Wallets of the selected coin. */ + val coinWallets: List = emptyList(), + val active: WalletMeta? = null, + val snapshot: WalletSnapshot? = null, + /** True when the freshest fetch failed and the screen shows old numbers. */ + val stale: Boolean = false, + val refreshing: Boolean = false, + val restoring: Boolean = false, + /** Index into the active wallet's receive addresses shown on Receive. */ + val receiveIndex: Int = 0, + val send: SendState = SendState(), + val draft: DraftState = DraftState(), + val message: String? = null, +) + +class WalletViewModel(app: Application) : AndroidViewModel(app) { + + private val repo = WalletRepository.get(app) + private val mutable = MutableStateFlow(WalletUiState()) + val uiState: StateFlow = mutable.asStateFlow() + + private var refreshJob: Job? = null + private var actionJob: Job? = null + + init { + viewModelScope.launch { + // All four flows read the same DataStore, so any wallet-store edit + // re-runs this block with a consistent view. + combine(repo.coins(), repo.selectedCoinId(), repo.wallets()) { coins, selectedId, wallets -> + Triple(coins, selectedId, wallets) + }.collectLatest { (coins, selectedId, wallets) -> + val coin = coins.firstOrNull { it.id == selectedId } ?: CoinSpec.SKY + val coinWallets = wallets.filter { it.coinId == coin.id } + val activeId = repo.activeWalletId(coin.id).first() + val active = coinWallets.firstOrNull { it.id == activeId } ?: coinWallets.firstOrNull() + val previous = mutable.value + val sameWallet = previous.active?.id == active?.id + mutable.update { + it.copy( + ready = true, + coins = coins, + coin = coin, + allWallets = wallets, + coinWallets = coinWallets, + active = active, + snapshot = active?.let { w -> repo.cachedSnapshot(w.id) } ?: it.snapshot.takeIf { _ -> sameWallet }, + stale = if (sameWallet) it.stale else false, + receiveIndex = if (sameWallet) { + it.receiveIndex.coerceAtMost((active?.receiveAddresses?.size ?: 1) - 1).coerceAtLeast(0) + } else 0, + ) + } + if (!sameWallet || refreshJob?.isActive != true) startRefreshing(active) + } + } + } + + private fun startRefreshing(active: WalletMeta?) { + refreshJob?.cancel() + if (active == null) return + refreshJob = viewModelScope.launch { + while (true) { + mutable.update { it.copy(refreshing = true) } + try { + val snapshot = repo.refresh(active.id) + mutable.update { + if (it.active?.id == active.id) it.copy(snapshot = snapshot, stale = false, refreshing = false) + else it.copy(refreshing = false) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + // The screen renders the cached numbers with the stale + // banner; with no cache at all it says "not synced yet". + mutable.update { + if (it.active?.id == active.id) it.copy(stale = true, refreshing = false) + else it.copy(refreshing = false) + } + } + delay(REFRESH_INTERVAL_MS) + } + } + } + + fun refreshNow() { + startRefreshing(mutable.value.active) + } + + fun messageShown() = mutable.update { it.copy(message = null) } + + private fun report(e: Exception): String = when (e) { + is WalletException -> e.message ?: "rejected" + is IOException -> e.message?.take(200) ?: "no route to the node" + else -> e.message?.take(200) ?: e::class.java.simpleName + } + + private fun action(block: suspend () -> Unit) { + actionJob?.cancel() + actionJob = viewModelScope.launch { + try { + block() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + mutable.update { it.copy(message = report(e), restoring = false) } + } + } + } + + // --- coin switching / Fibercoins --- + + fun selectCoin(coinId: String) = action { + repo.setSelectedCoin(coinId) + mutable.update { it.copy(send = SendState(), receiveIndex = 0) } + } + + /** + * Copy a picked image into app storage for use as a coin badge and hand + * back the stored name. Off the main thread — the source can be a large + * photo that needs decoding and cropping. + */ + fun importCoinIcon(uri: Uri, onSaved: (String) -> Unit) = action { + val name = withContext(Dispatchers.IO) { + importCoinIconFrom(getApplication(), uri) + } + onSaved(name) + } + + fun addFiberCoin(name: String, ticker: String, nodeUrl: String, icon: String?, onDone: () -> Unit) = action { + require(name.isNotBlank()) { "give the coin a name" } + require(ticker.isNotBlank()) { "give the coin a ticker" } + val spec = repo.addFiberCoin(name, ticker, nodeUrl, icon) + repo.setSelectedCoin(spec.id) + onDone() + } + + fun addErc20Token( + name: String, + ticker: String, + contract: String, + decimals: String, + icon: String?, + onDone: () -> Unit, + ) = action { + require(name.isNotBlank()) { "give the token a name" } + require(ticker.isNotBlank()) { "give the token a ticker" } + val parsed = decimals.trim().toIntOrNull() + requireNotNull(parsed) { "decimals must be a number — 6 for USDT-like tokens, 18 for most" } + val spec = repo.addErc20Token(name, ticker, contract, parsed, icon) + repo.setSelectedCoin(spec.id) + onDone() + } + + // --- create / restore --- + + /** Begin the create flow for the selected coin: fresh phrase + quiz picks. */ + fun startCreate() { + val coin = mutable.value.coin + val seed = Bip39.newMnemonic(128) + val rnd = SecureRandom() + val positions = (1..12).toMutableList().let { pool -> + List(3) { pool.removeAt(rnd.nextInt(pool.size)) }.sorted() + } + mutable.update { it.copy(draft = DraftState(coin, seed, positions)) } + } + + fun startRestore() { + mutable.update { it.copy(draft = DraftState(it.coin, null, emptyList())) } + } + + /** Quiz answers checked; wrong positions returned (empty = activated). */ + fun submitQuiz(answers: Map, onActivated: () -> Unit): Map { + val draft = mutable.value.draft + val seed = draft.seed ?: return emptyMap() + val words = seed.split(" ") + val wrong = draft.quizPositions.associateWith { pos -> + answers[pos]?.trim()?.lowercase() != words[pos - 1] + }.filterValues { it } + if (wrong.isEmpty()) { + action { + val name = defaultWalletName(draft.coin) + repo.addWallet(draft.coin, name, seed, restored = false) + mutable.update { it.copy(draft = DraftState(), message = null) } + onActivated() + } + } + return wrong + } + + /** The phrase is validated by the restore screen before this is called. */ + fun restoreWallet(seedText: String, onDone: () -> Unit) { + val draft = mutable.value.draft + action { + mutable.update { it.copy(restoring = true) } + val name = defaultWalletName(draft.coin) + repo.addWallet(draft.coin, name, seedText, restored = true) + mutable.update { it.copy(draft = DraftState(), restoring = false) } + onDone() + } + } + + private suspend fun defaultWalletName(coin: CoinSpec): String { + val count = repo.wallets().first().count { it.coinId == coin.id } + return if (count == 0) coin.ticker else "${coin.ticker} ${count + 1}" + } + + // --- receive --- + + fun pickReceiveIndex(index: Int) = mutable.update { it.copy(receiveIndex = index) } + + fun generateNewAddress(onDone: (String) -> Unit) = action { + val active = mutable.value.active ?: return@action + val addr = repo.newReceiveAddress(active.id) + mutable.update { st -> + st.copy(receiveIndex = (st.active?.receiveAddresses?.size ?: 1)) + } + onDone(addr) + } + + // --- send --- + + fun updateSend(transform: (SendState) -> SendState) = + mutable.update { it.copy(send = transform(it.send)) } + + fun resetSend() = mutable.update { it.copy(send = SendState()) } + + fun loadFeePresets() { + val coin = mutable.value.coin + if (coin.kind != CoinKind.BTC) return + viewModelScope.launch { + runCatching { repo.coreFor(coin).feePresets() }.getOrNull()?.let { presets -> + mutable.update { st -> + st.copy(send = st.send.copy(presets = presets, feeRate = st.send.feeRate.coerceAtLeast(1))) + } + } + } + } + + /** The Max amount, fee-adjusted on Bitcoin, full balance elsewhere. */ + fun sendMaxAmount(): ULong { + val st = mutable.value + val snapshot = st.snapshot ?: return 0uL + val balance = com.skycoin.wallet.WalletBalance( + confirmed = snapshot.confirmed, + predicted = snapshot.predicted, + hours = snapshot.hours, + spendableOutputs = snapshot.spendableOutputs, + ) + return repo.coreFor(st.coin).estimateMax(balance, st.send.feeRate) + } + + /** Build the plan and open the review sheet on success. */ + fun buildPlan(onReady: () -> Unit) { + val st = mutable.value + val active = st.active ?: return + val coin = st.coin + val send = st.send + val amount = if (send.sendMax) 0uL + else Amounts.parse(send.amountText, coin.exponent) ?: run { + mutable.update { it.copy(send = send.copy(planError = "enter a valid amount")) } + return + } + mutable.update { it.copy(send = send.copy(planning = true, planError = null)) } + action { + try { + val plan = repo.plan( + walletId = active.id, + toAddress = send.to, + amount = amount, + feeRate = if (coin.kind == CoinKind.BTC) send.feeRate else null, + sendMax = send.sendMax, + ) + mutable.update { it.copy(send = it.send.copy(planning = false, plan = plan)) } + onReady() + } catch (e: WalletException) { + mutable.update { it.copy(send = it.send.copy(planning = false, planError = e.message)) } + } catch (e: IOException) { + mutable.update { + it.copy(send = it.send.copy(planning = false, planError = e.message?.take(200) ?: "no route to the node")) + } + } + } + } + + /** After the biometric confirm: sign locally, broadcast, land on the result. */ + fun signAndSend(onSent: () -> Unit) { + val st = mutable.value + val active = st.active ?: return + val plan = st.send.plan ?: return + mutable.update { it.copy(send = it.send.copy(sending = true)) } + action { + try { + val txid = repo.signAndBroadcast(active.id, plan) + mutable.update { + it.copy( + send = it.send.copy( + sending = false, + sentTxid = txid, + sentAmount = plan.amount, + sentTo = plan.toAddress, + ), + ) + } + refreshNow() + onSent() + } catch (e: Exception) { + if (e is CancellationException) throw e + mutable.update { + it.copy(send = it.send.copy(sending = false), message = report(e)) + } + } + } + } + + // --- wallet management --- + + fun useWallet(walletId: String) = action { + val coin = mutable.value.coin + repo.setActiveWallet(coin.id, walletId) + } + + fun renameWallet(walletId: String, name: String) = action { + repo.renameWallet(walletId, name) + } + + fun removeWallet(walletId: String, onRemoved: (String) -> Unit) = action { + val name = mutable.value.allWallets.firstOrNull { it.id == walletId }?.name ?: "" + repo.removeWallet(walletId) + onRemoved(name) + } + + suspend fun revealSeed(walletId: String): String? = repo.revealSeed(walletId) + + override fun onCleared() { + refreshJob?.cancel() + super.onCleared() + } + + companion object { + private const val REFRESH_INTERVAL_MS = 30_000L + } +} diff --git a/android/app/src/main/java/com/skycoin/skywire/wallet/WalletModels.kt b/android/app/src/main/java/com/skycoin/skywire/wallet/WalletModels.kt new file mode 100644 index 0000000000..7931bc8910 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/wallet/WalletModels.kt @@ -0,0 +1,144 @@ +package com.skycoin.skywire.wallet + +import kotlinx.serialization.Serializable + +/** Which family a coin belongs to — the protocols this wallet speaks. */ +enum class CoinKind { SKY_FIBER, BTC, ETH, ERC20 } + +/** + * A coin the wallet can hold. SKY, BTC, ETH and USDT ship built in; fiber + * coins and ERC-20 tokens are added by the user — every fiber chain runs the + * same daemon and differs only in where it lives, and every ERC-20 speaks + * the same contract surface and differs only in address and decimals. + */ +@Serializable +data class CoinSpec( + val id: String, + val name: String, + val ticker: String, + val kind: CoinKind, + val nodeUrl: String, + /** %s is the txid; null hides the explorer button. */ + val explorerTxUrl: String? = null, + val builtIn: Boolean = false, + /** ERC-20 only: the token's contract address. */ + val contract: String? = null, + /** ERC-20 only: the token's on-chain decimals. */ + val tokenDecimals: Int? = null, + /** ETH family: etherscan-style history API base (Blockscout, keyless). */ + val indexerUrl: String? = null, + /** User-added coins: the badge symbol picked at creation — a key into + * the wallet UI's symbol set. Built-ins carry bundled logos instead; + * null falls back to ticker letters. */ + val icon: String? = null, +) { + /** + * Base-unit exponent: droplets 10⁻⁶, satoshis 10⁻⁸ — and for the ETH + * family, whatever fits the app's 64-bit amounts: gwei (10⁻⁹) for the + * native coin because wei overflows 64 bits at ~18 ETH, and a token's + * own decimals capped at nine for the same reason. + */ + val exponent: Int get() = when (kind) { + CoinKind.BTC -> 8 + CoinKind.ETH -> 9 + CoinKind.ERC20 -> minOf(tokenDecimals ?: DEFAULT_TOKEN_DECIMALS, 9) + CoinKind.SKY_FIBER -> 6 + } + + /** Decimals shown in balances and amount fields. */ + val displayDecimals: Int get() = when (kind) { + CoinKind.BTC -> 8 + CoinKind.ETH -> 6 + CoinKind.ERC20 -> minOf(exponent, 6) + CoinKind.SKY_FIBER -> 3 + } + + companion object { + val SKY = CoinSpec( + id = "SKY", + name = "Skycoin", + ticker = "SKY", + kind = CoinKind.SKY_FIBER, + nodeUrl = "http://node.skycoin.com", + explorerTxUrl = "https://explorer.skycoin.com/app/transaction/%s", + builtIn = true, + ) + val BTC = CoinSpec( + id = "BTC", + name = "Bitcoin", + ticker = "BTC", + kind = CoinKind.BTC, + nodeUrl = "https://mempool.space", + explorerTxUrl = "https://mempool.space/tx/%s", + builtIn = true, + ) + val ETH = CoinSpec( + id = "ETH", + name = "Ethereum", + ticker = "ETH", + kind = CoinKind.ETH, + nodeUrl = ETH_NODE, + explorerTxUrl = "$ETH_INDEXER/tx/%s", + builtIn = true, + indexerUrl = ETH_INDEXER, + ) + val USDT = CoinSpec( + id = "USDT", + name = "Tether USD", + ticker = "USDT", + kind = CoinKind.ERC20, + nodeUrl = ETH_NODE, + explorerTxUrl = "$ETH_INDEXER/tx/%s", + builtIn = true, + contract = "0xdAC17F958D2ee523a2206206994597C13D831ec7", + tokenDecimals = 6, + indexerUrl = ETH_INDEXER, + ) + + /** Keyless public endpoints; both are user-replaceable per token. */ + const val ETH_NODE = "https://ethereum-rpc.publicnode.com" + const val ETH_INDEXER = "https://eth.blockscout.com" + + const val DEFAULT_TOKEN_DECIMALS = 18 + } +} + +/** A wallet: one seed, one coin, its derived addresses. Addresses are public + * and cached here so opening the app never needs the sealed seed. */ +@Serializable +data class WalletMeta( + val id: String, + val coinId: String, + val name: String, + val createdAtMs: Long, + val receiveAddresses: List, + val changeAddresses: List = emptyList(), +) + +/** One remembered transaction — TxRecord flattened for the cache file. */ +@Serializable +data class CachedTx( + val txid: String, + val incoming: Boolean, + val amount: ULong, + val party: String? = null, + val timestamp: Long, + val confirmed: Boolean, + val confirmations: Long, + val fee: ULong? = null, +) + +/** + * The last successful view of a wallet, kept on disk so the tab renders + * instantly and honestly when the node is unreachable — the UI marks it + * stale rather than blank. + */ +@Serializable +data class WalletSnapshot( + val confirmed: ULong = 0u, + val predicted: ULong = 0u, + val hours: ULong? = null, + val spendableOutputs: Int = 0, + val txs: List = emptyList(), + val fetchedAtMs: Long = 0, +) diff --git a/android/app/src/main/java/com/skycoin/skywire/wallet/WalletRepository.kt b/android/app/src/main/java/com/skycoin/skywire/wallet/WalletRepository.kt new file mode 100644 index 0000000000..2442c2e029 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/wallet/WalletRepository.kt @@ -0,0 +1,334 @@ +package com.skycoin.skywire.wallet + +import android.content.Context +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import com.skycoin.wallet.AddressBook +import com.skycoin.wallet.SignedTx +import com.skycoin.wallet.TxPlan +import com.skycoin.wallet.WalletCore +import com.skycoin.wallet.btc.BtcWalletCore +import com.skycoin.wallet.eth.EthCrypto +import com.skycoin.wallet.eth.EthWalletCore +import com.skycoin.wallet.skycoin.SkyFiberWalletCore +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.Json +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.OkHttpClient +import java.io.File +import java.util.UUID +import java.util.concurrent.TimeUnit + +/** + * Everything the wallet screens need, behind one door: the coin list, the + * wallets and their sealed seeds, cached chain views, and the send path. + * Network work delegates to [WalletCore] implementations; nothing above this + * class ever touches key material or node URLs. + */ +class WalletRepository private constructor(private val context: Context) { + + private val seeds = WalletSeedStore(context) + private val json = Json { ignoreUnknownKeys = true } + + private val client = OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(20, TimeUnit.SECONDS) + .callTimeout(30, TimeUnit.SECONDS) + .build() + + // --- coins --- + + fun coins(): Flow> = seeds.store.data.map { prefs -> + // One stored list for everything user-added; the key predates + // tokens and is not worth a migration to rename. + val user = prefs[KEY_FIBER_COINS]?.let { + runCatching { json.decodeFromString(ListSerializer(CoinSpec.serializer()), it) }.getOrNull() + } ?: emptyList() + // SKY first, user Fibercoins in the order added, then the other + // built-ins, then user tokens in the order added. + listOf(CoinSpec.SKY) + + user.filter { it.kind == CoinKind.SKY_FIBER } + + listOf(CoinSpec.BTC, CoinSpec.ETH, CoinSpec.USDT) + + user.filter { it.kind == CoinKind.ERC20 } + } + + suspend fun coin(coinId: String): CoinSpec? = coins().first().firstOrNull { it.id == coinId } + + suspend fun addFiberCoin(name: String, ticker: String, nodeUrl: String, icon: String? = null): CoinSpec { + val url = nodeUrl.trim().removeSuffix("/") + require(url.toHttpUrlOrNull() != null) { + "the node address must be a full URL, like http://node.example.com:6420" + } + val spec = CoinSpec( + id = "fiber-${UUID.randomUUID().toString().take(8)}", + name = name.trim(), + ticker = ticker.trim().uppercase(), + kind = CoinKind.SKY_FIBER, + nodeUrl = url, + icon = icon, + ) + storeUserCoin(spec) + return spec + } + + /** + * Add an ERC-20 token on Ethereum mainnet: same chain plumbing as USDT, + * different contract and decimals. The contract must be checksum-valid; + * decimals must match the contract's own or amounts will be off by + * powers of ten. + */ + suspend fun addErc20Token(name: String, ticker: String, contract: String, decimals: Int, icon: String? = null): CoinSpec { + require(EthCrypto.isValidAddress(contract.trim())) { + "the contract must be a 0x… address (checksummed or all-lowercase)" + } + require(decimals in 0..36) { "decimals must be between 0 and 36" } + val spec = CoinSpec( + id = "erc20-${UUID.randomUUID().toString().take(8)}", + name = name.trim(), + ticker = ticker.trim().uppercase(), + kind = CoinKind.ERC20, + nodeUrl = CoinSpec.ETH_NODE, + explorerTxUrl = "${CoinSpec.ETH_INDEXER}/tx/%s", + contract = contract.trim(), + tokenDecimals = decimals, + indexerUrl = CoinSpec.ETH_INDEXER, + icon = icon, + ) + storeUserCoin(spec) + return spec + } + + private suspend fun storeUserCoin(spec: CoinSpec) { + seeds.store.edit { prefs -> + val current = prefs[KEY_FIBER_COINS]?.let { + runCatching { json.decodeFromString(ListSerializer(CoinSpec.serializer()), it) }.getOrNull() + } ?: emptyList() + prefs[KEY_FIBER_COINS] = json.encodeToString( + ListSerializer(CoinSpec.serializer()), current + spec, + ) + } + } + + fun coreFor(spec: CoinSpec): WalletCore = when (spec.kind) { + CoinKind.SKY_FIBER -> SkyFiberWalletCore(spec.nodeUrl, client) + CoinKind.BTC -> BtcWalletCore(spec.nodeUrl, client) + CoinKind.ETH -> EthWalletCore(spec.nodeUrl, spec.indexerUrl, client) + CoinKind.ERC20 -> EthWalletCore( + spec.nodeUrl, + spec.indexerUrl, + client, + EthWalletCore.Erc20Token( + contract = spec.contract ?: error("token ${spec.id} has no contract address"), + decimals = spec.tokenDecimals ?: CoinSpec.DEFAULT_TOKEN_DECIMALS, + ), + ) + } + + // --- selection --- + + fun selectedCoinId(): Flow = + seeds.store.data.map { it[KEY_SELECTED_COIN] ?: CoinSpec.SKY.id } + + suspend fun setSelectedCoin(coinId: String) { + seeds.store.edit { it[KEY_SELECTED_COIN] = coinId } + } + + fun activeWalletId(coinId: String): Flow = + seeds.store.data.map { it[stringPreferencesKey("active_$coinId")] } + + suspend fun setActiveWallet(coinId: String, walletId: String) { + seeds.store.edit { it[stringPreferencesKey("active_$coinId")] = walletId } + } + + // --- wallets --- + + fun wallets(): Flow> = seeds.store.data.map { prefs -> + prefs[KEY_WALLETS]?.let { + runCatching { json.decodeFromString(ListSerializer(WalletMeta.serializer()), it) }.getOrNull() + } ?: emptyList() + } + + suspend fun wallet(id: String): WalletMeta? = wallets().first().firstOrNull { it.id == id } + + private suspend fun putWallets(all: List) { + seeds.store.edit { + it[KEY_WALLETS] = json.encodeToString(ListSerializer(WalletMeta.serializer()), all) + } + } + + /** + * Create (fresh phrase, already quiz-verified) or restore. Restores probe + * the network for used addresses; a dead node degrades to one address + * rather than failing the restore. + */ + suspend fun addWallet(spec: CoinSpec, name: String, mnemonic: String, restored: Boolean): WalletMeta = + withContext(Dispatchers.IO) { + val core = coreFor(spec) + val seed = normalizeSeed(mnemonic) + require(core.validateSeed(seed)) { "invalid recovery phrase" } + + val (receiveCount, changeCount) = if (restored) { + runCatching { core.scanUsed(seed) }.getOrDefault(1 to 0) + } else { + 1 to 0 + } + val book = core.deriveAddresses(seed, receiveCount, changeCount) + + val meta = WalletMeta( + id = "w-${UUID.randomUUID().toString().take(12)}", + coinId = spec.id, + name = name.trim().ifEmpty { spec.ticker }, + createdAtMs = System.currentTimeMillis(), + receiveAddresses = book.receive, + changeAddresses = book.change, + ) + seeds.putSeed(meta.id, seed) + putWallets(wallets().first() + meta) + setActiveWallet(spec.id, meta.id) + meta + } + + private fun normalizeSeed(mnemonic: String): String = + mnemonic.trim().lowercase().split(Regex("\\s+")).joinToString(" ") + + suspend fun renameWallet(id: String, name: String) { + putWallets(wallets().first().map { if (it.id == id) it.copy(name = name.trim()) else it }) + } + + /** Deletes the sealed seed, the metadata and the cache. Irreversible. */ + suspend fun removeWallet(id: String) { + val all = wallets().first() + val gone = all.firstOrNull { it.id == id } ?: return + putWallets(all.filter { it.id != id }) + seeds.deleteSeed(id) + cacheFile(id).delete() + val remaining = all.filter { it.id != id && it.coinId == gone.coinId } + seeds.store.edit { prefs -> + val key = stringPreferencesKey("active_${gone.coinId}") + if (prefs[key] == id) { + val next = remaining.firstOrNull()?.id + if (next == null) prefs.remove(key) else prefs[key] = next + } + } + } + + /** The phrase in the clear — callers gate this behind the biometric confirm. */ + suspend fun revealSeed(id: String): String? = seeds.seed(id) + + suspend fun newReceiveAddress(walletId: String): String { + val meta = wallet(walletId) ?: error("unknown wallet") + val spec = coin(meta.coinId) ?: error("unknown coin") + val seed = seeds.seed(walletId) ?: error("seed unavailable") + val book = coreFor(spec).deriveAddresses( + seed, meta.receiveAddresses.size + 1, meta.changeAddresses.size, + ) + putWallets(wallets().first().map { + if (it.id == walletId) it.copy(receiveAddresses = book.receive) else it + }) + return book.receive.last() + } + + // --- chain view & cache --- + + private fun cacheDir(): File = File(context.filesDir, "wallet-cache").apply { mkdirs() } + private fun cacheFile(walletId: String): File = File(cacheDir(), "$walletId.json") + + fun cachedSnapshot(walletId: String): WalletSnapshot? = runCatching { + val f = cacheFile(walletId) + if (!f.exists()) return null + json.decodeFromString(WalletSnapshot.serializer(), f.readText()) + }.getOrNull() + + /** Fetch balance and history; persist and return the fresh snapshot. */ + suspend fun refresh(walletId: String): WalletSnapshot = withContext(Dispatchers.IO) { + val meta = wallet(walletId) ?: error("unknown wallet") + val spec = coin(meta.coinId) ?: error("unknown coin") + val core = coreFor(spec) + val book = AddressBook(meta.receiveAddresses, meta.changeAddresses) + val balance = core.balance(book) + val history = core.history(book) + val snapshot = WalletSnapshot( + confirmed = balance.confirmed, + predicted = balance.predicted, + hours = balance.hours, + spendableOutputs = balance.spendableOutputs, + txs = history.map { + CachedTx( + txid = it.txid, + incoming = it.incoming, + amount = it.amount, + party = it.party, + timestamp = it.timestamp, + confirmed = it.confirmed, + confirmations = it.confirmations, + fee = it.fee, + ) + }, + fetchedAtMs = System.currentTimeMillis(), + ) + cacheFile(walletId).writeText(json.encodeToString(WalletSnapshot.serializer(), snapshot)) + snapshot + } + + // --- send --- + + suspend fun plan( + walletId: String, + toAddress: String, + amount: ULong, + feeRate: Int?, + sendMax: Boolean, + ): TxPlan = withContext(Dispatchers.IO) { + val meta = wallet(walletId) ?: error("unknown wallet") + val spec = coin(meta.coinId) ?: error("unknown coin") + val seed = seeds.seed(walletId) ?: error("seed unavailable") + coreFor(spec).buildTx( + seed = seed, + book = AddressBook(meta.receiveAddresses, meta.changeAddresses), + toAddress = toAddress.trim(), + amount = amount, + feeRate = feeRate, + sendMax = sendMax, + ) + } + + /** Sign locally and broadcast; returns the network's txid. */ + suspend fun signAndBroadcast(walletId: String, plan: TxPlan): String = withContext(Dispatchers.IO) { + val meta = wallet(walletId) ?: error("unknown wallet") + val spec = coin(meta.coinId) ?: error("unknown coin") + val core = coreFor(spec) + val seed = seeds.seed(walletId) ?: error("seed unavailable") + val signed: SignedTx = core.signTx(seed, plan) + val txid = core.broadcast(signed) + + // A Bitcoin plan that paid change to a fresh chain address makes that + // address part of the wallet the moment the transaction exists. + if (core is BtcWalletCore) { + val idx = core.consumedChangeIndex(signed) + if (idx >= 0 && idx == meta.changeAddresses.size) { + val book = core.deriveAddresses(seed, meta.receiveAddresses.size, idx + 1) + putWallets(wallets().first().map { + if (it.id == walletId) it.copy(changeAddresses = book.change) else it + }) + } + } + txid + } + + companion object { + private val KEY_WALLETS = stringPreferencesKey("wallets") + private val KEY_FIBER_COINS = stringPreferencesKey("fiber_coins") + private val KEY_SELECTED_COIN = stringPreferencesKey("selected_coin") + + @Volatile private var instance: WalletRepository? = null + fun get(context: Context): WalletRepository = + instance ?: synchronized(this) { + instance ?: WalletRepository(context.applicationContext).also { instance = it } + } + } +} diff --git a/android/app/src/main/java/com/skycoin/skywire/wallet/WalletSeedStore.kt b/android/app/src/main/java/com/skycoin/skywire/wallet/WalletSeedStore.kt new file mode 100644 index 0000000000..6aa3f39f93 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/wallet/WalletSeedStore.kt @@ -0,0 +1,102 @@ +package com.skycoin.skywire.wallet + +import android.content.Context +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +private val Context.walletDataStore by preferencesDataStore(name = "wallet") + +/** + * Recovery phrases, sealed at rest. Same construction as [com.skycoin.skywire.core.SecretStore] + * — AES-256-GCM under a non-exportable AndroidKeyStore key — but a separate + * key alias and a separate store: coins and service passwords must not share + * a blast radius. The phrase exists in plaintext only in memory, on its way + * to derivation or signing; every send and every reveal is additionally + * gated by the biometric confirm at the UI layer. allowBackup=false keeps + * the ciphertext out of cloud backups. + * + * Deliberately NOT setUserAuthenticationRequired: address derivation (new + * wallet, fresh receive address) legitimately runs without a prompt, and a + * keystore-enforced prompt per decryption would also silently break the + * moment the user removes their screen lock — losing the seed with it. + */ +class WalletSeedStore(private val context: Context) { + + private val mutex = Mutex() + + /** Also holds wallet/coin registry entries — see [WalletRepository]. */ + internal val store get() = context.walletDataStore + + suspend fun putSeed(walletId: String, mnemonic: String) { + mutex.withLock { + store.edit { it[seedKey(walletId)] = encrypt(mnemonic) } + } + } + + /** Null only when the keystore was wiped under us or the id is unknown. */ + suspend fun seed(walletId: String): String? = mutex.withLock { + store.data.first()[seedKey(walletId)]?.let { decrypt(it) } + } + + suspend fun deleteSeed(walletId: String) { + mutex.withLock { + store.edit { it.remove(seedKey(walletId)) } + } + } + + private fun seedKey(walletId: String) = stringPreferencesKey("seed_$walletId") + + private fun key(): SecretKey { + val ks = KeyStore.getInstance(KEYSTORE).apply { load(null) } + (ks.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it } + val gen = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE) + gen.init( + KeyGenParameterSpec.Builder( + KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .build(), + ) + return gen.generateKey() + } + + private fun encrypt(plain: String): String { + val cipher = Cipher.getInstance(TRANSFORM) + cipher.init(Cipher.ENCRYPT_MODE, key()) + val ct = cipher.doFinal(plain.toByteArray(Charsets.UTF_8)) + return Base64.encodeToString(cipher.iv, Base64.NO_WRAP) + ":" + + Base64.encodeToString(ct, Base64.NO_WRAP) + } + + private fun decrypt(stored: String): String? = runCatching { + val (ivB64, ctB64) = stored.split(":", limit = 2).also { require(it.size == 2) } + val cipher = Cipher.getInstance(TRANSFORM) + cipher.init( + Cipher.DECRYPT_MODE, + key(), + GCMParameterSpec(128, Base64.decode(ivB64, Base64.NO_WRAP)), + ) + String(cipher.doFinal(Base64.decode(ctB64, Base64.NO_WRAP)), Charsets.UTF_8) + }.getOrNull() + + private companion object { + const val KEYSTORE = "AndroidKeyStore" + const val KEY_ALIAS = "skywire_wallet_seed" + const val TRANSFORM = "AES/GCM/NoPadding" + } +} diff --git a/android/app/src/main/res/drawable-nodpi/coin_btc.png b/android/app/src/main/res/drawable-nodpi/coin_btc.png new file mode 100644 index 0000000000..6402b75e43 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/coin_btc.png differ diff --git a/android/app/src/main/res/drawable-nodpi/coin_eth.png b/android/app/src/main/res/drawable-nodpi/coin_eth.png new file mode 100644 index 0000000000..c96f24cafb Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/coin_eth.png differ diff --git a/android/app/src/main/res/drawable-nodpi/coin_sky.png b/android/app/src/main/res/drawable-nodpi/coin_sky.png new file mode 100644 index 0000000000..aceb4f5302 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/coin_sky.png differ diff --git a/android/app/src/main/res/drawable-nodpi/coin_usdt.png b/android/app/src/main/res/drawable-nodpi/coin_usdt.png new file mode 100644 index 0000000000..c1bfa0f585 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/coin_usdt.png differ diff --git a/android/app/src/main/res/drawable-nodpi/skywire_logo.png b/android/app/src/main/res/drawable-nodpi/skywire_logo.png new file mode 100644 index 0000000000..786212a5af Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/skywire_logo.png differ diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000000..48f582e79d --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,15 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_media_back.xml b/android/app/src/main/res/drawable/ic_media_back.xml new file mode 100644 index 0000000000..388abb7f7d --- /dev/null +++ b/android/app/src/main/res/drawable/ic_media_back.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_media_forward.xml b/android/app/src/main/res/drawable/ic_media_forward.xml new file mode 100644 index 0000000000..2e1ae24a70 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_media_forward.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_media_pause.xml b/android/app/src/main/res/drawable/ic_media_pause.xml new file mode 100644 index 0000000000..200eab6170 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_media_pause.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_media_play.xml b/android/app/src/main/res/drawable/ic_media_play.xml new file mode 100644 index 0000000000..ef22509a8f --- /dev/null +++ b/android/app/src/main/res/drawable/ic_media_play.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_splash_mark.xml b/android/app/src/main/res/drawable/ic_splash_mark.xml new file mode 100644 index 0000000000..c529834da7 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_splash_mark.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/android/app/src/main/res/font/nunito_variable.ttf b/android/app/src/main/res/font/nunito_variable.ttf new file mode 100644 index 0000000000..2ec1f4b067 Binary files /dev/null and b/android/app/src/main/res/font/nunito_variable.ttf differ diff --git a/android/app/src/main/res/font/quicksand_variable.ttf b/android/app/src/main/res/font/quicksand_variable.ttf new file mode 100644 index 0000000000..8cd9133bc8 Binary files /dev/null and b/android/app/src/main/res/font/quicksand_variable.ttf differ diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000000..a8a8fa5518 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000000..a8a8fa5518 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/values-night/themes.xml b/android/app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000000..2667f3b2ee --- /dev/null +++ b/android/app/src/main/res/values-night/themes.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000000..b4bab98836 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,9 @@ + + + #0F7BF4 + + #FFFFFF + #0A101C + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000000..1d20687933 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,617 @@ + + + Skywire + + Home + Chat + Wallet + Settings + Apps + + SkySOCKS + SkyVPN + SkyDEX + SkyChat + Wallet + Fleet + SkyMeet + + + %1$d installed · %2$d running + All + Network + Finance + Social + Your apps + SOCKS5 proxy + Swap · SKY / BTC + Rooms and messages + SKY · Fibercoin · BTC · ETH + Visors you run elsewhere + Encrypted calls + Reconnecting + Starting + No exit chosen yet + Down + Up + Data + SkyVPN on/off + + %1$d hop + %1$d hops + + Hops — + Killswitch on + Killswitch off + %1$s SKY + + %d visor connected + %d visors connected + + + + Route length + Fastest + Balanced + Most private + A direct route to the exit — the quickest the network can be. The exit still cannot read your traffic, but it does see that this device is the one asking. + Traffic is relayed through intermediaries, so no single node sees both who is asking and what is being asked. Slower, and every hop is another machine that has to stay up. + Waiting for the visor to report its routing settings. + + + Network address + This device + Traffic exits + Not through SkyVPN + Exit location unknown + Waiting for the visor… + Shared carrier address + Not discoverable + This device’s own address does not change when SkyVPN connects: the app carries the tunnel, so its own traffic has to stay outside it. What changes is where your other apps’ traffic leaves the network — the exit above. + Exits %1$s + + Coming soon + Connect + Disconnect + Back + Save + Cancel + + + Skywire core + Shown while the Skywire visor is running. + Skywire running + Starting the visor… + Connected to the Skywire network. + Visor crashed — restarting (attempt %1$d). + + + Incoming calls + Call in progress + Incoming call + Calling… + Answer + Decline + Call in progress + SkyChat is using the microphone. + Allow the microphone so the other side can hear you. + Hang up + Mute + Unmute + Speaker + Missed call + + + Disconnected + Starting… + Connected + Stopping… + Restarting (attempt %1$d)… + Connect starts the Skywire core on this phone. + Joining the Skywire network. The first connection can take a few minutes. + The core did not come up. Check the process log. + Visor + Public key + Version + Uptime + More info + Less info + Transports + Total + DMSG servers + Service health + Copied + View logs + + + Logs + Core + Process + Follow + Pause + Share + Filter… + No log entries yet. + Fetching the log… + %1$d lines missed (buffer wrapped) + Core API unreachable — showing nothing new. The Process source works even when the visor won’t start. + + + Connecting… + Connection failed + The Skywire core is not running. Start it from the Home tab. + Waiting for the Skywire core to come up… + Server + Latency + Transferred + Reconnect + Pick a server below to connect. + SOCKS5 for other apps + Point a proxy-aware app or browser at this address while SkySOCKS is connected. Whole-phone routing arrives with SkyVPN. + Change port + Listen port + The SOCKS5 listener stays on 127.0.0.1 — only apps on this phone can reach it. + Port + Enter a port between 1024 and 65535. + Servers (%1$d) + Search by key, country or version + No server matched your search. + Service discovery returned no proxy servers. + Retry + Refresh + + + Unknown location + + + Connecting… + Connection failed + Blocked by killswitch + The Skywire core is not running. Start it from the Home tab. + Waiting for the Skywire core to come up… + Exit + Reconnect + Pick an exit below to route this phone through it. + The tunnel is down and the killswitch is holding every other app offline. Disconnect to restore normal networking. + Skywire’s own traffic stays outside the tunnel — that is what carries it. + Exits (%1$d) + No exit matched your search. + Service discovery returned no VPN servers. + Search by key, country or version + + This session + Ping + Transferred + Speed + Session + Total on this phone + Interface + + Killswitch + Keeps the network interface in place while the tunnel is down, so nothing leaks out around it. Other apps have no connection until it comes back. + Always-on VPN settings + For the strongest guarantee — blocking even before the app starts — turn on “Always-on VPN” and “Block connections without VPN” for Skywire in Android’s settings. + + Android has not granted the VPN permission. + The VPN service could not open its handoff socket: %1$s + The VPN permission is required to route this phone through Skywire. + Android has no VPN settings screen to open. + + + Transport + Change + Tried first when building the route to a server. The others stay as fallbacks. + Primary transport + Skywire tries this type first when it connects, and falls back to the others when it can’t. Takes effect on the next connection. + Recommended on mobile + DMSG + Relayed through a DMSG server. Works behind any carrier NAT — the connection a phone can always make. + STCPR + Direct TCP to the server. Fastest when it works, but needs reachability a mobile network rarely gives. + SUDPH + Direct UDP with hole punching. Needs a NAT that allows it — many carriers do not. + + + Chat actions + Reload + Starting SkyChat… + Waiting for the Skywire core to come up… + The Skywire core is not running. Start it from the Home tab. + SkyChat playback + Play, pause and skip a voice message or clip while it is playing. + + + Market + Market public key + The market to trade on. Connecting dials it over Skywire and opens its trading screen below. + A market public key is 66 hex characters starting with 02 or 03. + Recent markets + Reaching the market over Skywire… + The Skywire core is not running. Start it from the Home tab. + Waiting for the Skywire core to come up… + + + Enable Fleet + Lets your own visors reach this phone over DMSG and report their status here. Off by default. + Restarting the Skywire core so the change takes effect… + Turn Fleet on? + Turn Fleet off? + The Skywire core reads this setting only when it starts, so it has to be restarted. Any SkySOCKS or SkyVPN connection drops and has to be reconnected. + Restart core + + Your other visors, from here + Fleet lists the visors you run elsewhere — whether each one is up, what it is running, and for how long. You can restart one. Nothing else: no transports, no apps, no settings. + Turning it on makes this phone reachable over DMSG as a status endpoint, so those visors can connect in. Only visors that already carry this phone’s key connect — and they still cannot control anything here. + + Add a visor + On the other machine, add this phone’s key to that visor’s config: + This phone + Waiting for the core to report this phone’s key… + skywire cli config update hv --add-pks %1$s + Tap to copy. + Run it where that visor’s skywire-config.json lives, then restart that visor. It appears in the list once it connects. + + The Skywire core is not running. Start it from the Home tab. + Waiting for the Skywire core to come up… + Visors (%1$d) + No visor has connected yet. Tap ? above to see how to add one. + Refresh + Offline + Health + Unknown + Last answered %1$s ago — everything above is from then + Restart + Name this visor + Name + A name for %1$s, so you know which machine it is. Kept on this phone only — the visor is never written to. Clear the field to remove it. + Restart %1$s? It closes everything it is running, re-reads its config and starts again — offline for a few seconds, and anything routed through it drops. + Restart sent to %1$s. It goes offline and comes back on its own. + + The core could not be started again: %1$s + + + Skywire is locked + Unlock + Unlock Skywire + Use your fingerprint, face or screen lock. + + + Identity + This phone is a Skywire visor, and its key is what everything else addresses. Both actions below end that identity. + No identity yet. Connect once from the Home tab and one is generated. + Restarting the Skywire core with the new identity… + Replace secret key + New identity + Paste the 64-character secret key you want this phone to run as. It is checked by the Skywire core before anything changes. + Secret key + That is already this visor’s key — nothing to change. + Identity replaced. The core is starting with the new key. + New identity generated. The core is starting with it. + + Replace this visor’s identity? + The new public key will be %1$s. + This cannot be undone + Everything above is erased and this phone becomes the visor %1$s. Make sure you still hold the current secret key somewhere if you ever want it back — export the config first if you do not. + Replace identity + + Generate a new identity? + The new key is generated on this phone. Nobody has it yet, so nobody can reach you at it until you share it again. + This cannot be undone + The current key is destroyed, not archived. If you have not exported the config, this identity is gone for good — along with everything addressed to it. + Generate new identity + + + You will lose everything this phone holds as %1$s: its chat history and contacts, its groups, and the address other visors know it by. Nothing here is recoverable afterwards, and nothing moves to the new key. + + Continue + + + Config + The complete visor configuration, as a file you keep. It is the only way to move this identity to another device or bring it back. + Export config + Export the config? + The file contains this visor’s secret key in plain text. Anyone who reads it can run as this visor. Save it somewhere only you can reach — not a shared drive, not a chat. + Export the Skywire config + Config exported. + + + App lock + Asks for your fingerprint, face or screen lock when Skywire opens and when you come back to it after about half a minute away. Also hides the app in the recent-apps list and blocks screenshots. + This phone has no screen lock or enrolled biometric, so there is nothing to check against. + Open security settings + This phone has no security settings screen to open. + Turn on the app lock + Turn off the app lock + Confirm it is you. + + + Theme + System + Light + Dark + About + App version + Core version + + + Logs & diagnostics + Every log source, an export of all of them, and how much the core writes. + + Logs & diagnostics + Sources + The same viewer each screen’s Logs button opens, with every feed listed in one place. + The visor’s own runtime log. + What the core process printed — the only feed that survives a visor that will not start. + This app’s log, as the visor keeps it. + + Export all + Every source above in one zip, with the device details and the config — without its secret key. + Export all logs + Collecting logs… + Diagnostics exported. + + Core log level + How much the visor writes. Debug and trace are for reproducing a problem — on a phone they are a lot of writing for a log nobody is reading. + Set the log level to %1$s? + The Skywire core reads this only when it starts, so it has to be restarted. Any SkySOCKS or SkyVPN connection drops and has to be reconnected. + + Connect will start the Skywire core — coming soon. + + + Set up your %1$s wallet + Your keys are created on this device and stay on this device. Skywire never sees them, and they are never sent to the network. + Create a new wallet + Generate a new recovery phrase + Restore from seed + Enter an existing 12 word phrase + + Recovery phrase + Write these twelve words down in order and keep them offline. Anyone who has them can spend your coins. + Screenshots are disabled on this screen. There is no copy button. + Continue + + Confirm phrase + Enter three words from the phrase you just wrote down. The wallet activates once they match. + Word %1$d + type the word + That is not word %1$d. Check your written copy. + Enter word %1$d. + Activate wallet + Show the phrase again + Wallet activated + + Restore wallet + Word %1$d of %2$d + Paste whole seed + Twelve words read from the clipboard + Next word + Suggestions come from the BIP39 wordlist held on the device. Nothing you type is sent anywhere. + Restore wallet + This phrase fails its checksum. One of the twelve words is wrong or out of order. + Only %1$d of 12 words entered. A seed is restored in full or not at all. + Wallet restored. Scanning for balances. + Restoring and scanning for balances… + + Choose a coin + Choose a coin + Search %1$d coins + + Skycoin mainnet · native + Fibercoin + Bitcoin mainnet + Ethereum mainnet + ERC-20 token on Ethereum + Add a coin or token + %1$s Coin Hours + %1$d confirmed outputs · no Coin Hours on this chain + Ethereum mainnet · fees paid in ETH + %1$s on Ethereum · fees paid in ETH + Node unreachable. Balance and history last updated at %1$s, %2$s ago. Sending is off until the node answers. + Node unreachable. This wallet has not synced yet — balances show once the node answers. + Send is disabled: no route to the node + Receive + Send + Recent activity + See all + No activity yet + Coins you receive on this wallet will appear here. + Wallets + Received + Sent + from %1$s · %2$s + to %1$s · %2$s + within this wallet · %1$s + + Receive + Send only %1$s to this address. + Tap to copy the full address + Copy + Share + Address copied to the clipboard + Other addresses in this wallet + Addresses in %1$s + All of them belong to the same seed. Using a fresh one makes your balance harder to follow. + Default · in use + Never used + Generate a new address + New address generated in %1$s + Receiving on the selected address + + Send + To + %1$s address + bc1… or 1… address + 0x… address + Paste + Scan a QR code + Amount + Available %1$s %2$s + Max + Max sends every %1$s. Coin Hours are not sent — a share of them is burned as the fee. + Max spends every confirmed output and subtracts the miner fee, so the amount moves with the rate. + Max holds back a little ETH for gas; the exact remainder is computed when you review. + Max sends the whole token balance. Gas is paid in ETH from the same address. + Fee + Coin Hours burned + Coin Hours after + %1$s transactions cost no %1$s. One tenth of the Coin Hours entering the transaction is burned as the fee, and half of what remains moves on with the coins. + Fee rate + %1$d sat/vB + Estimated fee, %1$d vB + Economy + Normal + Priority + ~2 h + ~30 min + ~10 min + Network fee + calculated at review + Gas limit %1$d · up to %2$d gwei per gas + The fee shown is the worst case at today’s prices — unspent gas is never charged. + Token transfers burn gas, and gas is paid in ETH from the sending address — keep a little ETH next to the tokens. + Review + You are about to send + + to %1$s on the %2$s network. Nothing is signed until you confirm. + Amount + Miner fee at %1$d sat/vB + Network fee, at most + Total leaving the wallet + %1$s balance after + Coin Hours after + Back + Sign and send + Sign this transaction + Signing happens on this device. Your key is never sent anywhere. + Broadcast to the network + %1$s %2$s is on its way to %3$s. It stays pending until the network confirms it. + Done + View in history + Transaction id copied + + History + All + Sent + Received + Pending + Nothing here yet + No %1$s has moved in or out of this wallet. + No %1$s transactions match this filter. + Show my address + Today + Yesterday + + Transaction + Confirmed + Pending · waiting for the first confirmation + From + To + Wallet + Date + Fee + Confirmations + Transaction id + Open in explorer + Opens %1$s in your browser. The explorer will see this transaction id. + %1$s Coin Hours + + Wallets + ACTIVE + 1 address + %1$d addresses + Add a wallet + 1 wallet + %1$d wallets + Create new + %1$s · created %2$s + Use this wallet + Rename + Rename wallet + Reveal recovery phrase + Shows all twelve words in the clear. Asks for your fingerprint first. + Remove from this device + Deletes the keys here. Without the phrase the coins are gone for good. + Remove %1$s from this device? + The keys stored here are deleted. If you do not have the twelve recovery words written down, the coins in this wallet cannot be reached again by anyone, including you. + Remove + Keep it + %1$s removed from this device + Confirm it is you + The words of %1$s will be shown in the clear on the next screen. + Anyone who reads these twelve words can move every coin in %1$s, from any device, without your phone. Only reveal them somewhere no one can see your screen. + Screenshots are disabled. This screen closes on its own in %1$s. + Hide the phrase + + Add a coin or token + Fibercoin + ERC-20 token + Every Fibercoin runs the same node software as Skycoin. Name the coin and point the wallet at a node you trust — balances, fees and precision come from that node. + Every ERC-20 token lives on Ethereum and differs only in its contract. The decimals must match the contract’s own — 6 for USDT-like tokens, 18 for most — or amounts will read wrong. + Coin name + e.g. MDL Talent Hub + Ticker + e.g. MDL + Icon + Choose image + Remove + Node address + http://node.example.com:6420 + Contract address + 0x… + Decimals + 18 + Add coin + The phrase cannot be read — the phone’s keystore has changed since this wallet was created. + + + About this screen + Got it + + About SkyChat + Messages, calls, groups and channels that travel over Skywire rather than a company\u2019s servers. Your identity here is this visor\u2019s public key — hand it out with the QR button and anyone holding it can reach you.\n\nThis is the same chat the desktop runs, laid out for a phone. It keeps its own history on this device; there is no account and nothing syncs on its own, so moving to another phone means exporting from Settings inside the chat and importing there.\n\nLogs for SkyChat live in Settings \u25b8 Diagnostics, together with every other log this app keeps. + + About Apps + Everything your visor can run, in one place. A tile opens the app\u2019s own screen; the visor itself keeps running underneath whichever one you are looking at.\n\nSkySOCKS and SkyVPN send other traffic through the network, SkyDEX trades, SkyChat messages and Fleet watches the visors you run elsewhere. A greyed tile is not built yet. + + About Wallet + Skycoin, the Fibercoins that share its node software, and Bitcoin — all held by keys that are generated on this phone and never leave it. Skywire never sees them and they are never sent to the network.\n\nThe twelve recovery words are the wallet. Anyone who reads them can spend the coins from any device without your phone, and if you lose them nobody can recover the wallet for you — not us, not anyone.\n\nBalances and history come from the node each coin is pointed at, so a coin whose node is unreachable shows its last known balance and will not send. + + About Settings + Who this phone is on the network, and how the app behaves. The identity is a keypair in a file — back it up before you need it, because losing it loses your address, your chat history\u2019s other half and any rewards tied to the key.\n\nDiagnostics is where every log lives: the visor core, the app process, and each app that runs under it. That is the place to look when something will not connect. + + About SkySOCKS + A SOCKS5 proxy that comes out somewhere else. Pick a server and this phone offers a local proxy port; anything you point at that port leaves the network from the server you chose rather than from here.\n\nIt proxies only what you configure to use it — it does not capture the whole phone. SkyVPN is the one that does that.\n\nLogs for SkySOCKS live in Settings \u25b8 Diagnostics. + + About SkyVPN + The whole phone\u2019s traffic, through a server you choose. Android will ask to add a VPN configuration the first time; while it is on, every app\u2019s traffic leaves from the exit server instead of from here.\n\nThe key stays on this device and the exit server sees the traffic it forwards, exactly as any VPN exit does. Turning it off restores normal routing immediately.\n\nLogs for SkyVPN live in Settings \u25b8 Diagnostics. + + About SkyDEX + Trading against a market maker reached over Skywire rather than over the open internet. The screen is the exchange\u2019s own, running against the market you are connected to.\n\nOrders and balances belong to that market, not to the wallet on the Wallet tab — the two are separate.\n\nLogs for SkyDEX live in Settings \u25b8 Diagnostics. + About Fleet + The visors you run on other machines — a desktop, a server, a Skyminer — seen from this phone. It is a window onto them, not a remote control: what arrives is status, and the one action is a restart.\n\nOff by default. Turning it on restarts this phone\u2019s core, because the setting is read once while the visor builds itself.\n\nA visor\u2019s own log is on its card here, since that feed travels from the remote machine; this app\u2019s logs are in Settings \u25b8 Diagnostics. Names you give a visor are kept on this phone and do not travel with it. +\n + Battery + Skywire keeps running in the background, but Android may still pause its network once the screen has been off for a while — messages and calls then arrive when the phone next wakes rather than when they were sent. Allowing Skywire to ignore battery optimisation closes that gap. It costs battery, and the app works either way. + Skywire is allowed to ignore battery optimisation, so its connections stay up while the screen is off. Android\u2019s battery settings can take this back at any time. + Allow + Not now + This phone has no battery-optimisation screen to open. + Keep Skywire connected in the background? + Android can pause this app\u2019s network once the screen has been off for a while. Allow it to ignore battery optimisation and messages arrive as they are sent. + + Encrypt the config at rest + Seals the config file whenever Skywire is disconnected, under a key this phone will not hand out. Protects the secret key if the device is examined; it makes no difference while you are connected. + If this phone is reset — or its screen lock is removed and set again — the sealing key is destroyed and the identity in the config cannot be recovered. Export the config first if it matters. + Confirm to store the config unencrypted + Config encrypted. + The config will be encrypted when you disconnect. + Config is no longer encrypted. + diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000000..9857d85d1e --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,16 @@ + + + + + + + + diff --git a/android/app/src/main/res/xml/backup_rules.xml b/android/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000000..653d39fd5c --- /dev/null +++ b/android/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/android/app/src/main/res/xml/data_extraction_rules.xml b/android/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000000..ecfbc47c18 --- /dev/null +++ b/android/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,33 @@ + + + + + + + + + + diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000000..fe42b2cdf8 --- /dev/null +++ b/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000000..20ddb660ad --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,7 @@ +// Root build file — plugin versions come from gradle/libs.versions.toml. +plugins { + alias(libs.plugins.android.application) apply false + // Kotlin support is built into AGP ≥9 — no org.jetbrains.kotlin.android here. + alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.kotlin.serialization) apply false +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000000..2a9ea5cc7f --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,8 @@ +org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 +org.gradle.parallel=true +org.gradle.caching=true +android.useAndroidX=true +android.nonTransitiveRClass=true + +# Enabled parallel sync for Gradle 9.4+ +org.gradle.tooling.parallel=true diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml new file mode 100644 index 0000000000..6af9257fd8 --- /dev/null +++ b/android/gradle/libs.versions.toml @@ -0,0 +1,59 @@ +# Version catalog for the Skywire Android app. +# Versions verified against google() / mavenCentral() metadata on 2026-08-03. +[versions] +agp = "9.3.1" +kotlin = "2.4.10" +composeBom = "2026.06.01" +activityCompose = "1.13.0" +navigationCompose = "2.9.8" +lifecycle = "2.11.0" +coreKtx = "1.19.0" +splashscreen = "1.2.0" +datastore = "1.2.1" +# 1.1.0 (2021) is the last stable and predates the Authenticators API's +# current behaviour on Android 12+; the 1.4 alphas are the maintained line +# and the only ones that know about API 35/36 prompts. +biometric = "1.4.0-alpha07" +okhttp = "5.4.0" +kotlinxSerialization = "1.11.0" +coroutines = "1.10.2" +# Wallet: BouncyCastle lightweight API only (no JCA provider registration) — +# secp256k1 curve math, RFC 6979 nonces and RIPEMD-160 for address hashing. +bouncycastle = "1.80" +# QR scan (journeyapps wraps zxing with a capture activity; core also renders +# the receive-address QR). The one camera dependency the wallet adds. +zxingEmbedded = "4.3.0" +zxingCore = "3.5.3" +junit = "4.13.2" + +[libraries] +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splashscreen" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } +androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } +androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" } +androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } +androidx-biometric = { group = "androidx.biometric", name = "biometric", version.ref = "biometric" } +compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +compose-ui = { group = "androidx.compose.ui", name = "ui" } +compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +compose-material3 = { group = "androidx.compose.material3", name = "material3" } +compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } +okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } +kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" } +kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } +kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" } +bouncycastle = { group = "org.bouncycastle", name = "bcprov-jdk18on", version.ref = "bouncycastle" } +zxing-embedded = { group = "com.journeyapps", name = "zxing-android-embedded", version.ref = "zxingEmbedded" } +zxing-core = { group = "com.google.zxing", name = "core", version.ref = "zxingCore" } +junit = { group = "junit", name = "junit", version.ref = "junit" } +kotlin-test-junit = { group = "org.jetbrains.kotlin", name = "kotlin-test-junit", version.ref = "kotlin" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +# Kotlin support is built into AGP ≥9 — only the compiler plugins are applied. +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +# :wallet-core applies org.jetbrains.kotlin.jvm versionless — the plugin is +# already on the classpath via AGP's built-in Kotlin support. diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000..b1b8ef56b4 Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000000..a9db11550c --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 0000000000..249efbb032 --- /dev/null +++ b/android/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 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. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# gradlew 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 gradlew +# +# 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/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/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 -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || 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 + + + +# 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" ) + + 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, 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" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# 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" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000000..8508ef684d --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,82 @@ +@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 +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +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 + +"%COMSPEC%" /c exit 1 + +: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 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/android/icon-brief.md b/android/icon-brief.md new file mode 100644 index 0000000000..b39f7da371 --- /dev/null +++ b/android/icon-brief.md @@ -0,0 +1,144 @@ +# Brief: a custom icon set for Skywire Mobile + +Hand this to whoever draws the icons. Everything below is the constraint the +app already enforces — the placeholder set that ships today was drawn to it, +so a set that follows it drops in without a single layout change. + +--- + +## What we need + +A single-weight line icon set, 49 glyphs, for the Skywire phone app: the +SkyChat surface (which is HTML rendered inside the app) and the native +screens around it. They replace a set we drew ourselves as placeholders, +which in turn replaced platform emoji. + +## Why it matters here + +Two of these live somewhere unusual, and it shapes the whole set: + +- **They are inlined as SVG paths into a single HTML file** and coloured with + `currentColor`. So each icon must be *one path group, no fills, no + hard-coded colours, no gradients, no masks, no clip paths, no ``*. + Anything that cannot be expressed as stroked paths on a 24-unit grid we + cannot use. +- **They must read at 11px.** The delivery tick under a chat bubble is drawn + at 11–14px. That is the real test of this set, not the 20px menu size. + +## The grid and the line + +| | | +|---|---| +| Canvas | 24 × 24 | +| Live area | 20 × 20 (2 units clear on every side) | +| Stroke | 2 units, uniform, no tapering | +| Caps and joins | round | +| Corner radius | 2 units on square-ish forms | +| Alignment | snap to whole units; half-units only where a form must centre | +| Colour | none — strokes inherit `currentColor` | +| Fills | none, except the four noted below | + +Four glyphs are solid rather than stroked, because a 2-unit outline at 13px +leaves a hole where the shape should be: `play`, `dots`, `dotsH`. Draw these +as filled silhouettes with no stroke. + +Optical sizing matters more than mathematical: a circle and a square that +both measure 20 units do not look the same size. Balance by eye. + +## The palette they sit in + +Icons are monochrome and inherit their colour from the text beside them, so +there is nothing to specify per icon — but they are seen against these, and +must hold up on both: + +``` +Dark background #000000 surface #101216 text #FFFFFF / #9EA5AD / #6B7279 +Light background #FFFFFF surface #F6F7F9 text #000000 / #565C64 / #8A9099 +Accent #0072FF (both themes) +Status ok #3FD07E/#0B7A3E warn #F0A93B/#8A5600 bad #FF6B5E/#C42B1C +``` + +The typeface is Skycoin (geometric, generous counters, weights 400 and 700 +only). The icons should feel drawn by the same hand: geometric construction, +circles that are actually circular, few incidental details. + +## The 49 glyphs + +Grouped by where they appear, because a group should feel internally +consistent even more than the set does overall. + +**Identity and people** — `user` (one person), `users` (two or three people, +a group), `book` (address book, a bound book), `qr` (QR code: three finder +squares plus a scatter of modules), `edit` (pencil), `bookmark` (Saved +Messages — a ribbon bookmark). + +**Conversations** — `chat` (a speech bubble; used at 56px on the empty +state, so it carries more weight than the rest), `hash` (#, a group), +`megaphone` (a channel — broadcast, one-to-many), `pin`, `bell`, `bellOff`, +`link` (paired), `unlink` (unpaired), `leave` (leave a group: a door with an +arrow going out). + +**Composing and messages** — `send` (a paper plane), `paperclip` +(attachment), `mic`, `micOff`, `video` (a camcorder), `camera` (a stills +camera — distinct from `video` at 16px, which is the hard part), `image` (a +picture frame), `reply` (arrow curving back left), `forward` (arrow curving +on right), `trash`, `copy`, `download`. + +**Delivery state** — `circle` (sending), `check` (sent), `checkDouble` +(received/read), `alert` (failed — a warning triangle). These four are the +11–14px set. They must be distinguishable from each other at a glance, at +that size, in peripheral vision, because that is how they are actually read. + +**Calls** — `phone` (handset), `hangup` (handset with a slash), `callOut` +(arrow leaving, up-right), `callIn` (arrow arriving, down-left), `volume`, +`volumeOff`, `chart` (a spectrogram/level display — vertical bars). + +**Chrome and navigation** — `left`, `right`, `up` (chevrons), `plus`, +`close`, `dots` (vertical ⋮ overflow), `dotsH` (horizontal ⋯ overflow), +`gear` (settings), `lock`, `play`, `pause`. + +## Pairs that must not collide + +These are the ones a generic set usually gets wrong for us: + +- `video` vs `camera` — moving vs still, and both appear in the same + composer. +- `check` vs `checkDouble` — at 12px the difference has to survive. +- `bell` vs `bellOff`, `mic` vs `micOff`, `volume` vs `volumeOff`, `link` vs + `unlink`, `phone` vs `hangup` — each "off" state is the same glyph plus a + slash. Use one consistent slash: same angle, same length, same relationship + to the base glyph, across all five. Today they vary slightly and it shows. +- `users` vs `megaphone` — "a group" vs "a channel" is a distinction our + users have to make constantly. + +## Deliverables + +- One SVG per glyph, named exactly as above (`callOut.svg`, `checkDouble.svg` + — camelCase, matching the keys the code uses). +- 24 × 24 viewBox, paths only, strokes not expanded to outlines, no + ``, no `style` attributes, no `fill` or `stroke` attributes on the + paths themselves (the app sets those). +- A single contact sheet PNG at 16px and at 48px, both themes, for review. + +## What we will do with them + +Drop the path data into one table in the app. There is a single `icon(name, +size)` helper; nothing else has to change. If a glyph is missing the app +renders nothing rather than breaking, so the set can land in pieces. + +--- + +### The short version, if you want to paste a prompt + +> Draw a 49-glyph monochrome line icon set for a peer-to-peer messaging and +> crypto-wallet phone app. 24×24 grid, 20×20 live area, uniform 2-unit +> stroke, round caps and joins, 2-unit corner radii, geometric construction +> to match a geometric sans (Skycoin). Paths only — no fills, colours, +> gradients or clip paths; the app colours them with currentColor and renders +> them from 11px to 56px, so legibility at 12px is the binding constraint. +> `play`, `dots` and `dotsH` are solid silhouettes instead. Glyphs: [list +> above]. The five on/off pairs (bell, mic, volume, link, phone) must share +> one consistent slash treatment, and video vs camera, check vs +> check-double, and users vs megaphone must stay distinguishable at 16px. +> Deliver one SVG per glyph, camelCase filenames, plus a contact sheet at +> 16px and 48px on both a #000000 and a #FFFFFF background. diff --git a/android/implementation-report.md b/android/implementation-report.md new file mode 100644 index 0000000000..0e7a9014ac --- /dev/null +++ b/android/implementation-report.md @@ -0,0 +1,2709 @@ +# Implementation report — Skywire Android + +Running log of what was implemented, when, and how it was verified. +After finishing a part, add a dated entry at the top: what was built, key +decisions/deviations discovered while building it, and the verification that +was actually performed (commands, devices, measured numbers — not intentions). + +--- + +## 2026-08-08 — SkyDEX follows the app's theme, and three things about its chrome + +**Why:** the trading UI is a vendored single-page app with exactly one theme — +dark navy, declared as six custom properties on `:root` — so on a phone set to +Light it was the one dark screen in the app. It is a built bundle from another +repo, so the only lever this side has is a stylesheet layered on top; the same +lever the embedded chat already uses. + +**Built (`ui/dex/DexWebView.kt`, `ui/dex/DexScreen.kt`):** + +- **Theme.** `applyTheme` toggles `html.sky-light` / `html.sky-dark` and injects + a stylesheet written against the page's *own* tokens. Dark keeps the page's + design and moves one token (`--sky-navy` → `#0A101C`) so the document behind + the native header row is the app's ground; light re-points the set to the + palette in `ui/theme/Theme.kt` (`#FFFFFF` / `#0B1526` / `#0F7BF4` / `#FAFCFF` + / `#44536B`). Committed from `onPageCommitVisible` — the earliest point a + script provably runs in the *new* document — so a light load never paints + navy first, re-applied at `onPageFinished`, and driven live from a + `LaunchedEffect(darkTheme)` when the theme changes with the page open. + `LocalDarkTheme` is the source, i.e. the answer *after* the user's Light/Dark + override; `DexScreen` now has a second such consumer alongside chat. +- **The hard-coded darks.** Every translucent fill in the page's CSS is + white-on-navy arithmetic that turns to mud on white, so each is re-based: + form controls, the trade legs, `.addr-box`, `.recent-connect`, the progress + track, the product-card hover, and the two shadows tuned for a dark ground. + `color-scheme` flips with the theme so the engine draws scrollbars and select + popups to match. **Two exceptions are load-bearing and commented where they + are made:** `--sky-white` is the *ink* token and `.btn-primary`/`.btn-connect` + fill with brand blue, so those keep white text under light (re-pointing alone + would have put near-black on `#0F7BF4`); and `.trade-leg .leg-amount` is + re-declared transparent, because the light `.form-control` rule outranks the + page's own and would otherwise print a white box inside a tinted panel. +- **JS dialogs follow too.** `chromeClient` takes `isDark` and names + `Theme_DeviceDefault_Light_Dialog_Alert` / `…_Dialog_Alert` explicitly. These + are drawn by the platform, so an Activity theme cannot see a choice that lives + in a composition local — without this a `window.confirm` (which is what guards + cancelling a listing) arrived as a dark slab over a light page. +- **Cards/List, only where it means something.** The switch used to be a bar of + its own at the top of every tab, Settings included — a control above a form + with nothing to act on. `ensureViewbar` now takes a predicate computed from + the DOM (`table.table thead th` or `.card.product-card`), not a list of tab + names, so it follows the rows wherever the page puts them next. +- **…and it rides in the heading that owns those rows.** The market names its + grid (`.section-title`, "Available products") and its `.page-head` already + carries New Sell Order; every other tab's rows *are* the tab, so the page + title is the heading. Title left, switch right, one line. On `.page-head` it + is inserted straight after the `h2` rather than appended, so when three items + do not fit it is the page's own button that wraps and never the switch. +- **Clear history moves to Settings.** Beside the list it wipes it was one + mis-tap from the tab just opened; it belongs with the other set-once things. + The page's own button is hidden in CSS rather than script — script would let + it flash in on each of the page's 8-second re-renders. It is **re-implemented, + not moved**: History and Settings are separate React screens, so the button + does not exist in the DOM while Settings is on. What it does is entirely + local (`localStorage.removeItem('exchange:history')`), so the same key behind + the same confirm is the same action — including the part where a later poll + can re-save trades the market still reports as finished, which is the page's + behaviour and not a difference. Rendered as a `panel`/`panel-title` with a + `btn btn-connect`, matching Save addresses directly above it, and shown only + when there is something to clear — the condition History itself used. + +Both phone-only behaviours sit inside the existing `max-width: 600px` block and +are hidden by a base rule outside it, so a tablet keeps the layout the page +intends: its own heading-row button, no injected Settings panel. + +**Verified on the Android Studio emulator (arm64, 1080×2400, light theme), +connected over Skywire to a live market +(`024a37ba…43bdb9`, "Unofficial Skycoin Market"):** the trading page renders +light end-to-end — Market (banner, product card, blue buttons with white +labels), Settings (white fields, readable placeholders, the required-field +asterisk in a red that works on white), History; dark still renders as shipped +on the same build. Settings shows no Cards/List switch; History shows "History" +left and the switch right on one line with no Clear history beside it; the +Trade history panel appears in Settings, its confirm dialog opens, and OK +clears the saved history. `:app:assembleDebug` BUILD SUCCESSFUL, installed via +`adb install -r`. + +**Not verified on screen:** the trade builder (`+ New Sell Order`) is gated +behind saved wallet addresses and redirects to Settings, so the `.leg-amount` +rule above was confirmed by specificity against the page's bundled CSS rather +than by eye. + +--- + +## 2026-08-08 — A polish pass from device use: icon, light theme, battery, coin logos + +**Built:** + +- **Launcher icon** breathes again: the adaptive-icon foreground drops from + 56×42dp to 44×33dp, so the cloud's box half-diagonal (27.5dp) now sits + fully inside the 33dp safe-zone radius — no more crowding under OEM masks + or the launcher's parallax scale. +- **Hub:** the wallet tile's subtitle is now "SKY · Fibercoin · BTC · ETH"; + the hero's killswitch chip is words again — "Killswitch on/off" colored + green/red replaces the icon-only shield (`KillswitchChip` renders `Text`, + the shield glyphs and `HeroChip`'s dead `dim` parameter are gone). +- **Light theme:** `TransportPreferenceCard` and `MinHopsCard` now wrap + `SectionCard`, so every card on SkyVPN carries the outlineVariant hairline + and the 22dp radius instead of two of them floating borderless; the light + palette's card fill lifts to `#FAFCFF` and the surfaceContainer ramp + brightens a step, with the border rather than the fill drawing the edge. +- **Battery card no longer lies:** `AppVisibility` grew a `resumes` counter + bumped from `MainActivity.onResume()`. The system's exemption dialog only + pauses the Activity — start/stop never fires, so the old + `isForeground`-driven refresh missed the grant until an app restart. + Settings re-reads the exemption on every resume; Home's prompt joins the + same signal through its `combine`. +- **Coin badges are real logos:** SKY/BTC/ETH/USDT ship as bundled 128px + PNGs (CC0 `cryptocurrency-icons` set, no network fetch at render) mapped + in the new `ui/wallet/CoinIcons.kt`; `CoinBadge` takes the `CoinSpec` and + draws artwork → user image → ticker letters, in that order. +- **User-added coins pick an image, not a symbol:** the add-coin screen's + Icon row opens the system photo picker (`PickVisualMedia`); the picked + image is center-cropped square, scaled to 192px, and copied into + `filesDir/coin_icons/` (the picker's grant dies with the process — the + badge has to be our own file), with the stored name in the new nullable + `CoinSpec.icon` (backward-compatible, `ignoreUnknownKeys`). +- **"Fibercoin" everywhere:** every user-visible "fiber coin(s)" in strings + and the wallet copy now reads Fibercoin — one brand word, matching the + chain family's actual name. +- **Killswitch card button:** the "Always-on VPN settings" tonal button lost + its `contentPadding = 0.dp` override, which had the label riding the + pill's rounded edges (and spilling out at larger font scales). + +**Verified on AVD `skywire` (light theme, PIN 1234):** launcher drawer shows +the smaller cloud sitting with the same margins as its neighbors; hub hero +reads "Killswitch off" in red text and the wallet tile lists all four coins; +SkyVPN's Killswitch/Transport/Route-length cards render as one bordered +family and the Always-on button holds its label with real insets; the +battery flow was walked end-to-end — whitelist removed via +`cmd deviceidle whitelist -`, Settings offered Allow, the system dialog's +Allow flipped the card to its granted text immediately on return, no app +restart; the coin sheet shows the four real logos; a Testcoin was added with +a pushed test image through the photo picker — preview in the Icon row, then +the badge on the coin chip and sheet, surviving from app-private storage. +Debug APK built and installed via Android Studio JBR. + +--- + +## 2026-08-07 — The wallet learns Ethereum: ETH and USDT (and any ERC-20) + +**Built (wallet-core, new `eth` package — plain JVM, host-tested like the +rest of the money code):** + +- `EthCrypto`: Keccak-256 (BouncyCastle's `KeccakDigest` — the original + Keccak, not FIPS SHA3), BIP 44 `m/44'/60'/0'/0/i` off the existing Bip32, + EIP-55 checksummed addresses, and strict parsing (mixed case must be the + exact checksum; `0x` required). +- `Rlp`: encoder only — this wallet authors RLP, never parses it. +- `EthTxn`: EIP-1559 type-2 build/sign. The signature is the existing + `Secp256k1.signCompact` — Ethereum's recoverable form is Skycoin's wire + format over a different hash, and low-S means the recovery id IS yParity. + Plus the two ABI call datas the wallet needs: `transfer(address,uint256)` + and `balanceOf(address)`. +- `EthRpcClient`: JSON-RPC (balance, pending nonce, chainId, EIP-1559 fee + data, estimateGas, eth_call, sendRawTransaction) plus history via the + etherscan-style `?module=account` API, which Blockscout serves keyless — + plain RPC cannot list an address's past transactions, the same reason BTC + uses an esplora server. +- `EthWalletCore`: one WalletCore for the native coin and any ERC-20 token — + a token send is the same transaction with the value moved into `transfer` + call data and gas still paid in ETH. **Unit choice:** the seam's amounts + are 64-bit, and wei overflows them at ~18.4 ETH — so the native coin is + carried in gwei (exponent 9) and a token in its own decimals capped at 9, + with wei arithmetic in BigInteger strictly inside the core. Sends pick the + funded address (account chains have no change side), pad the node's gas + estimate by a fifth, price at 2×baseFee+priority, and refuse a token send + whose address lacks gas ETH with a new `WalletException.InsufficientGas` + that says exactly that. + +**Built (app):** `CoinKind.ETH`/`ERC20`; ETH and USDT +(`0xdAC17F…1ec7`, 6 decimals) ship built in; the add-coin screen grew a +Fiber-coin / ERC-20 toggle so any token is user-addable (contract + +decimals, checksum-validated). Fee UI: a gas card that shows rather than +asks (EIP-1559 prices itself; "calculated at review", then the worst-case +fee, gas limit and gwei ceiling), review-sheet branches for ETH (amount + +fee ≤ totals) and tokens (no total row — amount and fee are different +currencies), history fee lines always in ETH, `0x…` hints, and honest Max +notes (native holds back gas headroom, tokens send everything). + +**Verified:** vector tests in `EthVectorsTest` — Keccak reference outputs, +the four EIP-55 examples plus corrupted-case rejections, the standard test +mnemonic's first two addresses (`0x9858EfFD…`, `0x6Fac4D18…`), the RLP +examples from the design docs, and the **EIP-155 worked example reproduced +byte-for-byte** (our RFC 6979 nonce yields the document's exact signature — +RLP, Keccak, low-S and recovery id pinned in one assert), plus a type-2 +sign→recover round trip. `EthLiveTest` (opt-in, `SKYWIRE_NET_TESTS=1`) ran +against production: publicnode RPC balance, Blockscout txlist history, USDT +`balanceOf` and tokentx transfers all parse (2 tests, 0 skipped, 12.5 s). +Full wallet-core suite green; `:app:assembleDebug` BUILD SUCCESSFUL. Not +yet sent-and-received on-device with real funds — the SKY wallet's 2-coin +round-trip equivalent still wants doing for ETH/USDT. + +--- + +## 2026-08-07 — Eight more from use: the cloud jump, the hero's exit line, live tile numbers, chat file verbs, upload progress, a DEX list view, and heartbeats out of the room + +**The cloud that did nothing.** Tapping the raised Skycoin button from inside a +screen the hub had pushed (SkyVPN, Chat opened from a tile) appeared to be a +no-op. It was `navigateToTab`'s `restoreState`: popping to Home saved the +`[hub, pushed screen]` stack, and navigating to the hub restored it — pushed +screen back on top, "nothing happened". The cloud now has its own +`navigateToHub()`: same pop-and-save, no restore, so it always lands on the +services list itself. Tabs keep their save/restore behaviour untouched. + +**SkyVPN hero: the exit line and the killswitch light.** The exit line is now +flag + country name + the first 6 characters of the exit key (`🇩🇪 Germany · +02ab4f`). The exit's own IP was asked for and is genuinely not knowable from +this phone: service discovery's geo carries lat/lon/country/region only +(`pkg/geo.LocationData`), and the VPN handshake carries a public key, a TUN IP +and a gateway — no public address in either direction (the constraint +NetworkAddressCard already documents). The "Shared carrier address" chip — +the *device's* address, which SkyVPN never changes — is gone from the card; +the bottom row is hops plus a killswitch that is now a state light: one +shield icon, green (`successBright`) armed, red (new `SkyAccents.dangerBright`) +not, words in the content description where the unreadable caption used to be. + +**Live numbers on the hub tiles.** Three tiles now carry their one number: + +- *SkyChat: unread count.* The page's seen-counters live in its localStorage, + and the WebView is torn down when the tab closes — so the count is now a + server arrangement. skychat gained `/unread`: the browser UI POSTs its + total whenever `updateUnreadBadges` changes it, and between reports every + inbound message bumps the estimate — counted in `recordEvent`, the one + choke point every surface (DM, group, pair, files) already funnels + through. The hub polls it via `SkychatApi.unread()` while the app runs and + draws a filled pill beside the status dot (`99+` cap). +- *Wallet: the active SKY wallet's balance* (`12.5 SKY`) as the subtitle, + read from the wallet cache only — the wallet tab owns talking to the node, + and the hub must render with the node unreachable. +- *Fleet: visors connected*, when the ingest is on. Counted from + `visors-summary` folded into the hub poll as every third pass (15 s) — + the Summary-RPC-per-remote fan-out documented in FleetViewModel tolerates + nothing faster. + +**`__skychat_group_heartbeat__` rendered as a sent message.** The live +subscriber path always filtered heartbeats (session.go `onUpdate`), but +*replay* did not: `replayHistory` handed every decoded leaf to the handler, +so each reconnect/join resurrected the owner's liveness probes as chat, in +groups and channels both. Filtered in three layers now: the replay decode +loop (before the cap window, so probes cannot displace real history), a +guard at `groupInbox.deliver` (the filter `IsHeartbeat`'s own doc promised +pkg/visor would apply, and never did — also keeps `last_message_at` honest), +and a read-side filter in `GroupHistoryPage` for stores already written by +older builds. The page filters defensively too (SSE + history rows), for +rings and caches predating all of this. + +**Chat file verbs into the message menu.** The `download` / `re-request` +links under file bubbles moved into the per-message menu (⋯), leading it: +Download for the served copy (same `/files/` rule the old link used, via a +real anchor click so the Android host still turns it into a DownloadManager +download), Re-request when this device lacks the bytes. The caption keeps +name and size; the bare file card keeps its one-tap download icon — a card +with every action behind a menu would have no affordance at all. And sending +a file now shows a live counter: `/send-file` goes over XHR (the one thing +fetch cannot do is upload progress), a `2.1 MB / 10 MB` line under the bubble +fills continuously, holds the full reading for a beat at completion, fades, +and is removed. `resendFile` got the identical treatment. + +**SkyDEX: cards or a list, every section.** The trading page is a vendored +built bundle, so this rides the same injection lane as the phone stylesheet: +a Cards/List switch at the top of `.content` (re-inserted by the same +MutationObserver pass that re-applies the data-labels; choice persisted in +the page's localStorage). List mode flattens the labelled cards into hairline +rows showing only the primary pair — the first two kept columns, Amount and +Price wherever the table has them — with everything else, actions included, +arriving when the row is tapped open (chevron, not a per-row footer). The +market grid gets the same reading: amount and price on the line, seller and +Buy behind the tap. Card mode is exactly what shipped before. + +**Wording.** The chat link bar's "Connecting to X over Skywire…" became +"Establishing connection with X over Skywire…" — the route comes up between +two visors, and the old phrasing made the peer sound like a server. + +**Verified:** `go build` over the three touched package trees; +`pkg/skychat/group` Replay/Heartbeat/History and `pkg/visor` Group* inbox +tests pass; both of the chat page's script blocks pass `node --check`; +`make android-mobile` rebuilt the payload (64,028,968 B) and +`make android-apk-debug` came out BUILD SUCCESSFUL (74 MB debug APK). Not +yet exercised on a device — the badge poll, the upload counter and the DEX +list toggle in particular want an on-phone pass. + +--- + +## 2026-08-07 — Six from a test pass: contrast, the nav cloud, back, battery, hang-up, and the first message + +Six problems reported after using the app on a phone. Five were small and +local; the sixth was a design gap that a peer-to-peer chat has and a +server-backed one does not. + +**Light theme: the invisible controls.** The report named the voice-message +play button and the call screen's mic/speaker. Both were the same bug, not a +palette that was slightly too pale: *white ink on a light surface*. The call +screen's `CallButton` tinted every glyph `Color.White`, which is right for the +two filled buttons (Answer green, Hang up red) and wrong for mic and speaker, +which sit on `surfaceVariant` — a near-white card tint on the light theme. +Those two now take the theme's own ink and only switch to `onPrimary` once the +blue fill is under them; Speaker also takes a different glyph off than on, so +its state is legible without reading colour. In the chat page the voice player +was `color: #fff` on `rgba(255,255,255,.16)`, correct on a sent (blue) bubble +and invisible on a received one. Ink and disc are now variables (`--vm-ink`, +`--vm-face`) set per bubble side, and the waveform canvas — which was a +hardcoded `#f1f5f9` — reads its colour from its own computed `color` and dims +the unplayed half with `globalAlpha`, so one value covers both halves without +mixing an rgba string for an unknown ink. A `MutationObserver` on +`data-theme` repaints the players when the app's theme flips under them: +painted pixels do not re-colour themselves. The in-page call panel's filled +states (`.cp-ctl.hangup`, `.cp-ctl.muted`) now name the ink for the colour +under them. Separately the light scheme's `outline` went from `#7C8AA0` to +`#56657C` (~4.9:1 on white): it is not only hairlines, it tints the bottom +bar's resting icons, and at the mock's value those read as switched off. + +**The nav cloud sat inside the chat composer.** The raised Skycoin button is +drawn with `offset`, which moves painting but not measurement, so the bar +measured as the shell alone and Scaffold handed screen content the strip the +cloud was standing in. The bar now reserves the lift with `padding(top = +LIFT)`. Every screen loses 21dp it was never really allowed to use, and no +screen has to know the cloud exists. + +**Back left the app instead of stepping back inside it.** SkyChat's header ← +called the tab's own back, so it threw the reader out of SkyChat from an open +conversation; the phone's back gesture already walked the page's history. Both +now run one rule — page step first, leave only when there is none. SkyDEX got +the same plus a middle step: the trading page's own screens (it is React and +routes with pushState), then the market picker, then the hub. And the two hub +tiles that are also bottom-bar tabs — SkyChat and Wallet — are now *pushed* +rather than switched to: going the tab way rewinds the stack to Home, so +backing out of either landed on Home rather than on the hub they were opened +from. Tab roots use `leaveTab()`: pop if there is anything behind, Home only +when there is not. + +**Battery optimisation did nothing.** `openRequest` was handed the +Application by both callers, and `startActivity` from a non-Activity context +throws without `FLAG_ACTIVITY_NEW_TASK` — caught by the `runCatching`, so +Home's Allow silently did nothing and Settings reported "this phone has no +battery-optimisation screen". One flag. Verified on the emulator: logcat +shows `START … REQUEST_IGNORE_BATTERY_OPTIMIZATIONS … flg=0x10000000 … +result code=0`, and `dumpsys deviceidle whitelist` afterwards lists +`user,com.skycoin.skywire`. + +**Hang up took up to five seconds.** The call screen is drawn off +`VoiceCalls`, which a watcher fills from a 2s poll — so the red button did +nothing visible until the next tick. Hang up and Decline now remove the call +from the shared state *before* the request goes out and suppress it until the +visor agrees it is gone; a request that fails un-suppresses it and the poll +puts the screen back. The watcher's delay is also nudgeable, so the confirming +poll happens at once rather than up to a tick later. + +**The first message to a cold peer.** The real one. A DM send has always +dialled on demand, inside the first message's own request: for skynet that +means planning and building a route, which can take the better part of a +minute, and while it ran the sender saw a message sitting there with nothing +to say for itself — so they sent another, and another, each starting a dial of +its own, and concluded the app was broken. + +The handshake is now something the UI can ask for and talk about. +`dm.Controller.Connect` is `Send`'s dial without the send; `/link` in skychat +wraps it — `GET` reports (never dials, so polling is free), `POST` starts one +if needed and answers immediately, because a route must not hold a browser +request open. The page asks when a conversation opens, shows a bar above the +composer while it runs, and *holds* what is typed in the meantime: the bubble +opens as a new `queued` status (an amber clock, before `pending` in the +monotonic ladder) and goes out — in order, one at a time — the moment the link +is up. A failed link says why and offers Retry and Send anyway, so a held +message is never a trapped one; the same is true if the app has no `/link` at +all, which stands the whole mechanism down for the session rather than holding +messages for a handshake nobody is performing. A `queued` bubble that outlives +its page becomes `failed` on reload, which is the state that has a Resend +button. + +**Verified on the emulator (light theme, Pixel-class 1080×2400, debug APK with +a fresh `make android-mobile` payload):** the composer sits clear of the cloud; +SkyChat's ← returns to the conversation list from an open chat; the battery +grant round-trips (above); a received voice bubble's play button and waveform +resolve to `#000` on `#E4E7EC` in light and `#fff` in dark (measured through +the WebView's DevTools, `getComputedStyle`), with the sent bubble unchanged at +white-on-blue; and a DM to an unreachable peer showed "Connecting to +024ec474…58c7 over Skywire… · 1 message waiting" with the bubble on the clock, +then "No route to 024ec474…58c7 yet — they may be offline." with Retry and +Send anyway. Feeding the page a ready link drained the queue (held 1 → 0) and +advanced the bubble `queued` → `pending`. `go test ./cmd/apps/skychat/... +./pkg/skychat/dm/` green, including new coverage for `/link` (GET does not +dial; malformed, empty and null keys and unknown networks are refused; auto +prefers dmsg). `pkg/skychat/group` times out at 600s — confirmed pre-existing +by running it on a stashed tree. + +--- + +## 2026-08-07 — SkyVPN: real rates, route length, killswitch, and an honest address + +Four things asked of the SkyVPN card and screen. Three were straightforward; +the fourth turned out to be impossible as literally requested, and the +interesting part is why. + +**Down and Up were always zero, and the fields were never going to work.** +`AppConnection.upload_speed` / `download_speed` are not derived from the byte +counters. The route group exchanges them inside its ping/pong keepalive — +`handlePingPacket` stores whatever throughput the far side announced, and the +download figure is the remote throughput echoed back — so they need an active +route group, a cooperating exit and a completed ping round. On a phone they sit +at zero and never move. The SkyVPN screen had quietly known this for a while: +its rate row was already behind a `> 0` guard, so it simply never appeared. + +`bandwidth_sent` / `bandwidth_received` do move, so the new `RateSampler` +measures the rate here instead — bytes gained since the previous sample over +the time between them, on `elapsedRealtime` so a clock correction cannot +produce a rate in gigabytes. A counter that goes backwards means the app +re-dialled and ends the series rather than reporting the difference. Nothing +new is asked of the visor; the numbers come from a poll already being made. + +**Route length is now a control.** `min_hops` is a router knob — 1 allows a +direct route, 2 or more forces intermediaries so no single node sees both who +is asking and what is being asked. The card offers 1/2/3 with those words +rather than a bare number, since "min_hops" decides nothing for anyone. The +existing `setTransportPreference` already documented why the PUT must be +read-modify-write (it applies every field, so sending one alone would send +`min_hops: 0`, which the router reads as routing disabled); both setters now go +through one private `updateRouterSettings` so no future one rediscovers that. +Changing it re-dials a running tunnel — the setting only takes effect when a +route is built, so an established tunnel would otherwise keep the hop count it +was dialled with while the screen claimed otherwise. Unlike the transport +order, which the phone owns and re-pins every launch, this one is the visor's: +it persists it, and the phone profile's routing edit starts from the existing +object, so it survives. + +**Killswitch state is on the card**, read from the phone's own preference so it +is right with the core down — which is when someone checks whether they are +still covered. + +**The before/after IP cannot be done the way it is normally done, and showing +it anyway would have been a lie.** `SkyVpnService` excludes this app's UID from +the tunnel, and it has to: the visor is a child of the same UID and its dmsg +traffic is what *carries* the tunnel. So any address probe the phone makes — +whether from Kotlin or from the visor — leaves through the underlay whether or +not SkyVPN is up. It would print the same address twice and read as broken. +That is also why the desktop CLI's "Your current IP" does not port: that +process is inside the tunnel; this app is not. + +Nor can the exit's address be asked for. The VPN handshake is +`ClientHello{UnavailablePrivateIPs}` and `ServerHello{Status, TUNIP, +TUNGateway}` — private `192.168.255.x` addresses and a public key. Neither side +carries a public address, so there is no field to read. + +So the card shows the two things that are true: the address this device reaches +the network from, which SkyVPN does not change, and the country the traffic +leaves from, which is the thing that does. The device address comes from +`Overview.public_ip`, already on the wire in a poll being made anyway — but it +is not safe to print raw. The visor writes the *NAT type* into that field when +STUN fails and leaves it empty behind symmetric NAT, so `publicIpOrNull` gates +it and the UI says "Shared carrier address" or "Not discoverable" rather than +rendering the word "Blocked" as though it were an address. A note under the +rows explains why the device's own address does not move. + +A real exit IP needs a public-address field added to `ServerHello`, on both +ends, with version-skew handling — deliberately not done here. + +**Verified** on emulator-5554 against a live core. The hero card renders the +device address, hops and killswitch chips alongside the stats row; the SkyVPN +screen renders the Network address card with its explanation and the Route +length control. Tapping "2 hops" flipped the hint to the multihop wording and +`routing.min_hops` in the visor's own config became `2`; tapping back restored +`1`. The emulator sits behind NAT, so the device address correctly reads +"Shared carrier address" — the symmetric-NAT guard doing its job on the first +device it met. + +Not verified: Down/Up showing non-zero. That needs a tunnel actually carrying +traffic to a reachable exit, which the emulator has not established; the cells +render "—" from the null path, which is the same code path with no samples yet. + +--- + +## 2026-08-07 — CI for the app, and a release lane behind a `mobile-v*` tag + +Two workflows. Neither touches the desktop lanes. + +**`android-app.yml` — pull requests that change `android/**`.** Builds the +pure-Go payload, runs `:wallet-core:test`, assembles the debug APK and keeps it +as an artifact for triage. A PR that does not touch the app runs none of it. +The Go half is deliberately *not* under the same path filter and is not +duplicated here: `test.yml`'s existing `android` job builds the arm64 payload +and enforces its size budget on every PR, which is what catches a Go change +breaking the mobile variant. The payload is still built in this lane, though — +an APK assembled without the `.so` is not the artifact we ship, and packaging +is part of what is being checked. `LiveNodeTest` is already gated behind +`SKYWIRE_NET_TESTS=1`, so the suite stays offline. + +**`android-release.yml` — push `mobile-vX.Y.Z`.** The prefix is what keeps the +lanes apart: `release.yml` fires on `v*`, and `mobile-v1.0.0` does not match +it, so tagging the phone never starts a Skywire release. + +The tag is the version. `mobile-v1.2.3` becomes versionName `1.2.3` and +versionCode `10203` (`major*10000 + minor*100 + patch`), passed as Gradle +properties; `build.gradle.kts` falls back to the committed values so a local +build still needs no arguments. Minor and patch are rejected above 99, since +the scheme stops being monotonic there and Play and F-Droid both require the +code to only ever increase. + +The payload is the **NDK/cgo** lane, not the pure-Go one used for CI and the +emulator: cgo resolves DNS through bionic's `getaddrinfo`, which is the only +path that honours the phone's real resolver configuration. + +**On signing, the workflow refuses rather than improvises.** `assembleRelease` +has always produced an unsigned APK, and unsigned means uninstallable; the +debug APK is installable but is `debuggable`, which is not a thing to hand to +users of an app holding wallet seeds. So neither is a fallback. The build takes +its keystore from the environment — absent, `release` stays unsigned exactly as +before, so `make android-apk` is unchanged — and the workflow fails at a +preflight step, before the ten minutes of build, printing the `keytool` and +`gh secret set` lines needed. A later step re-verifies with `apksigner` and +fails if the APK came out unsigned anyway, which is also what catches the +unsigned build (its filename is `app-release-unsigned.apk`, so the expected +path is simply missing). + +Four secrets are required and are not yet set: `ANDROID_KEYSTORE_BASE64`, +`ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD`. The +key must be generated once and kept forever — Android will not upgrade an app +signed with a different key, so this key is what lets every future release +reach whoever installs the first one. + +Release notes range over the previous `mobile-v*` tag; left alone +`--generate-notes` walks back to whatever tag came last, usually a desktop +release, and the changelog would be every Skywire commit since. The APK ships +as `skywire-X.Y.Z-arm64-v8a.apk` with a `.sha256` beside it, attached to a +**pre-release**. F-Droid and Play come later. + +**Verified** by rehearsing the release path locally rather than by reading it: +a throwaway keystore, `assembleRelease` with the properties the workflow +passes, then the workflow's own verification steps. `apksigner` reports Signer +#1; `aapt2 dump badging` reports `versionCode='10203' versionName='1.2.3'`; +the APK is not debuggable. With the keystore variables unset the same command +still produces `app-release-unsigned.apk`, so the local path documented in the +Makefile is unchanged (its help text now names the signing variables). Both +workflows parse. The throwaway keystore and both rehearsal APKs were deleted; +no keystore exists anywhere in the tree. + +Not covered: the workflows have not run on GitHub — the first `mobile-v*` push +will fail at the preflight until the four secrets exist, which is the intended +behaviour but has not been observed. Runner-provided values (`ANDROID_HOME`, +`ANDROID_NDK_LATEST_HOME`) are asserted with explicit guards rather than +assumed, since neither can be checked from here. + +--- + +## 2026-08-07 — The phone stops shipping the deployment's survey whitelist + +`survey_whitelist` is not a battery field, but it turned up while reading the +survey machinery and it is the same question the hardening pass asked: what on +this device is readable, and by whom. Its keys authorise their holders to fetch +this visor's **log server, system survey and pprof over dmsg** — +`initDmsgHTTPLogServer` builds one allow-list out of them, and +`forward_proxy` uses the same set. On a fleet node that is the point: it is how +an operator inspects machines they run. A handset is not one of those machines. +Nobody deploys a phone, the survey exists for reward eligibility, and this build +sets no reward address — so the keys buy the owner nothing, and each is a party +that can read the device. + +Generation was writing seven of them. That is not the conf-service fetch, which +the phone already skips with `--nofetch`: they are embedded deployment defaults, +so `--nofetch` never suppressed them. + +Two sites had to change together, and the second is the one that makes it work. +`config gen` writes the field, and `startConfigRefresh` re-reads the key sets +from the conf service every hour and **overwrites** `survey_whitelist` with +whatever it returns — so emptying it at generation alone would have been undone +within the hour, silently. Both now consult one predicate, +`visorconfig.UseDeploymentSurveyWhitelist`, a build-tag pair in the shape the +tpviz and dmsg-ingest gates already use. + +Empty by *default*, not always: three ways in survive, deliberately. +`--surveywhitelist` at generation still applies (the flag's keys are kept and +only the deployment's are dropped), `user_survey_whitelist` is a separate field +that `EffectiveSurveyWhitelist` merges and config refresh preserves, and the +hypervisor keys Fleet adds are appended separately by `initDmsgHTTPLogServer` +and are untouched — enabling Fleet still authorises the phone's own hypervisor. +The visor's own PK is always whitelisted, so nothing local loses access. + +**Verified** with a control rather than an assertion: the same argv the app +passes, run through both builds. Desktop writes 7 keys; mobile writes none. +Build-tagged tests pin the predicate on each side, since a silent flip would +restore the keys in two places at once. Both tags build; `android-mobile-check` +passes at 63,963,432 bytes. + +The refresh half is verified by inspection, not end to end. With the interval +temporarily shortened the loop demonstrably runs and reaches this code path, +but the conf service returned a payload whose `prod` block would not parse +("unexpected end of JSON input"), so neither build could repopulate and the +comparison proved nothing. The temporary timing change was reverted. What +remains unproven is only that a `false &&` short-circuits. + +--- + +## 2026-08-07 — Battery: two periodic jobs the phone was paying for + +A survey of everything that ticks inside the visor as the phone configures it +— roughly sixty recurring jobs once the conditional ones are resolved against +the phone profile — looking for work with no corresponding benefit on a +handset. The 5-minute uptime/TPD heartbeat, the one we had flagged, turned out +not to be worth touching: it is twelve remote wakes an hour against the dmsg +keepalive's hundred and twenty, it cannot be disabled by config anyway +(`resolveUptimeTargets` derives the TPD URL from `transport.discovery`, +deliberately independent of `uptime_tracker`), and with no wakelock anywhere in +the app it does not fire during deep sleep at all. Two other things did. + +**The dmsg client republished its discovery entry five times too often, on +every visor in the fleet.** `dmsgc.New` builds a `dmsg.Config` literal with +`MinSessions`, callbacks and protocol, and never sets `UpdateInterval`. +`EntityCommon.init` then falls back to `DefaultUpdateInterval` — one minute, +which is the *server's* cadence, because a dmsg server's `AvailableSessions` +changes on every client connect. A client's entry only carries its delegated +servers, which on a settled client never change, and `DefaultConfig` sets +`DefaultUpdateInterval * 5` accordingly. That constructor is simply never +consulted: eleven of the fifteen client call sites in the tree build the +literal. The periodic tick is never short-circuited either — the `SamePubKeys` +guard in `updateClientEntry` only suppresses nudge-driven updates, and the due +timer always makes `due` true — so each one was a signed GET+PUT to +dmsg-discovery over a fresh dmsg stream. + +The default moved into `Config.Ensure`, which `NewClient` already calls before +`EntityCommon.init` reads the value, rather than into the one caller. Fixing +`dmsgc` alone would have left the other ten literals wrong and the next one +would have reintroduced it; `Ensure` is documented as the place that ensures +config values are set. The dmsg *server* has its own `ServerConfig` and keeps +its one-minute cadence untouched. Three unit tests pin the invariant, since +what let this survive was that nothing asserted the interval. + +**tpviz runs on the phone, and nothing on the phone can reach it.** The +network-visualizer backend is constructed whenever a hypervisor has a local +visor and started by `startUI` — which the phone must call, because that is the +localhost API the app talks to. `Start()` costs a geoip and cache fetch at +boot, a ~4m30s SD/DMSG cache refresh over dmsg-HTTP, and a 2-second websocket +broadcast ticker, all backing routes that this build cannot serve: the mobile +variant embeds no hvui at all. + +`tp_viz.enable` looked like the lever and is not one. It is written by the +generator but read nowhere, and it cannot be made to work: `FillDefaults` only +sets it inside its `DmsgDiscovery == ""` branch, so a config that already names +a discovery leaves it false, and an absent block is indistinguishable from an +explicit false — reading it is exactly what produced the `/tp-viz/` 404s the +comment there warns about. So the gate is a build tag, matching the +`hypervisor_dmsg_ingest` pair already doing this for the Fleet ingest client. +Every use of `hv.tpvizServer` was already nil-guarded, including the `/api/*` +routes it mounts at root — and none of those are routes the app calls, since +its service-discovery lookups go through `/api/svc-fetch` rather than tpviz's +root-mounted `/api/services`. + +**Verified** on a host build of the mobile variant (`make build-mobile`), +against a config carrying `tp_viz: {"enable": true}` so the tag is doing the +work: `/tp-viz/`, `/api/transports`, `/api/uptimes`, `/api/local-visor`, +`/api/ip-groups` and `/api/health` all 404, while `/api/about`, +`/api/service-health` and `/api/visors-summary` serve 200, and no tpviz line +appears in the log. For the cadence, with the phone profile applied (the +`-i`-forced `lan_dmsg_server`, a genuine 1-minute *server* entity, removed as +the app removes it): four `[dmsgC]` entry updates in the first 37 seconds — +registration plus session nudges — then nothing until a single tick at 5m01s. +The old build would have republished five times in that window. Both tag +variants build, `android-mobile-check` passes at 63,963,432 bytes against the +80 MB budget, and the dmsg suite is green. + +**Not done, and next.** The three `while (true)` poll loops in Android +ViewModels that survive backgrounding — Wallet's 30s is the expensive one +because it hits a remote Skycoin node rather than loopback, and its ViewModel +is constructed eagerly at app launch. `AppVisibility.isForeground` already +exists for exactly this. Below that: stcpr/quic/webtransport re-register to the +address resolver every 90s each plus two `Resolve` calls a minute, for +transport types that never establish behind carrier NAT. + +--- + +## 2026-08-07 — Redesign: one visual language for the whole app + +The app-wide redesign from the design mock: a new palette, two new +typefaces, a floating bottom bar with the Skycoin cloud on a raised +circular button, and the apps hub rebuilt as a living dashboard. All +Compose; no Go changes. + +**The design system (`ui/theme/`).** Primary moves from `#0072FF` to +`#0F7BF4`, with a deep-blue gradient pair (`SkyHeroGradient` for hero +surfaces, `SkyButtonGradient` for the round primary actions) defined once in +the theme. Light is the design's native theme: white background, cards in +blue-tinted near-white `#F7FAFF` behind a `#E7EEF9` hairline (`outlineVariant` +— the border is what makes a card a card at that fill). Dark derives the same +hues on deep navy (`#0A101C` background) rather than grey or black, with a +brighter `#4AA3FF` primary carrying dark ink instead of white. Status colors +consolidate into `SkyAccents` (success `#22C275`, its bright form `#5CF2A8` +for dots on blue, warning `#F59E0B`); the shared `CONNECTED_GREEN` / +`PENDING_AMBER` names survive but now point at the theme, and the four +scattered duplicate hexes (Home ×3, DexScreen's private shadow copy, the log +viewer's hardcoded `#0072FF` INFO blue) point at the same place. A `Shapes` +scale lands at 8/12/16/22/28 — chips at small, buttons and icon plates at +medium, cards at large, sheets and the nav shell at extraLarge. + +**Type: Quicksand + Nunito** (both OFL, bundled as single variable-weight +files — minSdk 26, so the wght axis is real). Quicksand Bold carries every +display/headline/title role; Nunito carries body (SemiBold) and label (Bold). +The Skycoin .otf family is gone with its callers. Two findings shaped the +weights: the old family shipped no 500 cut, which is why ~60 screens had +hand-bolted `FontWeight.Bold` onto body roles (all now harmless), and the +first cut at Medium body read *faint* on the tinted cards — user-confirmed, +light mode only. Related fix in the same pass: every `surfaceVariant` card +now sets `contentColor = onSurface` explicitly, because `contentColorFor` +resolves that container to `onSurfaceVariant` and quietly muted every card +title in the app; and light `onSurfaceVariant` sits at `#44536B`, darker than +the mock's caption grey, because that role carries real prose here. + +**The bar (`SkywireApp`).** The Material `NavigationBar` is replaced by the +mock's floating shell: a rounded-28 surface with a hairline border and soft +shadow, icons only (per feedback — labels removed, the icon's Rounded form +plus primary tint marks the active tab, Outlined+`outline` grey the rest), +and the Skycoin cloud on a 66 dp gradient disc riding 21 dp above the shell, +ringed in the shell's own color so it reads as punched through, with a slow +`PulseRing` behind it (shared composable; Home's Connect reuses it while +connected). The lift is `offset`, which moves drawing and hit-testing but +not measurement, and Scaffold places the bottom bar last, so the jut wins +the overlap without reserving dead space above the shell. + +**The hub is the mock's Apps screen, with live data.** New `HubViewModel` +polls the local summary every 5 s (local HTTP only — nothing crosses dmsg, +unlike the Fleet lesson) for every app's status: the header's "6 installed · +N running", per-card status dots (green running / amber starting / red +errored / border-grey stopped-or-unknown). Category chips (All / Network / +Finance / Social) filter the grid. SkyVPN is not a tile: it is the hero card, +full-size on or off (feedback), on the deep gradient with the two soft +corner discs, showing status + session length, the exit as a person would +say it — flag emoji plus `Locale` display name ("🇨🇦 Canada"), short key when +the country is unknown, "No exit chosen yet" before the first connect — and +live Down/Up rates plus session data from `appConnections` while it carries. +Its switch turns the tunnel off directly (stop app, then release the +interface — the SkyVPN screen's own order), and turns it *on* from the hub +only when nothing needs a screen: consent already granted +(`VpnService.prepare` returns null) and an exit already saved; otherwise it +opens the SkyVPN screen. The `vpn_last_server` / `vpn_killswitch` preference +keys moved to `VpnArgs` so both owners read the same names. SkyMeet stays +the one dashed coming-soon card. + +**Buttons became visible** (feedback: "so transparent, cannot see it"). +This M3 revision draws `OutlinedButton` borders with `outlineVariant` — a +hairline here — and standalone `TextButton` actions vanish entirely when +disabled. Every standalone action (Change port, Refresh, Retry, View logs, +Fleet's Logs/Restart, DEX disconnect, VPN system-settings, Settings' +identity/config actions, transport Change) is now `FilledTonalButton` on +`secondaryContainer`; dialog confirm/cancel pairs and inline links (See all, +Show more, Paste, Max) stay text, as convention wants. + +**The header (`SkyTopBar`)** is the mock's: left-aligned Quicksand title +with an optional live subtitle line under it, 42 dp rounded-square tonal +back and ? buttons. Same API plus `subtitle`; every screen inherits. + +Also: launcher/splash colors follow the palette (`ic_launcher_background` +`#0F7BF4`, dark window/splash `#0A101C` via `@color` instead of framework +black), and Home's Connect is the gradient disc with white Quicksand. + +**Verified** on emulator-5554 (1080×2400, API 34): light and dark +screenshots of Home, hub, Wallet, Settings; tab selection and hub +navigation; hero card off-state with 🇨🇦 Canada exit line; the cloud button +opens the hub with the pulse ring visible; core started on-device +(libskywire-mobile child alive in logcat) with the familiar cosmetic netlink +denials. Feedback applied live across three rounds (hero always big, flag + +country name, faint-text fix, icons-only bar, tonal buttons). + +**Known gaps, deliberate.** The embedded SkyChat page still wears its own +palette keyed to the old blue — it lives inside the Go `.so` and syncs only +a light/dark boolean, so it needs its own pass (and `make android-mobile`) +to match; visually close, not identical. SkyDEX's injected phone CSS keeps +its dark-only assumption and one `#00000038` card fill. The raster +`skywire_logo.png` stays `#0072FF`-blue where it appears untinted (lock +overlay); on the launcher and the nav cloud it is tinted white over the new +blue, so nothing clashes. + +--- + +## 2026-08-07 — Hardening: what leaves the phone, what is readable on it + +Four things that had been carried as open questions since the size work +settled, plus one audit finding. + +**Nothing this app stores leaves the device by itself.** `allowBackup="false"` +was already set and already refused Google Drive. It does not refuse the other +exit: the device-to-device transfer that runs when a new phone is set up from +an old one, which would have carried the visor's secret key and the wallet's +recovery phrases onto a second handset. `res/xml/data_extraction_rules.xml` +now declines both `` and ``, and +`res/xml/backup_rules.xml` says the same thing for Android 11 and earlier, +which ignores the newer file — minSdk is 26, so that is real devices rather +than a formality. + +Both are `exclude domain="root" path="."` — everything, nothing carved back +in. Naming the two sensitive files would have been narrower and would have +rotted the first time someone added a third store. There is nothing a user +loses: the identity is meant to move deliberately through Export config, and +the wallet is meant to be restored from its twelve words. The seeds and +passwords are sealed under non-exportable AndroidKeyStore keys anyway, so a +transferred copy would have been undecryptable ciphertext; this stops the +ciphertext travelling either. + +**The config can be encrypted at rest, and that is the largest piece.** +`skywire-config.json` holds `sk` — the whole of this phone's identity. It is +app-private, so the threat is not another app; it is the device being examined. +New `ConfigVault` seals it as AES-256-GCM under its own keystore alias +whenever the core is not running, and unseals it at start. Two files, never +both meaningful: plaintext while the visor runs (it opens the path it is given +and rewrites it), ciphertext while it is stopped. + +Sealing happens after the visor exits, not at generation — that is what lets +the visor's own runtime rewrites survive, since whatever it left behind is what +gets encrypted. It runs in the service's `finally` under `NonCancellable`, so a +crashed core still seals and a cancelled scope cannot skip it and leave the key +in the clear. `seal` writes the ciphertext, reads it back, and only then +deletes the plaintext; a power cut mid-way leaves both, and `unseal` resolves +that in favour of the plaintext. The outcome is never a config that is gone. + +The dangerous failure this had to not have: `ensureConfig` treats a missing +config as first-run and generates a **new identity**. A sealed config plus a +caller that forgot to unseal would look exactly like a new phone and would +replace the user's key without a word. So the unseal lives inside +`ConfigManager` itself — `ensureConfig`, `replaceSecretKey` and the readers all +go through it — rather than in the service that happens to call it. The two +synchronous readers (the public key on the identity screen, the redacted copy +in a diagnostics bundle) read through the vault too, so both still work with +the config sealed and the core down, which is exactly when the identity screen +is being looked at. + +Off by default and it stays optional. It buys nothing against a running +unlocked phone, and it cannot be recovered if the keystore is wiped — a factory +reset, or on some OEMs removing the screen lock. The Settings copy says so. +Turning it off is confirmed biometrically, because that is a security decision +being reversed; turning it on is not. + +**Battery.** The foreground service was already the baseline and was never the +issue: Doze is a separate mechanism that suspends the *network* of non-exempt +apps once the screen has been off a while, and a foreground service does not +opt out of it. Short naps cost nothing — dmsg sessions are TCP with their own +keepalives and resume when the window opens — but over a long idle they drop, +the visor reconnects, and what the user sees is messages arriving when the +phone next wakes rather than when they were sent. + +`BatteryOptimization` reports the state and opens the system dialog, falling +back to the full list on OEM builds where the direct intent does not resolve. +It is offered twice: a permanent card in Settings, and a one-time prompt on +Home that appears only *after* the core is running — before that the question +is about a problem the user has not got yet. "Not now" is remembered for good +and silences both. `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` is declared; Play +restricts it to apps whose core function needs it, and an always-on P2P node +that is also a VPN service is on that list. + +**The audit found one gap.** FLAG_SECURE covered the four wallet screens that +can show or take a recovery phrase — seed backup, verify, restore, reveal — and +the app lock holds it session-wide when enabled. It did not cover Settings ▸ +Replace secret key, which is a field you paste a visor secret key into in the +clear. That is the same class of secret as the twelve words: it *is* the +identity, it cannot be reissued, and anyone who reads it owns the visor. It has +the flag now. `SecureWindow` moved from `ui/wallet/WalletUi.kt` to +`ui/components/`, since it stopped being a wallet concern. + +**Size, re-measured rather than assumed:** `libskywire-mobile.so` is +63,963,432 bytes against the 83,886,080-byte budget `make android-mobile-check` +enforces — 76% of it. Debug APK 74 MB. + +**Verified on the emulator.** Config encryption end to end, which is the one +that mattered: toggled on with the core down and 9,513 bytes of plaintext +became 9,541 bytes of `skywire-config.json.enc` with no plaintext beside it — +exactly plaintext + 12-byte IV + 16-byte GCM tag — and `strings` on it returns +nothing. Connecting restored the plaintext at 9,513 bytes and the public key +was **unchanged** (`02498cde10…deadef20` before and after), which is the +no-new-identity property. Disconnecting re-sealed it. Settings still showed the +key while sealed. The Home battery prompt appeared once Connected, with Allow / +Not now, on an emulator confirmed absent from `dumpsys deviceidle whitelist`. +The secret-key dialog screenshots as a pure-black 21 KB PNG where a normal +screen is 140 KB — FLAG_SECURE holding. + +**Also:** the bottom bar's Skycoin cloud went 44dp → 56dp. It carries no label, +so it has the label row to grow into, and it is the one slot aimed for by shape +rather than read — at 44 it was a slightly large icon among four icons instead +of the centre of the bar. Checked for clipping at the new size; there is none. + +**Not covered:** device-to-device transfer cannot be exercised on an emulator, +so the D2D exclusion is verified by declaration and not by observation; Doze +behaviour was reasoned from the platform contract rather than measured over a +long idle; and the keystore-wiped path (factory reset with a sealed config) +is handled with a stated error but has not been provoked. + +--- + +## 2026-08-07 — SkyChat wears the Wallet's design; one header for every tab + +Three things, all cosmetic in the sense that no protocol changed, and none of +them cosmetic in the sense that anyone using the app will notice all three. + +**SkyChat is redesigned onto the Wallet's palette, in both themes.** The chat +UI is one 12k-line HTML file served by the Go app and shared with the desktop, +and it was a single dark theme built on its own token set — slate greys, a +cyan-ish `#0ea5e9` accent, green section headings. It now carries the exact +tokens from the Wallet design: `#0072FF` on `#000000`/`#FFFFFF`, the same +surface ladder, the same status colours. The old token names are kept as +aliases pointing at the wallet ones, which is what let the whole app re-skin +at once instead of rule by rule. + +Light arrives through `prefers-color-scheme`, and either theme can be forced +with `data-theme` on ``. The phone forces it: the app has a +Light/Dark/System setting the WebView knows nothing about, so a phone set to +System-dark with the app pinned to Light was opening a black chat inside a +white app. The theme is named in the URL query (`?theme=dark`) and read by a +script in `` before the stylesheet is reached, so there is no flash of +the wrong theme. `ChatWebView.applyTheme` sets the same attribute on an +already-built document, for the case where the app recomposes without the +WebView being recreated; note the Activity declares no `configChanges`, so a +*system* uiMode change recreates it and takes the URL path instead — +applyTheme is the narrower belt-and-braces path, not the main one. +`loadedUrl` deliberately stores the address without the query, so the theme +is never mistaken for a new URL. + +`LocalDarkTheme` is new in `ui/theme/Theme.kt`: `isSystemInDarkTheme()` is the +wrong question once a user override exists, and anything handing a scheme to +something outside Compose needs the answer *after* that override. + +Type is the Skycoin face, shipped: `skycoin-regular.otf` and +`skycoin-bold.otf` now sit in the app's own static dir and are `@font-face`d, +so the page needs no network to look right and the desktop chat gets the brand +face too. The family has no 500 cut, so every weight in the file is now 400 or +700 — an unpinned 500 silently resolves to Regular and flattens the hierarchy. +Numerals are tabular throughout. The `'Monaco', 'Menlo', monospace` stack that +public keys used is gone: the Wallet renders addresses in the brand face and +this now matches it. + +**Icons: 49 drawn glyphs replace the emoji.** Nearly every icon in the chat +was a platform emoji pasted in as an HTML entity — 📎, 📣, 🔔, ⋮. Three +problems. They are rendered by the platform's font, so the same button was a +flat glyph on one machine and a glossy cartoon on the next. They ignore +`currentColor`, so a menu row that tints itself orange to say "muted" kept a +yellow bell. And they sit on a different baseline at a different weight from +the hand-drawn SVGs the file already had. One `ICON_PATHS` table, one +`icon(name, size)` helper, 24-unit grid, 2-unit stroke, round caps — the +Wallet's drawing. A missing name returns `''` rather than throwing, because an +icon is decoration and a typo in one must not take a menu down. + +Four plain-text strings lost their glyph rather than gaining a drawing, since +no markup reaches them: file previews now read as the file name, and the +"attachment" preview as *Attachment*. `_msgPreview` is one of them, and it is +protocol-visible — it rides inside a reply so the quote renders for a peer who +lacks the parent. + +**No scrollbars anywhere.** The `::-webkit-scrollbar` block that drew an 8px +bar down every list is replaced by a global `width:0; display:none` plus +`scrollbar-width:none`. Every container still scrolls; only the indicator is +gone. It also reclaims the width, which is why a narrow column used to reflow +the moment its content passed the fold. + +**One header on every tab except Home.** Back at the left, the name centred, a +circled ? at the right, and the two round buttons are the same size so the +title sits still as you move between screens. Back on a tab root goes to Home; +on a pushed screen it pops as before. The ? opens a few sentences about that +screen. Fleet's ? keeps opening its own sheet rather than a dialog, because +its guidance ends in a command with a copy button — it just answers "what is +this tab" first now. + +The ? replaced the per-screen **Logs** action on SkyChat, SkySOCKS, SkyVPN and +SkyDEX, and each of those help texts says where the logs went. A log viewer is +not wanted from the screen being used; it is wanted when something is wrong, +and then all of them are wanted, which is the list Settings ▸ Diagnostics +already keeps (it lists exactly these four app sources plus core and process). +Fleet keeps its per-visor Logs button: that feed is a remote machine's, +arriving over dmsg, and Diagnostics only knows about this phone. SkyChat's +overflow menu is gone entirely — with Logs moved it held only Reload, and a +wedged page is reloaded from the Retry the error state already offers. + +**One bug found while testing, one fixed.** The recording lock pill — the +target a finger slides up onto to record hands-free — was pinned at +`right: 20px` while the composer row's own padding was 16px on a desktop and +12px on a phone, putting a 40px pill 4px and 8px to the left of the 40px +button it is supposed to sit above. On a phone that is a finger sliding past +the target. The row now names its padding (`--composer-pad`) and the pill and +the video self-view are inset by it, so all three are concentric at every +width. The lock threshold itself is a pure 56px vertical distance and never +hit-tested the pill, which is why the gesture still worked while looking +wrong. The phone misalignment predates this redesign. + +**Verified on the emulator (Pixel, arm64, `make android-mobile` + a fresh +APK).** Core connected, Chat opened: header shows back / SkyChat / ?, and the +page renders the redesign — drawn QR, address-book and gear icons, the +Connected pill, filter chips with All filled blue, Saved Messages behind a +drawn bookmark on a tinted circle, a grey CHATS label, no scrollbar. Settings +▸ Theme ▸ Light and back to Chat rendered the page in the light theme, every +icon inverting with `currentColor` and the drawn set holding up on white; the +app was pinned to `theme_mode=DARK` beforehand, so a system night-mode flip +correctly did nothing. That path exercises the URL query, since returning to +the tab rebuilds the WebView — `applyTheme` itself is not separately covered. The Chat ? dialog renders its copy and the Settings ▸ Diagnostics +pointer. A conversation shows blue sent bubbles with the tail corner, drawn +delivery ticks, the waveform player, and the touch action sheet with drawn +reply / forward / trash. Go builds clean; a temporary test confirmed both OTFs +and the page are reachable through the embedded FS (549,375 / 67,292 / 74,840 +bytes). The lock-pill fix was confirmed on device by the user. + +**Not covered:** the desktop chat has not been opened against this build (same +file, so the redesign lands there too, including the light theme on a +light-set desktop); no screenshots of the light theme inside a *conversation*; +the icon set is our own drawing, not the design team's — `android/icon-brief.md` +is the brief for replacing it. + +--- + +## 2026-08-07 — Wallet: SKY, fiber coins and BTC, keys never leaving the phone + +The Wallet tab is no longer a placeholder. It is a native Compose wallet for +three kinds of chain behind one interface: Skycoin, any fiber coin the user +adds (same daemon, their node URL), and Bitcoin mainnet. Seed generation, +address derivation, transaction construction and signing all happen on the +phone; the network is asked only for balances, history and broadcast. + +### 1. `:wallet-core` — the crypto is a port, not a binding + +The decision the wallet hinged on: how to run Skycoin's crypto on Android. +gomobile would have meant a second Go artifact next to the visor payload and a +JNI boundary for key material. Instead the needed slice of the reference +implementation is ported to pure Kotlin in a new `:wallet-core` JVM module — +no Android types, so every byte of money-handling code runs under host-side +unit tests. BouncyCastle's lightweight API supplies the secp256k1 curve math, +RFC 6979 nonces and RIPEMD-160; the JCA "BC" provider is never registered +(Android ships its own crippled copy under that name). + +Ported faithfully from the reference repo: the deterministic keypair iterator +(`secp256k1Hash`, the sha256-until-valid step, the chained wallet seed), the +address codec (`ripemd160(sha256(sha256(pub)))`, version byte, 4-byte +checksum), the skyencoder transaction wire format (little-endian, u32-prefixed +slices), inner-hash/sign-hash construction, and the whole of +`transaction.Create`: MinimizeUxOuts spend selection with its three-phase +ordering, `ceil(hours/burnFactor)` fee, proportional hour distribution with +the remainder rules, the force-an-extra-input change-hours recovery, and the +retry at full share when change hours would otherwise burn. Burn factor, max +decimals and the size cap are read from the node's `/api/v1/health` at plan +time, so a fiber chain with different rules is honored automatically. + +Signatures are the one deliberate deviation: the reference signer draws a +random nonce; ours is RFC 6979 deterministic, then low-S normalized with the +recovery id flipped to match — the chain verifies recovery and malleability, +not nonce provenance, and a phone's RNG is the one component with a track +record of losing coins. + +Bitcoin is the same shape: BIP 39 → BIP 32 → BIP 84 (`m/84'/0'/0'`), P2WPKH +receive and change chains, destinations in every standard form (base58check +P2PKH/P2SH, bech32 v0, bech32m v1+), BIP 143 sighash, DER low-S, RBF +signaled, dust folded into the fee, and an esplora client (mempool.space by +default) for the chain view and sat/vB presets. + +### 2. Tests are against the reference implementation, not against ourselves + +`wallet-core` carries 18 host-side tests, and the load-bearing ones compare +against outputs of the Go implementation rather than hand-computed values: + +- The cipher testsuite's golden files: seed → secret/public/address chains + must match, and every stored signature must recover to its stored pubkey + through our port. +- A Go fixture generator (run against the local reference repo) emits five + `transaction.Create` cases — change, multi-input, send-all, exact-amount + with the extra-input recovery, zero-hour mix — and the Kotlin port must + reproduce the chosen inputs in order, every output's coins and hours, the + inner hash and the **byte-for-byte serialization**. +- The BIP 84 chain from the canonical test mnemonic, generated with the Go + repo's own bip32+segwit code, must match — and does, including the BIP's + published first address. +- The BIP 143 native-P2WPKH example: our sighash equals the vector, and + because the BIP's example signature is itself RFC 6979, our DER signature + matches it byte for byte. +- `LiveNodeTest` (opt-in, `SKYWIRE_NET_TESTS=1`) parses production + node.skycoin.com balance and 150-transaction history through the real + client. + +### 3. App side: sealed seeds, cached truth, one ViewModel + +Seeds are sealed with a new AndroidKeyStore AES-256-GCM key +(`skywire_wallet_seed` — deliberately not the service-password key; coins and +passwords must not share a blast radius). The key is not auth-bound: address +derivation legitimately runs without a prompt, and a keystore-enforced prompt +would silently brick the seed the day the user removes their screen lock. +Every send and every reveal instead goes through the shared `Biometrics` +confirm — the phone's own credential — before the seed is touched, and the +prompt states the consequence *before* authentication, never after. + +Wallet metadata (addresses are public) lives in the `wallet` DataStore, so +opening the app never decrypts anything. Each wallet's last good chain view is +cached to disk; when the node stops answering, the screen keeps the cached +numbers under an amber banner naming the time it was last true, and Send is +disabled — a wallet that cannot reach a node can still be read, but not +spent from. + +The whole flow shares one Activity-scoped ViewModel: the freshly generated +phrase and the send draft live in memory only and never ride in navigation +arguments. `FLAG_SECURE` is set per-screen (backup, restore, reveal) through a +helper that on dispose respects the app-lock preference already holding the +flag session-wide. + +Screens follow the wallet design set: coin chip → balance → per-chain +sub-line (Coin Hours for the Skycoin family, confirmed outputs for BTC), +Receive with QR and the same-seed address sheet, Send whose fee card is the +only thing that changes between chains (hours burned/after vs sat/vB presets +and slider), review sheet with exact figures, result screen with the txid, +history with filters and day groups, transaction detail with explorer +link-out, and the wallets manager (rename / reveal / remove, plus create and +restore for more wallets per coin). QR scanning is zxing's embedded capture — +the one camera dependency — and paste/scan both strip `skycoin:`/`bitcoin:` +URI prefixes. + +Terminology fixed after review: Skycoin is the original chain, fiber coins +are separate chains built from its codebase. The review sheet names the +coin's own network ("on the Skycoin network", "on the network"), and +only actual fiber coins carry the fiber label in the coin sheet. + +Cleartext policy changed from a loopback whitelist to base-allow: the shipped +Skycoin node is plain HTTP, and user-entered fiber nodes are free-form (an +operator's bare IP, usually without TLS) so they cannot be whitelisted by +domain. Nothing secret rides those connections — signing is local and chain +data is public. + +### 4. Verified — with real coins on the production network + +Unit: 18/18 `:wallet-core` tests green (`./gradlew :wallet-core:test`). + +Emulator (API 36 arm64, PIN set), against production infrastructure: + +- Create: intro → twelve-word grid (screencap returns black — `FLAG_SECURE` + held; content verified through the accessibility tree) → quiz rejected a + planted wrong word naming its position ("That is not word 6.") → activated. +- The derived address was accepted by node.skycoin.com, and the first refresh + wrote the cache snapshot. +- **2.000 SKY was received from a real wallet** (txid `86f1aa99…9f5cc5`): + balance 2.000 SKY / 27,243 Coin Hours, green `Received +2.000` row, + detail screen with Confirmed pill, the sender's fee of 6,054 hours and + confirmations counting. +- **Sent the 2.000 SKY back through the app** (Max): the fee card projected a + 2,725-hour burn — exactly `ceil(27,243/10)` — the review sheet promised + amount 2.000 / burn 2,725 / balance after 0.000 / hours after 0, the PIN + prompt gated signing, and the node accepted the broadcast: + txid `bc65baea…b98aedeb`, confirmed on chain with precisely the promised + 2,725-hour fee and 24,518 hours delivered. The counterparty confirmed + receipt. Balance and history rows updated to the empty, two-transaction + state. +- Bitcoin: created a second wallet; mempool.space answered (0.00000000 BTC, + "0 confirmed outputs" sub-line, Send enabled); the fee card showed live + Economy/Normal/Priority presets and the sat/vB slider; the fresh bc1q + address was accepted by mempool.space's address endpoint. +- Fiber: added a coin through the form (name/ticker/node URL); it appears in + the coin sheet as "Fiber coin" and opens its own setup. +- Addresses: generated a second receive address from the sheet; the node + accepted a balance query spanning both. +- Reveal: PIN prompt with the named-wallet warning first, all twelve original + words back from the Keystore, 2:00 countdown, black screencap. +- Offline: with wifi+data off, the next refresh tick raised the amber + "last updated at 05:29, 1 minute ago" banner, disabled Send with its + caption, and kept the cached history; re-enabling recovered silently. +- Insufficient balance, wrong quiz word, damaged addresses and a wrong-chain + address all surface their specific errors inline. + +Not covered: a fiber chain with non-default verification parameters (none is +publicly reachable to test against), a real BTC spend (needs real BTC; the +signing path is vector-proven), and restore-scan against a wallet with deep +address usage. + +The Settings tab was the last placeholder. It holds the four things that are +about the phone rather than about an app — who this visor is, how to get its +config off the device, what guards the app, and where the logs are collected — +plus the theme override and the version card. + +### 1. Identity, and the flag that does not exist + +`config gen` has no `--sk`. `-r/--regen` takes the secret key from the config +it is about to overwrite (`gen.go:933-947`), so installing a key means writing +it into that file first and letting the regenerate read it back. Everything +else is the first-run pipeline unchanged — same argv, and the phone profile is +re-applied at the next start — with one thing the generator does for free: +`mergeExistingApps` keeps per-app argv instead of rebuilding it. + +**The key is validated before anything is touched, and that is not politeness.** +Handed an SK whose public half will not derive, `config gen` does not fail: it +silently generates a fresh random keypair (`gen.go:674-677`). A mistyped paste +would land the user on a brand-new identity with no error anywhere. So the +pasted key goes through `config pk` first, which both validates it and derives +the public key — which is then what the confirmation dialog quotes, so the user +approves the actual outcome rather than a promise. + +**New identity deletes the config instead of regenerating one**, so the next +start runs the untouched first-run path. One pipeline for a new identity, not +two. + +Both operations clear `local_path` — chat history, app work dirs, transport +logs. A visor's key *is* its identity, and carrying a previous identity's +messages under a new one puts a conversation on screen that nobody can +continue. It also makes the warning true: both flows are confirmed twice, and +the first dialog says exactly what is lost rather than saying "destructive". +`users.db` deliberately stays — the local API account is the app's own device +credential, not part of the visor's identity. + +One bug this would have had: `VisorApi` caches this visor's public key for the +life of the process, and every `/api/visors/{pk}/…` route is built from it. After +an identity change the cache addresses a visor that no longer exists and every +call 404s until the app is killed. `forgetIdentity()` is called the moment the +identity changes. + +### 2. The one Go change: the CLI printed to stdout on every Android run + +`getInterfaceNames()` (`gen.go:2261`) called the standard library's +`net.Interfaces()`, which Android 11+ denies unprivileged processes. It runs at +flag-registration time — i.e. on **every** invocation of the binary — so on +Android it put `Error: route ip+net: netlinkrib: permission denied` on stdout +ahead of the output of whatever command was actually asked for. Harmless until +something parses that output, which `config pk` now does. It now uses +`anet.Interfaces()`, which is what the rest of the repo already uses for exactly +this reason (`pkg/netutil/net_native.go` carries the comment). Off Android anet +is a straight pass-through. + +### 3. App lock + +`BIOMETRIC_STRONG | DEVICE_CREDENTIAL` on API 30+, `BIOMETRIC_WEAK | +DEVICE_CREDENTIAL` below it — not a preference: the strong pairing is rejected +outright on API 28-29 (`PromptInfo.Builder` throws "Authenticator combination is +unsupported on API n"), and the weak one is what androidx's own deprecated +`setDeviceCredentialAllowed` resolves to there. Fingerprint or face when one is +enrolled, the device PIN/pattern/password otherwise — the phone's own bar, which +is the bar this asks for. `MainActivity` is now a `FragmentActivity` because +`BiometricPrompt` hosts itself in a fragment; nothing else changed, since +FragmentActivity *is* a ComponentActivity and only AppCompatActivity would have +demanded an AppCompat theme. + +**The lock is drawn over the app, not in place of it.** Composing the navigation +tree only while unlocked would tear down the back stack and the embedded chat +WebView on every glance at a notification. What keeps content from leaking +anyway is `FLAG_SECURE`, set for the whole session while the lock is on: the +recents snapshot is taken as the app *leaves*, before it is locked and with the +last screen still on it, so blocking it has to be a flag that was already set. +Screenshots go with it, which is the same promise stated the other way round — +and the setting says so. + +Two decisions the obvious implementation gets wrong. The lock state starts +*locked*, because a fresh process is exactly the case that must ask, and the +gate ignores it entirely while the preference is off. And a ringing or connected +call is shown *through* the lock: a call screen holds no secrets, and every +dialer on Android surfaces one above the lock screen for the obvious reason. + +### 4. Logs & diagnostics + +The aggregate home of the shared viewer: every source listed in one place +(core runtime, the captured process output, each app), Export all, and the log +level. Sources are named **product first, process second** — "SkyVPN +(vpn-client)" — because the list is otherwise four process names and the +process name is the part you actually need there: it is what the config calls +it, what the API route is keyed on, and what a log line says. The viewer's own +app bar shows the product name alone; "SkySOCKS (skysocks-client)" does not fit +a centered title, and the row that opened it already showed both. + +**Export all writes the config redacted.** A diagnostics bundle is a thing +people attach to an issue and the config carries the secret key, so `sk` is +stripped; the full file has its own deliberate export behind a biometric check +and a warning that names what is in it. Nothing fails the export either — a +source that cannot be collected becomes a line in `collection-notes.txt`, since +the bundle is most wanted exactly when things are broken. + +The log level is `log_level` in the config, written from the phone's preference +on every launch like the transport order and the Fleet opt-in, and read once +while the visor builds its module graph — so changing it restarts the core, with +the same confirmation Fleet's toggle uses. + +### 5. Smaller + +Core version comes from the running visor's summary, not from +`libskywire-mobile.so --version`. The binary would answer with the core down +too, which is tempting, but §2 is why: the CLI writes to stdout before any +command runs, and scraping it means parsing whatever else happened to be +printed that launch. Theme override (system/light/dark) is a Compose-level +choice — the palette stays brand-locked, this only picks which half is drawn. + +**Verified on the emulator (Pixel arm64, Android 17 / API 37):** + +- Log level: DEBUG chosen with the core down saved silently and appeared as + `"log_level": "debug"` at the next start; chosen with the core up it asked + first, restarted, and came back `"info"`. The five chips wrap to two rows — + the first cut put them in a `Row` and TRACE rendered one letter per line. +- Export all: 10 files, 180 KB. `sk` absent from the redacted config, `pk` + present, no collection notes. Stopped apps' feeds are empty files, not + errors (the server's 500 "no new available logs" contract). +- Replace SK: an all-zero key was refused in the core's own words ("invalid + secret key") with nothing touched; `…0001` derived + `0279be667e…16f81798` in the confirmation, and after the two dialogs the + config carried that keypair, `local/` was recreated empty (a fresh 32 KB + `skychat-history.db`), and Home reported Connected under the new key — + which is also the proof that the API client dropped its cached identity. +- New identity: fresh random keypair, and every phone-profile pin survived the + regenerate (`cli_addr: ""`, absolute `local_path`, `bin_path`, + `dmsg_ingest: false`, `log_level`). +- Export config: the picked document is byte-identical to the on-device config, + secret key included. +- App lock, with a device PIN set: enabling asked first ("Turn on the app lock" + / "Confirm it is you"), and from then on `screencap` returns black — FLAG_SECURE + working. Cold start raises the prompt automatically; away 5 s returns straight + into the app, away 40 s asks again. Disabling asks too. The emulator has no + enrolled biometric, so every prompt fell through to the PIN keypad — the + fingerprint sheet is untested and wants a real device. +- Dark/light both rendered; `config pk` output confirmed clean of the netlink + line after the Go fix; `go test ./cmd/skywire-cli/commands/config/...` passes. + +**Not covered:** the fingerprint/face path (no enrolled biometric on the +emulator), hardware-backed Keystore behaviour, and FLAG_SECURE in the real +recents list — all of it wants a real device. + +--- + +## 2026-08-06 — Fleet: the visors you run elsewhere, seen from the phone + +Off by default, and that is the feature. The phone's core ships API-only — the +visor's local HTTP API on loopback and nothing else. The config section it +lives under is historically called `hypervisor`, but on this build it is just +the API. **Enable Fleet** hands that hypervisor its dmsg client, which starts +the RPC listener other visors dial in on; from then on any visor carrying this +phone's key in its own `hypervisors` list connects and reports status here. + +The bool (`hypervisor.dmsg_ingest`) already existed from the lite-core work. +What was missing was everything around it. + +### 1. The one piece of Go: a restart route + +The hypervisor mux had `POST /visors/{pk}/shutdown` and `RestartApp`, but no +way to restart a whole visor. The visor's own `Reload` RPC is exactly that — +close the module stack, re-read the config, run again, same process — and it +was already in the `API` interface and the RPC client. So the route is a +wrapper: `POST /api/visors/{pk}/restart` → `ctx.API.Reload()`. + +**It answers 202 without waiting, and it has to.** `Reload` cannot return: it +tears down the RPC transport the call is riding, so the caller's outcome is an +EOF whether the restart worked or the visor died. `cli visor reload` has always +handled it the same way (goroutine, fixed pause, then "Visor reloaded"). The +route reports *dispatched*, not *completed*; the real outcome is the visor +dropping out of `/api/visors-summary` and coming back. Whether the visor was +reachable at all is already settled before the handler runs — `visorCtx` +answers 404/503 for a PK this hypervisor cannot reach. + +### 2. The toggle, and what it costs + +`hypervisor.dmsg_ingest` is read once, while the visor builds its module graph, +so flipping it means restarting the core. The preference is the source of +truth and `ConfigManager.applyPhoneProfile` writes it into the config on every +launch — same arrangement as the transport order, for the same reason. + +`SkywireCoreService.restart()` is **not** a suspend function on the caller's +scope. Fleet is a pushed route: a back press would cancel its view-model scope +somewhere between the stop and the start and leave the phone with no core at +all. It runs on a process-scoped job, serialized behind a mutex, and callers +follow it through `CoreServiceState` like any other lifecycle change. The wait +before restarting is not politeness — the child holds `:8000` and a racing +spawn dies on the bind. `HomeViewModel`'s auth-recovery path (stop → drop +users.db → start) now goes through the same helper via its `between` hook; its +old inline version waited only for `Stopped`, which `Failed` never becomes. + +Android 12+ refuses a background `startForegroundService`, and stopping our own +FGS is exactly what can cost us the exemption — so a failed re-start lands in +`CoreState.Failed` with the reason, where Home shows it and Connect recovers. + +### 3. What a row says + +`/api/visors-summary` per visor: online/offline, version, uptime, **transports +broken down by carrier type** with the total under them (a bare "24" says +nothing; "stcpr 10 / webrtc 14" is the diagnosis on a visor that is reachable +but unroutable), and health. The local visor is filtered out — it is not part +of anyone's fleet and it has the whole Home tab. + +Actions: **Restart**, and **Logs** — `/api/visors/{pk}/runtime-logs` resolves +remote visors through the same mux, so the existing viewer got a `visor-` +source and nothing else changed. + +Three things came from using it: + +- **Naming.** A row that says 66 hex characters does not tell you which + machine it is. Names live on the phone (`core/VisorNames.kt`, one JSON map in + DataStore), not on the visor: Fleet is a read-only window onto those + machines and a label would be a strange first exception. The cost is honest + and stated in the dialog — names do not travel to another device. +- **The "Add a visor" instructions are behind the app bar's `?`**, not a + permanent card. They are read once. The empty list points at it. +- **A snackbar, not a card, for action outcomes.** A restart is fired from a + card that can be anywhere in a scrolling list and its outcome arrives seconds + later; the card is the wrong place to say it. + +### 4. The bug that mattered: a 5-second poll broke the thing it polled + +First cut polled `/api/visors-summary` every 5 s. Every poll makes the visor +fire a `Summary` RPC to each remote over dmsg, and from a phone those routinely +exceed the server's own 5-second budget for them. Polling faster than they +complete stacks calls onto one dmsg stream until it breaks. Measured on the +emulator: the peer cycled `summary RPC slow (>5s)` → `connection is shut down` +→ evicted from `remoteVisors` → redialed, roughly once a minute, forever. + +The screen looked *fine* through all of it, because the server keeps serving an +evicted visor from its summary cache for three minutes and renders it +`online: true` — deliberately, so slow peers don't flicker. But `visorCtx` +resolves actions against `remoteVisors`, which no longer had it. So every row +said **Connected** and every Restart answered **503 "currently disconnected +(last seen 1m45s ago) — retrying"**. Two green screens, one broken button. + +Three changes, in order of how much they fix: + +- **Poll every 15 s.** Nothing here is second-by-second data. This alone ends + the eviction cycle. +- **Retry a 503 restart** three times at 3 s. The server's message says + "retrying" and its code comments expect the UI to; a hypervisor client's RPC + conn idle-closes after ~2 minutes and redials within seconds, so a tap can + legitimately land in that gap. +- **Say how old the numbers are.** `last_seen_at` is now rendered whenever the + snapshot is over 45 s old — including under a green dot. An uptime can be + minutes stale while the row reads Connected, and that is exactly the window + where actions fail. + +### 5. Two things fixed on the way + +- **The straight apostrophe is broken in the Skycoin typeface** — huge + sidebearings render "this phone's key" as `phone ' s`. All of `strings.xml` + now uses U+2019, which the family has and kerns correctly. Verified by + screenshot at 2× crop. +- **The log viewer claimed "No log entries yet." while it was still + fetching.** Harmless for local sources, wrong for a remote visor whose first + page travels the whole ring buffer over dmsg — measured 8.1 s, then 3.8 s. + It has a loading state now. The remote feed is also titled with the visor's + name when it has one, read from the same store. + +`AppRouteScaffold` is deleted — Fleet was its last user, every hub route now +has a real screen. `InfoRow` and `formatUptime` were duplicated in HomeScreen +only because the shared `InfoRow` lacked a `valueColor`; it has one now. + +### Verified + +Emulator `skywire` (1080×2400) + a desktop visor on the Mac +(`02acc53c3d…48c56fd6`), phone `03202fd6a2…f4528ada`, added with **the exact +one-liner the app shows**: `skywire cli config update hv --add-pks `. + +- **Fleet off = no ingest.** Config carries `"dmsg_ingest": false`; the process + log has `Hypervisor enabled` and `Hypervisor HTTP serving on 127.0.0.1:8000` + but **zero** `Serving hypervisor RPC over DMSG`. The desktop visor dialed the + phone **21 times** and failed every one (`dmsg error 202 - cannot connect to + delegated server`, `i/o deadline reached` on `…:46`). +- **Toggle on.** Confirm dialog → core restarts → config rewritten to + `"dmsg_ingest": true` → `Serving hypervisor RPC over DMSG addr="03202f…:46"`. + The desktop visor's `Serving RPC client...` landed **8 s later**, and the row + appeared: Connected, `darwin_arm64`, uptime, stcpr/webrtc counts, healthy. +- **Restart.** `POST …/restart → 202` (20-byte body). The desktop log unwound + all 37 modules — `Shutdown complete. Goodbye!` — and re-entered + `main module set to hypervisor` 0.05 s later; ~6 s end to end. Uptime in the + app went **2h 29m 42s → 2m 31s**. Snackbar: "Restart sent to test." +- **Offline.** `kill -9` on the desktop visor at 09:07:54; the row read + **Offline** (grey dot, health `—`, uptime frozen at its last snapshot, + "Last answered 6m 45s ago — everything above is from then", Logs and Restart + both disabled) when checked at 09:13:10. The flip is not immediate by + design: the server holds an evicted visor as online for its three-minute + cache-freshness window first. +- **The 503 path**, before the poll fix: four attempts logged 3 s apart, and + the failure reached the user in the server's own words rather than a code. +- **Logs.** A remote visor's own lines render (stcpr re-registration, address + resolver binding the Mac's IPs, self-probe) under the visor's name. +- **Rename** persists across an app reinstall (DataStore). +- **Toggling back off** is symmetric: config returns to `"dmsg_ingest": false`, + the core restarts, `Hypervisor HTTP serving` appears and + `Serving hypervisor RPC over DMSG` does not. The screen returns to the + explainer and the list is dropped. + +**Not covered:** a second remote visor (list ordering with more than one row), +and offline behaviour on a real phone — the emulator's dmsg is slow enough that +its timings are a worst case, not a typical one. + +--- + +## 2026-08-06 — SkyChat Settings, and a chat that can move to the phone + +The phone app embeds skychat's own page, so this is a change in +`cmd/apps/skychat` that the phone gets for free — and it is the phone that +needed it. A new visor is a new identity with an empty store: install the app +and every conversation you have had is simply not there, because none of those +messages were ever addressed to this key. Nothing syncs, and pretending +otherwise would need an identity that spans devices. So: a file. + +### 1. Settings + +The sidebar header had a bell for notification preferences and nowhere for +anything else. It now has a **⚙ Settings** dialog with two sections — +Notifications (the bell's whole contents, moved: permission line, the two +switches, the muted list) and **Import / export chat**. The bell is gone; +it was a second entry point to half of one screen. + +Opening Settings still carries the notification permission ask, which is the +one thing about the old bell that was load-bearing: browsers only honour that +request from a user gesture, and the preference defaults to on, so without an +ask on open a fresh profile silently drops every notification. + +The dialog is the one that can outgrow a phone viewport (the muted list has no +fixed length), so it caps at `86vh` and scrolls its body with the title and +Close pinned. + +### 2. What an archive is + +`GET /export` → one JSON file: the address book and every stored message, 1:1 +and group. `POST /import` merges one back. `cmd/apps/skychat/commands/transfer.go`. + +The line drawn is **data vs identity**. Messages and the names you gave to +keys travel. The visor's keypair, group membership and its key material, +pairing ratchets and in-flight transfers do not — importing a group's messages +puts the conversation on the phone to read; it does not make the phone a +member, which still means rejoining. The UI says so rather than leaving it to +be discovered. + +Two details that are Android's, not skychat's: + +- **Export is a navigation, not a fetch-and-blob.** `ChatWebView`'s + `shouldOverrideUrlLoading` already turns a same-origin main-frame navigation + into a `DownloadManager` request *with the basic-auth header attached* — a + blob URL built inside the WebView would not get that. Zero new Android code. +- **The filename is in the URL** (`/export/skychat-.json`, a prefix + route whose suffix the handler ignores). That same path never sees + `Content-Disposition`, so it names the download from the URL: + `URLUtil.guessFileName` on a bare `/export` yields `export.bin`, which the + import picker's `accept="application/json"` then filters out. Import needed + nothing new either — `onShowFileChooser` was already wired for attachments. + +### 3. Import is not Append in a loop + +This is the part with a real decision in it. `history.Store` grew +`Import(msgs, groups) (ImportResult, error)`, implemented in both backends: + +- **No per-peer rate limit.** It exists to stop a peer filling the disk over + the network at 20 messages/minute. An operator restoring their own archive + is not that, and applying it would have delivered a handful of messages per + conversation — the failure would have looked like a successful import. +- **Duplicates are skipped**, so importing the same file twice changes + nothing. Identity is the envelope ID where there is one and + (timestamp, direction, text) where there is not — older plain-text messages + carry no ID. +- **Every other guardrail stands**: size cap, whitelist, total-bytes cap, and + the per-peer FIFO cap. +- **It reports what it will not keep.** `Expiring` counts records already + outside `--persist-ttl` (default 30 days — the sweep takes them within the + hour) and `Evicted` counts what the per-peer cap pushed back out (default + 500). Both are silent losses otherwise, and "imported 2000" while 500 + survive is the number that sends someone to wipe their old device too early. + The UI names the flag to change in each case. + +`evictOldest` now returns how many it removed, for that last count. + +### Verified + +`cmd/apps/skychat` standalone on `127.0.0.1:8801` with `--persist`, driven +through the page in a browser: + +- Import → export → import round trip: an archive of 2 messages + 1 group + message + 1 contact imports as `{messages:2, group_messages:1, contacts:1}`; + exporting reproduces it byte-for-byte in content; re-importing **the + exported file** reports `{duplicates:3}` and stores nothing. +- The imported contact appears in the sidebar as a conversation (the page's + `syncHistoryPeers` is re-run after an import). +- Refusals reach the user as the server's own words, not a status code: + "not a SkyChat archive" (400), "archive is version 99; this SkyChat reads up + to 1" (400), unreadable JSON (400), `POST /export` (405), `GET /import` (405). +- Settings renders both sections and scrolls; the file input is cleared after + each pick so choosing the *same* file again still fires. +- Go tests: `pkg/skychat/history` (import table across both backends — not + rate limited, idempotent, ID-less dedup, cap eviction counted, TTL counted, + limits rejected, full store) and `cmd/apps/skychat/commands` + (export contents, persistence-off export, round trip, no-overwrite of a + local name, the three refusals, method guards). Both pass. + +**Pre-existing and unrelated:** `pkg/skychat/group` hangs its test binary past +600 s in cxo connection code. Confirmed on a clean HEAD as well — it is not +this change. + +--- + +## 2026-08-06 — SkyVPN: the phone's TUN, on loan from Android + +The whole phone now exits through a Skywire visor. This is the one part of the +app that needed new Go, because the thing vpn-client wants — `/dev/net/tun` — +is the one thing an Android app may never open. + +### 1. The handoff: Android owns the interface, the core borrows the descriptor + +Android hands out a TUN only through `VpnService`, and only after the user has +granted the system's VPN consent. The core runs as a **child process** of the +app, so it cannot inherit the descriptor either; it has to be sent. + +**Go** (`pkg/vpn/tun_device_android.go`, new — the only new Go in the app): + +- `newTUNDevice()` returns a device with **no descriptor yet**. It cannot have + one: the shared client calls it before it knows the address the exit will + assign, and on Android the address is an argument to creating the interface, + not something set afterwards. +- `Client.SetupTUN` (`pkg/vpn/os_client_android.go`, new) is therefore where + the interface actually appears. It sends + `{"op":"establish","addr":"172.16.0.12/29","gateway":…,"mtu":…,"dns":…}` over + an abstract unix socket and reads back the descriptor as `SCM_RIGHTS` + ancillary data (`ReadMsgUnix` + `ParseUnixRights`). +- The descriptor is put in **non-blocking** mode before `os.NewFile`, which is + what registers it with the Go runtime's netpoller. A blocking one parks a + `Read` in the kernel with no way out, so replacing the interface — or a + killswitched stop — would hang the copy goroutine forever. Non-blocking, + `Close` unblocks it with `os.ErrClosed`. +- Reconnecting to the same exit lands the same address, and then the request is + satisfied by the interface already up: it is left alone rather than swapped, + so a killswitched reconnect has no seam. + +**Kotlin** (`core/SkyVpnService.kt`, new) is the server on that socket. The +abstract namespace has no filesystem permissions to lean on, so the peer's UID +is the whole gate — `peerCredentials.uid != Process.myUid()` is refused before +a line is read. + +### 2. The route calls became Android, not no-ops + +`os_linux.go` shelled out to `ip`/`nmcli` and raised capabilities. None of that +exists for an app process, and none of it is needed — `VpnService.Builder` +declares the address, MTU, DNS and routes and the system installs them. So the +client half of `os_linux.go` moved into `os_client_linux.go` (now +`linux && !android`) and `os_client_android.go` says the same intentions the +way Android allows: + +| Shared client calls | On Android | +|---|---| +| `SetupTUN` | establish the interface with these parameters, take the descriptor | +| `AddRoute` / `ChangeRoute` | already declared by `establish()` | +| `DeleteRoute` (the two half-space routes) | drop the interface — the interface *is* the route | +| `DeleteRoute` (a `/32` direct route) | nothing: our UID never entered the tunnel | +| `SetupDNS` / `RevertDNS` | travels with the interface | + +`DeleteRoute` on the default route is not a shortcut: it is exactly where the +shared client says "stop carrying traffic", and it is only reached with the +killswitch **off**. `tun_device_unix.go` is now `!windows && !android` so the +`water` path stays untouched everywhere else. + +### 3. What makes the killswitch real + +The service keeps **its own** copy of the descriptor. The core closing its copy +does not take the interface down, so when the tunnel drops there is a live +interface with nobody draining it and the packets go nowhere. Releasing it is +an explicit decision in exactly three places: a `down` from the core (killswitch +off), a control-socket EOF with the killswitch off (the core was *killed*, not +stopped), and the user disconnecting. A crash-restart of the visor +(`CoreState.Restarting`) deliberately keeps blocking; a terminal core stop does +not, because a phone blocked with nothing left to reconnect is a bug, not a +killswitch. + +`--killswitch` is set through the visor's own `killswitch` PUT field rather +than by rewriting argv — it is a first-class setter (`SetAppKillswitch`) that +adds the bare flag or strips it. Like the transport preference, the **phone's** +stored value is authoritative and is re-applied to the config whenever the core +comes up. + +### 4. Screen + +`ui/vpn/` repeats the SkySOCKS shape — service discovery (`type=vpn`), tap an +exit, `PUT pk + killswitch + status`, poll — and adds the killswitch toggle +(with a deep link to Android's own Always-on VPN settings, the stronger +guarantee this app cannot provide itself) and a live stats card from +`…/apps/vpn-client/connections` + `…/stats`. A lifetime byte counter is kept in +DataStore, written in 8 MB chunks rather than on every 2 s poll. + +The list/status pieces both screens now share moved to +`ui/components/ServerUi.kt` (`SavedServer`, `ServerRow`, `SectionCard`, +`InfoRow`, the formatters, the status colors). + +**One unit bug fixed on the way:** `AppConnection.latency` was decoded as +nanoseconds and divided by 1e6. The visor already converts — +`ConnectionSummary` in `pkg/app/appserver/proc.go` stores +`time.Duration(…Milliseconds())` — so the wire value is milliseconds. Harmless +on SkySOCKS (whose latency is always 0, so the row never renders); it would +have shown every VPN ping as `0 ms`. Field renamed `latencyMs`. + +### Verified + +Real device (`DM_B70104`, Android 15, LTE): consent → connect to a US exit → +**Connected**, interface `172.16.0.12/29`, stats ticking (↑118.9 KB ↓198.8 KB, +session 55 s). Stopped there — the tunnel takes over the phone's networking and +the device was needed. + +Emulator (arm64, API 37), full pass against a Singapore exit +`036433b792…83c74724`: + +- **Whole-phone traffic exits at the visor.** From the emulator shell (uid + 2000, inside the tunnel's UID range): `ip-api.com` → **207.148.77.89, + Singapore, VULTR**. Host egress without the tunnel is `45.56.79.245`. +- **The visor stays outside it.** `ip rule` routes uid ranges + `0-10230`, `10232-20230`, `20232-99999` into `tun0`'s table — a + one-UID hole at **10231**, which `pm list packages -U` confirms is + `com.skycoin.skywire`. That hole is `addDisallowedApplication(self)`, and it + is what keeps the dmsg traffic carrying the tunnel out of the tunnel. + `ip route show table 1019`: `default dev tun0`. +- **Killswitch on.** Visor `kill -9`'d (an unclean loss — no `down` sent): + `tun0` still up at `172.16.0.20/29`, HTTP from a routed UID **times out after + 20 s**. Screen reads *Blocked by killswitch*. +- **Killswitch off.** Same `kill -9`: `tun0` gone within seconds, traffic + immediately direct again (`45.56.79.245`). +- **Disconnect** releases the interface and restores networking. +- Toggling the switch rewrites the config as expected: `--killswitch` appears + in and disappears from vpn-client's `args`, and the re-dial establishes a new + interface (`tun0` index 19 → 20 → 21 across the run). +- Builds: `make android-mobile-check` (63,766,824 B, budget 83,886,080), + `make android-apk-debug`; `pkg/vpn` compiles for android/linux/darwin/windows + and `go test ./pkg/vpn/...` passes. + +**Not verified, and it needs a real device:** killswitch behaviour under Doze +and OEM background kills, and whether a plain (non-foreground) `VpnService` +sharing the core service's process survives long idle periods on aggressive +OEM ROMs. Also unverified: `adb shell` on the physical device reported the +carrier IP with the tunnel up, i.e. that ROM appears to exclude the shell UID +from VPN routing — the emulator does not, which is why the routing evidence +above was taken there. + +--- + +## 2026-08-05 — SkyDEX: a gate on the trading UI, and a phone layout for it + +The two things the SkyDEX screen shipped without, both now closed as far as +this side of the repo can close them. + +### 1. The trading UI is no longer open to every app on the phone + +**Why it was:** skydex-client's UI and control API had no authentication of +any kind, and Android has no per-app network namespace — a loopback listener +is reachable by every installed app holding INTERNET. Behind that port sit the +live market session, the registered wallet addresses, and placing or +cancelling orders. skychat solved the same exposure with `--password-file`; +skydex-client had no equivalent because the server that serves the UI comes +from the skycoin repo and takes an address, not a listener or a handler. + +**Built** (`cmd/apps/skydex-client/commands/auth.go`, new): + +- `--password-file`, reading the format skychat already uses — one line of + `":"` — so one writer serves both + gates. Empty (the default) still serves ungated: **the desktop shape does + not change**, one server on the configured port. +- With a password set, the wrapper takes over `--addr` with a basic-auth + reverse proxy and moves the engine to a loopback port drawn fresh at every + start. The credential is stripped before the request is forwarded. +- Fails **closed**: `--password-file` pointing at something unreadable or + empty refuses to start rather than quietly serving the surface open. +- Android side: `core/SkydexProfile` (mirroring `SkychatProfile`) pins + `--password-file` next to the loopback `--addr`, `SecretStore` grows its own + skydex secret, `SkydexApi` sends the credential on every call, and the + WebView answers the challenge. + +**Verified on the emulator**, with the phone connected and trading: + +- From `adb forward` — a different UID, the same reachability another app has + — `GET http://127.0.0.1:8051/api/status` → **401** with + `WWW-Authenticate: Basic realm="skydex-client"`; a wrong password → **401**. +- The app's own listeners, read from `/proc/net/tcp` for its uid: 8000 + (visor), 8001 (skychat), **8051 (the gate)**, and a random high port — 33043 + on that run — where the engine actually is. +- Go tests (`auth_test.go`): the record formats that do and do not gate, the + middleware's three answers, that `gatedServer` moves the engine and proxies + the path through only with the credential, and that with no password it + returns the configured address untouched. + +**What this does not close, precisely:** the engine's own port answers +ungated — measured, not assumed: `curl` to 33043 returned the full market +status. An attacker must now scan ~28k ephemeral ports, re-rolled at every +app start, instead of connecting to a documented one. The operating system +offers no way to bind a socket only this UID may connect to, so **closing the +last gap needs one upstream change**: `skydexclient.Run` accepting a +`net.Listener` (or exposing its handler). At that point the proxy here +collapses into wrapping a handler and no second port exists at all. + +### 2. The page has a phone layout + +**Why:** the trading UI declares `width=device-width` and is built on +Bootstrap, but its own ~10 kB of CSS contains **no media query at all** — +every padding, grid minimum and flex basis in it was measured on a desktop. +(The earlier entry called it "desktop-width"; that was wrong in a way worth +correcting — it renders at the real width, it just has no breakpoint.) + +It is a built bundle from another repo, so the lever is a stylesheet layered +on top, injected exactly like the header hide and scoped to +`max-width: 600px` so a tablet keeps the layout the page intends +(`ui/dex/DexWebView.kt`). The WebView stopped zooming out to fit +(`loadWithOverviewMode = false`) since the page now has a layout at the real +width; pinch-zoom stays on, because this is a screen full of hex. + +**Seen working on the emulator:** all five tabs on one line — **Settings**, +which holds the wallet addresses, was previously off the end of a scroll +strip; the banner's *Open Settings* now a full-width button instead of a word +wedged in a corner; the page's padding no longer spending an eighth of the +screen; and the Settings form one full-width column. + +### 3. Tables became cards, because they were unreadable and unreachable + +With a live listing on screen the tables turned out to be worse than cramped. +My Listings is **ten columns**; a phone showed the first five and cut the rest +off, and the column past the edge was **Actions** — the one holding Cancel. A +sideways scroller does not fix that: nobody scrolls a table they cannot see +the end of, to reach a button they do not know is there. + +Each row is now a card. What a closed card shows is chosen by column *name*, +not position: `Type`, `Amount`, `Price`, `Status`/`Lifecycle`, capped at four, +plus the Actions cell, always, as a full-width button. Everything else — the +id, the escrow address, the transaction hashes, the timestamps — is behind a +**Details** toggle on the card. Amount and Price take the weight the market's +own product card gives them (plain and accent), so a row reads as a trade +rather than a grid. + +Two judgement calls worth recording: + +- **A closed card shows one status badge, not the chain.** The lifecycle is + three badges and two arrows — "Pending deposit → Confirmed → Listed as + product" — of which only the last is news. The completed and future steps + come back on open, where "why is my deposit still pending" is actually the + question. The current step is the one the page paints `bg-info`. +- **An Actions cell with no button is removed**, not left as a labelless "—" + floating on its own line, which is what a finished order produced. + +This is the one part that cannot be pure CSS: a `` carries no clue which +column it is in, so the labels are copied off ``, and the choices above +need to be made per cell. The page re-renders its tables **every eight seconds** +while polling, so a MutationObserver re-applies all of it and the open cards +are remembered outside the DOM — otherwise every card snaps shut mid-read. + +**Also fixed, and it was a real bug:** a WebView with no `onJsConfirm` +silently suppresses `window.confirm()` and hands the page `false`. The page +guards cancelling a listing or an order behind exactly that call, so **Cancel +did nothing at all** — the worst possible failure on a screen holding escrowed +coins. `chromeClient` now answers `onJsConfirm`/`onJsAlert` with a native +dialog; verified by tapping Cancel on a live listing and dismissing it. + +**Verified on the emulator against a live market**, with a listing, two +cancelled orders and a history entry present: all three tables render as +cards; Details expands to all ten fields and survives the eight-second +re-render; Cancel raises its confirmation. + +**Not exercised:** the sell-order trade builder's stacking rules — reaching it +needs a market that has enabled a sell coin, which the throwaway local one +cannot do without a chain node. + +**Worth knowing before upstream changes:** everything in this section binds to +names inside a *vendored, pre-built* bundle — `.app-container > header.header`, +`.panel.table-wrap`, `.table`/`thead th`, `.badge.bg-info`, and the header +texts `Actions`/`Amount`/`Price`/`Type`/`Status`/`Lifecycle`. If the skycoin +repo rebuilds that bundle with different markup, none of it errors: the page +just quietly returns to a duplicated header and clipped tables. skychat has no +such exposure because its UI source lives in this repo. The durable fix is to +land these breakpoints upstream; until then a build-time assertion over the +vendored assets would turn a silent regression into a failed build. + +--- + +## 2026-08-05 — SkyDEX screen: native market entry, embedded trading UI + +**Why:** SkyDEX is the desktop flow — market public key → connect → the +trading UI — and the only part of it that does not belong in a WebView is the +key. Sixty-six hex characters want the phone's own field, its paste +behaviour, and a list of the markets this phone has already used. Everything +past the handshake is the page the desktop already serves. + +**Built** (`ui/dex/`, `api/SkydexApi.kt`): + +- Native header: market-key field with validation, a recent-markets dropdown + (names learned from the handshake, in `AppPreferences`/DataStore), and + Connect. Once there is a market it collapses to one row — dot, market name, + shortened key (tap to copy), **Disconnect** — and the page takes the screen. +- Connect is three steps in one action: `PUT …/apps/skydex-client` with the + argv carrying `--market-pk`, then `status: 1`, then a `POST /api/connect` + on the app's *own* control API once its listener answers. +- The trading UI in a WebView below, with the page's own header suppressed + (see below). `Logs` in the bar, scoped to `skydex-client`, like every app + screen. +- `core/ConfigManager` now pins a third app's flags on every launch, and the + loopback-address rewrite it shared with SkySOCKS is one helper. + +**Hard-won facts:** + +- **`SetAppPK` refuses this app.** `PUT …/apps/{app}` with a `pk` field is + allow-listed to `skysocks-client` and `vpn-client` (`api_apps.go`), and the + flag is `--srv` regardless. The market key therefore goes through the + `args` field — the whole argv, rewritten. +- **`--market-pk` connects nothing.** It is the value the page pre-fills its + connect form with; the engine dials only when something POSTs + `/api/connect` (`skydex-client/commands/api.go`: *"The client never + connects automatically"*). Setting the flag and starting the app would have + left the user typing the key a second time into the page — exactly what + entering it natively was meant to avoid. The native side POSTs it, and the + page, which reads `/api/status` on load, comes up already on the market. +- **Two servers answer different questions.** The visor (`:8000`) owns the + app — argv, running or not. skydex-client (`:8051`) owns the market + session. The screen polls both: an app that reports a market is by + definition an app whose UI is up, so one `/api/status` call is also the + readiness probe. +- **`--addr` defaults to `:8051` — every interface.** Same exposure the proxy + had, with a worse payload: the trading UI has **no gate at all**. (The + one-time-code scheme in the app list is `skydex-market`'s operator panel, + not this.) The profile pins the host to loopback. That closes the Wi-Fi + side; it cannot close the on-device side, since Android has no per-app + network namespace and the app exposes no auth flag to pin — noted below. +- **The page drew the same header we did** — brand, connected dot, market + name, shortened key, Disconnect — two identical bars costing a fifth of a + phone screen. It is hidden with a stylesheet injected at page-finished, not + by removing the node: the page is React and would put it straight back. The + native row is the keeper because it stays reachable whatever the page's own + layout does. +- Stop-before-configure carries over from SkySOCKS unchanged. The `args` + field triggers a server-side `RestartApp`, which is a no-op when no proc is + running (`api_apps.go:577`) — so with the app reliably stopped first, one + PUT carrying `args` + `status: 1` is correct in every case. +- **Ship-blocker checked, and it is already clear:** `go.mod` has no + `replace` to a local skycoin checkout. The engine comes from the pinned + `github.com/skycoin/skycoin v0.28.6-0.20260730141451-1bb474401424` and is + vendored, so a release build needs nothing resolved here. + +**Verified** on the emulator (`sdk_gphone64_arm64`, Android 17 / API 37, +light + dark), against the market `024a37ba…43bdb9` ("Unofficial Skycoin +Market"): + +- Typed key → **Connect** → *"Reaching the market over Skywire…"* → the + trading UI rendered, connected, in ~20 s: Market / My Orders / My Listings + / History, the wallet-address prompt, and *"No products available right + now"* (the market had no live listings). +- On-device config after connecting: + `--addr 127.0.0.1:8051 --market-port 8050 --market-pk 024a37bae6…` — the + loopback pin held and the key was appended, clobbering nothing. +- **Disconnect** dropped the session and stopped the app + (`skydex-client: Context canceled, shutting down`), and the panel returned + with the key still in the field. +- The recents dropdown offered *"Unofficial Skycoin Market · 024a37ba…43bdb9"* + — the name came from the connect handshake, not the user. Picking it and + reconnecting took ~25 s on a warm route. +- `Logs` opened the shared viewer titled **skydex-client**. +- Recents and the argv survived an APK reinstall: reopening the screen + pre-filled the key with no typing. +- The page's own header is gone; the embedded UI now starts at its tab bar. + +**Control test** (that the flow, not the market, was being measured): a +desktop visor built here with `skydex-market` autostarted answered its own +`skydex-client` over loopback with +`{"connected":true,"currencies":["BTC","LTC"]}`. + +**A first-connection transient, not a fault:** the phone's first target was a +freshly-stood-up desktop market. Its visor logged the accept, but the +client's `get_currencies` came back `read response: EOF` and the following +dmsg dial to `…205ee:8050` timed out. Retried against the same market later +the same day, it connected normally — so the first dial to a market whose +route has never been built can fail once and is worth simply repeating. + +**Deferred at the time, done the next day** (see the entry above): the +trading UI's phone layout, and closing the trading UI to other apps on the +device. + +--- + +## 2026-08-05 — The address book moves to the visor, so names reach every surface + +**Why:** a nickname lived in the chat page's `localStorage`, which meant the +two places a name matters most could not see it — a notification title, which +skychat composes in Go before any UI is involved, and the phone's native call +screen, which is Kotlin and cannot read a WebView's storage. Both showed 66 hex +characters for someone the user had already named. (The profile package's own +header had flagged this: *"the address book … fixed that one device at a time"*.) + +**Built:** + +- `pkg/skychat/contacts` — the address book as one small JSON file beside the + profile, written temp-then-rename so a crash mid-write cannot turn a power cut + into "all my contacts are gone and the app won't start the feature". +- skychat serves it: `GET/POST /contacts`, plus `POST /contacts/import` for + migration. Import **fills gaps only** — it can never revert a rename made + since, which is what makes running it on every page load safe. +- `displayName(pk)` is now the single answer to "what do we call this key", + used by every notification title (DM, group, file, missed call). +- The page reads and writes the server book, keeping its in-memory map for the + synchronous render paths, and pushes any surviving `localStorage` names up + once before dropping them. +- The Android side caches the book (30 s) and resolves it for the ringing + notification and the full-screen call screen. + +**Resolution order, now the same everywhere:** the operator's nickname → the +name the peer publishes about itself → the shortened key. The published name is +never consulted in the notification path (it is a network fetch); the UI writes +one into the book when there is no nickname yet, so by the time it matters it is +already a nickname. A name the user chose is never replaceable by the person it +labels. + +**Verified** on the emulator, naming the desktop peer "Alice" in Contact +Settings: + +- `skychat-contacts.json` on device: `{"037f16ce…": "Alice"}` — server-side, + and it now survives a WebView cache clear and is shared by every UI of this + visor. +- Message notification title: **Alice** (was `037f16ce…ecf2`). +- Ringing notification: **Incoming call / Alice**. +- Full-screen call screen: **Alice**. + +**Also:** the Contact Settings copy said "Saved here, on this device", which +stopped being true — corrected rather than left to mislead. + +**Not moved:** contact *membership* (`skychat_contacts`) and avatars (`i_`) +are still per-browser. Only the name is needed outside the page, and avatars are +data-URL blobs that would want a different store. + +--- + +## 2026-08-05 — The phone is a generic sink for the notification hub + +**Why:** the hub was decoupled from skychat so anything — an app, the visor, +later the hypervisor — can publish. The phone consumed it but presented every +event as a SkyChat message, which quietly undid that: one `messages` channel, +`CATEGORY_MESSAGE`, a SkyChat title fallback, and a single notification id for +all tagged events. skydex-client already publishes (market alerts, lifecycle), +so this was not hypothetical — its alerts would have landed in "Messages", and +its `skydex-lifecycle` tag could overwrite a chat notification. + +**Built:** + +- `MessageNotifications` → **`NotificationBridge`**: app-agnostic, and named + for what it is. Presentation is now driven by the hub's `app` field. +- **Per-app Android channels**, created on first use. An app the phone has + never heard of is not dropped and needs no code: it gets a channel named + after itself at default importance, so the user has a real switch for it in + system settings the day it first appears. The `CHANNELS` table only gives + known apps a nicer label and a deliberate importance (SkyChat interrupts, + SkyDEX doesn't, visor events are quieter still) — adding a row tunes an app, + adding a *notification* needs no row at all. +- **Tags namespaced by app**, so two publishers that both say "lifecycle" + cannot replace each other's alerts. Untagged events stack. +- **`(*Visor).Notify(title, body, tag)`** — so a visor-side notification is one + line at the place that already knows the thing happened, with the `visor` app + name (`NotifyAppVisor`) stamped for sinks to key on. Intentionally unused + today; it is the seam the next notification is written against. + +**What this buys:** a new notification — "peer went offline", say — is now one +`v.Notify("Peer offline", …, peerPK)` at the point of detection. No Android +change, no new endpoint, no channel to register; the phone shows it under a +"Skywire" channel the user can silence on its own. + +**Verified** on the emulator: a missed call published by skychat arrived as +`channel=app_skychat category=msg importance=4` — routed from the event's app +field into a channel created at run time, where it previously landed on the +hardcoded `messages` channel. The unknown-app fallback is the same call minus +the table hit and is not separately exercised on device. + +**Worth knowing (hub-side, not changed here):** the stream is **live-only, no +replay** — anything published while the phone's SSE connection is down (visor +restart, doze, a network flap) is gone, since with zero subscribers the hub +falls through to the host-OS tier, which on Android is nothing. The bridge +reconnects within ~2 s, which is the whole mitigation. A small ring buffer plus +`?since=` on the stream would close it, and would matter more for a +notification the user is expected to act on than it does for chat. + +--- + +## 2026-08-05 — Calls have audio, a screen, a log, and messages have notifications + +**Built:** + +- **Real audio on a call.** The visor has no audio device on Android, so it + borrows the phone's. `pkg/skychat/call.Bridge` presents the host app as an + ordinary Source/Sink pair; `VoiceAudioEngine` (AudioRecord/AudioTrack) plays + the device and carries PCM over two long-lived streams on the visor's local + API. `VoiceCallService` runs them for exactly as long as a call is connected. +- **A call is a screen, not a notification** — in either direction, on every + tab, the Chat tab included. `ui/call/CallScreen` takes the whole display for + all three moments of the same event: placing one (Calling… / hang up), being + rung (answer / decline / ringtone), and being in one (mute, speaker, hang up, + live timer). With the app backgrounded the system is asked to raise it via a + full-screen intent. + - **A call being placed had no state anywhere.** It is in neither the ringing + list (that is the callee's) nor the active list (that starts at + "answered"), so the caller saw nothing for the whole ring. The call manager + now tracks outbound invites in flight and the visor serves them at + `…/skychat/voice/dialing`. The same registration makes hanging up DURING + the ring possible: there is no session to close, so cancelling the invite + is the hang-up — before, a caller could only wait out the dial timeout. +- **Message notifications**, for every kind of message, from the visor's + existing `/api/notifications/stream` — which turned out to have been built + for exactly this consumer and never had one. +- **A missed call is a message**, in the conversation it belongs to, with the + notification that follows from being one. +- **A Calls tab** beside Channels: every call — incoming, outgoing, missed — + with the time, the duration, and a tap to call back. +- Home: the visor card now shows public key / version / uptime, with + everything else (transports **by type**, DMSG servers, service health) behind + **More info**. + +**Two defaults that were wrong for a phone, both found by testing:** + +1. **skychat only ran while its tab was open** (autostart off, like every other + app on the phone). A chat app that runs only while you are looking at it + cannot receive anything — no message notification, no ringing call, no + missed call recorded. `auto_start` is now pinned on for skychat alone. +2. **`--persist` is off by default**, so nothing was ever stored: every + conversation was erased on the next core restart, which happens on every + crash, reconnect and app update. Now pinned on with the DB under the app's + local dir. The Calls tab depends on it too — the call log is call records + read back out of message history, which is why it needs no store of its own. + +**Traps worth keeping:** + +- **The `/api` route group applies `middleware.Timeout(30s)`** — a deadline on + the whole request, which silently severs any long-lived one. The microphone + stream broke and reopened every 30 s for a whole call with no error anywhere; + the fix is to register outside that group, as the notification stream already + documents. Both audio routes now live at `/api/voice-audio/{pk}/…`. +- **A foreground service typed `microphone` is REFUSED** — SecurityException, + crashing the app — unless RECORD_AUDIO is already granted. A call can arrive + before the user has ever been asked, so the service starts as `mediaPlayback` + (honest: audio out, none in) and promotes itself the moment the grant lands, + before a single frame is recorded. +- The visor's skynet-first voice dial had to be given a bounded slice of the + call's budget or dmsg — the carrier that actually works to a phone — was + never reached. (Landed with the earlier voice work; the audio only made it + visible.) + +**Verified** on the `skywire` emulator with an audio-capable desktop visor +(`-tags voiceaudio`, cgo) as the second party: + +- Desktop → phone call, answered on the phone: `MODE_IN_COMMUNICATION` with + `Recording active: true` in `dumpsys audio`, both streams live, the chat + page's call panel running with a timer and level meters on both sides. +- Microphone permission is requested exactly when a call connects, and the + call continues (receive-only) while it is outstanding. +- Full-screen call UI on the Home tab with Decline/Answer; ringing notification + confirmed posted with `category=call`, `importance=4`, full-screen intent. +- Phone → desktop, placed from the chat page's ⋮ Call while ON the Chat tab: + full-screen `Calling…` with Hang up, then the connected screen with the timer + and mute/speaker/hang-up when the desktop answered, then + `Outgoing call · 03:40 PM · 1m 4s` in the Calls tab beside the earlier + `Missed call · 03:01 PM`. +- Missed call: `Voice: missed call with 037f16ce…ecf2 logged`, an Android + notification on `channel=messages` `category=msg`, a `📞 Missed call` row in + the conversation, and the record in the Calls tab as + `Missed call · 03:01 PM` with a call-back tap. +- `skychat-history.db` created on device; skychat autostarts with the core. +- `go test ./cmd/apps/skychat/... ./pkg/skychat/call/`, golangci-lint clean on + every touched package. `pkg/skychat/group` hits the 10-minute test timeout — + untouched by this work. + +**Still open:** *declined* is not distinguishable from *no answer* in the log — +both are a call that rang and stopped, and telling them apart needs a decline +signal shared by the chat page's `/voice/decline` and the phone's visor API. +Speakerphone uses the deprecated `AudioManager.isSpeakerphoneOn`. + +--- + +## 2026-08-05 — `skychat://` links open the phone; voice calls come back + +**Built:** + +- **`skychat://` deep links.** `MainActivity` now answers `ACTION_VIEW` for the + `skychat` scheme (both `skychat://[/]` and the opaque + `skychat:invite:<…>` form) and parks the link in `core/DeepLinks`. The app + navigates to the Chat tab and hands the address to the page, which opens + **Add by address** with the field filled and the lookup already run. + - It stops at the resolve. No contact is added, no chat opened, no group + joined — a link that arrived from outside gets the user a *look* at who or + what it points at, and the tap that follows is the consent. + - Only `skychat` is claimed. `skycoin://` is deliberately left alone: the + Skycoin wallet app already answers it on the same phones, and a second + filter would put a disambiguation chooser in front of all of its links. +- **Voice calls work on the phone** — the Call row in a conversation's ⋮ menu, + the incoming-call banner, answer/decline, and the active-call panel. Two + separate defects had to be fixed; see below. +- Hub: **Pay-with-Sky tile removed.** At most one "coming soon" tile at a time + (now SkyMeet alone) — several of them read as an unfinished app, one reads + as the next thing being built. +- Naming: the menu row is **"Call"**, the banner **"Incoming call"** (was + "Voice call" / "Incoming voice call"). + +**Why voice was missing — two independent bugs, both outside the Android app:** + +1. **The phone runs no visor RPC port, and skychat could only reach the visor + through one.** Everything skychat relays to the visor — pairing, group + chat, and all of `/voice/*` — goes through its pair-RPC client, which dials + `cli_addr`. The phone profile sets `cli_addr: ""` on purpose: on Android any + installed app holding INTERNET can connect to another app's loopback + listener, and the visor RPC has no authentication of its own. So the dial + always failed, `/voice/incoming` answered 503, and the page hid the Call row + (it polls that endpoint every 3 s and degrades when it 503s). Everything was + working exactly as designed and the feature was invisible. + - Fix: `pkg/visor/local_api.go` — a visor running internal-mode apps + publishes itself, and skychat's `connectPairRPCLocked` prefers that over + dialing. The mirror image already existed (an internal app publishes its + HTTP handler and the visor serves it in-process rather than dialing its + port); this is the same trade in the other direction. + - What the registry hands out is a wrapper whose **`Close` is a no-op**. The + caller treats it as an RPC *client* and closes it on every redial — + closing the live visor there would shut the process down. + - Unregistration is compare-and-clear, and the test that covers it needed a + stub with a real field: `proxyDefaultAPI` is an empty struct, and two + pointers to distinct **zero-size** allocations may compare equal in Go, so + with an empty stub "a different visor unregistered" and "this visor + unregistered" are literally the same test. +2. **A call to a phone timed out without ever trying the carrier that works.** + `initVoice`'s dialer prefers skynet and falls back to dmsg, but handed the + skynet attempt the caller's whole 30 s. Route setup to an unreachable peer + does not fail fast — setup nodes, then a local BFS, then a direct-transport + dial — and a phone accepts no inbound connections, so it consumed the entire + budget and dmsg was never reached. Measured live: `local BFS found no path`, + then `stcpr` dialing the emulator's egress IP, then `context deadline + exceeded`. The skynet leg now gets its own 10 s slice (`dialSkynetBounded`), + which is what preferring a carrier has to mean if the fallback is to matter. + +**Verified** — Android Studio emulator `skywire` (arm64, 1080×2400), pure-Go +payload lane, plus a desktop visor on the Mac as the second party: + +- `adb shell am start -a android.intent.action.VIEW -d "skychat://03565f7a…9ce3"` + from Home → app switched to Chat → **Add by address** open, field prefilled, + resolved to "Person · direct message", `Start Chat` waiting, nothing added. + No keyboard: `openAddressModal({focus:false})` on the link path, or it would + have risen over the result the dialog exists to show. +- ⋮ in a conversation → **Call** present (it was absent before the seam fix). +- Device log: `[skychat]: Pairing: using the in-process visor API (startup)`. + Confirmed the config it ran against: `cli_addr ''` with skychat still carrying + `--pair-enable` — i.e. the exact configuration that was silently dead. +- Desktop → phone call (`skywire cli skychat voice call `): phone + logged `voice: INCOMING CALL … RINGING`, the **Incoming call** banner appeared + with Decline/Answer, Answer connected, and the active-call panel ran with a + live timer (00:04) and You/Peer level meters. Same call before the dial fix: + `context deadline exceeded`, no ring. +- Hub screenshot: SkyMeet is the only "coming soon" tile. +- `go test ./pkg/visor/ ./cmd/apps/skychat/...` pass; golangci-lint clean on + both. `pkg/visor/rpcgrpc` `TestSystemStatsCollect` fails, and fails the same + way on a stashed tree — pre-existing, unrelated. + +**Not done (agreed as its own piece):** the phone still has no audio device for +a call. `pkg/skychat/call` on Android compiles the **PulseAudio** backend +(GOOS=android satisfies the `linux` build tag), which has nothing to talk to, so +capture and playback degrade to silence and the call connects mute in both +directions. Real audio needs a bridge — either the Android app capturing with +AudioRecord/AudioTrack and shipping PCM over new endpoints, or the WebView page +doing it with getUserMedia (already permitted here for voice messages). The +first also allows a call to survive the Chat tab being closed and can back a +proper incoming-call notification. + +--- + +## 2026-08-04 — Chat tab: skychat embedded, gated, and made to fit a phone + +**Built:** + +- `ui/chat/` — the Chat tab is now skychat's own web UI in a WebView, not a + placeholder. `ChatViewModel` waits for the visor API, starts the `skychat` + app (autostart is off for every app on the phone), then polls the app's own + HTTP surface until it answers; only then is the WebView created, so the + chat never opens on Chromium's error page. `ChatWebView` holds the Android + half: the password gate, media permissions, uploads, downloads, and the + rule for what may leave the page. + - Android back ↔ page history. The UI already pushes a history entry when a + conversation opens (`_enterChatPane`) and answers `popstate` by going back + to the list, so `BackHandler(enabled = canGoBack)` → `goBack()` lands + exactly on Telegram behaviour: back closes the chat, back again leaves the + tab. + - Top bar overflow: **Reload** and **Logs**, the shared viewer scoped to + `skychat`. + - Uploads via `onShowFileChooser` → `StartActivityForResult`; the callback + is answered on cancel too, or the page's file input is dead for good. + - Downloads: the page's `` is on renderable media, which WebView + ignores and *renders in place of the chat*. Same-origin main-frame + navigations are turned into DownloadManager jobs carrying the gate's + Authorization header; anything off-origin goes to the browser. +- **skychat now runs password-gated on the phone** (`core/SkychatProfile.kt`, + `api/SkychatApi.kt`, `SecretStore.skychatPassword`). See below — this is the + security-relevant part of the step. +- `core/ConfigManager` pins skychat's argv on every launch the way it already + pinned skysocks-client's: `--addr` host forced to loopback, `--portless` + stripped, `--password-file` pointed at a record it writes itself. +- **Web UI (`cmd/apps/skychat`), the phone pass.** The one-pane breakpoint + already existed; what did not survive contact with a 406px viewport: + - the composer needed 489px and overflowed by 62px, which scrolled the + header's back button off screen — `.message-input` had `flex:1` with no + `min-width:0`, so it refused to shrink below its placeholder's intrinsic + width. Fixed generally, plus a tighter composer at the breakpoint. + - `` → `