From ef43096dbab3cedef512c062635e035c4b2aed31 Mon Sep 17 00:00:00 2001 From: "MohammadReza P." Date: Mon, 3 Aug 2026 10:13:31 +0800 Subject: [PATCH 01/37] =?UTF-8?q?feat(mobile):=20add=20skywire-mobile=20li?= =?UTF-8?q?te=20multicall=20core=20(playbook=20Step=20=E2=88=921)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One binary for the phone — cmd/skywire-mobile: the visor, the CLI `config subtree, and the 4 client apps (skychat, skysocks-client, vpn-client, skydex-client) running in-proc via the launcher's existing registry, with the `app ` subtree as the per-app exec fallback. New `mobile` build tag strips ~51 MB of embedded desktop assets, every one behind an existing graceful-absence path: - pkg/geoip GeoLite2 db (30 MB) → OpenEmbedded errors, geo shows "??" - manager UI static (6.8 MB) → "/" serves a one-line API-only page - vendored skycoin-web browser wallet (11 MB) → /wallet/* 404s - tpviz legacy UI (2.8 MB) → tp-viz routes 404/500 - browse handlers → 404 (desktop feature) API-only on the phone: new hypervisor.dmsg_ingest opt-in (Fleet, Step 7) gates the remote-visor dmsg RPC ingest on mobile builds via a tag-paired seam; desktop behavior unchanged (ingest always on with the hypervisor). Build/CI: make build-mobile / android-mobile / android-mobile-check / android-mobile-ndk (NDK-cgo release lane; -checklinkname=0 required by wlynxg/anet on GOOS=android), an `android` CI job with an 80 MB size budget, and .gitignore entries for the android project + keystores. Measured: libskywire-mobile.so android/arm64 = 63,242,536 bytes (63.2 MB raw / 21.2 MB gz), from 110.6 MB unstripped multicall and a 265.6 MB naive 5-binary payload. Desktop smoke verified: authenticated localhost API, all 4 apps in-proc (skysocks-client Running against a public proxy, browsing via 127.0.0.1:1080), exec fallback child, Fleet toggle off/on, per-app log feeds populated in-proc, RPC port closed. Test files reading stripped embeds are tagged !mobile (geoip, tpviz, wallet-handler, svc/geoip). --- .github/workflows/test.yml | 22 ++++++ .gitignore | 11 +++ Makefile | 39 ++++++++++ cmd/skywire-mobile/skywire-mobile.go | 75 +++++++++++++++++++ cmd/svc/geoip/commands/root_test.go | 6 ++ pkg/geoip/geoip.go | 50 +------------ pkg/geoip/geoip_embedded.go | 62 +++++++++++++++ pkg/geoip/geoip_embedded_mobile.go | 23 ++++++ pkg/geoip/geoip_test.go | 2 + pkg/tpviz/tpviz.go | 6 +- pkg/tpviz/tpviz_extra_test.go | 6 ++ pkg/tpviz/tpviz_legacy_off.go | 11 +++ pkg/tpviz/tpviz_legacy_on.go | 13 ++++ pkg/tpviz/tpviz_test.go | 2 + pkg/visor/hypervisor_dmsg_ingest.go | 18 +++++ pkg/visor/hypervisor_dmsg_ingest_mobile.go | 21 ++++++ pkg/visor/hypervisor_handlers_browse.go | 2 + .../hypervisor_handlers_browse_mobile.go | 49 ++++++++++++ pkg/visor/hypervisor_handlers_wallet.go | 2 + .../hypervisor_handlers_wallet_mobile.go | 27 +++++++ pkg/visor/hypervisor_handlers_wallet_test.go | 2 + pkg/visor/init_apps.go | 6 +- pkg/visor/static_embed.go | 14 ++++ pkg/visor/static_mobile.go | 13 ++++ pkg/visor/visor.go | 6 +- pkg/visor/visorconfig/hypervisorconfig.go | 6 ++ 26 files changed, 439 insertions(+), 55 deletions(-) create mode 100644 cmd/skywire-mobile/skywire-mobile.go create mode 100644 pkg/geoip/geoip_embedded.go create mode 100644 pkg/geoip/geoip_embedded_mobile.go create mode 100644 pkg/tpviz/tpviz_legacy_off.go create mode 100644 pkg/tpviz/tpviz_legacy_on.go create mode 100644 pkg/visor/hypervisor_dmsg_ingest.go create mode 100644 pkg/visor/hypervisor_dmsg_ingest_mobile.go create mode 100644 pkg/visor/hypervisor_handlers_browse_mobile.go create mode 100644 pkg/visor/hypervisor_handlers_wallet_mobile.go create mode 100644 pkg/visor/static_embed.go create mode 100644 pkg/visor/static_mobile.go diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4f6e8e2b01..bd5ebda3bf 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -217,6 +217,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 17dd6d2bc9..b20fd21e28 100644 --- a/.gitignore +++ b/.gitignore @@ -137,3 +137,14 @@ 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 diff --git a/Makefile b/Makefile index 277d8dded7..67597faed4 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 .PHONY : host-apps bin .PHONY : docker-image docker-clean docker-network .PHONY : docker-apps docker-bin docker-volume @@ -191,6 +192,44 @@ 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 + +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) ${BUILD_OPTS} -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) -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) -w -s -checklinkname=0" -mod=vendor -o $(ANDROID_JNILIBS)/libskywire-mobile.so ./cmd/skywire-mobile; \ + ls -la $(ANDROID_JNILIBS)/libskywire-mobile.so + build-merged-windows: clean-windows powershell '${OPTS} go build ${BUILD_OPTS} -o $(BUILD_PATH)skywire.exe .' diff --git a/cmd/skywire-mobile/skywire-mobile.go b/cmd/skywire-mobile/skywire-mobile.go new file mode 100644 index 0000000000..ba851af532 --- /dev/null +++ b/cmd/skywire-mobile/skywire-mobile.go @@ -0,0 +1,75 @@ +// Package main cmd/skywire-mobile/skywire-mobile.go c4-vis-cli +/* +skywire-mobile is the lite multicall core for the mobile app: the visor, the +CLI's `config` subtree and the four client apps in ONE binary, shipped to +Android as libskywire-mobile.so. It is a build VARIANT of this repo — build it +with `-tags mobile,withoutsystray` (make build-mobile / android-mobile), which +strips the embedded desktop assets (geoip db, manager UI, browser wallet, +tpviz) while keeping the visor's authenticated local API intact. + +Apps run in-process by default: importing the four app command packages below +registers their launcher entries (launcher.RegisterApp) at import time, so a +config entry with `binary: ""` resolves to the in-proc RunFunc. The `app` +subcommand is the per-app exec fallback — a config entry with a non-empty +`binary` execs ` app …`, and the whole pipeline (arg stripping, +isRawProcessApp) keys on the subcommand being named exactly "app". +*/ +package main + +import ( + "log" + + "github.com/spf13/cobra" + + sc "github.com/skycoin/skywire/cmd/apps/skychat/commands" + xc "github.com/skycoin/skywire/cmd/apps/skydex-client/commands" + ssc "github.com/skycoin/skywire/cmd/apps/skysocks-client/commands" + vpnc "github.com/skycoin/skywire/cmd/apps/vpn-client/commands" + cliconfig "github.com/skycoin/skywire/cmd/skywire-cli/commands/config" + "github.com/skycoin/skywire/pkg/buildinfo" + "github.com/skycoin/skywire/pkg/flags" + "github.com/skycoin/skywire/pkg/visor" +) + +// RootCmd is the skywire-mobile root: visor + config + the app subtree. +var RootCmd = &cobra.Command{ + Use: "skywire-mobile", + Short: "Skywire mobile core (visor + config + client apps)", + Long: `skywire-mobile — the lite skywire core for the mobile app: +the visor, config generation, and the skychat / skysocks-client / +vpn-client / skydex-client apps in one binary. Apps run in-process +inside the visor by default; 'app ' runs one standalone +(the launcher's exec fallback).`, + SilenceErrors: true, + SilenceUsage: true, + DisableSuggestions: true, + DisableFlagsInUseLine: true, + Version: buildinfo.Version(), +} + +// appsCmd MUST be named "app": the launcher's exec fallback runs +// ` app …` and isRawProcessApp keys on that args shape. +var appsCmd = &cobra.Command{ + Use: "app", + Short: "skywire native applications", +} + +func init() { + appsCmd.AddCommand(sc.RootCmd, ssc.RootCmd, vpnc.RootCmd, xc.RootCmd) + RootCmd.AddCommand(visor.RootCmd, cliconfig.RootCmd, appsCmd) + // visor.RootCmd computes its Use from os.Args[0]; pin the mounted name — + // the same post-mount rename cmd/skywire/commands does. + visor.RootCmd.Use = "visor" + // Install the flag-aware `help` on the visor subtree (as cmd/skywire's + // init loop does): visor.RootCmd is runnable with no Args validator, so + // without its own help command `visor help` would be swallowed as an + // ignored positional and START the visor. + flags.InstallHelp(visor.RootCmd) + flags.InitFlags(RootCmd, false) +} + +func main() { + if err := RootCmd.Execute(); err != nil { + log.Fatal("Failed to execute command: ", err) + } +} diff --git a/cmd/svc/geoip/commands/root_test.go b/cmd/svc/geoip/commands/root_test.go index 16c8870729..4b9ec2610e 100644 --- a/cmd/svc/geoip/commands/root_test.go +++ b/cmd/svc/geoip/commands/root_test.go @@ -4,6 +4,12 @@ // ipFromRequest header/remote-addr precedence, the HTTP API server // (startAPIServer driven end-to-end and shut down via SIGTERM), the command // metadata/flags, and the CLI Run path (subprocess) plus Execute's --help. +// +// !mobile: these tests exercise the embedded GeoLite2 DB, which the `mobile` +// build variant strips (pkg/geoip/geoip_embedded_mobile.go). + +//go:build !mobile + package commands import ( diff --git a/pkg/geoip/geoip.go b/pkg/geoip/geoip.go index 784130e0bc..636ff4141a 100644 --- a/pkg/geoip/geoip.go +++ b/pkg/geoip/geoip.go @@ -5,47 +5,16 @@ package geoip import ( - "bytes" - "compress/gzip" - _ "embed" "errors" "fmt" - "io" "net/netip" - "sync" "github.com/oschwald/geoip2-golang/v2" ) -// The GeoLite2-City database is committed gzipped (~30 MB vs ~62 MB raw) to keep -// the embedding binaries — every visor and dmsg-server — that much smaller. It -// is decompressed once, lazily, on first EmbeddedDB() call and cached. -// -//go:embed GeoLite2-City.mmdb.gz -var embeddedGz []byte - -var ( - embeddedOnce sync.Once - embedded []byte -) - -// EmbeddedDB returns the raw bytes of the embedded GeoLite2-City database, -// decompressed from the committed gzip on first call and cached thereafter. -// The standalone geoip service uses this when no external `--db` path is -// configured; the visor uses this for in-process lookups. Returns nil only if -// the committed blob fails to decompress (build/repo corruption), which callers -// surface as "database not initialized". -func EmbeddedDB() []byte { - embeddedOnce.Do(func() { - zr, err := gzip.NewReader(bytes.NewReader(embeddedGz)) - if err != nil { - return - } - defer zr.Close() //nolint:errcheck - embedded, _ = io.ReadAll(zr) - }) - return embedded -} +// The embedded database and its accessors (EmbeddedDB, OpenEmbedded) live in +// geoip_embedded.go; the `mobile` build variant replaces them with stubs +// (geoip_embedded_mobile.go) so the ~30 MB blob never ships to the phone. // Result is the schema returned by both the in-process Lookup and the // HTTP geoip service. The JSON tags match what `cmd/svc/geoip` returns @@ -103,16 +72,3 @@ func Lookup(db *geoip2.Reader, ipStr string) (*Result, error) { } return res, nil } - -// OpenEmbedded returns a *geoip2.Reader backed by the embedded database. -// Reuse the reader — it's safe for concurrent use. -// -// It calls EmbeddedDB() (not the bare `embedded` var) so the lazy gzip -// decompress is guaranteed to have run: since #3500 the DB is decompressed -// only inside EmbeddedDB()'s sync.Once, and a caller that reached -// OpenEmbedded() first would otherwise get OpenBytes(nil) → no database → -// empty geoip results (the regression that dropped every visor's country -// code from the node list on v1.3.85). -func OpenEmbedded() (*geoip2.Reader, error) { - return geoip2.OpenBytes(EmbeddedDB()) -} diff --git a/pkg/geoip/geoip_embedded.go b/pkg/geoip/geoip_embedded.go new file mode 100644 index 0000000000..5fc44462f8 --- /dev/null +++ b/pkg/geoip/geoip_embedded.go @@ -0,0 +1,62 @@ +//go:build !mobile + +// Package geoip pkg/geoip/geoip_embedded.go c0-com-util +package geoip + +import ( + "bytes" + "compress/gzip" + _ "embed" + "io" + "sync" + + "github.com/oschwald/geoip2-golang/v2" +) + +// The GeoLite2-City database is committed gzipped (~30 MB vs ~62 MB raw) to keep +// the embedding binaries — every visor and dmsg-server — that much smaller. It +// is decompressed once, lazily, on first EmbeddedDB() call and cached. +// +// The `mobile` build variant strips this embed entirely (geoip_embedded_mobile.go): +// both visor consumers already degrade — LookupGeo → nil +// (pkg/visor/init_services.go) and routing-policy geo.country() → "??" +// (pkg/visor/policy_provider.go). +// +//go:embed GeoLite2-City.mmdb.gz +var embeddedGz []byte + +var ( + embeddedOnce sync.Once + embedded []byte +) + +// EmbeddedDB returns the raw bytes of the embedded GeoLite2-City database, +// decompressed from the committed gzip on first call and cached thereafter. +// The standalone geoip service uses this when no external `--db` path is +// configured; the visor uses this for in-process lookups. Returns nil only if +// the committed blob fails to decompress (build/repo corruption), which callers +// surface as "database not initialized". +func EmbeddedDB() []byte { + embeddedOnce.Do(func() { + zr, err := gzip.NewReader(bytes.NewReader(embeddedGz)) + if err != nil { + return + } + defer zr.Close() //nolint:errcheck + embedded, _ = io.ReadAll(zr) + }) + return embedded +} + +// OpenEmbedded returns a *geoip2.Reader backed by the embedded database. +// Reuse the reader — it's safe for concurrent use. +// +// It calls EmbeddedDB() (not the bare `embedded` var) so the lazy gzip +// decompress is guaranteed to have run: since #3500 the DB is decompressed +// only inside EmbeddedDB()'s sync.Once, and a caller that reached +// OpenEmbedded() first would otherwise get OpenBytes(nil) → no database → +// empty geoip results (the regression that dropped every visor's country +// code from the node list on v1.3.85). +func OpenEmbedded() (*geoip2.Reader, error) { + return geoip2.OpenBytes(EmbeddedDB()) +} diff --git a/pkg/geoip/geoip_embedded_mobile.go b/pkg/geoip/geoip_embedded_mobile.go new file mode 100644 index 0000000000..3e19335360 --- /dev/null +++ b/pkg/geoip/geoip_embedded_mobile.go @@ -0,0 +1,23 @@ +//go:build mobile + +// Package geoip pkg/geoip/geoip_embedded_mobile.go c0-com-util +package geoip + +import ( + "errors" + + "github.com/oschwald/geoip2-golang/v2" +) + +// The mobile build strips the ~30 MB embedded GeoLite2-City database — the +// single largest asset in the visor. Both visor consumers already degrade +// gracefully on the error path: LookupGeo → nil (pkg/visor/init_services.go) +// and routing-policy geo.country() → "??" (pkg/visor/policy_provider.go). + +// EmbeddedDB returns nil: no database is embedded in the mobile build. +func EmbeddedDB() []byte { return nil } + +// OpenEmbedded reports the database absent in the mobile build. +func OpenEmbedded() (*geoip2.Reader, error) { + return nil, errors.New("geoip: embedded database not included in this build") +} diff --git a/pkg/geoip/geoip_test.go b/pkg/geoip/geoip_test.go index a45674edfe..48e1060824 100644 --- a/pkg/geoip/geoip_test.go +++ b/pkg/geoip/geoip_test.go @@ -1,3 +1,5 @@ +//go:build !mobile + package geoip import ( diff --git a/pkg/tpviz/tpviz.go b/pkg/tpviz/tpviz.go index 6c8e9c8a82..93cd1f246a 100644 --- a/pkg/tpviz/tpviz.go +++ b/pkg/tpviz/tpviz.go @@ -4,7 +4,6 @@ package tpviz import ( "bytes" "context" - "embed" "encoding/json" "errors" "fmt" @@ -32,8 +31,9 @@ import ( tptypes "github.com/skycoin/skywire/pkg/transport/types" ) -//go:embed legacy/* -var legacyFS embed.FS +// legacyFS (the embedded legacy JavaScript UI, `//go:embed legacy/*`) lives in +// tpviz_legacy_on.go; the `mobile` build variant leaves it empty +// (tpviz_legacy_off.go) so the ~2.8 MB bundle stays out of the phone binary. // wasmDistFS holds the experimental WebGL/WASM tpviz view (dist/*). It is only // populated in a build with `-tags tpvizwasm` (see tpviz_wasm_on.go); the diff --git a/pkg/tpviz/tpviz_extra_test.go b/pkg/tpviz/tpviz_extra_test.go index c8b23399c2..17c7fdcd7b 100644 --- a/pkg/tpviz/tpviz_extra_test.go +++ b/pkg/tpviz/tpviz_extra_test.go @@ -2,6 +2,12 @@ // // Coverage for the CXO-subscriber integration, geoip/IP-group caches, // the embedded-visor refresh path, and the server lifecycle helpers. +// +// !mobile: shares helpers with tpviz_test.go, which is excluded from the +// `mobile` build variant (its tests read the stripped legacyFS embed). + +//go:build !mobile + package tpviz import ( diff --git a/pkg/tpviz/tpviz_legacy_off.go b/pkg/tpviz/tpviz_legacy_off.go new file mode 100644 index 0000000000..78e8ad08d8 --- /dev/null +++ b/pkg/tpviz/tpviz_legacy_off.go @@ -0,0 +1,11 @@ +//go:build mobile + +// Package tpviz pkg/tpviz/tpviz_legacy_off.go c4-app-rewards +package tpviz + +import "embed" + +// Mobile build: the legacy tpviz UI (~2.8 MB) is NOT embedded — the phone app +// never renders tpviz. legacyFS stays empty so every ReadFile on it errors and +// the tp-viz routes answer 404/500, mirroring the tpviz_wasm_off.go pattern. +var legacyFS embed.FS diff --git a/pkg/tpviz/tpviz_legacy_on.go b/pkg/tpviz/tpviz_legacy_on.go new file mode 100644 index 0000000000..a358d02c44 --- /dev/null +++ b/pkg/tpviz/tpviz_legacy_on.go @@ -0,0 +1,13 @@ +//go:build !mobile + +// Package tpviz pkg/tpviz/tpviz_legacy_on.go c4-app-rewards +package tpviz + +import "embed" + +// legacyFS holds the legacy JavaScript tpviz UI (~2.8 MB: index.html, +// bundle.js, textures). The `mobile` build variant leaves it empty +// (tpviz_legacy_off.go) — same pattern as the tpvizwasm pair. +// +//go:embed legacy/* +var legacyFS embed.FS diff --git a/pkg/tpviz/tpviz_test.go b/pkg/tpviz/tpviz_test.go index 65591f0bd6..682698ed6f 100644 --- a/pkg/tpviz/tpviz_test.go +++ b/pkg/tpviz/tpviz_test.go @@ -1,3 +1,5 @@ +//go:build !mobile + // Package tpviz pkg/tpviz/tpviz_test.go package tpviz diff --git a/pkg/visor/hypervisor_dmsg_ingest.go b/pkg/visor/hypervisor_dmsg_ingest.go new file mode 100644 index 0000000000..1f8acecf6b --- /dev/null +++ b/pkg/visor/hypervisor_dmsg_ingest.go @@ -0,0 +1,18 @@ +//go:build !mobile + +// Package visor pkg/visor/hypervisor_dmsg_ingest.go c3-vis-core +package visor + +import ( + "github.com/skycoin/skywire/pkg/dmsg/dmsg" + "github.com/skycoin/skywire/pkg/visor/visorconfig" +) + +// hypervisorIngestClient returns the dmsg client the hypervisor serves its +// remote-visor RPC ingest on (Hypervisor.Enable only starts ServeRPC when the +// client is non-nil). Desktop builds always hand it over — a configured +// hypervisor manages remote visors. The mobile pair gates it behind +// hypervisor.dmsg_ingest (the Fleet opt-in). +func (v *Visor) hypervisorIngestClient(_ *visorconfig.HypervisorConfig) *dmsg.Client { + return v.dmsgC +} diff --git a/pkg/visor/hypervisor_dmsg_ingest_mobile.go b/pkg/visor/hypervisor_dmsg_ingest_mobile.go new file mode 100644 index 0000000000..2530c0366f --- /dev/null +++ b/pkg/visor/hypervisor_dmsg_ingest_mobile.go @@ -0,0 +1,21 @@ +//go:build mobile + +// Package visor pkg/visor/hypervisor_dmsg_ingest_mobile.go c3-vis-core +package visor + +import ( + "github.com/skycoin/skywire/pkg/dmsg/dmsg" + "github.com/skycoin/skywire/pkg/visor/visorconfig" +) + +// hypervisorIngestClient: on the mobile build the remote-visor dmsg ingest is +// OFF unless the user flips the Fleet opt-in (hypervisor.dmsg_ingest). With a +// nil client Hypervisor.Enable never starts ServeRPC, so the phone core stays +// an API-only localhost surface — no dmsg listener for remote visors, no +// battery cost. The local HTTP API (startUI) is independent of this client. +func (v *Visor) hypervisorIngestClient(conf *visorconfig.HypervisorConfig) *dmsg.Client { + if conf.DmsgIngest { + return v.dmsgC + } + return nil +} diff --git a/pkg/visor/hypervisor_handlers_browse.go b/pkg/visor/hypervisor_handlers_browse.go index b61c502796..43b77a9334 100644 --- a/pkg/visor/hypervisor_handlers_browse.go +++ b/pkg/visor/hypervisor_handlers_browse.go @@ -1,3 +1,5 @@ +//go:build !mobile + // Package visor pkg/visor/hypervisor_handlers_browse.go c3-vis-core // browse.js virtual-browser engine in the NATIVE hypervisor UI (the same one the // wasm-visor runs), backed by /api/browse/* instead of the wasm JS hooks. diff --git a/pkg/visor/hypervisor_handlers_browse_mobile.go b/pkg/visor/hypervisor_handlers_browse_mobile.go new file mode 100644 index 0000000000..cd83319239 --- /dev/null +++ b/pkg/visor/hypervisor_handlers_browse_mobile.go @@ -0,0 +1,49 @@ +//go:build mobile + +// Package visor pkg/visor/hypervisor_handlers_browse_mobile.go c3-vis-core +// Mobile build: the in-browser mesh browser and the embedded manager UI are +// desktop features. The /api/* surface is untouched; "/" serves a one-line +// identification page so a health-check in a browser shows the build is alive. +package visor + +import "net/http" + +const mobileIndexHTML = `skywire-mobile` + + `skywire-mobile core — API-only build. The manager UI is not included; ` + + `the visor's authenticated local API is served under /api/.` + +// postBrowseFetch → 404: the in-UI mesh browser is not part of the mobile build. +func (hv *Hypervisor) postBrowseFetch() http.HandlerFunc { + return browseNotAvailable +} + +// postBrowseClearnet → 404: the in-UI mesh browser is not part of the mobile build. +func (hv *Hypervisor) postBrowseClearnet() http.HandlerFunc { + return browseNotAvailable +} + +func browseNotAvailable(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "browse is not available in this build", http.StatusNotFound) +} + +// uiHandler replaces the embedded manager UI: "/" identifies the build; every +// other non-API path 404s (the API routes are matched before this catch-all). +func (hv *Hypervisor) uiHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/" || r.URL.Path == "/index.html" { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache") + _, _ = w.Write([]byte(mobileIndexHTML)) //nolint:errcheck + return + } + http.NotFound(w, r) + }) +} + +// getUIVersion answers an empty fingerprint: there is no embedded UI to version. +func (hv *Hypervisor) getUIVersion() http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.Header().Set("Cache-Control", "no-store") + } +} diff --git a/pkg/visor/hypervisor_handlers_wallet.go b/pkg/visor/hypervisor_handlers_wallet.go index 6170660855..b7031c0735 100644 --- a/pkg/visor/hypervisor_handlers_wallet.go +++ b/pkg/visor/hypervisor_handlers_wallet.go @@ -1,3 +1,5 @@ +//go:build !mobile + // Package visor pkg/visor/hypervisor_handlers_wallet.go c3-vis-core // wallet ("wallet HV-served" mode, docs/design/gui-app-serving-modes.md). // diff --git a/pkg/visor/hypervisor_handlers_wallet_mobile.go b/pkg/visor/hypervisor_handlers_wallet_mobile.go new file mode 100644 index 0000000000..28baa8ddff --- /dev/null +++ b/pkg/visor/hypervisor_handlers_wallet_mobile.go @@ -0,0 +1,27 @@ +//go:build mobile + +// Package visor pkg/visor/hypervisor_handlers_wallet_mobile.go c3-vis-core +// Mobile build: the browser wallet is desktop-only. Tagging out the real +// handler file drops the vendored skycoin-web bundle (~11 MB, its only +// importer) from the binary — the phone wallet is native app-side code and +// adds no Go payload. +package visor + +import ( + "errors" + "io/fs" + "net/http" +) + +// WalletUIFS reports the wallet bundle absent on the mobile build (same +// message the desktop handler serves when its embed is missing). +func WalletUIFS() (fs.FS, error) { + return nil, errors.New("wallet UI not embedded in this build") +} + +// walletHandler answers 404 for the whole /wallet/* mount on the mobile build. +func (hv *Hypervisor) walletHandler() http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "wallet UI not embedded in this build", http.StatusNotFound) + } +} diff --git a/pkg/visor/hypervisor_handlers_wallet_test.go b/pkg/visor/hypervisor_handlers_wallet_test.go index 5fa0b2c600..88a3f35f2c 100644 --- a/pkg/visor/hypervisor_handlers_wallet_test.go +++ b/pkg/visor/hypervisor_handlers_wallet_test.go @@ -1,3 +1,5 @@ +//go:build !mobile + package visor import ( diff --git a/pkg/visor/init_apps.go b/pkg/visor/init_apps.go index 0d98ea7030..01e3aab160 100644 --- a/pkg/visor/init_apps.go +++ b/pkg/visor/init_apps.go @@ -944,8 +944,10 @@ func initHypervisor(ctx context.Context, v *Visor, log *logging.Logger) error { conf.SK = v.conf.SK conf.DmsgDiscovery = v.conf.Dmsg.Discovery - // Prepare hypervisor. - hv, err := NewHypervisor(conf, v, v.dmsgC) + // Prepare hypervisor. The dmsg client feeds the remote-visor RPC ingest; + // hypervisorIngestClient is tag-paired — desktop always passes it, the + // mobile build passes nil unless hypervisor.dmsg_ingest (Fleet) is set. + hv, err := NewHypervisor(conf, v, v.hypervisorIngestClient(&conf)) if err != nil { return fmt.Errorf("failed to start hypervisor: %w", err) } diff --git a/pkg/visor/static_embed.go b/pkg/visor/static_embed.go new file mode 100644 index 0000000000..3e1e4f62ec --- /dev/null +++ b/pkg/visor/static_embed.go @@ -0,0 +1,14 @@ +//go:build !mobile + +// Package visor pkg/visor/static_embed.go c3-vis-core +package visor + +import "embed" + +// ui holds the built Angular manager UI (static/, ~6.8 MB), served by +// uiHandler at "/" and exported via HypervisorUIFS (visor.go). The `mobile` +// build variant replaces it with an empty FS (static_mobile.go) — the phone +// core is API-only. +// +//go:embed static +var ui embed.FS diff --git a/pkg/visor/static_mobile.go b/pkg/visor/static_mobile.go new file mode 100644 index 0000000000..72c1807b96 --- /dev/null +++ b/pkg/visor/static_mobile.go @@ -0,0 +1,13 @@ +//go:build mobile + +// Package visor pkg/visor/static_mobile.go c3-vis-core +package visor + +import "embed" + +// ui is EMPTY on the mobile build: the ~6.8 MB manager UI never ships to the +// phone. It stays a valid embed.FS VALUE (not nil) on purpose — initUI()'s +// fs.Sub still yields a usable empty FS, so the `*uiAssets == nil` fatal in +// cmd.go never trips when a hypervisor config section is present. "/" is +// served by the mobile uiHandler stub (hypervisor_handlers_browse_mobile.go). +var ui embed.FS diff --git a/pkg/visor/visor.go b/pkg/visor/visor.go index 39bde41d5b..81ec782acf 100644 --- a/pkg/visor/visor.go +++ b/pkg/visor/visor.go @@ -3,7 +3,6 @@ package visor import ( "context" - "embed" "errors" "fmt" "io/fs" @@ -1044,8 +1043,9 @@ func (v *Visor) FetchAllTransportEntries(ctx context.Context) ([]*transport.Entr return entries, nil } -//go:embed static -var ui embed.FS +// The embedded manager UI (`//go:embed static` → var ui) lives in +// static_embed.go; the `mobile` build variant replaces it with an empty FS +// (static_mobile.go) so the ~6.8 MB bundle never ships to the phone. // HypervisorUIFS returns the embedded Angular hypervisor UI filesystem (the // built static/ assets). Exposed for consumers like the standalone-html diff --git a/pkg/visor/visorconfig/hypervisorconfig.go b/pkg/visor/visorconfig/hypervisorconfig.go index 50995e8f79..7665c00421 100644 --- a/pkg/visor/visorconfig/hypervisorconfig.go +++ b/pkg/visor/visorconfig/hypervisorconfig.go @@ -56,6 +56,12 @@ func (hk *Key) UnmarshalText(text []byte) error { // HypervisorConfig configures the hypervisor. type HypervisorConfig struct { Enable bool `json:"enable"` // Whether the hypervisor is enabled (starts DMSG RPC + tracking + web UI on visor startup). + // DmsgIngest is read by MOBILE builds only: it opts in to the remote-visor + // dmsg RPC ingest — the listener other visors connect to when this PK is in + // their `hypervisors` list (the phone app's Fleet toggle). Off by default so + // the mobile core stays an API-only localhost surface. Desktop builds ignore + // it: there the ingest always starts with the hypervisor, as before. + DmsgIngest bool `json:"dmsg_ingest,omitempty"` // UIDisable, when true, suppresses ONLY the web UI (HTTP) server while the // hypervisor's DMSG-RPC listener + managed-visor tracking (and `hv ls` over // the visor RPC) stay active. Toggled at runtime with `hv ui enable/disable`. From 2ffaae8078f67e4b1f5c0a6777b9663f0b277f77 Mon Sep 17 00:00:00 2001 From: "MohammadReza P." Date: Mon, 3 Aug 2026 10:45:26 +0800 Subject: [PATCH 02/37] fix(mobile): stamp a semver-safe version into pkg/buildinfo for skywire-mobile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 0's on-device smoke exposed that /api/about and --version read the repo-local pkg/buildinfo, which the mobile targets didn't stamp — and that stamping it with the bare short hash `git describe --always` yields on a tagless checkout FATALs the visor at startup (visorconfig.Parse semver-checks pkg/buildinfo.version). Add MOBILE_VERSION (git describe --tags, falling back to v0.0.0-) and stamp both buildinfo paths in build-mobile / android-mobile / android-mobile-ndk. Alias the misleadingly named WASM_BUILDINFO_PATH as SKYWIRE_BUILDINFO_PATH — it is the repo's own pkg/buildinfo, nothing wasm-specific. Step 0 on-device smoke (DM-B70104, arm64, Android 15) passed with the NDK-cgo binary: on-device config gen (bionic DNS OK), dmsg connected, skychat + skydex-client + skysocks-client Running in-proc in one process (~88 MB RSS), all listeners loopback-only, mesh browsing verified from the host through the phone's SOCKS5. Sizes recorded: pure-Go 63,242,536 B, NDK-cgo 63,237,504 B (both 21.2 MB gz). vpn-client fails at the Linux route code as expected — Step 6 android glue is the fix. --- Makefile | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index 67597faed4..9d1d8b2caa 100644 --- a/Makefile +++ b/Makefile @@ -101,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 @@ -205,12 +211,20 @@ MOBILE_TAGS := mobile,withoutsystray # 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) ${BUILD_OPTS} -o $(BUILD_PATH)skywire-mobile ./cmd/skywire-mobile + ${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) -w -s -checklinkname=0" -mod=vendor -o $(ANDROID_JNILIBS)/libskywire-mobile.so ./cmd/skywire-mobile + 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 @@ -227,7 +241,7 @@ android-mobile-ndk: ## Release lane: NDK/cgo android build (DNS via bionic getad 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) -w -s -checklinkname=0" -mod=vendor -o $(ANDROID_JNILIBS)/libskywire-mobile.so ./cmd/skywire-mobile; \ + 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 build-merged-windows: clean-windows From 238f05dd62009e95047b8bf622850ad32eacf34d Mon Sep 17 00:00:00 2001 From: "MohammadReza P." Date: Mon, 3 Aug 2026 11:34:29 +0800 Subject: [PATCH 03/37] =?UTF-8?q?feat(android):=20app=20skeleton=20?= =?UTF-8?q?=E2=80=94=20theme,=20splash,=20bottom=20bar,=20apps=20hub?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New android/ Gradle project (Android Studio opens this dir): Kotlin / Jetpack Compose / Material 3, single Activity. Gradle 9.6.1 (committed wrapper), AGP 9.3.1 (built-in Kotlin — no kotlin.android plugin), Kotlin 2.4.10, Compose BOM 2026.06.01; minSdk 26, target 36, arm64-only. - Brand theme #0072FF: white/black backgrounds, near-black dark surfaces, dynamic color off; container roles overridden so no M3 baseline purple. - Splash (core-splashscreen): Skycoin mark on white/black, short fade. - Bottom bar, 5 slots: Home · Chat · Skycoin mark (icon-only, opens the apps hub) · Wallet · Settings; hub grid with SkySOCKS/SkyVPN/SkyDEX/ SkyChat/Wallet/Fleet tiles + SkyMeet & Pay-with-Sky "coming soon"; SkySOCKS/SkyVPN/SkyDEX/Fleet push full-screen routes, back returns to the hub. Icons are Material placeholders behind Painter params; the Skycoin mark vector is a geometric placeholder awaiting the brand SVG. - Placeholder BiometricGate so the nav structure is final from day one. - jniLibs packaging: useLegacyPackaging=true (the core service execs libskywire-mobile.so from nativeLibraryDir — needs extracted libs). - Docs: android/README.md (build/run/test); android/implementation-report.md (dated progress log). - Makefile: android-apk / android-apk-debug bridge targets; mobile targets now also stamp the repo-local pkg/buildinfo (MOBILE_APPINFO) with a semver-safe fallback version — a bare-hash stamp fatals the visor at startup (visorconfig.Parse semver-checks it). Verified on a real device (arm64, Android 15): splash → themed Home; all five bar destinations switch; hub tiles push and back returns to the hub; light + dark checked via screenshots. --- Makefile | 11 +- android/.gitignore | 13 + android/README.md | 143 ++++++++++ android/app/build.gradle.kts | 71 +++++ android/app/proguard-rules.pro | 2 + android/app/src/main/AndroidManifest.xml | 35 +++ .../java/com/skycoin/skywire/MainActivity.kt | 36 +++ .../com/skycoin/skywire/SkywireApplication.kt | 9 + .../java/com/skycoin/skywire/ui/SkywireApp.kt | 129 +++++++++ .../com/skycoin/skywire/ui/chat/ChatScreen.kt | 15 ++ .../skywire/ui/components/BiometricGate.kt | 15 ++ .../ui/components/PlaceholderScreens.kt | 74 ++++++ .../com/skycoin/skywire/ui/dex/DexScreen.kt | 16 ++ .../skycoin/skywire/ui/fleet/FleetScreen.kt | 16 ++ .../com/skycoin/skywire/ui/home/HomeScreen.kt | 52 ++++ .../com/skycoin/skywire/ui/hub/HubScreen.kt | 117 +++++++++ .../skycoin/skywire/ui/navigation/Routes.kt | 22 ++ .../skywire/ui/settings/SettingsScreen.kt | 15 ++ .../skycoin/skywire/ui/socks/SocksScreen.kt | 16 ++ .../com/skycoin/skywire/ui/theme/Theme.kt | 74 ++++++ .../com/skycoin/skywire/ui/vpn/VpnScreen.kt | 16 ++ .../skycoin/skywire/ui/wallet/WalletScreen.kt | 15 ++ .../res/drawable/ic_launcher_foreground.xml | 23 ++ .../src/main/res/drawable/ic_skycoin_mark.xml | 20 ++ .../src/main/res/drawable/ic_splash_mark.xml | 24 ++ .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + .../app/src/main/res/values-night/themes.xml | 12 + android/app/src/main/res/values/colors.xml | 4 + android/app/src/main/res/values/strings.xml | 32 +++ android/app/src/main/res/values/themes.xml | 16 ++ android/build.gradle.kts | 7 + android/gradle.properties | 5 + android/gradle/libs.versions.toml | 36 +++ android/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 48462 bytes .../gradle/wrapper/gradle-wrapper.properties | 9 + android/gradlew | 248 ++++++++++++++++++ android/gradlew.bat | 82 ++++++ android/implementation-report.md | 101 +++++++ android/settings.gradle.kts | 22 ++ 40 files changed, 1562 insertions(+), 1 deletion(-) create mode 100644 android/.gitignore create mode 100644 android/README.md create mode 100644 android/app/build.gradle.kts create mode 100644 android/app/proguard-rules.pro create mode 100644 android/app/src/main/AndroidManifest.xml create mode 100644 android/app/src/main/java/com/skycoin/skywire/MainActivity.kt create mode 100644 android/app/src/main/java/com/skycoin/skywire/SkywireApplication.kt create mode 100644 android/app/src/main/java/com/skycoin/skywire/ui/SkywireApp.kt create mode 100644 android/app/src/main/java/com/skycoin/skywire/ui/chat/ChatScreen.kt create mode 100644 android/app/src/main/java/com/skycoin/skywire/ui/components/BiometricGate.kt create mode 100644 android/app/src/main/java/com/skycoin/skywire/ui/components/PlaceholderScreens.kt create mode 100644 android/app/src/main/java/com/skycoin/skywire/ui/dex/DexScreen.kt create mode 100644 android/app/src/main/java/com/skycoin/skywire/ui/fleet/FleetScreen.kt create mode 100644 android/app/src/main/java/com/skycoin/skywire/ui/home/HomeScreen.kt create mode 100644 android/app/src/main/java/com/skycoin/skywire/ui/hub/HubScreen.kt create mode 100644 android/app/src/main/java/com/skycoin/skywire/ui/navigation/Routes.kt create mode 100644 android/app/src/main/java/com/skycoin/skywire/ui/settings/SettingsScreen.kt create mode 100644 android/app/src/main/java/com/skycoin/skywire/ui/socks/SocksScreen.kt create mode 100644 android/app/src/main/java/com/skycoin/skywire/ui/theme/Theme.kt create mode 100644 android/app/src/main/java/com/skycoin/skywire/ui/vpn/VpnScreen.kt create mode 100644 android/app/src/main/java/com/skycoin/skywire/ui/wallet/WalletScreen.kt create mode 100644 android/app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 android/app/src/main/res/drawable/ic_skycoin_mark.xml create mode 100644 android/app/src/main/res/drawable/ic_splash_mark.xml create mode 100644 android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 android/app/src/main/res/values-night/themes.xml create mode 100644 android/app/src/main/res/values/colors.xml create mode 100644 android/app/src/main/res/values/strings.xml create mode 100644 android/app/src/main/res/values/themes.xml create mode 100644 android/build.gradle.kts create mode 100644 android/gradle.properties create mode 100644 android/gradle/libs.versions.toml create mode 100644 android/gradle/wrapper/gradle-wrapper.jar create mode 100644 android/gradle/wrapper/gradle-wrapper.properties create mode 100755 android/gradlew create mode 100644 android/gradlew.bat create mode 100644 android/implementation-report.md create mode 100644 android/settings.gradle.kts diff --git a/Makefile b/Makefile index 9d1d8b2caa..da835cb9e8 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +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 +.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 @@ -244,6 +244,15 @@ android-mobile-ndk: ## Release lane: NDK/cgo android build (DNS via bionic getad 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 + +android-apk: ## Build the Android APK (release; unsigned until release signing lands) — run android-mobile-ndk first for a fresh Go payload + cd android && JAVA_HOME="$(ANDROID_JAVA_HOME)" ./gradlew assembleRelease + +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..c66bd96367 --- /dev/null +++ b/android/README.md @@ -0,0 +1,143 @@ +# 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. + +## Testing & debugging + +App logs: + +```sh +adb logcat --pid=$(adb shell pidof -s com.skycoin.skywire) +``` + +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 +``` + +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 + │ └── ui/ # theme/, navigation/, components/, + │ # home/ chat/ hub/ socks/ vpn/ dex/ + │ # fleet/ wallet/ settings/ + ├── res/ # splash + mark vectors, 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..4226b1dee3 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,71 @@ +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) +} + +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 = 1 + versionName = "0.1.0" + ndk { + // The Go payload (libskywire-mobile.so) is arm64-only. + abiFilters += "arm64-v8a" + } + } + + buildTypes { + release { + // Minification/shrinking is deferred to release hardening. + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + + 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) + // Local-API client — declared now so the module dep set is final. + implementation(libs.okhttp) + implementation(libs.kotlinx.serialization.json) + 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..fac5574abc --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + 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..af7826cca2 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/MainActivity.kt @@ -0,0 +1,36 @@ +package com.skycoin.skywire + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +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. + */ +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + val splash = installSplashScreen() + super.onCreate(savedInstanceState) + enableEdgeToEdge() + // Short fade from the logo splash into Home. + splash.setOnExitAnimationListener { provider -> + provider.view.animate() + .alpha(0f) + .setDuration(250L) + .withEndAction { provider.remove() } + .start() + } + setContent { + SkywireTheme { + BiometricGate { + SkywireApp() + } + } + } + } +} 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/ui/SkywireApp.kt b/android/app/src/main/java/com/skycoin/skywire/ui/SkywireApp.kt new file mode 100644 index 0000000000..ee0858b198 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/SkywireApp.kt @@ -0,0 +1,129 @@ +package com.skycoin.skywire.ui + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.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.material3.Icon +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import com.skycoin.skywire.R +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.navigation.Routes +import com.skycoin.skywire.ui.settings.SettingsScreen +import com.skycoin.skywire.ui.socks.SocksScreen +import com.skycoin.skywire.ui.vpn.VpnScreen +import com.skycoin.skywire.ui.wallet.WalletScreen + +/** + * Main scaffold: bottom NavigationBar with 5 slots (left→right): + * Home · Chat · Skycoin logo (apps hub, icon-only) · Wallet · Settings. + * Bar items and hub tiles take a [Painter] so the designed logos drop in + * later with zero layout change. + */ +private data class BarSlot( + val route: String, + val label: String?, // null = icon-only (the center logo slot) + val icon: @Composable () -> Painter, + val isLogo: Boolean = false, +) + +@Composable +fun SkywireApp() { + val navController = rememberNavController() + val slots = listOf( + BarSlot(Routes.HOME, stringResource(R.string.tab_home), { rememberVectorPainter(Icons.Outlined.Home) }), + BarSlot(Routes.CHAT, stringResource(R.string.tab_chat), { rememberVectorPainter(Icons.AutoMirrored.Outlined.Chat) }), + // Center slot: the Skycoin mark only — no label — opens the apps hub. + BarSlot(Routes.HUB, null, { painterResource(R.drawable.ic_skycoin_mark) }, isLogo = true), + BarSlot(Routes.WALLET, stringResource(R.string.tab_wallet), { rememberVectorPainter(Icons.Outlined.AccountBalanceWallet) }), + BarSlot(Routes.SETTINGS, stringResource(R.string.tab_settings), { rememberVectorPainter(Icons.Outlined.Settings) }), + ) + + val backStackEntry by navController.currentBackStackEntryAsState() + val currentRoute = backStackEntry?.destination?.route + + Scaffold( + bottomBar = { + NavigationBar { + slots.forEach { slot -> + NavigationBarItem( + selected = currentRoute == slot.route || + (slot.route == Routes.HUB && currentRoute in Routes.hubPushed), + onClick = { navController.navigateToTab(slot.route) }, + icon = { + Icon( + painter = slot.icon(), + contentDescription = slot.label + ?: stringResource(R.string.tab_hub_description), + // Logo slot slightly enlarged; tint = selected state + // comes from NavigationBarItem's default colors. + modifier = Modifier.size(if (slot.isLogo) 32.dp else 24.dp), + ) + }, + label = slot.label?.let { { Text(it) } }, + ) + } + } + }, + ) { innerPadding -> + NavHost( + navController = navController, + startDestination = Routes.HOME, + modifier = Modifier.padding(innerPadding), + ) { + composable(Routes.HOME) { HomeScreen() } + composable(Routes.CHAT) { ChatScreen() } + composable(Routes.HUB) { + HubScreen( + onOpenRoute = { navController.navigate(it) }, + onOpenTab = { navController.navigateToTab(it) }, + ) + } + composable(Routes.WALLET) { WalletScreen() } + composable(Routes.SETTINGS) { SettingsScreen() } + + // 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() }) } + } + } +} + +/** + * 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 + } +} 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..83c0f98c88 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/chat/ChatScreen.kt @@ -0,0 +1,15 @@ +package com.skycoin.skywire.ui.chat + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.skycoin.skywire.R +import com.skycoin.skywire.ui.components.PlaceholderTab + +/** Chat tab — the embedded skychat web UI loads here (WebView) when it lands. */ +@Composable +fun ChatScreen() { + PlaceholderTab( + title = stringResource(R.string.app_skychat), + subtitle = stringResource(R.string.chat_placeholder), + ) +} 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..ac00e0d80b --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/components/BiometricGate.kt @@ -0,0 +1,15 @@ +package com.skycoin.skywire.ui.components + +import androidx.compose.runtime.Composable + +/** + * Placeholder biometric gate so the navigation structure is final from day one. + * The real BiometricPrompt flow (BIOMETRIC_STRONG | DEVICE_CREDENTIAL, launch + + * return-from-background with a grace period) replaces the body with the + * settings/app-lock work; the wallet reuses the same helper for seed reveal + * and send confirmation. + */ +@Composable +fun BiometricGate(content: @Composable () -> Unit) { + content() +} 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..121dd70b69 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/components/PlaceholderScreens.kt @@ -0,0 +1,74 @@ +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.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +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.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.skycoin.skywire.R + +/** 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, + ) + } +} + +/** + * Shared scaffold for the full-screen app routes pushed from the hub + * (SkySOCKS / SkyVPN / SkyDEX / Fleet): top bar with back, placeholder body. + * Feature work replaces the body; a `Logs` app-bar action joins every app + * screen so each app's log feed is one tap away. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AppRouteScaffold(title: String, subtitle: String, onBack: () -> Unit) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(title) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.back), + ) + } + }, + ) + }, + ) { padding -> + Column(modifier = Modifier.padding(padding)) { + PlaceholderTab(title, subtitle) + } + } +} 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..1e293cb9c1 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/dex/DexScreen.kt @@ -0,0 +1,16 @@ +package com.skycoin.skywire.ui.dex + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.skycoin.skywire.R +import com.skycoin.skywire.ui.components.AppRouteScaffold + +/** SkyDEX route — market PK entry + embedded skydex UI WebView. */ +@Composable +fun DexScreen(onBack: () -> Unit) { + AppRouteScaffold( + title = stringResource(R.string.app_skydex), + subtitle = stringResource(R.string.dex_placeholder), + onBack = onBack, + ) +} 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..1e1c906bb8 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/fleet/FleetScreen.kt @@ -0,0 +1,16 @@ +package com.skycoin.skywire.ui.fleet + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.skycoin.skywire.R +import com.skycoin.skywire.ui.components.AppRouteScaffold + +/** Fleet route — opt-in dmsg ingest toggle + status-only visor list. */ +@Composable +fun FleetScreen(onBack: () -> Unit) { + AppRouteScaffold( + title = stringResource(R.string.app_fleet), + subtitle = stringResource(R.string.fleet_placeholder), + onBack = onBack, + ) +} 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..57502b367c --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/home/HomeScreen.kt @@ -0,0 +1,52 @@ +package com.skycoin.skywire.ui.home + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Button +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.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.skycoin.skywire.R + +/** + * Home tab. The core-service work turns the Connect button into the real core + * start (foreground service → poll /api/ping → Disconnected/Starting/Connected) + * and adds the visor-info card below it. For now it is the themed shell. + */ +@Composable +fun HomeScreen() { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Button( + onClick = { /* starts SkywireCoreService once the core service lands */ }, + enabled = false, + shape = CircleShape, + modifier = Modifier.size(160.dp), + ) { + Text(stringResource(R.string.connect), style = MaterialTheme.typography.titleLarge) + } + Spacer(Modifier.height(24.dp)) + Text( + text = stringResource(R.string.home_placeholder), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } +} 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..e67a7883e3 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/hub/HubScreen.kt @@ -0,0 +1,117 @@ +package com.skycoin.skywire.ui.hub + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.Chat +import androidx.compose.material.icons.outlined.AccountBalanceWallet +import androidx.compose.material.icons.outlined.AltRoute +import androidx.compose.material.icons.outlined.CandlestickChart +import androidx.compose.material.icons.outlined.Contactless +import androidx.compose.material.icons.outlined.Hub +import androidx.compose.material.icons.outlined.Videocam +import androidx.compose.material.icons.outlined.VpnLock +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.rememberVectorPainter +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.ui.navigation.Routes + +/** + * The apps hub behind the center Skycoin-logo slot: adaptive grid of rounded + * tiles. SkyMeet and Pay-with-Sky are greyed "coming soon" tiles with no click + * action. Tiles accept any [Painter] so the designed logos are a drop-in later. + */ +private data class HubTile( + val name: String, + val icon: @Composable () -> Painter, + val onClick: (() -> Unit)?, + val comingSoon: Boolean = false, +) + +@Composable +fun HubScreen( + onOpenRoute: (String) -> Unit, + onOpenTab: (String) -> Unit, +) { + val tiles = listOf( + HubTile(stringResource(R.string.app_skysocks), { rememberVectorPainter(Icons.Outlined.AltRoute) }, { onOpenRoute(Routes.SOCKS) }), + HubTile(stringResource(R.string.app_skyvpn), { rememberVectorPainter(Icons.Outlined.VpnLock) }, { onOpenRoute(Routes.VPN) }), + HubTile(stringResource(R.string.app_skydex), { rememberVectorPainter(Icons.Outlined.CandlestickChart) }, { onOpenRoute(Routes.DEX) }), + // SkyChat / Wallet tiles re-route to the same destinations as their tabs. + HubTile(stringResource(R.string.app_skychat), { rememberVectorPainter(Icons.AutoMirrored.Outlined.Chat) }, { onOpenTab(Routes.CHAT) }), + HubTile(stringResource(R.string.app_wallet), { rememberVectorPainter(Icons.Outlined.AccountBalanceWallet) }, { onOpenTab(Routes.WALLET) }), + HubTile(stringResource(R.string.app_fleet), { rememberVectorPainter(Icons.Outlined.Hub) }, { onOpenRoute(Routes.FLEET) }), + HubTile(stringResource(R.string.app_skymeet), { rememberVectorPainter(Icons.Outlined.Videocam) }, onClick = null, comingSoon = true), + HubTile(stringResource(R.string.app_paywithsky), { rememberVectorPainter(Icons.Outlined.Contactless) }, onClick = null, comingSoon = true), + ) + + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 104.dp), + modifier = Modifier.fillMaxSize(), + contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(tiles) { tile -> AppTile(tile) } + } +} + +@Composable +private fun AppTile(tile: HubTile) { + Surface( + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier + .fillMaxWidth() + .alpha(if (tile.comingSoon) 0.45f else 1f) + .let { m -> tile.onClick?.let { m.clickable(onClick = it) } ?: m }, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(vertical = 18.dp, horizontal = 8.dp), + ) { + Icon( + painter = tile.icon(), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(40.dp), + ) + Spacer(Modifier.height(10.dp)) + Text( + text = tile.name, + style = MaterialTheme.typography.labelLarge, + textAlign = TextAlign.Center, + ) + if (tile.comingSoon) { + Spacer(Modifier.height(4.dp)) + Text( + text = stringResource(R.string.coming_soon), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} 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..1812426789 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/navigation/Routes.kt @@ -0,0 +1,22 @@ +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" + + /** Routes pushed from the hub; the bar keeps the hub slot highlighted. */ + val hubPushed = setOf(SOCKS, VPN, DEX, FLEET) +} 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..4fc7b40b86 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/settings/SettingsScreen.kt @@ -0,0 +1,15 @@ +package com.skycoin.skywire.ui.settings + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.skycoin.skywire.R +import com.skycoin.skywire.ui.components.PlaceholderTab + +/** Settings tab — identity / config / export / app lock / logs hub. */ +@Composable +fun SettingsScreen() { + PlaceholderTab( + title = stringResource(R.string.tab_settings), + subtitle = stringResource(R.string.settings_placeholder), + ) +} 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..3ea06b7bc4 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/socks/SocksScreen.kt @@ -0,0 +1,16 @@ +package com.skycoin.skywire.ui.socks + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.skycoin.skywire.R +import com.skycoin.skywire.ui.components.AppRouteScaffold + +/** SkySOCKS route — server list / connect / expose-port flow. */ +@Composable +fun SocksScreen(onBack: () -> Unit) { + AppRouteScaffold( + title = stringResource(R.string.app_skysocks), + subtitle = stringResource(R.string.socks_placeholder), + onBack = onBack, + ) +} 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..b32e77d475 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/theme/Theme.kt @@ -0,0 +1,74 @@ +package com.skycoin.skywire.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +/** + * Brand tokens: blue-dominant, no dynamic (Material You) color — the palette + * is brand-locked on both themes. + */ +val SkywireBlue = Color(0xFF0072FF) + +private val LightColors = lightColorScheme( + primary = SkywireBlue, + onPrimary = Color.White, + secondary = SkywireBlue, + onSecondary = Color.White, + background = Color.White, + onBackground = Color.Black, + surface = Color.White, + onSurface = Color.Black, + surfaceVariant = Color(0xFFF2F6FC), + onSurfaceVariant = Color(0xFF44474E), + primaryContainer = Color(0xFFD9E8FF), + onPrimaryContainer = Color(0xFF00264D), + // Container roles drive the NavigationBar surface + its selection pill — + // without these, M3 baseline purple leaks into the bar. + secondaryContainer = Color(0xFFD9E8FF), + onSecondaryContainer = Color(0xFF00264D), + surfaceContainerLowest = Color.White, + surfaceContainerLow = Color(0xFFF7FAFE), + surfaceContainer = Color(0xFFF2F6FC), + surfaceContainerHigh = Color(0xFFECF1FA), + surfaceContainerHighest = Color(0xFFE6EDF8), +) + +private val DarkColors = darkColorScheme( + primary = SkywireBlue, + onPrimary = Color.White, + secondary = SkywireBlue, + onSecondary = Color.White, + background = Color.Black, + onBackground = Color.White, + // Surfaces near-black per spec, not Material's default dark grey. + surface = Color(0xFF0A0A0A), + onSurface = Color.White, + surfaceVariant = Color(0xFF15181D), + onSurfaceVariant = Color(0xFFC4C6CF), + primaryContainer = Color(0xFF003B80), + onPrimaryContainer = Color(0xFFD9E8FF), + // Same container-role overrides as light: near-black blue-tinted bar, + // blue selection pill — no baseline purple. + secondaryContainer = Color(0xFF003B80), + onSecondaryContainer = Color(0xFFD9E8FF), + surfaceContainerLowest = Color(0xFF060708), + surfaceContainerLow = Color(0xFF0C0E11), + surfaceContainer = Color(0xFF101216), + surfaceContainerHigh = Color(0xFF15181D), + surfaceContainerHighest = Color(0xFF1A1E24), +) + +@Composable +fun SkywireTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit, +) { + MaterialTheme( + colorScheme = if (darkTheme) DarkColors else LightColors, + content = content, + ) +} 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..efb60ed0c2 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/vpn/VpnScreen.kt @@ -0,0 +1,16 @@ +package com.skycoin.skywire.ui.vpn + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.skycoin.skywire.R +import com.skycoin.skywire.ui.components.AppRouteScaffold + +/** SkyVPN route — VpnService TUN handoff, killswitch, live stats. */ +@Composable +fun VpnScreen(onBack: () -> Unit) { + AppRouteScaffold( + title = stringResource(R.string.app_skyvpn), + subtitle = stringResource(R.string.vpn_placeholder), + onBack = onBack, + ) +} 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..93a04e69f8 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/wallet/WalletScreen.kt @@ -0,0 +1,15 @@ +package com.skycoin.skywire.ui.wallet + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.skycoin.skywire.R +import com.skycoin.skywire.ui.components.PlaceholderTab + +/** Wallet tab — the native Compose Skycoin wallet is built here. */ +@Composable +fun WalletScreen() { + PlaceholderTab( + title = stringResource(R.string.app_wallet), + subtitle = stringResource(R.string.wallet_placeholder), + ) +} 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..e67d3ab619 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,23 @@ + + + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_skycoin_mark.xml b/android/app/src/main/res/drawable/ic_skycoin_mark.xml new file mode 100644 index 0000000000..d2b03fede9 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_skycoin_mark.xml @@ -0,0 +1,20 @@ + + + + + + + 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..036945750a --- /dev/null +++ b/android/app/src/main/res/drawable/ic_splash_mark.xml @@ -0,0 +1,24 @@ + + + + + + + + + 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..62e15201ff --- /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..7434aa4465 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #0072FF + 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..188c0bf9cd --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,32 @@ + + + Skywire + + Home + Chat + Wallet + Settings + Apps + + SkySOCKS + SkyVPN + SkyDEX + SkyChat + Wallet + Fleet + SkyMeet + Pay with Sky + + Coming soon + Connect + Back + + Connect will start the Skywire core — coming soon. + SkyChat is coming soon — the chat UI will load here. + The native Skycoin wallet is coming soon. + Identity, config export, app lock and logs are coming soon. + Server list and connect flow are coming soon. + Whole-phone VPN with killswitch is coming soon. + Market connect and the SkyDEX UI are coming soon. + The opt-in Fleet status view is coming soon. + 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/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..e157fc88f7 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,5 @@ +org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 +org.gradle.parallel=true +org.gradle.caching=true +android.useAndroidX=true +android.nonTransitiveRClass=true diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml new file mode 100644 index 0000000000..5e0c2274ae --- /dev/null +++ b/android/gradle/libs.versions.toml @@ -0,0 +1,36 @@ +# 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" +okhttp = "5.4.0" +kotlinxSerialization = "1.11.0" + +[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" } +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" } + +[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" } diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..b1b8ef56b44f16b14dc800fa8103a6d89abb526f GIT binary patch literal 48462 zcma&NV{|3jwk;gnwr$(CRk3Z`Sy9Ed?Nn^ruGlsztklcC=e7I2x9>aqJFB(1eyu-q z%|3b`eLzVT6buar3JMAc2#EOW{C^)LAZQ?YaW!FjX$1*JIcZUG1yyl%HEd!6f#E+}*Jo*NafvM<-FbE0;-_L#rp}qdn%JEoAVNlEB#J^Oq`mU_#*ev4HLmc> zjXz_hFft^><#omb;Zer-%wm4hxo!wjuX3hBldg(^-RiOleKin`>KHfL3P*{k?(rji(#j2Cc0K509#>qu=-T&B!-5EBi(+ zIuTD-qfcAYgS@`Fb2^-p)4#o6A3z0&fp?~cV=CRsAeCmO4ZQ5kKgC%0el=Q&Rhd#k zaGmAbUW8uKC}-C0s~2);d{;mpsNBx9rn__66W{AhaSvJEK+c0b6ARO+l(CI7E|S5x zhaYP--@F<|99X&)9`q^2(^-Zu^Tzfm)v|gkTJHQ!G*zIg5hzoygeXZoYUEJ;iFkE# zq^r$*c|>Hmn3GapzcDYnjgSFiO^NFyTR5AH#mh%zRToMpEi(r)1$5)h455DuV}0al z!*psWuL@Ke-2gvftfMEGf9YEi^<{B@qru zINgo+YsE&LN?)1qItJoNhISp-fZ86`XR#*6xcvM~_7=JHUX;K9*=Gu5X~ zix|O2d=&C#u_w{=B$eCpJ4L*6i7={j+{Og~`Emz@&98}6s<-p^)`0fXE4cJBP{>)Ltb>JwcqI>yz z0-r-SEhC@p)XOoh|1|XgjFaREHfsu4dAGVz*k#m+V<4 zHqvlud6=;#QWHUoTR_a8Y8+heN?M%n1@0YLiaN@GuOPNd26tik7eKulTx?mM-R!1H znB6+H{^krFXg_b{y=QeCT~qR3T4}l+b!Oz9;~|3*6F<3?#|DYYW&1RtFE)ILZ!`85 zVmvrZkLTzf31unH7Cc5E0iFShqlBE9hgEnRJH1juII*vyp&xd!g`q}X_6WT6E$hhQ`Vdp9k^<)VS?lj!cTh z7FQcQAVA@jL^cXod8cnhKG2TS9+;QU6Kq>}UOY3&TL9gXbl{Fv8@WsF=z7>X0To@$ zY@Oi1uc|MdJ$>Kn{@!g_e`-I&Tpwfg9cr>(iakDX1qciCG_1y!Di#4_)lE!bWJbrp z5aUonb6m-?tiQyR_`P#~SOu+tb_ev6JO>EbEhHK@KbeT0_FDo>dl9bMg)>xmCNB*g zG5NC8ABavuTEZVGW6jP*nAqRt3W?7Iigc-EE~zpNJXRAE z>`~RO9$892j&I1kV;9U)xT8^}IeV`n{}QDtj2o-RBt`DGZUOO;O*lFCb_vpyGh*;95PfeGu!dyrmZ9VJ3Z*upg z6R-3Lr%_55$Hw1^{+KWx0#z`T7O6sXo1h;m?B_ur`X2bFz-SzDrL zpk^@B<+I6imc@7vip za%1jMB7q@1j# zz{u?YojZMW{5j$@h=v4iu2mTu7IzI|)Sxn!74=*J>1a&?Xjt z2%JhSi#4huEcD9qdR9Lj4vwmfnL{%+vQ{f-KgYeqin(OPd8+(g*Uq#TLxQjD4 zLCL%ul(V&PAPlAx8D`@K8Rc`{GPecQ<)d=KWel0ejFeeXGQ6o7601B!!I@RY&eDriADD6wP6DcFKDLZ|lO#YwnrNCZ)zRJpdxX_nPZa4j#$j6v!h|6p!dH}MY6#B`@%6=) z-HigguDACKBULnon^FKzazF|Y1{t(U5rUGnEU|}djVsWT-F>@@mNx?_$kF51QF4C5 zStKR$^3(fw85(4HGs9{mUTtn1)3PwxTN?6}j;32&vJ^BiPHfndLkdU5sOemXKGyCZ z@<7j(k>DNeo~QXyJkFWk!7(y1SB%nA3{v~P2c8ooKa4auM!el!Q_=;lJ$c5ADqE+^ zX8*|A99v;jWPrm(8=h;2ZAj|(vVbx~wQ{N%v;eYLD_BB2LAEWCs@xauyBDl(_HIBvA(XJ7B1E;O zJYCJ8xFJh7f5sr;Y#Wp_`$4Z_H4e9bGiBp?Qu&2!@%Bl2dT5evfFO*^hLDiBu2%Jl z*WAlL5PaQ7skJa(qVysky}DQquZ8U?2@UyJ8zB#=U_E>MgE%XA$CtfL31m$rATJvC zs@!crc0=128PM=Zp zW_5Czv9))n_8Ru?{pxM2F8^r%*O41}RnONbSj*piG%`nyF>6ky=|;B&k8iot(J=kyoU3p<_zaAX(1ijzf*uXA zZ_5jeC{Lks+&QeFIlmzZi3+fsF4fNW^~kvC4Q*T-vrNP!x9xnen12lZQM=1_MdW76LKX(GuW`%T~dM^YX6+ras|Xy4Qhfcq=D+z-P-ea z`T;^gj3+grr3^hwqcNTJErl$z+k>{bYFm6QV%7Opth?9+>|Dn)O@`7F@=j-XSqGPW zjUAu%b3Er@;j1%RZxVDhI3sakg-gvTLOSV7;FV6ED=(5;UG??=WADZw^=$4AyFh#}VMe3afM^pF zFa}-nM8X=K?Jy02*o02@6k{ z%O!hBhjXlXKdhy3A{xGB<##e|j3^dFv~~%v2_H{t(mN7NVeS~51?D&Ozbxa`qwZ_4 z;C#Q#fL1sua%ggucgIEHZtcY=Ag&GgE|h7Q{77D!WUq`;SSGEE0pU;aoj<7-JCAvf zduN=(tx3Mb+EUXKoax|v;8b@#HJ&Q|!g4ryrl|R>WlAv?IH`bk)I24;eE4NIq@SLK31LD4+w~#3iN{=<`<1R!t^$@K5>U6%W=%8_ANuR5 zs(IDuI18ftirTDARnGmF%;iz+4{MlMihJw_l!0Y)NttXC_t+s)V<EY>=Xin*nGX79k6vQ?beRk zy_J>@YSC_gMIG$yjO-y&o>S6xtfT27aSs>e|`x(f2R1bM}*518~%x>1Yct=18b&Z>GiS*>VB$+i2876zL)1cT zN33g=g|>xWE2)dds5m2+8Vy)m-u@NHOlGYxxjam21r1;xWtT0TgqKZrl}*LSkqFt4 zNTI1=3o%C*!-i;iWnlca$stRdwITA1?#fD~5OIqIQAM18BwO_u>hqL&OAANiF|8rG z_IZ9mp?FA-{Gq9+Ky<#NgL1gWJixfO0ziP$4T4G>vsvqC-NQh+A64F4! z-(t<=AbPSG%`mTl6BJtH~3RmvPhQlE-EUkEoBIP(_WMN zK~Fe!siee{M*ns1hkp5(2}vX#%u+T!Abh=<_gEx_QW?h4V@B>uOCEetEe01tl)^`V z(=cOLmuOB;8&&m%_6pcyrt83UXkJ`f9I&0KxY09}RTTs!l^_7~8$tPA%Hm#&$k0;# zF;O0zCGo0IN)X~SyKDoY1DW{Ulce|V9w=ld;U`z$t$>8U!Gu8V?_LAJAudt3eI#*! z2i9~F=kP5m>!bmb%1e~b1!1gz01Py(Yw5gOsFN#o1a&d|=PpgN(#UVreY9^99I0iG zaYE@>(C^V7pnoB~#w$2C1_TIb1N5Je&iao?S2A*TF>@vpHg`31{uk<9{zf_}s&z%dL-Fo)C$yl$%pAdqU!HJgp zh_{m1imk{&{ScyeuziqZHu5cto0{S}^BlXu% z0~;>_yHGd#?Kt8ErxK)z6ojj5SacQobw)-8`c!$HOI*V6eyqou{1Upm%_p!BY^t(D zDtn(oQ!jff`ddGSD;P8Hes!v)OKW-*>mS&#i0ow87;h>(=Cu0>b4)|=EegbN5=Xkh z9Ge13=3z#sk+fT<)PuUUf_%Nx@l!P?t*mni^94p^Ax6b2SVL5U>9dHH!H4DL4}@?@ z?Gpq$C**OmWliYA{5s<|EZ@QI2{-K#brFxfA~AIqq&-WSALHWQ8}%mvaNFasrtnE{ zg=sB4-RF!?)nf{>Wo~kNFgYefoFHBcSr*;iF9B!R=5Np|jv>Uf+mcarG-XGy*kP{z zISVyoPcl_9cOg-@613Qx16OGF#sH&2NTHDa_}vyidmxS~pMfY#AeQvu?AXpWNzi7A z*6&7a7!C9HRU+N{>WYTh0GXoBnXw{lQby^XShgDOw@e8TP}9Y*oFV4MVF#@Ds2A+A zXBEt3a@-IIl)TOcXx;0P;|ihR%Tq@DXeG5p-O{!T7Sg$s1 z8OA4iOx-!>6eK^x{jU-0SvByimK|nZik5zKIvvWVGE)4=x^&5Nx%Qgje!k3VoizaB zip#?$u(R8u{wUFC>tVR8oA%7fs?xEu(gYn>y6BB%vwPR9&RoZE%%RK! zl#Qnkl^+Y*Y4L{Xk(YX&aGj|zSpqO_;C3CTepA!L#4EXO|(eA`Fi+2EQ3!C zo^SpVP?{chQ3uaxu7y>w213e22cdA#l-M2kStPE%sq6vE4M*?3At!S7tIp(tQg(Ml zECjeJw8)*#LYYk_+Txv3rxsH9jJZBRrHp29yJ(^;_PEdn%#U1q`r89}38;XeF{ee& zsZEsUbJ{LtwOjU{vjL(Wvs2!Bx;#^Mzld&TjS@oo3kk=0P36MC-Ie6eHNN&{8b^s z0@jcbdejrrj!>r#Wu=3H1dgjeOI}NkhmE}K+UK&M>%7b!n&{0Zixk%^)6#@=V~IZN zxG>9kl&STQth}qScidfg58d2dF|v_U<@+V^eE@$4x;7oS3)MvWusA?9+%rN>aY#eA_6 zic@S(@e9$9tQM-&-7>X8~#n{5G}nuOu=dSyN+b~jA;_SExZ1H9Q1A}}Rz;XtXUIOP0~ zZzS|~T+%de-nGI$s?wxaJoe+99vmo%xm8o8SNEsAqAE)4LNvHc-1AX24C4k4u3vZmov^_VcxgGxapV(8)_K(^8= z2d{xCrmk(x&514Ly?e{Mf6}h3=oeP7+ZE{%B^c-kK8g0W{tYw3q%zty_Rd@1nbnyHMwabNp-sSyzpV4v>QsnKcQjF67%g~n&3t^1MesVxCzfJ5b=SOI#YfPP^^JGQw=9L1RCMFbrU{8O0LWOUdBK#j&{`tzXX zpe2_{+-8$a+o#%8MUlL4$yK`*--z&3{@Y?jP!m{g5nM+Ht=bD3o}Ok~sBQ_!^!->! z?NDVtyLXzmGYCEmjSCDK*q?Aq1;8fz9l9|z@~l{)R6GfKELc^(nV+TjjI^n0M+S0i z@YOu*Tk>|M6a0_n$(E;#^1Zgif<-CpYiMvyT+Y*9Z?&~IKSwsLa5Q#p_?FqK3lKIw zlp6Hk%lio6)yq>m-`QT2Nj-q!aX7~Hlm^Xh6FNbw z$#ri(Kk*GUHXORu@`aYQU@ zB~S-oIO^~abRPocemkm!W73dbb!j^_xgo_@#W#6p12>w^{){VfeX?U71Xyn9&E zHa1#*!4c;?r}jv7dMN`g#&R_S215)dccDOJr=uz%LIz@zia+LIFjRakROr?P zQ|Xw0Pa8o7&W=fw17`+SqepsQ-Os5v3ncD5|N?N(AHH&`>hLY+CLOluJ z_ErpaT49zK(UcdNmQ%iA-`jS`A_1c|$W86{d_T_T2V-HH3xUqpX0QJSH%i>1i>#vK z&y{;5)^pMB=u;&_DEWakQU>j&+opIrBf~2GUh{`kG{|Z&2Z}5dwG}>Y{W_uQHaR$_ zYH%}$c`CGC-FGCetRdQ@RZ2-%ucC_|R?mHzYEnqC%u9zRBH8wx7po`=EVPMpq+hL2 zTdjVhQn$)++17^cn;<3=bxJy0Z$U;i3AqJMPJO&SuieU&0eVX?eLEEI7Av@#PV_ZQ zsa>I>B5HE996O$z6HyJfhEt^aC><@AnzeN`xs@lv>^pPFtcodrcGyqPSB?#C`Piu0 zh5=hAW|OtT9hs*G?7}@*mG_f7ae@-Nz4{qvne66kco^uD$(JbCo2ttqUm-SMy@kx% z!eDt?5>w5)M!E#C!b#Iu9GqyhUs|QoYWHtR{4espRS-LUt=viY2iygF=-j3kcU#uF z{ka2=zsOuLR}s;&PbbrB`zty&NfZpV*Y;~i*W$EH0JOGS&FMS%VK@)f*%OOrcU3P9 zq4zjhMpx}oc`PWtP!o5Bdlp=(A***TZwVwuZbuB1Pibv5uiHvW{PsE-k5IfCgUz~l z0nMeZU0R>(ajoQ0G%Il)z0BgRR*bsdz5NcqJ<)niF6|PUO0i}<4)q>6wx4K(5>Y_I z4$WMkbCOQFs(krBnl zx85i0*7%Zm(&nKNP?AQ}d~6@?D9dO%@}ouN2paSR;zyUqJuw)1SRy=g%o;g(BD|Bh ztnKV(4fcBgDJ~M@%}n-6ow3xOhnC>C^d?PbS(9=TnO)k5p+W;pu2F4eiG7ts zJVL4M(NiZPQDy*9`H>-P0GWY#=UTnh8feiNF}hCs`8^ZDKy;XIL^9K4Ps&y^#DQSE z-?J z@YOQ9NQi>ZP>^ix5K`R07kWj?`R(B?E*OyR1$Vd;8p%2Y2zEYt4CJM~gVX%MO(E1B zzXhsHn~R1ifq9~dtzuH!*3&W;r`D(Sjrc)m#EI%`Car;CMWcU0c+0r?O!)HpjEvyP zb^;pO-Bn6e-+>dS^o{q&8yEH9v}vuXX`W;NPRlwJdX|59`z?~z{pFE!^u{3k{KkJ55^ zD;F0ldy9W*`d5YP|0(E6|K%}9|D^SIq>wO)4^cJ+yCa&xl*3}hpvcQ1eP_k;@>tz= zOZnw)#fxHc81jPcTM#)jgy|0?n0(jd3IPu-lJ&Tm`#F1)o$GTwYp@dlqy-qiHFCHS zKgikMUx|%x=_%B)>n_y^+HvD2=nP`}-G_0A7)I$yc4`tXS-On8qOkNp>Q^$|Ew%Jm zYx34*(*Z3SF}xw$CA?nG9O3ZH7l)@Dp4EyH>8eXDb}AFz)k*T53iA~gRu&e15u@|% z9Rw?69nQOeJhv^^unjd-VGFwbDzf9K{i(U{xxHyM@-aI+0qP{TU0G~w+Fs>taL#Ik z4+92(Z7n%+okd478;__0GkE`&(C`k8h@?UNnM=F%A~2|TKo)q9F<5`s)KwxJRw~k; z4giS~|8AIVG;rde6I^W6m9fliR^7YT*>&x7wv^?xu(5p45n{|2F>x%?9Jq+~Tqo9# zChbeGm@9!(s;uIKae_4h@`~yIj`Tqct+-M>d>~2PCiQ?UmFUioyy&~h_DTBQ--W|q zqA^UaJMTz4tEggQ*_cQ_LA7j7bLyz8#cpGggy;YBVk!%oSdufoh5-FYAQ)v=d$Bi`G$^~ zm!O;En#M9uCykPzLZ5SHa%?hDHP5P;T4HN0L6J*r9DAvC1WWPOrd{*obfr3yJ?Kl3 z^_6dnXRoi4<$Tr!=4mhHg6ig~BatHR zv%ZMJr-`8w_JyFEzUSQdp0HT>|9QQG?IXj$7Rbx4E)%HauDyY!tedHP ztIbq;D)ckd-eirAHOG7icBH23*ApHA@nG*Jdh}~G?L5C^Xw^+nLWG+>hRi&(fnpY5 z?^hj4si6I{m1u^%i_yk$tco}28X8|}g5*tAEZYF37$f(+xT%XvO^`i^Ig}%cydrwF zlpL!xdO->&@q|8MiJrAxt;z2CP*a+EvV`_2& z<1=p{zjhmmYVkpx#RV=#zuy&7^2Trn=H$nT{OBVF*0z|QH!NxBF%gbqT!BEx zKB!SsSUwSo1Zr?kMM%N)@hG=&m`vRQ6QK6=oIvnUI+|C)dGKM@jNwqG2Xi8;YCUHYRh? zbl@DN-za)+0F9kw>Yv=ioL)01uFp7@AVEB0AH-nmB%j$RC_totFy4BKd;OPCMUMBb zu3oUUK`|{AvkM+@KPZD4Tn$(VlQi&aWV*Uf@DO|FQjLOoVw&C@z~Um*h%Ka-C=n4H z@(Lf&MDJXNS{3Hs@J)11(zo9tGp>wS^b9{Q1WN=Ktn>ZieRZS?k`gb7P4n?cl^7^* zG5-oARAG#i<*z`J0ski%;QCLD-T$AbOHq<{KxIb4=QJRn@MGj=ns0WhZX+uX z=oTjz`o-VviMt1mB0W1vA*7oq1ENz{<*-EU)U;r*ODfV!G-?hdnzhM@rRZ=|qaFTN zX*t~$gc-)M7GS{#34R-n`B)eAPfebN46~61R?j^(Pg3TXR1PyQrO7Mf@xf<3VL0`4 zh(i?-SktJu8Oj?KIy4p@%5ZH;P&p5LB8 z^}7P)9h}vUP+1Hd3nNzNcbR`%1>dSZbWhiXe-CcB+s9e)_w<{bypZ(@cQT`P@ch=d zSOPhExgI31MVFPsClEXe>$~qYQ+d}7(!BE*9y%AjQ47BMDt=#>`1ie)|ES{pFFdHa zI)CK`f3x>)DtZnm!f5=e@g;3iK^jf!RU6hpjYu^V#q0uWLuJ-6={Ua3gDi9#*P7;- z`rm*5)n{2QE{UZ01PVy@_9(amogzzOwYcVgp2>LsJ(}hKbX_!ayZ7=U{!p{BHussVj(W z2z3$zu7h$KK<%}P0YBJ+)0unV*xD&6GusXqs=M=Cl&fP@Ttzfq?>H9TW#qDId+C7? zhD;;HOxDJR4dc_xI7-b6N6nZ@bUWueDk<_9Rju2I*o(i)M0&~%C^ zc)a<25M<^NrsjAccydV2HJu_-1W>b;xrB~Mi@c7FrW-94$-GnKXvF7( zA68!d!gkIo8(URS{(u{zRtrF}B$9@*)KH9POqOW-B$za4Sg-A&PM*on$>$o#L7pH~ z&YW8oJX3T!!@2r4Rr6ac0ZDbtB1b5yc$5}7oZSDvGF0FWTpZ#r7@GfM^MmC-p{9Qj z_JmmlTxO(^(NHqBc$ECU$jQp^;)%xnyr$qvNTd`R@j$8JppDCGQAHQ7?fja9McCUZ^;``VW$1+G#=<;K{_OfH- z_$fp~S3K`;jPNNZnkB@=DFQy3{6+Bq9nOf3~dr4q8zD_t{P4-^%<4kj!U z0aj`=#@G*w?!4fpM? z8Pwb15(Ka*TtDN-2aWK>*hh{R_C}*e*vSTkHdM(ETM!JrJ=1h?(_WL}2p#QXjrKZ_ z0k_yu^;~)#*r>sQP7d_4VBRvWJCzw#TxA{*hktwQI3ST{8{>3$KHJIgMGK6I!d}Q zinmfq&RLRxX8P)_@@vVr0gPu7*)uU<%xS{|Eg;*w1}2=C&?7B zSX?OLt-gZO+<4@tLeF+K0~*|xwMD__KxWgGfsUpj)KyeCM3J-f*uxe|xk;Dlqq%1< zL(PaY@U(>Z#k!C!B45JlmE^~wHSH;r1c^kWTG9_VT~1LN6$a6Yg@kNF?&b0hs+5Dw=0j zR(wcEYmdfgojx+Hzu89*C}4$I7^?^vYKhF(`>=MC)VeeFR}}?j#XeLnp8OhW9%9ND zt6utD8DHnQj5@YJv+$USdN{8apQir2)Z{8_s!BABmG2O#pz5lSh|gf#CI8X4I|U4g zhQwk=VEV+j+-KNxuIk96Bi%^(Sf9}A7o$zHJ5mV~)qP))QQY&^>9}z9z9)PWpw>8T z7#NWNEtnUoUl{DP5(lmy<3;tpLJ3hG|;CGB`3**uH0tf9>;7w;Aq9SRVg1FDpI5y~rY#B|eCNpAXD z9692@_%$t2^nu&4lU~(~_iVf|Cs|mXs-xKlY$-~FZB$!oDK#)JgHZCG)ySDURM=@(i zCpd{Er89|l&)(&5>L6LuWY3yC6)`jPz(Po8pY=AYIBnx3y2Qx6*sT42mpR$zwx!!< zHHCc~tbF^-bje?bo#~Q59Dmw_-VcliCn^FfI*EV)U1NkNA`6Cm=^%j`%M?1Zxa=1U zn#DPNc32&XHHfUfmPx*J+3_GA&g-_pd#wO=Q^5bdhzmm)>s@yO0q|>ROV(hkhJWf@ zqWjI#+9Wx%C+!kp&kxX|XPS5m9CBC&3r>}SwdFd#YF_W78A*CN6mFC)qzOjM);Z&v z#MjdXXMw63v*tbvY+$tDmuHNFunOlRM#qe|eV&|$98!xy{n)-=N?lrkr0_}U^sz|x zs0y);(2Dooa;(9zHzRi=I{GSVcv!6jl%ck@)>JODfR? z%aI)0HvbhzY9K7eYsntq#JvWzj$WCuoyGoPY7;LSPfZlFiWU)X?(-p}s4FXQcpIp00;%Jv;k0t@2vBu4i;rh-?{z}cHTLL9Rz zT8r(1Ws*H~EyH+adP$cGv|7HkeS9p6eOEI*`idH3twkEJ*72|ey4JgISglGV0Vo@qe#)f-=|g%l$S&Onwl@mmdn|sjXXYaQ4MlfzjiK1* zY&hWQyc9?G2}2s1fYnQ}LXpq{!&Kr97d?=a?_xXAU0SXrZE?T+=9os2*v9%Csph*M zW{}m4+PIRmHEI;<=c5$PMrfg#MTs);4Tb_0**o}*cimSWRcxo(;G&&NV+-?W7v*%4ACG#t5J zQP=$g-(mN*;B6s)d9JNkF0#Zz_WA>J;{=2a!IJsiqCV!YLjJ(wUJ`3b$>qcZ!HjDT z2xm;fMSbtJ|3o~tc!jJ+U8a)vX@NcxU8y#u!Puq%R~{sps0msRFO2!GM4}786S7* zxgNmf{q@|Sdnf6_he>gEGX7Hn)uih5nL&&t4`O{?V;;bdl1U~9RAnjNmt~1UPC3mh zrR8ZtHzz1(yOYSK$OjKf;InJ+7mH$WfqI^OG3dhA+S!YmIgRv>2H78?<6A=~%E{ug^P+^b*+f=j32&Nv&Ypq?DcH&Busg^AUDE|p; z8(tQxZs1+0gUX<5~Ah zT0cGckI5%nM~d`uaMJ$o%2bt^##I0UdaQ2>-bpsP4P1Vk8r7EOSr+a!D*Z4shiKFL z35Lvs^i;#;G{%ksUUo8(Nj2DY?u5->J8kqS_#{B`HqS(UkzR|K5&6XI_#FH4?$ znMXeTb$nmr1`|{n*#5H1T%vtU4-H)vrtAchme!ZG#@c+Hrf4uxx$;VU(Dr~N-ich4 zMKpdwot^bPY#kBILFgi?i3W_kV%vn2J+%R5x}TL8I?B~o#VXlmr?i=y`yJi-><;X* zPCDrsU51x;mkr+t18lPs=6)r^gEh2$saaA!qv_< zKQP13J}ptHaUjT_(*x+P}wfV-}57aU3rp#3AB&~e3%y}0ju#22u5@mUIT!GA{* zd%-e2DTmr#$(P6^$&N0oCgR)F9IPR~!Q!x6YI*7dx6LR6n8tj(#1~!0rofeMtT#g* zW%-p@V09>&o>iz0j66K^soJWg(o9#T(8Xx-P3?;J|t~nIDSGPq(?-B zOoNnc5HZhsW(m6!J+yj~kjmjV6GKvhO>%^v5`O2I@4B$Z!~DgelYWdC4P>YfmI$TR zq`atDEhIt5ua)PS;Yz1`FX@3Na6j^uBx_rNKTmgboWGwE6O5;iQiN6Q8>ZX%ApVJS zTEf6oj=@?7klS(JaijG|(gO@dTgxB3#H)4&?+@VWkTc)dl;qK|uv;WRI*cG2`6PiF z4+svy+Bfn&Fs57Jz6i!C(w$w@VWPAbRGak~oN>3vUg|Mmk0NpfURt0*DSJ_e*Gi8I zqshW4F}L&aS8x~4*#{4vOc`gKW99cx*L^69fgPj#?++q9LidItd}<@&#E{ZGz7g|c zFX$uKJ;Qv^NpN*e&EL;l@1br8j8oxO3e`g<911L_jr~Xb0)t$x$A~dFay9(}gt4&L zyb=1<`|)_7(!^xJ14xLBGKXO3`R^_;F01 zG70TiF<5(=pRsJYj!^XjLl_vFJOQPhN#Pkr#G0-m#xG>q)GAHjE4WFhe7Zi83;gte zdDv6+)qrgh3F0}$gPmtb9-Ff1m|xDD$6jX)Dcd5Ms-(@nKM_3)2+hfh6@Cs@-=%Z_ zIinf|ck6rN{EOadGmJ-rzvxZnAL)(mf108HL2v&m)%=a*?3CnX2ZfOQY?ha_11m@UzRqlkhrVbQ@0M(tSSTerx}IH@Dn2={w$iGqU#`v}PuV7I&A9JYNP%sqMn z1bTq*Ok{V>SlVH8H*4X-lO?VzaDQzAaLvc1tTL+To)YOuj^V8mQ?)K-FT(s_!ds-O zeb$rKRR-~g^+_aiGtH6kbJ)!K^ie;ipJ8e;>iy2}73i(1RY-~!(tk2zPj;pwB4k1a zVa~7lF^EE`UH=#eb**88zBH%!WkO0S?_Zu0KpRtXN+XMsAwfT56IZI}&cs+R5N~p3 zlQH7o$(zsQQBPIRmD)i>TfdcgCSKbVVD;VCmO3l1VNbV&rWc9o>Pk>ex!)Nap%NtP z&kKIFMm@k9-HeXj2$((SmG+a-dXvl7q(7n=8)cELHf!@Le+X)=++(}pKC*dcns?>G zVa*fV{2FDIJNaK_jq)WE9MvxiTm6sI%YUn|S=oP0Z`vE#GMZa`4V5byxmv0@8@Zb~ zyBOJuTAG>Im^uIL@!ZrWJy6xL{%n;pEwY87Y^xYSfmmgRcgcEDfz4TJ#{;n|g>8(> zv$(RLnp4oD1Mj>H@ar|0RCy}E{GwvuKOf1FS}O&z-Q)MmCVEK{p~b2xFj@lTn}#s4xg7h+r;n$TZDlT2AXAv z7R^$J?R|*xL^>7HI}e>7{HszA#Y_e8=~8*3zy_J$ejuhByeI0I!w-&%MW7Q-FGMKU z8qPm&IdU3w#^#`d%Vcn&q^w;EEr|w2F@ax^`R;a@p>l`U-T%~f&^`#zG}qdSV)A<0 z^*U=#=#o&gd{o+*s#j$xf+2y^t1Wj9_h}(DNi^aK#jI}z)v1rk-H)gocbgc`wB*?$ zfg~22r!^VEN+n>U8|3{Ebe#!9k|dF8lV*9c&9H~&g|$Ymc-2O^j9w$Q^I)ldd}5zv zQkBFDS2TxDn`p}-{-`br?tUCgyfr0Wbf3QeATbp=9sN|e90U^eVOu0~VT$1A5))@C zPcwzUn7bP^Gd~hLA@8EwiklMmlc^(;uPE%tLecC-iZ$_~jNJnZYn1A%r}=VE(-LG; znh6Q+b;zKz_N7)0SH7t~u#)e>Pr194w7xp;V&CpmJw5j6zBO%yB zjVf*iveYaWlrE~+p8YYym=-QmTd_F!`)ATishn6(oD}hTE2AqnVPF_os`ca^ET@@Z zoo~4YJASOBn<;8#(#3G>n1E)&@JA^3LV7mK^kaJ$((~ASWup3G(%#8O%xFX8XSiN~ zUF0&gDyT`FzIjtA`<-+9RXEKbwu%RtcrG!#-aoN0aj)i z(G|=#b_!z{o1}cIyw#n=j~Ac|NnR@<-CW$c%JFBFTi5JW0BX#4k2o2w{L0EglSN7E zFUcmFVF&U6NBA7!t`Lut>faDk>pW>Lz9BSzsqWvnI<+L#wg=zw+aeL6=70S773#Rq zG@fVM9=1ZibB`>L>hKz>rHG}`pX;dZD>I!_x~u>jsx3;0d$`Q%t7d<8^lkl8w0WZ3 z(HGiok6h^#G2EzIH}G*;!U8FW>@|C+wE+z{@e{wwWEkzUEiT0aDJo2JwZR{zcX$Bz ze2pzE&vKCc6@vE*GIv1LZ=qSg~HR)Jf|ljt#^m2hZF4z|32*7{hd|u`C7{C zjG>}`{SC3Dnc~5%D4yBa!V@}xSBtQ$ZWY^qs3)9jTuIXYMgPF5E0*&A0B(=JEntcVgC%ZO4UKHyuzuSblKNHWJ}OzVpeS z?8|{P8FtkJ=~%YMf1h*@o-YsZkLVQU!43cY~nWEmBt#&Ar%7WClZK8 zSe-!M)B8((tj^wSIm3?e5oe&mQs6BAE#Y7K*^boU^Z#aITL%-H zul5Gx*FKM}n~RnE*Ko3}nXrk8nTw0Ok-d?{|KMda<$n9cFHzkfb4wa&Dp0x>XjayP zg-KZ^Ayey*gb`NecHls@$a-2|Z!Xe^@P`uYYo`Q*jKzDQGPFf^GDQ5rd(-X3n)&f|bD>?`-DktKL<0hWK!cPS>L^@|VH6## zG*0#NtGfzpZpt+e{yL@K$|Lg*JfO%I+hp&kR;NxOJ+y2H49xZA7=^RKObPZi6 zL&R70!l_{PTFcxI#h+WsO^Y<`hE*z1vg9n7nG-6n0xBU8F8yDd}=?${Kl$qim3(S98@^W*vvSs{l zU}!oUIXap-i#nT`er(?avm4Q4-snuM&-cwu#-M{K8n;l1gP$ z3sw?`ls1z%eb%&mNBvLuEci8}-Q`|kUw6;F0-pHb?+A)+BLSn7_@my}6u%J=Ub~(* zU1n~wcfO|73IBZF;|Bhy$0FeO^>lmmZz?ZuZC8$p6<>B{Lsp-*mS05IVU00ergKWv z(LIsLS=?(>QLLQQ?bdTpyO?iiEL`;>(XJw^lA*7FCd|$g@c3VRy#tUf-Lfs*_HNs@ zZQC|>+qT`k+qP}nwz1o`ZNC1_y*J{2=fCentcZ%LwW?M`<;L&dcdwa@4GT@LCkltq=Xfy+OasOLT!lXrqy` zEW9YuDcfQtJ$oJ|Ln|b|q*_a|YPgCbBBfQ|5;-1(P3R`sK~3T`TtVV6yrtDbioJKI zPDV1BAaj#O~V^ll>$# zNC?nv_r5RiH^A2t<)qzcvns9Qd$_UU$`jN;KUSNqMCQiCFCi3A$*D#(v=FXCqz$SB zyC8vjHyJhMy$5kCi}FBy0NdSCJa6{q(|*9I^zwX1NHX*dHOIDB8bsI3_{(*-kkQV@ng|lWd*nWx!(xQ1stGMcRDjH=YUQvY2^uCZuO%-0Jw5az*F1nW_|h zR~z5DT4j&Z7527|#z9b}pmRW}p^|OrU(TWox^&Kn>YUn%%JlZJ^16vzy|O|GnZsf3 zSXEMjOhuYZlh*ikE0&zHt5va@6&GI{1&D+NPop@Tss&f!V4;}nqX@iOvdonoDa}J_ zE-u%qrrUpYVYSGU5NeXJr?#B#3dkObD8uk*U|u*zS;T2YgAk;_kdF0s4A6A*YGO4)#dKwYLQi+*i=C3N85d93 zAe#Lng7EX?@}-FPvIdp0y!`J@^1tg|IHwZ=C-i6LW7u!d>#==7<(?=6?caFCo;)AM zwwV6XHIU7}%D3 z75#&7SiVq=f6k4N*gy{?o~K9`+fsId8Co*62ksPHLm=SB>G)@44I(Fbs1stfE==|e z5WM)k7Hs~OwT#*$%<~0|BEb_6HV0F0=kYy;P zdAZbN(@{*9FL}4bSi-&#J^2;N`G{J?KFD@i^8BEXQq3$Q#~shvw_cx5r%ZlgHz2&Y z*cU<9UD1(G6qg=Yx{LRix``xh^Yi7@j|r7hm00t{(0ei78ZQbt`JV={$XlXvX91YH zxbI<;-YQG@9xrY>Ar~yWklR>hQ-X6TUxD-S!;~b9lu;Tu@f59S=euifnkTO2C*G;S z@TJZ5{$VG<^ThBbq_74=9q9r7DxC6VBngr@olJ}~W87-NEagn(;M*)7Oj2!(TG+}U zsLu!TV4B7DH{}gtanAHawLkpH5_$jk$0~;0`rM1Hjkl;4D-KsjXTl<*z|E`_8Nlb6 zroi&vNu(socja8wZ}9J>;D}esqgs4BR?_u7ZyELz2k%GQjtG%Vx+yeS&QI*AK1Q~e z;1-8)WjT?WqB>et(n%42u5UPI+!F^B7Hx#oW{i;??}{9#vpvk}lwvHPB$=-+pnIAL zGBd3sTO%TRGFw?`Nh>DzU#VeO7C?`w!-QT4ZgBE!WsS1clJ&i=m$ zHn^;?BNx^_wESMCsSKfxi542WFvUJUh%GpT-JP-b+D|wh`H$h4?*AT6uKyK)=>%&^oOXr5Al10+ld z9x<66pEk?hlV|$s!otJ~_Kz3DcB~XFzWq<@HMwvNFc2}VQuS$6g{U$+nN4G0`E zua0)-H1D8k;mm6E{(!pNomCz*qxv$pI3NvG>(+Q4AcJvK#K8 zb9SOKS@GC!pN|JW#<}*37GFj>D1wi~_)k#-N5izNy0%(q7hMm?oL_Ju8jMFGA9bKb zv$!gbC9lC0>Unx?+*3GF(6ZZH<(4j|5-Om02Y2z2IG_&xn+2Z`6;N1An(~^lQwwUQ zOiKj)?fuj7EGlb8nv@wDs4us&o=Bt%l*TAhB{h=R+Pddpm83-ms{V0T&ofYt=D7dS=Kr=V{~wzR|1=j_+3Fh+3mcp0J6k#Z&$+yVt*OJ$s$BYK zRx!5u|IH#%N;9@dV#r@$o(;Dy3GBon{2-)SK+R!>`0yL(nq~lFeelQy_)_BZt2i}m z8rSXb0|MpaMQpG<_IaUCD@=+=`KtLmC}H1)-vV;8Y!fw&`K2B6oou$QOj%XL`Ye$dX*5~GV? zjoCc8{4m*B_lFn=K@#mp@(*Vga>;sjA3Ds|(a_aGGbuFi)9-z>)&hY^h=PM>jvvAt z$Q7Zfbr%lPeu2OFHW3uNyavs`ezAXnB`OuCGx+U1e%!gwF?S3T3XLaG+BzOfiLB-f zLsTI!R2nT{#3)Z+EHpqiKXE$CK-~2S!*Tvgi)l{*o7SZiuHQf&N=jK$gt6|+nF)`Gm z!Txq?dNfctW^}=z-436nDud8w974=Iuf~cqED93ykXqf1w8FZK9fiO>iyHhGH6`Xa zy99CYP)x3@)FSqPdVt-Br1$H%x6;EwpuBzZ?#_D^RUI0KPMzf^_Q2rPhK)0jFB8Xm zlV*;2seylEHqM|s4!E5>k-zx$17R0R2*LcwM(ea^%K>Rf92id$mc6SChy+Lhh?+zh zvO6({dx7GOFjsuW1#TIks9C3Y1NS^K;IL#Bmt5WRAnNcc>QhlO{Vj2vmon)s*asQd z33&IEDekAAXHibwHHW4Kjin6FB;UgbL))#+*%fRgjq!Uy)J$xt^A4P* z=wpGU$DPMXW)DL%DW!nu39E+G5tKB@YM$r#?rOf~PwEaIWOZ?-rZteokPGZsqWYS4;B z|0LjjIbp)2Q9#;HApIi0rAAv&MKYgXU3KhsoOYe|YT)zr{({<}EXL67@nFgE$g8n) zlwsHK7H3m?1l)9j7MVEeKIFU&$Urel=||l_I+%2%vpEWGJ4%Ae=4~9emV-GN((dey zu%{X&7)-JZ@$2L0Yqtni7;-H%fWs%8= z=kT2S6oOA<-_q!hTShh=6tYB`my{cf^+Lx>yzS~3hAy^=8Fn4^M9*a;F$7-pPb`5WTTi>BH<(hQt<2d>L}bEO@qeR~R5CV6M#}U~hOs$t?sI z7o&N-naKA!$TJ z>&^XTo(>zGjv|b*XTI$ut5?7&&KtRH*Xif1`>gBEp7*Joo(B{{&6%EYr?;2euFLC6 zyxINGDCvA&Z9Ke6+p?I9Q!BMcUI`b0h}(?yqWH@VsM zQOR!?^5j*fLK3_B=$34i3+r{u7IgD)M~W2q7y3L-307k;BupXtBuqlRxD3=-rhwa9 z?bS^@iS*Hnd^;p2cOp}nC~VDSN?;3$3z!yI^$)`1W?UAhtCjjqn>M&ph0;8EaiL{z zu|C4KQm1Ko&6~iXk*x&^ph_a+*qDsevtmcT;T0k>1Tvc@2_|YU#phijBjGm~(FAS> zlUlF>J!lV+cX^mbgNt|q+%c)}o#I2L8tL)BII4PpHABevx1oqq4Fk=enLf)lPJppehzt;iO9UQ2qK{ycJZ}25$Em8#QCj@IGeY)Ih;t1C_j5#Indn9> z?q%Mr*&t<`FGYDnXUw!Q9F(&(vc=j2NyA|}`{O%(aBk4&ic|F*CyG^zcJTh7Jbkku znj-MdZ0aPz3?=kXncCW=-<;dP;J9T1y-C;{aJj^)J(P2N6H-0wO?ZvS=U!GHKVCK< z=aWv?u%5>H&8MwXa49`eLmGW<%;nt}*#2=)K*`axE(dLvH|fGa6F34#8tRY?cr_y0 ze3Ys0rp;JgADiP65s|!r+v;Bhhv}`Vm{n>M24Hc%zOJ&UhG2A;(vSJbsM4>fU{u2_ z-6VIhEcV`qxROML_k8tmxBr)-{ z0Nki4Ka!>@`U^UZ)eJ*+dVEKh%hU52puWKbEG44AD>zWsBPQobQCa)OTlz41wS`U5 zA(_e!#MIkQ_D?<^L@2G~TpSiQGc{2i*D?M}9=ed6<%52)rPN_&_Zz}kJyQ*xrss+n z+*}R)Uzw_8MN}8>Nin$jkrHrz;R3n*HT*JD&M9fIRS?wRHq#A#i(f4q5+z;_5Ij)k z55fi>(u^$A=GCiS!o_k6hWVWf;@9>(C^LB-^lw%JYn+7v`}UC04jw=#dbI?>PxGb< z^hYM;a|^$Xv8HwRyEFBlC0EGDeVFD zsI=F15ChE=aHP6tL~Ao9#WHh`H@ZcicgWiJi5Wg12JkaFg6%fLuw^#2^+FGSBYJC) zcLQaBfXhJJeIf<*h>U>kVP9*cRCfKc<$@qO~wd*)<>-)SK6P zJ@I^4#us1Hf$yt#&=?VaIkhDY^^W;!&OFd#L5S3wEK(42b#OVRSI3Yn=DLC>djb3m zOx*FMX7ymI4;B56>=L7Cv?Opmx_j#kUAIX{b-S2c8Z$v=gOMvo?-ij^Qg7+-IsiMdRFM)v7G{O9O zb{zD!lmDA*H)}70ZFQ4xTkLM$F*jknM@CK!9fA;1rEyA1T;kT|rRhl7MQ@3Z8K3<$ zthbXo^c6w1sy3usEhrD|+wtJ{DqW>!SzzMAYG&n5P_48!FI7^!mt^UsJ=Ii%VFz|f zC`{_0n8zVxPB%8P&U9wpG3=awF3lq(pY)ZY+X0iPX>u?nXvOVKqHlZ!kPr!p?==9sB_~DS`Wz) z-C{l?ZU7>v`xhem*b=STWhZXwe7a@WUN>CeYu(sj2^yMe+X__p(O0XKfx z%AXEQxVFsfTzy)ozm#eCQhr*;4iF$jVCn@40VgXeH%1E z29UQ3y$aVZ3TOp-E~*g`Gz^slv`Lf|RO$MFBa@P)tKRuI=cc?XxIqzmXgmw~OWv_3 z79M~sk*g{jtNxD4ShkFGO@d3`N{)-(L`+B$P3o{T)|L%BE`c71nj=koezdtBY4~a%t^5r3-m!3Kj%V`9dB?v%w?BxOI$&~!jUNWa z@o8Q~I6n%f3*aDLLYK<|4FU2X@*``7jnlDRq5+VebLwb4vJVL_1XDYFTUc;$dW3relP0}p?81NZ&{!uRJU{&9)O%uEL4Mkts~ z&T=;)Kjl_c^Tc3YX*8y9Lb`*cpyU^wFHkn{Z--k1SA~|n0bO2_YwyEVv91paW(>>D z5A?fn$`0!!94mEWTUFmE5+yocu&wZDj;aE3+jOFJ95*T%`pKWaqKNiaixt!T^#`@p zHlA$6Fj^5&7!Hb19 zHyE9zQWe<12XmH)8IDIOtwPeM zHRd&LKn-qMRQRtyy5LYzR9#*8JDBD2K-E^^INa=#S{XA+rW5XKtg>7Nn^Of&Vhir! z+P>KycTUF|e~Hw_vAX%ap<+u9o9)jcAVaw~|4zkmS zZa8>nl~i|D8zjQ^%<{;ZR6cbVD>%?nlBzUD&(9h}VOpBkVW!AuVW!MGuz;OfTWE_| z{yi!0mE#74$DH%4$iv357s-5PS(g3aXJUS?=I-+Jz4Y{Czu2{VMepL1!wV0l8b0k) zSH~&|HJ~YYm{WKY&gKO*WNzB=l|JE3C?T`VIh$Fi$wHFx68QWYRy%ziF%z4Zc<{>B zjkGSyv*i{+F*O@tKQ!EDM%7xw!z{Yx)~Woo$kr{Z7+t7ve;X$MoE{R-LVe22TZY;% zOIFYRqSw}4;Mcno^z?O*G8Q`&wbgNV%>E*DX{fnqK*lP#K0dvcU3endLW%GugLOH< z>Y{oG#ECe$UPvO#$t@?@GA5JFE*6oY@?+$jRxnx(BiZ8q{AuRkwymR+;{*D6-bh*) z-5@PC8lo`?K**Ec9*n$U>OJRjK0H$J@vnMoQZa4ti zMegzJ2oft=1Y+aEG$4JE9{t_I{tH*SwKVixk$IyL|hvQq*qu&_4C6X zp>36)v+qAXl|OfXL8koN-RrhNjjA36)N;pjmTkOO>jg}c>35j<2gH)fb7QYv#8VV2-AXJ1-O{Vpi$uIz3lMp3dl`?Wwpp>|6_$}|ROmbQ- z+O3VID2pdMNR%dc(_#%+-P-%bNIb5Irk&d>rOY(_mq8%P;dkWuH0mR4vhl=r?rV5g z%=n2Yz2%@f5#I6!(KxF>D%1-3IyJU|VW-!(l$}cWBQtobb>#9D+>HlD>@kp+qgiCj zU_Y+2nP+9m^gw~vIRygs?R~aXBZ*Vk8cFZj_&b8(pTaY{Y}cTT z*fRuKeL3=89rk16#2TNQ%KL}Ryx)%5M0MHy=A(uL9M*f_;^wBL-FO~J+@|(7I)GQF zGxu8y$fzRDE)xoI0MCR3S^FKd3Mzir$&35HZu)9V$~5*Kk^r{%vt!7ISD#%fswRS1 z7x8ugQ&u(usOPXbN5Z5URhEFc|NLc;g}f4JzVjlUxu&$T#yH-Omy4s=$~b=B<)v}= z;R7RHY}oe#TExRVjM2_)jF*Q3%G{)3ZZqgSTa^}wnjk_InITrx)tW> zN_A5pLZ9CogVv`5^1_9Jm_n4I&Od-1kC6YSPp-Oxyt0!D zIplg&zC_?4NKvoQui_?BUY3EYOP5n0W0#hYf21a%4Fg1xeEs;w-CE2d_X6pd9A`2e zuiIRY)}Lqe0J(eXdpq{`UG}5w@h=I2qwDlnybY&n3-F)3(mWK*z~Y1=sqQ352UCF4 zQlI=T^y5Lp>gG~>1T94`()}Z4=w<|*zIWTL=+#(!PT$k6nPOoI-RVk#s?iWB=$tTc z;v`#9_oLoCy7W1j8Mn^hfr?}kDKcERb3jxH4>hafqve(?N%m6{o48;*Aj`VQb5)Ul zHK-31_Fm*+OH8EXSzh8{$7fljqN=ahTv<75(Rp-SR$Zz#EMGFOcXfT5%J^HHx8x@r zP2)nIWHes~>%OVy%4>O3(0{X?N*ukyQv5>kKb>M|32-D&p%1(V8j7s?3w|Lp63nOV z937ts^a~AioVI92W$?353}~XMK~{A}5JkKH5b=n9Ciq@IDBAB;Z!IUAV+ciiDvH*j zMD^3Dk+a${QM5$azio{#f^OHOx>LnJ+5kbRm4^N`5ii4(4>XD|b?3s1jrWv1Z}MFy zT9v+!?Ds9SiLUpcRnr?JG+C=^SKkC=BwXt~F8Tyir)=)czcAl$Z)2R5pR!H;e=OVl z8*}D=$~ONscK(|=^G~^sSitaqkw<2U?vov$hY7)fa=I8~62|7IuK10w(qZq9BnSjK zt$S9yI^QU{77(-&cteiu27n8-8*tNC&-dMPS#upD2hi$Q=J$O0#Os?xwTN{WtSzZC zp0+5nsTrDO-C3RykP7Y)6z8U{uiQ@973Pg|STBrbPO4R4VU>jA3ZJD%OK)mD`u%Bq zjUA|-$B9L(11X}nY*naJ%@8ESe`WsFWU8vR= z2;2}9@)$?_zbc_riw26%Kg!e8Kd<=z-OEDxpIr0*^LqcyFQ+uzy_6rD_)MF*+Au)L zK+sV!gc8RX!}1A93BeHY86igj>{s@tCS@2Inb@Wg|3Ir$G(TxPHZ`*>y-_zsskEEv zlcqu`YL%;Yn6XuOyEIg6vQ;HLymz>grb&Gw(*q#A5?6USh=@|D2=%(`I*cmsk7f^9^}}P? z?OW5EW$5ivagZURMyiQ!)dSTd0?Cq6Pu{r&OKRfiuu+&nj(M|bhppFk4ze_}sSz1;);PvKNiaE=q^G|5w^Vy2SN zBs0Xts91C^d0dq<=JmXesd8D;1K5UvF9?WTYl6d%lJqXxN`Pj}5LxPgSRE$%)Se9Nn;^;MLmXCiH$)23AiNRlj3 zB5S`@U11=y{xj(rqgS3zSUD^dhUILAwb|IZt>UN#gv=Rm63ig{MK*6HQPQQC{?1ODO*flB7}Q(AO3hFI}(g&O+0tS_v* zssss=fjAF6c7M%h{bJFcbm>-<=R>Xa4X{qGb3|a97zk+R8pO+p(k2^QM<;%(sz0y~ zRB?%#!Lct8vXEtAzqvF2#xo$NsieLB9TCSs^E_?X{@2BD7<@uv#vvJzQhJD^v3!dT zl|$vIA|g+p5nMz|Au5{UAyp|$2kfI)S~hhN0%yOnr(#(o-&bKg$Y+VeF{*sx3Du~N znZWwrE{QHx{GA?2J*uLTQ+AKA)Nbt+N2AXvftlF`pev3SOJ$4`MSDf=HiGkA5i0UO zd~$T7PLbVXMt2^U57wmD5}@X1U>&QO#B&jZ0J18_+exP+Z@5Me9xd0Jbq&L^e7(>X zNNZ(5fx4(0i?cEE=!j+2!b@EfJXIo&j};GwfS*019h#N=Yt|*|0J4`!D5 zN_q7;3^d-)FNmK&7&H^rwGK+yh}q{Hpt?|PFC?Fm#mlG5xknmlrQ>IgB05c3KF~=a zh6K*nAvP~CiOXlXY$wlxYQ8_)WN;>NeiQS5Mb-&Nuox?GER-8$-`li(QhmzUy}Keq zW@+_RPM`C|bx|r{2{VLpv4kQKehI>QOprT%3zknCxVb_F`5u!3W#trOn>06Z6D*XH z=M)M2!jWK4RGLfuttE%E2P@F6hVZljI&jmjn43^ zPJ~{D)br75_H1XB8(ej-Emk3-$#Qk8x9>hEB<9vjxJQ=EG&)&*v=3TD&pvVnxeR-) z?Lb+YlOky39f%jYERz8;%h7@zQH?O%8>!r^nUZ(>IPqq+lbCHA8Ax24#IZ@dwzGe_ zNr{+ocSoD-L2*Xdg%@t^OiJbgq#@1W&4(>T_SLJKpM5HrJSQaRRfbG&uyI9+T~>My zyWR{C12~~%bhg$$vJk%xRx<*^v~v)B^3%hV33i~-tUvA5Sfb|5i=rmc9n>)2!GqKa z^P&<_F>DtK$|77CJ5xuKX-Q%!OtxP3n%EsDQrn82M%6F*?l55XtzSVcMPQG0ZuQjl zmq*Ic&aackwk$S6PqbQ!TT;VJDSX~x&h0RoXfrD8&a{@qUZfVn6$ilU9V(GVzCpk^ zP$Zf;Ui%dnVGK2;ueF6kZ zFhW{mY7j^Tftei%owFtP`AO&4M?tOT( z;Htw$hS6rDA9#f<0l{2DA~U)NOfScqg!^m^q#5Caibizsnh)JfGIIAiSiC=S%J|_X-AWeS|ich7A5v3!>zaS0qG@+}6 zF+61ADkXR}zFbZ1mX?PdOp=@C9DI^|;2Tz^0qedK3>_4z?WYMY85qL(rt=Zq14q`G zmX)L~hGa0K_F1zeK5O`YjYkt&x-#C=rX%}-v%xC}Z95zssU#Mk{YR8Je z@U4Wha=tl!xo6aPg=VsfWT-Uw*s!bATd!Jrcam6JES#?b>09?3j3HtW9zjdZo{@vm z;Qsw!K~TU*LK!uvRJbS;OkNH2Wt%Y^x3I4&v!zodO!!r6#`%hm7yl~tBXG|sE%(t= zztYj^vC$ivB^+7S$l7s@do8-L_omu&g;hi4Q7^#p%DB);DAqKLC_yf{M--fbVCW4Q zpLSAJpyR=Jw|FpZ7!OY9&`o&H;FE5C-006%H7z?V^+c?EUl19l4m+%pxM%W-d$e~- zt(|&Ex@CFK^ihfbnmM|@OUuO+x=YOaa6Up`MZSv=z+ zj&v;Xfs>|(JoZyyf*n#2H&qEvkEBqz1th01TIY?cy1siJEZd%upf04|88q_e^UcqIJI$qO^tX{0Q=;ytn*d0;d>W zpbMg2hvsXQ_P18QOkwPq?4dM+V|(uRBPZ<<$bpw08v0vS$9$VUpbm=Fv(IMqMe~ij zM>0rOq>iZMoC}d%y?jB;97(AMLyv&6Zzi(5LIvB?<#Ywf0)mZ_~Rdangdl z&@8jcCHuwoEo63_;{rqY2HFx=n@YZylX9a} zl&P9Yv{)Lgc|b3Q1o2l|SANshLidoYfmF5?I`bsF`E$9kGP};}K?$qva#L^~CH` z!TFGfb4WF(Bq_ENC#V_OREgx>tR!Qa(Jg2?b%7g;M5AE-&>&(JHfZkcmN2s4eJeN!nCrcl9Way`gTk=o|nGo|BD1pGHLvB0ih$H-WM^@K##RBrgEQ`4$CSNzg z8QjInTy|bpvXE2PqeM9*$mGvZ!Ps7Fn?$@*V_0OIlsGq$7xq#m0A&oC)8WX5OB{I{& z&m4D92ULj=J&5P>4A>lRn(KPS@|aiq-&TfHnOC`uYpkgbZ!za!sgrKX&HmC&DR$Qw znLUwmqe#(ab!;OBsne)NG--Cm>qV#<+25uf(vCyt?AGIMoJse#4t}n3bFn42(girok)X zsLlF0m3f3uPV@^VjN3J zs7vW$dREOUH=t;vnxK-_6qp*ejG&zM*m*>v9wu&xniWe@+eJ-67VZtoVET-b0X5{6 zr(c*Y=7z@KB`=B#zMR8)M_(&sn@t?LtNkyD`lrk0nJapT+`Ued`PVEyOY{v7f2Alh zxP{mY>C3kmqt~@Sx9=weAH3PUD&9e;-4Z?DM%u2JrA~7?nOo3Fg!@?ilHRb~Q9Vh0 zS~k)vttP$Xy9A>{?$-j{oKIM^!~^qOk9nFfO9U;uX<{Z}MGPU&T0}pPw4d7EHF*^c z(1Qo888T#p5hW(|Q-(yg#r6vVzhg0gpd>56bb9oH0wu}%3M)p2fxFLEy>QG4R_-h8 zU+Al?!eBv?3%sHzLA?4>j0E@%7$S|RYf_S$ylY+ z4n%*ot_mG#p83HvVERPUjJRH!Ay-9T%yQe2biJr+b%|?XeE(`??bZyWEqp{h5`F<$ z|26&q>X&o$0crC>TI-zNN~}*w7-kFnefLs z2fQs{{%-wM-9ryBgJ*Iuv&{5yuKy+Eoc^si>??Jju|gyAn_Uf`ajXB1%g`EBtwiQ1 zx^awk%lc*V?-yf2mx&<2oHk?3d{TaxpMu&Sc>d+t2h>+*DNg;iw%P+Pbq56MHt1{8 zuC!j;1YlpBL2hXi-rks7|L=db0Mz7?nWiEF08stMZRP$Sn6!kAqm#as74d%`|J5u1 zZ`hY{-1iNNl z1=2bj@r1^~3~TeQTAAId%fY2ha|!FRU6VMpiAkkk@VViqVwhBxz8SBI0v70InyyD6 z3Bn|Jj3nVomoatTh{xa7jx;yvi_UnW_#l*M<|9E)rOc4j#iVycL>cKHTtp3#k-nKL z+7?|mS#aSINetxl?nE8)%Zyk>!C1k`<{`huyPwZD2`YbK4!99|Okznl56^r1}88nU&cpyn*~f zRP2FGaX0@#FpvKuii!WfqnQ6~#DBA2l_uoxjK6W&?wmdns)%IKg2?m;9KE4d3H+J4 z{P-@21_oU4WQ76zv4`7rf2c8VBqkLlTWX8sn;VP7*r9$|Zvr<123Vyh&st-dNnOt) zxtL4AjW-w3bde9fPrdt&)f0toUJ2&UdD?Duy5Ap7dEF=0V82i93p+Kxkri{*^!QAa z`)V#?MO?Egc}EaN?0rV`N9>*U}noU~6E-WouZiR;Mgh z;i}OVBurvrDpRj7!i%ICbMj)VT&(w5JB7dEWs8$MSfbZaa1D^jw$rlh41JSI!*+g5 zc`HjldKt~dEdKiq-t`OW#SHiFi#h4kU3|pR`S;CF5SvpIp|Cl8#>|qEO zL6o_yj`uN0$wSqXQfj)_qWIKrnS3$j-u8y`GrF8k5xy*m3E_xC>4xG+3@28lsi2dl zG->G?bNPxG)$u+RlKOK*4722EnDvKFTfCP}MVn#i1AP7T_HVVXeMTs4JO zpT_!OPG@)cEQ+es9a7Q~8ZJxuwg`RN6PqI_ZGrR{=g#vc28nWQy+I8dcb5dFR^-u; z&&P%sTVJJ;F`R;9s*$hDbF31St>mkHWdp=P*}5fF!x?lQhPw$TMi}e=#xDm^PWJok zBklIX+F!cN8)z!@No~Er@9ywmEwj?-&7I}xh?Aw0SPtK(3EQ+5LHqwwu+}k1p;#vH zrvh`dw3QgL-4@kIQ!Av--?{@#~s8|+dQ;(;Mo#ndpY6spn{3TJBv8{Ee0%vgX2)N zCCV1=Y(p9TH+hpYR^mG9QF6nF>tHb9wDPpXRlL7F+QvVV*IK(W=+D|wiR-*I;elS7 zY`O=x^{a5b-2CDtug6c%+y!Jb>;Y$1|5k+KbP-$ndnLz+PK~0IJ6_kenCmP!NG!nT z0oX@l4sD#DBU$@kjnc{sh4baeOf!mqY{x0?+@X-P%tFTkGt+fK8Xnl}SW!g#bX7&^ z+2;eo?q}&im*rirs}E*eubvzp8ZZ##(eDL0O^$sfaX!0;rmj^d#vG<0v5$vbadqkM z;c@S>jXq)Rz%lvuo_XtEk0U!0-X%0LG%_Oo&y;sC!y!Vzbv!1e%gjo7+E(!P5CXQg zglw~&%zv|GAITU4^EUXYL*ba5L|+fG{n2f#<$P`;XXQzw!rFG>1xIQtjYXPCx$0Tg z_y1H9*k8*NMu;cG(T9I5k|_z+!6-KvLctWLG?awCF`Wto6>5{_B*kX_J!#TlRfW|Q zTxT2;H#0}=YR;55U1N;$dTp5H%;k}GCmbbyfA00QK5!SnK;wWT_=y7G3YX(F_2ej zekKG-;-FFYlnsInfBS-ue-l(=JyzlnCV;dv+bFa!pd>$1xZyr37BgGGzr|0+^O~0j z15^}t&e-E6dU|#)QNVmuka5beLq1^$=n5hx6Mg@fLV!rjf(f07zjUyE!{MRr^$O81 z9c&-SdtEZ{pn(T}h6ZnUS7wPMBn?d!5HMe!BHRBbb05=@24O?2h_`+1 zSkky=Y6p<;hK&MFs_UV3Pi4-ZFlQ5qOdAaJ4>=1O04Q<~*!bCF?FPS~o{er4?b z@BAktYAQF=_~SF#TF%vAsN~HdgBetV+7Sn}tl<@KS7SOg0f&fC(;da%oL1YWSL+*m zGM#5P_te#*^#`lcd2E#Bzrd<*Ozyihcs6GM{UIN@;iOnS-MRs~qr?3IfIIow<-ibm z1axfeXk3WdOtrvL9~RrkL@RPE27Wm{vO5xg=Y{Si6xRMyB}nHWVL(7VUs(tiyCf+=eFX z^v*e{k1Tj6MkZdZ0LiaYY^zFpCUo+Dxx=bBlNeU*IS#VeeOAzI)Vt^$zh$j^EZMHM z**h+Kz~xZ6N@mz-#ETTbxO`K|Nr-N;@=2jQ#7ZgkFx(W;GWygjB|Jx@jU+qS`t!IrL_@Mh#X_TZx%@ z^4p_*L+-*ol_Bw(5gpCY^}j0qLkVl4eKqJivQEuSwK~_wQU=a?(Pr}B&EB% zySux)K|s1&x?55}O1is2>5>k~O$h(?yyyFj*W>Z~9|mI&_Fz2Mnsd!nbFSyU4NmP* zk_r34gxePNOJ$h6cykvyCw$qW0>}3|r&9U*AFcQWu@^Z90;YM#zVCO^+rx zNH@pXoqevqr|SqP@$wvXr8J@&d_JP>=uXmMSW8G@sN0shx}NXhJ^U;k3^P3*Y9*{X zT_){Q>`WUL%w79gi?=u4Dq=QB^rnC>Qexc!1mCKET58qi_4>ylhJterN@VVP&{9R} zf`VGjgzL=<92XlYXsi4V{!C1%tpasaKFas6LJV)K-=vfm;P_v(pq!FX4Y?&YsVKhO zR%%faHzRDbQ!M3E;64T2WnRzcuczPxKYjJ4E?oK+r6|}!&xa}zY4)CB2A?|sZ9Z0a z|7}5bo3I!eu5axh5J}j*49lzaa_Zc8rw3g>pdb(cSDK@($H8DyJ~4-_*`cwZ$s? ze5h6-?o%Yb`5-tXa|0?FF6Y2tk6?PhbB~VSfa6cTW01)6;9^4dE+jka44m<(+qOx| zS7+%A4{cV1vYAlL_6DE@7TAVxXLfPEJy)0APHnPc=nL6sYxCkc(#=FY#J=VU)@bgA z0_~_L;7&Dz1PtGWxfn&<4}Ma94p>_udw=f*7k4kv58VQ0lC!J^kehlmGtWV4Mi6UiYHz1L*lE`k@;g5_yK$-= zZtu<-NFGqxlm4JpB#T7g%Ex-iNmQO!&y7g$cHfwbO|=&7md}4l4Mn9|n24rEQ^>Ux zYO+gTedMAD(2~_1Q6k*FOpy38A*yn7gLcbXj?+s+U;2tl$BG4xn$@hHmfNzSfuA*V zDR8OI{FbT?yi6r34Q}@hSTAGKo2ggB19-#DmV2x|Zadz2|rHCQV8f=qYq3S-XQKr)V!L{fbjC(JB{i1oZ ziF#JsGKmxT>@0|5a3}*}b2#dWUIr!i`8n>4;r7E*)&qvB!SvEbZkC%_T$i>HF_iTK znSw(apn9nYdcK)KaXd!E__$?es}T}>(H*ztldjGo3~FxJOQHIwDEbA;V7L2u0y+iR zI z`Ta|+1SVzj1fro-ACvhOxw!`lkeVnt+5zUv+2Q>l6W3DEHS!?GkLeUc=jF=*DYi;4 zgAmXvqwtL98S&@oBP*(OL2;6Q!{jJ!x!SIzc(UKP=n25KVnzea3MJKb=3u8Cm>iLlc zo>?@$-95+WQf~)EAZt_5R=Kx&-+eesXf5(h%iWVsgV-k<5sR4Bt?SzA!_Si!Vs17{ z{6tvfF)5Sptk|88Zta~Yi^wNgFB3D>72<4rA$j}O^elvaJgTjo4ShF~YmiNpHeGbr zyKXGp)-!&Ibd!z^zbI+4QbF?)fGbwcwDyLFza9Z}=ghoEC1>_-5DRf*_-4`0`D_3% z-j$9^NUELnMfu|?&hgFGHu3n@;Oi!chfyGFC1tj zysM2L<;pVB&eZILeivP-DG6^E!_0P@Pv$*0)yMcNP8S ztipdgy#t~iDVyOeruzZb?;xzt0NZ53utk9^3ZvN}(iFQco`XI5+!2~Bt*g7s$UI9V zqTk}E=N|5KTZK~u!6+3ngR++0rc2UcL~b2^1ySOpH^5EkBa;19dk^IoLT_D(^eYV? zh)u!~KjQmm97L8GO!T6q$6zM-+4)P@I(QCal||#8B$YWzh+EnD6~{;lGD;KM(2Z~x zbfm^>#(c>3<`9QS(Mb$0_NoT37Om8`p*ft5u4+)-eY&scXqIdG8ph(=r%k3w~PVLOXd zvY%SJgzTUS)}20bSmIE#Ku2ArE#^+hFkz~5s)Jq}y~;DcyBxahE*PlD`+}A(u^rn<&8zczVDn%^A5dk-Vy_mr0qL*uM z+kH(G>dhnCDc>o`r?(AIs+^*rfe)ECTkV3CYD3Q#19fXQhe<>BD4P`WFJ{4fglrGp zMC#o(hLNzR_6BG%EOWFS0kBYlhLR^aX`ly0}L;y&ATq9Kgir+g(JSTR7eC^Kd70rtk@Qwh@u3M8?jc zvgkQ+ER2q@6iY?Es?2yUOPXy52HHmmw09OlCy8i1JSX$cFQ?Kz?WxLaD*;xXXdOZ= zBkjariS2=U=4{ztOD4WdLby%7@-N=%81G7r_onmAC}*~wh&dH`ElcXAaT1YCg!*3c zydPyIQxoLY1}B)t!AYV-sVm|=v@yqXQI~?W4Le?d1`+uZEGOQ|ee*VGf zrT|&74wW?}lFB{`V02N9RseY6=RHwR+vczuOFPU6KW$IutXl`cwNkIGa12qG zrJ%bP3TNk7J?}yS3x6XEWxoN1EKl;n-Jr)OR82@8A-lLcqJ0m!DhivFnJu)P!CIZozRj3Dupfu>UuxP6njtRWN0x(t)#GPjJ(W*QX;@KZebajIc;dm zCW~hL0jRsrD=aVq-P|3Oy{?-lW2lzd!ihrjVFr)oLbOS5oQOiE*S-!;?Lbx&bB@wB zIBCNkoH#5Y8I#5PlHx>EpLUEIfBnTV;pU3R%nfkZ z!YFhE-!>M@7lKEDX})s?nHWmd;*DDNM6GEm7PaY{ePtQ7vU*E6^Yo7t_xmKXg?pIw zLetbL($kGYR?TwDFJ{6?y@??DP->A;k*WI-u5h`r_Fj=a1?c8CaYv_fx+w3Y&sz)# z5l!Eerg8T>?FtY$ym)%@xf}a@V)bx@rCghzp-=;#(K|s@NOO*IZA)NzB23n8Oyp`N z6Y_)!pjq5GpOl;|9mspLVAjuk4Swf>dB>Z+oWGfksTiJHt6LL8{)`TN&}5mlo&S@f zn?k$j;4E88b8ms}U06xznINvR%znonws$*X0nXu~KR;D&0=; zq1MxLBj~1VFmZ3_rpJ&0B|edG0LL4z$TA%JtOE-~IHfCXompV+wy z8-&6rt-RaR;6BG2HZ5IoYkQ!W1K80!*5H1C5|T&@US7!VmLWU9nG%2IR0sf%g(q;p zir%R2#OCiM-FRbfu?u|_l)-Q7I{}F_K#B)nXF9wXSLm-9xO`&}clEL58GaMK6`1Uo zQKob~3zs=o{h-kD;27bhfCkdw{8=X?mD$rB(iIfJLV2z}Inma$btemM>{3VY_dH`c zRmH*W_;0{4Bi*0y!=kq3gCg}!KzsqQv(?<&2%Y|52_E_JZZE7axCF6;pWKz-h9;(1 zFEg|lBDp{TkLtU9pc8X{8!)$h;lT}wYiX`cFvH{sCC$IJ1nrkGsX1R-c54t zLc9jBHVaK(PZqQAK)*w|rQxaCi@4yDsR;BKp_0+QMY4^V@oQdty=y?g5jigp7$EqZ zjDUR~x@7qfAlguTFi<0JZx{E(?05$3ZrE!(`+7JwC(6-O)0zPfL-;9#k~GMZLtGy?nM#)>2+T`kNj ze-Cd%!Vd{3rx0cOIo+1L-plN7F!@)*0?vWum?{xsvwILKF<=UycOWzqNrt^1DAHo{ z&>l4+Ab^}}aY{#leq4;cq6#<-V$Ho7UKVZ81@Wh+CFOY)SxBEZUOMd5^n&4mJBI5y zhiL&%RP$EK=dU%dsx>v_%dKWSAnH{~OU>To6_twC8@+RTFwOV zjN#5sZh{G`WWFrn$+vV8xa_EdxGegTh$iG5fdf8|IkR2eF_u{^F!2%tv7EYty{ytY zfTzxF4)ngPoP_WTG|Fer08u&Q$%>o}_7yWw_VUke{^I-nDIPLL`#{~ep5)0hW*8ez z$=vvIc7ys0bTt^Z4cC$pSAr8jP+)*}S0n5;J4~41b{%cIM*fv_$1_a{7~CzEGF*%a zmo!~DyV(mH=a!>N6aTXY|l>8fd_G+w#(nF|q5jcLBA z13?#dl>PPCA}RNzqD6oVO(@OKym{I-Pa5JmLRwqW$FBiUBnL+P2)@~J(ec|s_sm!R2@$OKicGYN*2GqU(J&T z{Lqn)*=vxuAX1Gv0Dk!C`pCTtlDrGq_gKcHI?^jian>rS^UL?G0{-ilaNK#DTyw56 z{Mo5FbQ?Hew~5Kllovle5o!-n7?EA%~9 z%jQnBip8H@%a9KGo;gZW59-6s%P>_Y62@fk&z9tt_3vec<8wZNl}y-DPVJOG|Iin_ z626Fx(_8z21@R?Y6h3=m$wyZ(m0~u^gGm$C_>_E9bIWd}w}}Fi6`vO0&SEgSdVWB! z70oGSTwI5)%Dq)n3w0Upp_=|g;_;3OZw=}>WJUsdX*M=A4EsAwYD>0ZPrKc^Y`%(P zR4QJgyJNu4aNup&3279U6_ zdbsfLmw#jb+-(ai0SJf=$M4ESh--^XS307Zgwt`pJ8{}aNm%u@LRcdGx zw~H)F7#NIpX{7#kW5V(1H5 zz5AdL#5;!Xs~elu2h{fX{pR6_V=3+&^ruJ{iTx$`s^O_)RYD@?{ol+}(o43PDCFcy z>6@z&ig(9lnQ&Je#^YG*qG0nV5izc-nDi1Oya!vptC5L&xq!LbWas62!Jk9@Hgg$u zcf|NzytpAfC_?Eo)ZG&ywyD+)KyrtAk@F|5=o#Mda4t2W8yW1la)U@5zE9jn2t8L( zX81%5B2%>F4iIQQ*!=|^;t?PSN?@8gFwrSJ@S3$#y8xt&xUbuD-u=7}9#eLWR72-qTT@xu+BTcA6}iClYMq3D|3PS&w~_olnHK zbbUG}X3XIIUV2VpcbYSqR^lWK`E;G4pb|N_JYdhO-P9g;3Pq zx#XGZHE!5Xc?m~}&3$AbIXJZLI=xQV><&VT5CXbQ&*Kz10ue(bo$2A61QOcN*>`p;EOKRNXLPtn*{8w3F-Cleb(>;Dq;Q;C(4 zd?J7xq=(1C&}V+H(IjuWE!QWIPhSF^7YZk!fUfOIo+QzqwU^5k7P>3Y8U%-;?GA!O zHYcntF5ohIP^By2K2uO|W-gA~czK@O*61M(U{K*rXX`j+=FR!L5*bC z8%ZNoC}V;XL!Kpb>sP)JkSj_sf;rwMx2$<+g%bK77T7~8tSw-VD@GV=JA)2g5Hs@& zN(X^2sMAj;J;5fpbBvQ$s%Wr@mKo`t|+60qbQv%_fRc(1N8*2fDS zc~Y)?i3pyo`Y`?2GK=TmHMB1Sk?@)-KhzR}Oj=qWo(Ut-uUx}_lC%xNatZzBfmEBJ zSB2ILfPtS-VxP5RivoeD?|F1}MKFC}S2DXwe+>&i*)@^(pNc<0Ylm@t;ENoizkQkG z#jnpbKyf#qNVcsT*VPwT{GWW9AfDFmg(z^eN2;&JR3~wRYIg?8~`b z6w+Q}ETeZ#j>1Z?z5425VK$AnXI=J;)o?YW1AC@*n=7rc0xy8rmLo~Jcb!bgn3ceG zv1@S2g~rpP*}ia;hD~CRV%Kn2XA_Ux$o_4-22CZ*sM5r!eGy6Peeyw==5WHgAUBr! zfvRYibkq^Pj~pB0`BIi)Xx#xu3H)+%OM`sS+HY@3+2tFUh{#~*CgyA#2A6>lqfn z6S5O{6{Wk3D3`MS+HG^VfwulGBaN;h`#huNIg<4%zjQE;0edb^GBt_26eM9Eg~2<= z%x&8wNd;sz2J(b`T`Vn+b%GZu!pg_&@u44I_b|jc_M^Ast*GX% z~cER`C{E`DzN*%y4r>@ti4A$Le2~6EEK|BE&%nFopIQQ zN!-D9pX<=ija}?3M}Wur)SnR4!Q^=N{TZI>K-5OX+PuZ@ecEdP)O|3 z;Z49IgbEtgSJg(*(Aa^$Aoi=5ZV6^_E4HzP)mn?bbRzqSk-Q@}P! zU^@l7uS{R0FQ1#*uh%#!jP+VDBI7|deK+xz-o;cMwsFQa_N6oU`m|HL^uTLD=QXI? zqFiDND9*>fT!W9Zuh{5;R})jH-(6Au;dQ~kD`bIM)20??E{+DjC_(m7K9a=~L+3%m zmtNX7LSUw(wb78YdD4gQYKDwb0w6BK=Xyc%RRPAvWSvJs>w0h2R385!%w)PxhWr&M01bMie zx>a1ez2u_4;Q$qR#^a%(z`bD;W}PcbW;gZp$;XJ(jj16;20aY3xp5(V_)^EWM`}Gr zK#ADYB0DVWY&9JP_oH)FDL~K(Y0HNT%jo5+7MAC6`q*B*BqP)IfOA zSs1}p4ht#5?g87B?XYTl`HxLvWh($kg4e|Fz2Zvohr;hXR?n)(=s&V%ugp%$J_YTVFooJk<#&j9b704}aM+b!QM* zY2B{6NUDF@2GpzM?B-{6Ghg#rk|qw*Qr=FO%CA^HN`cxwni?*?^I8;o%^2I|#b!@H z!~kFZVrVLm*xR}zG$0!nJB)j{!+gufR3EieNl0$mvb9e%%PXc-huMH^XTw*p?1 zYyBDhW(uaF%N2hMyCTWakzvUi@hY_+R8p{u`b*vcrP^U z_*g|+yWK|d2olI`sQ^ThBwo*25*7;P@yH3tB(f9HU$-isz0RnuWHIEzUyNIb?n@Re zv$Du(b|ul3b3Fq0U>?6%DxBrqHZ@M!(Q9Sr<$XXSD&RZR=lmi8#WaVOpR03FJ!gJX7}xq)vi!L65L~h`COI7w7PQN!xMG^TmKZsOTAK%u z#7EYSymBa>Y&`4@Ffm&lxog|JGhG>BPx$u;Ig zhanra)@5TBV{@8(le)od=MZScTHK2=8cikHIuNW>^0PQLiQ-@U95r?P0sc?spnX8XB-Fwp8ZN9nk*gQNY==j2)0kCP> zDS3wH9LV%ani_3bU2|xy#zAU$rwL<`uAe~6y>{(&G8kQVUiZh>m`rur~bZ0XVL~QQ(q<_ClM)5o8+`+95hA?X0lOj&2f6?i%}xEm~y3R zZA1w3h^*;MJ*GFdRrP9o(a}EeSy$0MRB1H>ND#EI?o(ILX|D1yXsML7Jz;PiQelZ+ zp!i9t0BZQ}Y0c!zH|4A21GdDR7i)Cpg{XY}^=@lm1vWb9>y^p4F^Fj{5|XH~U(`1y zf0U&kUb4c0uQ(#`!MNRwE;%*DP}`saRhM}Q@8)WSInEKkDq_N)ih@A^4cDIuzpTR1 zg1^TRqQx;vVRq~}7XnA(a3&`_p-X}Rp+M!R82&a9yRuU2)qbcH!*(OuBG-ZxL$7^3 zk&b$I^~5I@OdQRRR`nvwa|Z8Ax*#R#RSH|9#$u7?>1oDhG*RHFDlwSr4bi&61QLwz zDLzl|vh{cbR+{+2Riced&uLkYy9`dK_ScE8u`N&ueqg2cUruA%=)P)#35CF58vwV> zIFPBlmMmvWShXzwjAC;X9Q9dnE`&F@@U8Utn=nx1ySEfLX(0((;LiiMhO*{o z332vyIVs;A+_1A?y(oW|?Fl2oUa(^_iON_+oYqiYgd}-iq2eyFl8e*2C7b|Q$7#)w zm1s2=sH^Fdv2u>d+BWU{?4KqFr-5CP>KbEH1xpYDVVij6M-c8AG=ym^@?d!I(P`9u z(W@77VDq{wy0<#R`)C@Tr;x*YPD61$^u=U&KnFrtLk+}c7XYQ}!}&%5t49-o8#I6j z8$BWc@|_PmISg)MZFq}`=(Tu&Y0*gn=!zUT%R6}HnzGC1I3zr#o#GHqMQG@>OzQj7okNAF z(psjhjkl6sE-6TI^GhnVg0K&Qnd~;28l$D{!$=pSZL9m)_hz5f__8{k;McQxsl7yL zoV4+ZL@DetHhsB+u&|Sr*#=j%+t!eitu!F$RMK>tLL_&GeKR_!oe^eQ=FnS3U9fs4 zI?FrCXlH>RT``+eW}G!(+Yec7JR&Y?WJi( zmoa%r*|6?kWI2MyMWFR&UR94W?=gsTJxJ}_*g_YkdUWL!owBrj-lX=Hx;)8+BIbFr zftcCqOWQ7{96mH7cGBrD==xgg7+$j^gyKT_a)O9QZ?{T>TX!jrkd>J#Cm|;2;tO2| z=43{SY5NJhTQKQ*&oeNy$u#WO!de&b$r+usOzH|f+vA&o_9PCcYXVad((7s>b=O!Z zxvTY)LL%1i&SDV@+C7(o`!I)3_ln}{m?q?=Y~@fKh>zj!lY5>N_O3$Ml2U5KPx+(7 zN0LYrf4JaN?NRvbXSVht{+PCc8`(XyfG??_f2D8e;jKH>`WI|T!;WbjqP9zrm*ZR7KW`bM%aMZ4>;lijsSslVlc+pT}&WfxFuQSMv0}uM1%mqJA$7GWa z6pIIode$f6LrBHlm1tMmunGE`=P4W`HIGYvT#t8kYINF0AA{{c=jGrCMA7YO`<&7m znPRW=3T+R(iyAEZD5LAgt+0a^)JQ95Y} zArV<65fxQBr;(Bl?f2HlYs0 ziGdJ%;O|#epl^W;RG_kRG@~>7OHhi=$l8MLJ1b@ZM>7{2pdviba?Qm47dPlXx4gn5 zAS((u#k2^#&-gl#^es}6f5-WyC+g41pS(8g(F7)M06uYiwe0*BfoQ)={+9!*<1+zM zpe4zFKtG#={Y zWh|VWfPQ@cp#n$BpCHi$Fq3A1NJ*f0`j5@bc=iX#zgcbujwXNJ%$8|Sv|Ql8_W^R* zf9Tq6-~sy2ga7Yw^MCDC&}KYbVj#*CIDmc}rk9j|j8g*IG1;2^%l?~tkPAUa! zoPSK-#rj{#|LUpVSk(V~Fn@15{M8crTjX;6d-DGbxPRIH@BK7?9A#=eKOijruWrUa zH|Ben#;-;|-(p?xH>CfwTj$T*@7>LQyk=br|G@pFquD<@LjKJ8-uCLNSK7B=k^Fbg zA3CS~4E^4B>8qpGw|FJ}1N48^U;fBn>u1XM)-XTrI(OM$QvTNt=KtpC^fUK+i;S=aQLge^)V~~G-zzMBoxuDS=O(|*`v;1gKX3c@GJ`*k za60qfF#ev4`Df+EpE=)Gb$=Bt{1(v`f5!Qj&icO6_{Yu)@%|;?4@$*8G>EADkeqE{m7WL`BO#91q`=2-V`_;N1uP(+}zs&l(<<*~)e?RN~b;0jj z5a;|l`5!F*{S5hjw(!SY+EDOI$ls&#chmVlGroU@`a19UEsRQj$M}a?NO>s;-~$;5 R2np~f1o-$>Q}y+){|A@R9n$~+ literal 0 HcmV?d00001 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/implementation-report.md b/android/implementation-report.md new file mode 100644 index 0000000000..9102139dc6 --- /dev/null +++ b/android/implementation-report.md @@ -0,0 +1,101 @@ +# 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-03 — Android app skeleton: theme, splash, bottom bar, apps hub + +**Built:** the `android/` Gradle project (this module) — Kotlin / Jetpack +Compose / Material 3, single-Activity architecture. + +- Gradle 9.6.1 (committed wrapper) · AGP 9.3.1 · Kotlin 2.4.10 · Compose BOM + 2026.06.01; version catalog in `gradle/libs.versions.toml`. Note: AGP ≥9 has + built-in Kotlin — the standalone `org.jetbrains.kotlin.android` plugin must + NOT be applied (build fails with a dedicated error if it is). +- `minSdk 26`, `targetSdk 36`, `compileSdk 37`, arm64-v8a only. + `useLegacyPackaging = true` so the Go payload is extracted at install time — + required for exec-ing `libskywire-mobile.so` as a child process later. +- Brand theme (`ui/theme/Theme.kt`): primary `#0072FF`, white/black + backgrounds, near-black dark surfaces, dynamic color off. +- Splash via `androidx.core:core-splashscreen`: Skycoin mark on white (light) / + black (dark), 250 ms fade into Home. +- Bottom `NavigationBar`, 5 slots: Home · Chat · **Skycoin mark (icon-only, + enlarged)** · Wallet · Settings. The center slot opens the apps hub. +- Apps hub: adaptive grid of rounded tiles — SkySOCKS, SkyVPN, SkyDEX, + SkyChat, Wallet, Fleet, plus SkyMeet and Pay-with-Sky greyed as + "Coming soon" (no click). SkyChat/Wallet tiles route to the same + destinations as their tabs. SkySOCKS/SkyVPN/SkyDEX/Fleet push full-screen + routes; back returns to the hub. +- All icons are Material Symbols placeholders behind `Painter` parameters — + designed logos drop in later with zero layout change. The Skycoin mark + (`res/drawable/ic_skycoin_mark.xml`) is a geometric placeholder awaiting the + brand SVG. +- Placeholder `BiometricGate` composable so the navigation structure is final + before the real app-lock lands. +- Root Makefile bridge targets: `android-apk` (assembleRelease), + `android-apk-debug` (assembleDebug). + +**Verified:** debug APK (44 MB, includes the 63 MB payload compressed) built +and installed on DM-B70104 (arm64, Android 15); splash → themed Home; all five +bar destinations switch; hub tiles push their routes and back returns to the +hub; light and dark checked via screenshots. AVD `skywire` +(android-37.0 google_apis arm64) created for emulator work. + +--- + +## 2026-08-03 — On-device smoke of the Go core (real device) + +**Built:** version stamping fix in the root Makefile — the mobile targets now +also stamp the repo-local `pkg/buildinfo` (`MOBILE_APPINFO`), with a +semver-safe fallback version (`v0.0.0-` when no git tag is reachable), +because `visorconfig.Parse` rejects a non-semver build version at startup. + +**Verified on DM-B70104 (arm64-v8a, Android 15), NDK-cgo payload:** + +- `config gen` ran on-device: DNS + HTTPS fetch work through bionic (the + reason the NDK/cgo lane exists). +- Visor connected to dmsg from the phone's network; local API answered on + `127.0.0.1:8000` through `adb forward`; auth + CSRF enforced. +- skychat, skydex-client, skysocks-client reached **Running in-proc** — one + process total (~88 MB RSS), every listener loopback-only, no RPC port. +- Real traffic: desktop browsed the web through the phone's SOCKS5 over the + Skywire mesh (skysocks-client → public proxy server). +- vpn-client start fails on-device with "could not find default network + gateway" (Linux `ip r` route code) — expected; the Android VPN glue + (VpnService TUN handoff) is a later part. Failure was contained; the visor + kept running. +- Sizes recorded: pure-Go 63,242,536 B; NDK-cgo 63,237,504 B (both ≈ 21.2 MB + gzipped). + +--- + +## 2026-08-03 — `skywire-mobile`: the lite Go core (one binary) + +**Built (Go side, commit `ef43096db`):** + +- `cmd/skywire-mobile`: one multicall binary — the visor, the CLI `config` + subtree, and skychat / skysocks-client / vpn-client / skydex-client. Apps + run in-process via the launcher's internal-app registry; `app ` is the + per-app exec fallback. +- `mobile` build tag strips ~51 MB of embedded desktop assets, each with a + graceful-absence path: GeoIP DB (30 MB), manager web UI (6.8 MB → "/" serves + a one-line API-only page), vendored browser wallet (11 MB → /wallet/* 404), + tpviz legacy UI (2.8 MB), browse handlers. +- API-only surface on mobile: new `hypervisor.dmsg_ingest` config bool gates + the remote-visor dmsg RPC ingest (off by default; the Fleet feature flips + it). Desktop behavior unchanged. +- Build/CI: `make build-mobile` / `android-mobile` / `android-mobile-check` + (80 MB size budget) / `android-mobile-ndk`; `android` CI job in + `.github/workflows/test.yml`. + +**Verified (desktop smoke, macOS host build of the same variant):** all four +apps started in-proc from one binary (three Running; vpn-client blocked only +by macOS route permissions); authenticated API + CSRF; exec fallback spawned a +real child process; Fleet toggle verified off→on; geo lookups degrade to +"??"; RPC port closed; SOCKS5 browsing through a public proxy worked +end-to-end. Binary: 63.2 MB raw / 21.2 MB gz (from 265.6 MB naive five-binary +payload). diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000000..363b4d9621 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,22 @@ +// Skywire Android app — the monorepo Android module. +// Android Studio opens THIS directory; the Go payload lands in +// app/src/main/jniLibs/arm64-v8a/ via `make android-mobile` at the repo root. +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "skywire-android" +include(":app") +// The wallet work adds :wallet-core (private git submodule at android/wallet-core/). From 1f9ad959dc0721652f0a57a615f890d0c9aef249 Mon Sep 17 00:00:00 2001 From: "MohammadReza P." Date: Mon, 3 Aug 2026 12:25:57 +0800 Subject: [PATCH 04/37] =?UTF-8?q?feat(android):=20brand=20assets=20?= =?UTF-8?q?=E2=80=94=20Skycoin=20logo,=20typeface,=20designed=20top=20bar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Real Skycoin cloud at res/drawable-nodpi/skywire_logo.png (trimmed to content, #0072FF = theme blue): untinted 44dp center bar slot, splash icon (53x40dp layer-list in the masked circle), adaptive launcher foreground (white-tinted on the brand-blue layer); the hand-drawn placeholder mark vector is deleted - Skycoin typeface (Light/Regular/Bold + italics) in res/font, applied across the full Material 3 type scale (ui/theme/Type.kt); roles that default to Medium are pinned to Bold — the family has no 500 cut and they would silently resolve to Regular - Shared designed top bar (ui/components/SkyTopBar.kt): centered bold brand-font title, tonal circular back button, background-blended container, actions slot; all pushed app routes use it via AppRouteScaffold Verified on device (light + dark): splash, bar pill, hub, SkySOCKS top bar, launcher icon in the drawer. --- android/README.md | 3 +- .../java/com/skycoin/skywire/ui/SkywireApp.kt | 16 +++-- .../ui/components/PlaceholderScreens.kt | 29 ++------ .../skywire/ui/components/SkyTopBar.kt | 68 ++++++++++++++++++ .../com/skycoin/skywire/ui/theme/Theme.kt | 1 + .../java/com/skycoin/skywire/ui/theme/Type.kt | 54 ++++++++++++++ .../main/res/drawable-nodpi/skywire_logo.png | Bin 0 -> 35936 bytes .../res/drawable/ic_launcher_foreground.xml | 36 ++++------ .../src/main/res/drawable/ic_skycoin_mark.xml | 20 ------ .../src/main/res/drawable/ic_splash_mark.xml | 33 +++------ .../app/src/main/res/font/skycoin_bold.otf | Bin 0 -> 74840 bytes .../src/main/res/font/skycoin_bold_italic.otf | Bin 0 -> 74740 bytes .../app/src/main/res/font/skycoin_light.otf | Bin 0 -> 71664 bytes .../main/res/font/skycoin_light_italic.otf | Bin 0 -> 75260 bytes .../app/src/main/res/font/skycoin_regular.otf | Bin 0 -> 67292 bytes .../main/res/font/skycoin_regular_italic.otf | Bin 0 -> 75060 bytes android/implementation-report.md | 28 ++++++++ 17 files changed, 192 insertions(+), 96 deletions(-) create mode 100644 android/app/src/main/java/com/skycoin/skywire/ui/components/SkyTopBar.kt create mode 100644 android/app/src/main/java/com/skycoin/skywire/ui/theme/Type.kt create mode 100644 android/app/src/main/res/drawable-nodpi/skywire_logo.png delete mode 100644 android/app/src/main/res/drawable/ic_skycoin_mark.xml create mode 100644 android/app/src/main/res/font/skycoin_bold.otf create mode 100644 android/app/src/main/res/font/skycoin_bold_italic.otf create mode 100644 android/app/src/main/res/font/skycoin_light.otf create mode 100644 android/app/src/main/res/font/skycoin_light_italic.otf create mode 100644 android/app/src/main/res/font/skycoin_regular.otf create mode 100644 android/app/src/main/res/font/skycoin_regular_italic.otf diff --git a/android/README.md b/android/README.md index c66bd96367..21d839d43c 100644 --- a/android/README.md +++ b/android/README.md @@ -136,7 +136,8 @@ android/ │ └── ui/ # theme/, navigation/, components/, │ # home/ chat/ hub/ socks/ vpn/ dex/ │ # fleet/ wallet/ settings/ - ├── res/ # splash + mark vectors, adaptive icon + ├── res/ # brand logo (drawable-nodpi), Skycoin + │ # fonts (font/), splash + adaptive icon └── jniLibs/arm64-v8a/ # Go payload (gitignored build artifact) ``` 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 index ee0858b198..2d2e4d49f8 100644 --- a/android/app/src/main/java/com/skycoin/skywire/ui/SkywireApp.kt +++ b/android/app/src/main/java/com/skycoin/skywire/ui/SkywireApp.kt @@ -8,6 +8,7 @@ import androidx.compose.material.icons.outlined.AccountBalanceWallet import androidx.compose.material.icons.outlined.Home import androidx.compose.material.icons.outlined.Settings import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Scaffold @@ -15,6 +16,7 @@ import androidx.compose.material3.Text 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.painter.Painter import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.res.painterResource @@ -57,8 +59,8 @@ fun SkywireApp() { val slots = listOf( BarSlot(Routes.HOME, stringResource(R.string.tab_home), { rememberVectorPainter(Icons.Outlined.Home) }), BarSlot(Routes.CHAT, stringResource(R.string.tab_chat), { rememberVectorPainter(Icons.AutoMirrored.Outlined.Chat) }), - // Center slot: the Skycoin mark only — no label — opens the apps hub. - BarSlot(Routes.HUB, null, { painterResource(R.drawable.ic_skycoin_mark) }, isLogo = true), + // Center slot: the Skycoin cloud only — no label — opens the apps hub. + BarSlot(Routes.HUB, null, { painterResource(R.drawable.skywire_logo) }, isLogo = true), BarSlot(Routes.WALLET, stringResource(R.string.tab_wallet), { rememberVectorPainter(Icons.Outlined.AccountBalanceWallet) }), BarSlot(Routes.SETTINGS, stringResource(R.string.tab_settings), { rememberVectorPainter(Icons.Outlined.Settings) }), ) @@ -79,9 +81,13 @@ fun SkywireApp() { painter = slot.icon(), contentDescription = slot.label ?: stringResource(R.string.tab_hub_description), - // Logo slot slightly enlarged; tint = selected state - // comes from NavigationBarItem's default colors. - modifier = Modifier.size(if (slot.isLogo) 32.dp else 24.dp), + // Logo slot: slightly enlarged and UNtinted — the + // brand cloud keeps its color; the selection pill + // alone signals the active state. Other slots get + // the bar's default selected/unselected tint. + modifier = Modifier.size(if (slot.isLogo) 44.dp else 24.dp), + tint = if (slot.isLogo) Color.Unspecified + else LocalContentColor.current, ) }, label = slot.label?.let { { Text(it) } }, 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 index 121dd70b69..9ee166a33a 100644 --- 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 @@ -5,22 +5,14 @@ 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.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material3.ExperimentalMaterial3Api -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.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import com.skycoin.skywire.R /** Centered placeholder body for tabs whose real screens land later. */ @Composable @@ -45,27 +37,14 @@ fun PlaceholderTab(title: String, subtitle: String) { /** * Shared scaffold for the full-screen app routes pushed from the hub - * (SkySOCKS / SkyVPN / SkyDEX / Fleet): top bar with back, placeholder body. - * Feature work replaces the body; a `Logs` app-bar action joins every app - * screen so each app's log feed is one tap away. + * (SkySOCKS / SkyVPN / SkyDEX / Fleet): [SkyTopBar] with back, placeholder + * body. Feature work replaces the body; a `Logs` app-bar action joins every + * app screen so each app's log feed is one tap away. */ -@OptIn(ExperimentalMaterial3Api::class) @Composable fun AppRouteScaffold(title: String, subtitle: String, onBack: () -> Unit) { Scaffold( - topBar = { - TopAppBar( - title = { Text(title) }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.back), - ) - } - }, - ) - }, + topBar = { SkyTopBar(title = title, onBack = onBack) }, ) { padding -> Column(modifier = Modifier.padding(padding)) { PlaceholderTab(title, subtitle) 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..bbddc30032 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/components/SkyTopBar.kt @@ -0,0 +1,68 @@ +package com.skycoin.skywire.ui.components + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +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.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.skycoin.skywire.R + +/** + * The app's top bar: centered title in the Skycoin typeface (bold), a tonal + * circular back button, and a container that blends into the background so + * screens read as one surface. Every screen with a title uses this — one + * design, many entry points (app screens later add actions like `Logs`). + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SkyTopBar( + title: String, + onBack: (() -> Unit)? = null, + actions: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit = {}, +) { + CenterAlignedTopAppBar( + title = { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + }, + navigationIcon = { + if (onBack != null) { + FilledTonalIconButton( + onClick = onBack, + modifier = Modifier + .padding(start = 8.dp) + .size(40.dp), + colors = IconButtonDefaults.filledTonalIconButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurface, + ), + ) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.back), + ) + } + } + }, + actions = actions, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.background, + titleContentColor = MaterialTheme.colorScheme.onBackground, + ), + ) +} 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 index b32e77d475..b7cec7abd6 100644 --- 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 @@ -69,6 +69,7 @@ fun SkywireTheme( ) { MaterialTheme( colorScheme = if (darkTheme) DarkColors else LightColors, + typography = SkywireTypography, 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..51edc2c68b --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/theme/Type.kt @@ -0,0 +1,54 @@ +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.FontStyle +import androidx.compose.ui.text.font.FontWeight +import com.skycoin.skywire.R + +/** + * The Skycoin brand typeface (res/font/): Light / Regular / Bold + italics. + * Applied across the whole Material 3 type scale so every screen inherits it. + */ +val SkycoinFontFamily = FontFamily( + Font(R.font.skycoin_light, FontWeight.Light), + Font(R.font.skycoin_light_italic, FontWeight.Light, FontStyle.Italic), + Font(R.font.skycoin_regular, FontWeight.Normal), + Font(R.font.skycoin_regular_italic, FontWeight.Normal, FontStyle.Italic), + Font(R.font.skycoin_bold, FontWeight.Bold), + Font(R.font.skycoin_bold_italic, FontWeight.Bold, FontStyle.Italic), +) + +private fun Typography.withFontFamily(family: FontFamily): Typography = copy( + displayLarge = displayLarge.copy(fontFamily = family), + displayMedium = displayMedium.copy(fontFamily = family), + displaySmall = displaySmall.copy(fontFamily = family), + headlineLarge = headlineLarge.copy(fontFamily = family), + headlineMedium = headlineMedium.copy(fontFamily = family), + headlineSmall = headlineSmall.copy(fontFamily = family), + titleLarge = titleLarge.copy(fontFamily = family), + titleMedium = titleMedium.copy(fontFamily = family), + titleSmall = titleSmall.copy(fontFamily = family), + bodyLarge = bodyLarge.copy(fontFamily = family), + bodyMedium = bodyMedium.copy(fontFamily = family), + bodySmall = bodySmall.copy(fontFamily = family), + labelLarge = labelLarge.copy(fontFamily = family), + labelMedium = labelMedium.copy(fontFamily = family), + labelSmall = labelSmall.copy(fontFamily = family), +) + +private val Base = Typography().withFontFamily(SkycoinFontFamily) + +/** + * Roles whose Material default weight is Medium (500) are pinned to Bold: the + * family ships no 500 cut, and unpinned they would silently resolve to Regular + * and flatten the title/label emphasis hierarchy. + */ +val SkywireTypography = Base.copy( + titleMedium = Base.titleMedium.copy(fontWeight = FontWeight.Bold), + titleSmall = Base.titleSmall.copy(fontWeight = FontWeight.Bold), + labelLarge = Base.labelLarge.copy(fontWeight = FontWeight.Bold), + labelMedium = Base.labelMedium.copy(fontWeight = FontWeight.Bold), + labelSmall = Base.labelSmall.copy(fontWeight = FontWeight.Bold), +) 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 0000000000000000000000000000000000000000..786212a5aff3792a78d674ab626caa830d9c6436 GIT binary patch literal 35936 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 UpL6)hHu&$NtddLy{-($O0fwheng9R* literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml index e67d3ab619..49811736f5 100644 --- a/android/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -1,23 +1,15 @@ - - - - - - - - + + + + + + diff --git a/android/app/src/main/res/drawable/ic_skycoin_mark.xml b/android/app/src/main/res/drawable/ic_skycoin_mark.xml deleted file mode 100644 index d2b03fede9..0000000000 --- a/android/app/src/main/res/drawable/ic_skycoin_mark.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - diff --git a/android/app/src/main/res/drawable/ic_splash_mark.xml b/android/app/src/main/res/drawable/ic_splash_mark.xml index 036945750a..c529834da7 100644 --- a/android/app/src/main/res/drawable/ic_splash_mark.xml +++ b/android/app/src/main/res/drawable/ic_splash_mark.xml @@ -1,24 +1,11 @@ - - - - - - - - + + + + diff --git a/android/app/src/main/res/font/skycoin_bold.otf b/android/app/src/main/res/font/skycoin_bold.otf new file mode 100644 index 0000000000000000000000000000000000000000..9ef702074416cda7fdce85c237546d0fc865f50c GIT binary patch literal 74840 zcmdqKcR&=!`#-+B$K9<5p2E4Ou>iK%3j%hDU5VJif(?|T2m(^YSily|#KdUqVsBVb z>;+4V#xz@s4O@&RYD~=An9PyI_c)o;yS7-d5! z>es{fda)DICLJCe)(GX3IZm7tZ%auGk}7rKxJ`DBOSwBCa#FHTmo>%r#)n}-Qp%(z zm%JZve6<@K=RPGda)RySori}be>dunN=`~md$ER#LjNzKyl-+!Qu2kii58Aqxe;Z0 zay$dYaZ@vAH5yZ?NXc&MNVLE@gM34pS-Bd zdF$Ur{!;x^1(KSld3*!S{F7Cx4S`#r>%i);`v9+=2pY z>p4%Msg^cym4%gB+Q@n7w0`m;S4|hGy|2Jk(0!_TK5JZ391>meCV?@Wu1N{_EL*IZGrJkqeoM}eYs0n2XDfXAJApJ~ zRhzYH>DQ*cU#s@)eVMjOfjC-KDsbEo&c>y1spwD=y5tME&Cv$?=Zj2RN@`qEqOV`` zmMvT2`+oqh_3MXq*B#8d4|Y%FW}3ThzX2LPPQe)jgNP&&^IYDNx^B9 zuZrqauA7EbVAwYyG9|{Ap5hxFH_66@Mvi6eV$!3dA9T7l&za>0ohP1(wL<)|DQXNz`L9$<@NwbdGG?S4tVrBpK@GL>Pi z^hFP&G+`#2IZYUhQt}eU8hN%-gP4C*y?{SyS;dUwpbg!+&V5_X=6YCr%+94 z;}hX3+Q{~P$NQAG(uQ`8ZJpb_Sw|6hyjsGs>yWsD7f7k1~?%#Fz&i}i{cV65n zx%0=JZ|;n_GxGLtx1ZjAa{KMuvu;nh{nqWQ+v&GcZb#pK^S1x(mPKD0mMWOTK>kPn zGYBRIACtu9sqBA_H;!l7-16Ue=)5{;k9y7keNqJeA#b4_R)iF-#8u`zIWK6LRX7P+ zWmQPW*SPACs5QA-oHysg`Es>E7j?OM&^}-18gLD{Mv$>hAZeRHI<-(_U@OSJ04|Vg z4QbexYX@1_f$PY1;)1w0Kv!KLGrMu!xgK0kt{2yv3+CSB`fwp!U#=h5A1d}hZV-f7 zC>I9#Hxzp9a4wu1!Hwibaih607|aZACO4aVn|q)8kXym6;Z}2Nx%Hr*4csPf3%8lu z%H?s}xozAIZYQ^kE8uo>d!PmHZI$8l*~E`)n5p947?!!P1y zaZC6GToV5wzZfDvm7mAI%M(77{{S+0658I#9pI*N5nMcI^$o0ucewFf7C#<2)3_XN z5nq}2KY?GyFXv;qbbcHk&Bt@u++1!twDmb0 z;ojxubMJ5;a0>xz0k@1>&MoDh@FDylZUWzr?+x=PPPDjH*rWOgryX+>d5ca)K9JHHdkK@HdjF< zd~#f6d{+9ylcihWBejDU--3ZKfk3K(6zXBpTVZ;GFoB_%rjbBtEKr-qO~%09=H64L zXEmlK4^wb}E9A~{UvXEtTijjl2ksZ{5A@Ij2&=+Z=Y9DGd~-g4Z_jt(dt=c3_yPO~ zpeT{g;Ail2`S2jtVD*Gs1b{if~J~ zFZ?RVI)lzrS5;R_S6f$K*I3s=*IL&}*Go50H%vE1H(r;n%hAo(E!M5jtK`W|&||H%v9mHq19HHsl&M8Fm`>8;%*y7|t2KHQY4ZH~eh)-SFI~ zH&!%C##+Yu#%9JqV<%$|V_##Kag@C*HgSTOE>0C^i}S_BVy?JJ+$ruC zkBMi*bKjL z_AV_RTbk@WHZmm^f5B;S2~kkA*=MlYu{iY+tU!%ZK7-W`#i@^Av=aPg-1s+3!Hq9X z_R-pncRXWKpn)VnV~!gaqF^#X`3%wW6SQZDGR_3$GXyOp;IE%jGEw>Lr@roTFdB=yl>8AOuu**|Km+J2HFF-QRqog7m=NCTdtJqIb^ zQ^u6%~6-Az{?VM>3~ zmCvvg*d^)gBh1n5bVnjgot1R;F|@QhnWf3$rOB+)WVkYx$;xLq&^?)L!Z@wUn8qYGSZaPJ`o~U) zOi4+~Ot3|#DT$Q0m{>KHp6q;?o?QAmDk(Fu>^D{{8FLgDnGgk2q`NI4EfW5~sPu7Z zsjxpHnY|Ykml4OzWM#N1k)ZI%gt)}$xWu@$thmH98#qTK(2q{nlFj)>?nsYUSH%?Y7nG zwbkmi)7ojLwbM>*$G@fe-oK^#-oK^ey$a92r3%l#r3%l#r3%mAPwj`lpV|+9KeZqJ zei}SK4W6F{&rgHrr@`~n;Q49rT50fFY4BQU@LFl`T50fFY4BQU@LFl`T50fFY4H3t zc>WqZe+{0$2G3uEr_q7GzXs1=gXgco^Vi^Mbl@ML!3)sf1!!;rw0>#x@2}0jzc&B= z+Wh-#^Y5?Czki?xN23FOjSl=ZI`G%%z&}vymqri%8a?=H^x&`2gTF=({;jofw$}RB zTI*kHZJez&c&)W@w${ekM#EbhZJcei{(v{mZ|v{mZ| zv{mZ|YV#kc&3~XapMlzZ25R#esLf}fHlKmod0l>bjQ zS%hLw#lRX_%SFRF+Xj1PJnWZEFa*|dt6(+d!D?CtPi6`%wdzXod1IVl0OH-<^q3_zr=sVe+?7oTmCXLrT8oSRsI?b zo+ADRf0Mt(-{$Y|-|=_3liW@Id;T5_p&$4kVGKRwAHg7c%>T^)!aw1E<^KcY=r{fu z|2zK&PkB4{2~4Fwd6_SPId+eK4&S{ErV}Udf*|MwHyBX{!6=AA1^64L!9LA_y*&%& z+;nb^;4XLw6=7sm5-JOxf|pR7!TWe9N&}g z1*0yQf0OS6)2^>-&BBr$1ba4=595ajR&F^Aw`%Y$RTpXqHDSVe3qFFcP@CH<)PXry zPpB`vE;JAt3XO!uyosB@CGzgP2h6;tLNlSc&_ZY__zA6G^aTikLTh-B+QR&6FLV$( z3Y~-?7=oR#W7k#aCUl2M*i+~w^cI4JH(?xx2z`ZqLVsa^Fi;pI3>HF#Fc^$Og<h*}0nE~c!Xn{A z7^h2wrNS~{xv&Ce>M9{uSS_p(*1}+2FKiGt3Y&z@!WI~>dBQefyRbvpDdfYD-7ORV zPq5Cb!Bx`>+Xl0^)w~I0c7=b8sqKiNMWJJJ0qU4=N7qu^G!@qpnMqs~~zIL`PZth}0HOR={YCuWLo ziz~%E@uYZFd?Nl;p+beK6}nbfRAFm{uPQt;bu^7P%{Jwk&bjN{tGX|B-{*eGqn<~G z#}bbn9%nr6RWwwzR;*t!uwu`O85I{)JX!IkxrTYFdA0e3`I`B8rG}NdR9ar?x5{E= z@5-d|(#q#6KlbE3YkD^E?B}`KbGPRi&wF0Ayjpq1c`f()!qVO{#xl#2XSr9!TBT=| z{j>f>q^stv66 z+iMM88}ZuvuidHMqWYxjXKU1{(Y=PP#+({I*L1Jhux8Ji$u-Z_GSzBXYiO;hwT{;M z#oOZD);rRBo_B$dmrqll$v&%nKK7w-P{#UxTDxa$TkTo3H`G2=`+gl`o!9I1sFPS{ zeVx;F>(%X1H@fb^y2t8%U(Zx8rQXl=E7$j{Kdk3qHk;mTOS22jn>63h{EOzlwy4u$P>bm;@>(`-xwqwY zKb>C_zfixo{5JZX_4~1vsa2y^%UZqgujAj{f1Lkp|MmW-{OtkZ0gD2@3V0S+Gq7i1 zM&R1Ohpo-6JG4I4rcRsoZ3efQ*oL&(-{xGK2W|Cj>$mOHc4*tBZE3sYcKh1>r+t(5 zDeX76f6<{?hY=mlb@;iXuH&$du^lIMT+nfS$0Hp}I@ReE&}nF=q)u}?t?hKA)9oOA zP`#k8K@mY&L34tZ2W<;F8uVq*ouFrLh;OuhBkPT~-`Lr?XXhcEZJkp(zuS3x=aZek z@BCYrhF$t}iRv=H%epQ{yIkw?xU0Eqt*!&Rrgojz^^0yTyG3;SpxZ~?o_4R@y?^(V z?xg$r?q7Ak-TiU*=RKzPSkU969?yD;J-vIj?0LM`>%HFV-KzID!It1b!KuMZ-mLQG z!Z)|S`RSYXKE3*c_nFq`!#)T4obDrs)Cg%2(k*0INMcBC$d@6%_pRQyS>HGM_UpT# z?}5IT`~KdqLcd1+I`r$^Z$!Vuelz+l>zCi}M86CD9`vu?KcxS({-+061`HkW!GLW8 zJ{@plz#jvv3~Vqkc;K*s?+(l#`02nO2bK(~I;i!aK7$emO&hd#(B(nD50(bk9^7JZ z$Y9&xw+7D}yk_u@&4-JV9%`TexUTIz9x z*3d0}B-JyS?Spfu&@qF)M})<<4ut#2xxDnE7Rd0V!hWce)HTgkS>oRmk(-K5vi$vQdIqg96eF7@!* zNUi;;^$N9~pq`d|d$hg1^kKrH7!pB}-<~)-U6iZS=K8{d14qe4@nQE%jazjL4)i7? z*GBFfEm{ur9z8IaG$XPvm0Ho)$YpXN_xMiHlCe9Iwk8;BHT~AY<)4uYq6H{cW8w>a)s}|XiY0n??Z9F$+bokT3;3jk?YH~4#w4^-lJQ~74+uqUuDp4n3is+ zos8Ot==V}16X-^E&!9)~(v!B38k@``Xe1T*OZ0*y3-lHhj4EmItg4O*R~Z3?tj#DM zz^Yj3wc-J?2a&HmAD}ni#GHUKJork#WkNAW`er zq+}nhK}hj{rbG^Epf_(O05Bc^C`BRK$-YQheq`tNBjhA07)`=S%#?9q{Y3d4c{}M~ zA3;!Bo=a#W+C_ilqXVCjJA``Gy^rqHBmTh?nuz98tV0Z*-2!+Uf03G*%#T6c0eIaO z{r{QuQ3CZhH_59@+Df!4ZA;{~dh-!fr1KQA1$}ns-s_6+nQ($Es@%>S<9hs&^Gi9$F$g+ zN$pMYUAOkyjM}4+_l(V$I<0a5U5FCq?dLM=<1zA?bcfUdU(pET0T|9?siUbRf=1)v zD0PUol{%TsS!m1N1p}Z#)GAwP``OeA_7Zqm3NMH$EZnoJurO+OxOccMI^1WYJzRQk z>&EQ|iD-FnU~5XW59ufeq!C#ngY(BNnI`^vNk8MmnTyFPa+U^cCA0x~+UlyTCo)gE z_sr@jn(Z0#Akdoe37!0e8WmC$e#DCi{8a|s+P$z8f=u*<951~AM$iMeYJvIegi+PD zb~e!`_6(_u$@~YKs6qH^jGh+(?}KQK&a{T*SrI1e`2f1fc%OW5*YSS)H?d%F=0zvdAPdX=c{+X;Y_6 z%84XmQ-l1u8-s2KBPSqxS5&3lP_-%RroFm> zc9gd~B(x*lLQ1MTnZcrxHhQIN8fc|kMb;1i+vtKuAP(T#0aJZVat&)gEI5p&07LDj zVRSMp@DnYNFxl%0_U7+e^AR~kPGx=+yDMtnkad1UlvgSUD}Y3G>ks((fUgM_hAXsS zIh2PMCMC?p1Df}y$B=9S&BSD)T?Wbt~f2lZ18ilsiDRZYDP&o3znCF2(?SSwJl17{GHAos`vW$$P zo<^6VG(OYovR1Yb%j1F+VVWZcNqdZY;|@;qQXjfV!eXy zooBqg6>TDoGs)l5Nm7)_JP@<#4vy!+P*|UV?o>QNN?w>cejHur~sWyZoI-s4lT@GvV7(0jUR3&OUcT4OXiB^$MSn%r=DtWUGQ|nz8iX? z>=|XSJhqv@-s?)yCi8!k_C3&j(2fmeGMMt$ENUB6^e>p_D~~=9yYEy~-WX zaertRZ<#FjrkE_hPQ`N?#=lMOmGNh_O#Up>#CwA`$sT`IqaFs8Z#f{{r_(h^(F&xs zfHWOYvH>Lr&lx833EGDW6E{tyf|sQ;wa@|5Op_&hmdWBf8_m>3$L*ieH(X4(DNxJ& zNT$4qK$f$`5U-b;+IvCqiiYB~8;aMi@Nip9xVL#7Gs`f{bjFB2V;yY=I{WtHPcC~O zA$w9c#fd-O(8nAO+ZnP{1e5TP4Mtg?*s(E@P5A#>BWg$%AQ${;cMvr7*8p zD8&#OV5l{kLZCHM9+q$H`2a%Dhj*a_%#-Gu%rk+;mat(0j?n)-pbS2&5H!PLY$Fwrmy6_3hP*Xh6~IOs_-t>Ff7F(Z=FX zsa3kcay4~z(uZ-R4e8y3_>ssJwhc*}6ZU5uBs<8~57)02)9)Gmur8*gr%g>D;+XOI zhkW+u6&&{7M%GVVnFh77_O%RqWngQVJy`m{L~B_;U}}5q46r$nv=7sW=i5W1h3eq! zHH*GyL!VkW7#=f8@o_}@&?Gl^gAF2&q7UxSq4#rmuH zJN|6Bc+EabS^`eA6j`cDKz*STEQ0{kI4e{+a2ic4SZ>(;!2_0?EQR1SbR3ATcpK2T zk$OC$9#0tU&9YaP!WN7@#I(eHhdx>$?!fHyAkjJTllnxD?m;?=vina>sJnLw?8@S8 zPo8g69;%%TKG%(h14j1*YY%p^90lnZz!%Al!Ock?4QLA8N?t;rXV8*NuRPkj!4u1! zJbQs;xwyh)xg&e@)jLRuR|rW*ciN9gqmyGtjY`fxbZB$_;lrC_M|;cJR{t-${qoBf zUw{97pRXG>?9Lap|)C1Y@>njjp?6c}8QbubX07tB=v@ zGuEnoo4ie01?g`+bcPHBi?tDkdwR=J@5!@Q*Bf6_h0P01X3fyP+T2)v)h6b_QWJGA zIRKB1{cTvTE-hp!3PL!z-Ck;)P1`fp38V5yh@3(jm|9d!c=(>^Lhr(Y-Gx3WC8^Tf z#FY3k%&Lq?+_>9^T&Dr+356#nc5n7F@%0}14`(dOA(KgeIUte94M@FvK~zsDPrkaC zdmYQD)J()e1x@|g6q=(P{8v+{)MPAI$S$JLGE<51?+VdbAly6@$~DPb|E|#eC&P~o zf8u4i=W3nO5yh`bmS0yxG^~MWD0)k8d9W6VbwFilPj7>X!N^y*7;RI0%fZIvDbH`c zy4z=q&KB`B2cJ5cQ~ZOp-c)4WV3NB(|G}lay&pJvKK0lOe$K2)`!q1(KkLA(+-Q=& zmd{8ugnmNgPe2C4WhsN!p(dx@O1G)ByouJ)Q~jbJPMrQ)Y<^2Wc1ZA;mf#IF>i$M+ z($}8+L0|K^NIsdcKT^E=qdw%*R(t9a8S~Oa_9yZvA_vn-aswKYES9lv;Sq2(aXZlm zOuKirfXkJ5gc4Zmt|e%)W{aw3@?w<8EnDIX+LHMLWL`n6uH|4ly=*iflGccz0hR~Mrj|{#jk#I;mLEV-=Rr|FFp9biiu!?3 zl*43(G*s;W@>Vk)X+O`#_OAu))lm>bbrz)2%sNx7hp@x zzC|_K?MW#7FJ1o#O97P+DRXuR^y+H$)86)MCl;4NUJk;#^sp0Yt4j}@{bbf;p2_~B zbsPL$*6k>wS;=fp9fmTEQ5eLAzgjEy@+(T%>nYqpZdbMln|>#dGYW6$QMLpl1C+>A zhLVZ-duoSOz5~ubxo`0UX{V;Ds7AKJteB#t+1U~HCu{LT|7ln41P9 zQJ+>ZFxQsi?Kq)$J5DIxjt$J)QHA=-H4L;yNiPZBj=QQZ{s)vQh{!86g|XofaNg!5w4`m`wsJo?NT|N~kFuGWPVu(z%!$(`%pN;$>N{_d$zsWT7_9FZ$=-!)maSNk zyMD zb*2U-+g$oRJo}}N{{k`rv{;2q{4gckIckL}tI>yLNA{(X1S#KS*_ORa9lv73@8ovF z*YPsA%a92S-Z4B=0Bu(`jW70=3KRih+0~gEOVS)2QULAB2B0yWm44Y9&p(@GiQKpDOevYFP?U;Oanoku=< z$w+bdQ@+GnrWF(lp?)RjTs$jfN*q(@A?=j~XgavaIA!wBzp}tZWrilewaz58<&Pgr zXNS6#OqbG9#w3K2o+R|htP3K&XPCd_-DPB%$gCEGLirF;Wg-{&&9 zy6wovqQeEG`B^}~%moAY3p7`FSdx`NIlM6Ca)&G9ab&VRKG&)>l)*T18G((V0b{8( ziisl!{P`~ClIFW`rwN(IGMQmXm(VJ@hxTuzV*$T>ynC2lt~cj(HbXT;xi)o^b+jS& zzXYnMKHlW&sLK(bhA8= zU}sPYvdmHHa_LG(tCsW3pL0O<=g=7_`f5q$sI3x>b&x_cgHZpeOMT1ZLH2r%70&FV z*9)moNMGk+5;xXWxx@DCI-2A&)GQq|$$z*VEbW!E}`l`7#A2k<&L*wxG&3-+V;v!*TP4epF}vapdRq=0+_m$dkLAe0n4M$1$Hc^q@qxW=Ijm+LQ8N#-OqSS-r zXQ9KIvxhAM`i(;T@>_p}GPjSbYt7-tgl|oE89u|m-UP{PHvWTi&FSbhmiV~AtERPs zVPIEHQOKW_u9LvO46H>L%^jO+JnjFd8P;)$@R!*|`F53O)bojpobgkP>E0!)9Bg=u z$)d_L7r^(yhZ}oY9@uL_wXpnp6kd-)lWZ?ze7H98OexQZn)wyC(7)J<+Ky^l3QdDb zMVi9#w}WkuQ20IVAt%Fo=jGg4vma)*ac%24ShzdMRXJEcbJENdGJ*6yG2|9p85=%W z^u9QS^7^^&%$Pfkq>{IC$p*4+#l|HoMfs$%zk3op#E!kW#|%6yZss`GL`fF)sEZz6 zclAb>cT;Xq+>{p-H|60Kdv}VXj5E@wPatFeu^Xc0`Z4C$4r0Rc(JsuBV<%42haH=7 z@6%h18#p}r8f$Sl_2rSpyrTO$YHPZ$qjp!+2A6Qu)_94t_EFSk3bCX17qmUoNa3*$ zazJQH*D$}h1HyHbz{IU#1=M{7`Ih|@E8zrEt_$Ze#^a>`%6FlKJ(6WRw;VN7=@q+< zm(MB%JE+-_eSk?WE4D4t8yA4xHorkD!wiM_z`);d! zH$qZ6oRzKp5w#*vtDn)9H)_QYS64No0nu{&z;G8buuO|G&cWC%$48en_3l4$wI}_s zi3V?#Ljid7q}-;9yx1_l1-CZ**WmeAwEQec-I>$1!?&S<;8 zbCH$-`z`iP02H0J*fqFwXdateNAK9|(@-||WgyDk=rdMqJ6l04s75mv88rA#Wo-0n z7Ol1wJkK~iD=UtS_?smnT1ryi%H7~Y)-7ji70R7_6fon}&0_wURPX?$ zH~*~IXj+Xo^tOkMsWd%16Q0d~UZP}UZr+mR`<54M*u2B3|F}*=IjbHq6ulXRI*`RE zy)2%R5zEAAJqILABk_m5Z-=qUw!i5$#Wd?J-;%HiUT$y9RsX+5RsEl~fK*f1|q1beak0jm22R9CLQWtsesDwF>SWv>*PzHxN>r@!g83nPM6s-qW* ze+GfwlGnTnF>KEdJn2gsv>43#Hl2*^zL5nnM~)QiI&#FeYozx`Tg*rw`nlX$#nltn zMnOuDoX+V`404xHgu&0f8GkJ|Hl~O;#MdoK?WZNU_5BDoFaq@Sn%0c#l$6FZXr7qT zkOq04{XdKemu_yV*1EdCdE?L4$b73rW}BRju!!%d$0w?N%>pBe_Z3T0gHPld1LUXd zvw`gKq$C$y`~rPykS}2BbDtPXZU#!<(WimVeZfOdE-89A({9yW)ZC4S@=uXGY%Q#s zoSU^Ig^UnoAe0g#xs_EIUoh@yKWsP-C!;3EB#HN_^@(h~BwKrQXi!wL8yj;Dhf=8@ z$nqugCefbE2Xqhm@|Vz;?=gM(8|cgTpf792M5(H6`BGE(4>674)pgVW5T?t3yC^Xr zEwD2K4(Zqe_Iy&tzDQTM12lJ+s;V&7c~$A0o^|d4sPZZ(*wG(l%|P>KJEn?#lgYZ{ zES0LTA1~(g0*6CzJfQsw!xdX_HJ%SxZTLstrfMPZ7$0NpF-Qo7pp1k+Q8;CLW$XH$ z(y(kTnQyTCIx}Nt;=gD$qU9dc3AWG82;bZCz(M;8={u(8AzJwy+uqotQayc>sUPFp z+PGcOW~OLKXkH55Sr(3TlLxS<3RGV-#s;GwG}4H*x( zs%Eh$gXyXXTQ)pERKf!Wyc`}7C>JU9)g57G`&8GyZE*Ptib^!dB2CkoxMQI-u^fQG|zl~Ad+ zuw57eSSTf4mLNDyX|f{*v0Kr**!s8##>}$i_ZBN{GFNc}+McdaxYKng3M|%&jz<73 zBeR}x5UxRXo`Sw($ku`-j=}qh0!pOi)l9DxI!~?vg1d9air6eB{L>87_XeTWWZ(9L zrs*vg;h;EWz~+>I;EZbcPK7U=VHjSqbQW_N;%^pmSUd(aw=c^TC-Q0q;}7+gPf;7d z8Cao@GsH{7x--g)UIVWHel$j{dhvh-#XoR^eh)$dCe}ZXk`QE3h#(8Pq6{&W0$QL> z!+$h_f-370rSX`~VbdV@ILqMNP~a#Ic2~)vIM*vH3bC_0OhIsV2Tt+R&^1PvoObla zS?)4~m$MwLuS{SEOmAtg)wPPH&qzS)HpIwC1ASW3jI`6MYyZDl_{C9R79))%)$JqM zF5`ccaG#beim2?6)rgo^2%xh@GGWf-oas|1r_4x3^kUkFISa&L_4Rp^kK`O6Uy#$A zcOBTWW5q#oO{5#k2&ynLYEtwBM4~VZIyM`Mo;W@3WqkFK*dB%mari@^g){M9&VLhc z4`qd%z)E#17LRwM%dKf8%sIpm>v%lJ;*EmaYV7wx9xRCk?jcz@K^8Xk*HGPopKjUtz~Q z=9Q0;o3nU9#2Dg9V|?zD>svoPhA8x&+NvCi;0_Nt9r2tcKgyG(azTZb1Be}D;e}cV zp|@G{*gjF+wM>Ju>{I--uh!j(=9DBsq<@@k=$I41I}!LQH;{Gk!1^LKUMIWJ20qux z`TT>&4}_jixQRe{#asD&IBh3=bK>lkD<}GQ^|mB*9XjBRH->)ljkmqp=PZ!T5zDCU z+`*9rPUjNN7;T@%R(ffS5mWfEW}px(QDV+>9=FtLD4)A zn$&x)Az+XQO31?&+DQL(?y2L5U_+qq^c3K3@zr?wzW80tkKFfpGgs8Q;01B^ zj6ch-mCR>xX}ewM2Qf5`SXucSRZ^s}>|^CZ*XS}vIq<}=_%$uimYJlk_4_Cy!V&ex z*ohkT=A(*jW652uvNLL1scp|fR3(1NDJnLCNZlZ+3it;k+BCzs@j3vjqWkON>Ou?M!fp+n*yD)um{Ps6(*OU7rfIj?R_QUUtTtNq$ekFjQ2IGn+I9Wi||893WO zr%RT-uuHbUF4@cMlI^fd_OiWctzBkhXpQPvo1rE-;LmDI1~?mF-ckqjLI_h9demQz zbhxe^kW{n7F@PKD01mPKJ9xemqG~rgVCK`OU^tcokRj=Ki(V zPauLxb2Qr#imL^#D>ey>UAMP{CdvG+YRo8jH?M__o&cJVp>DG)<>vt%+BUoFjeU0H zYHoj4O$#TbmHrGZt;hEAX%!j(ts#}%-N{makg9dErK~j%=K@YNmMjH+|BI z(MzHceLg57D%uv4F%+?IY!@F+--sF1opyi>4P)U%2jI!fZr!&22)hPiLOgQGN}97?M~=NU?)fXI$;dB)34XgxX7Tjp>mXk@n_ z(VWY4StvpRguRh(3x#0owEZ);5b8#LJi3QU8;X|ECaY*Ik@f@A3oWYAGEi^&dOF zKS?4fA5L3<-~(o5IRkPKcME@yQ4Wx&gfQaAocVHit z27@u~vzJMg^3h$V;MQhQYBl9af3wfTxDl!{Ezr6Bf6`_9Uxm=&xobqin6>K8f7w_u zM#DyY(a>D^!AntNuZR{X_2MC5;G}(~!lGz9Y^$@lfs-M4jJK|Ur&4sJ%kJjs7+>jd%EPx?WJ25#&?5bx5U z6AP=Z|8>NbxLeWp(xA}{86DM+^d>_KV)u)Wu_$iX807KAc;%$TuZIL;Ea|Lp@J4g_Drp-8ZWAeAXPzkjZ<33Dg=d3ArI zGtET_OEzR=XXeF=vVR3GbO}u8l6LT;74~otgLnxac<1eqjB-!L_z{0R*$aCNmbH{y zEagm1vXO!RC_F?<1lhcP=i;Rz4g;OnA}M|C2Bj0wCw!q#C~-BNp-&*H#(Y9K7o-IN zD^l{4B^lcgZi|q6@E4C?(D)=;#fo)Z@{2seU^#RW=SS4jPRt8Inb4pYAv8ugg$5JC zn$U&;#jd9<~V0u zLp^7FM5|%%s?=y;MJ>lWs#bbpMa3g!>nZQHBXHaP8RP*A(TQhU)8E^92`)}>f0~ZH zfpj?tyfvr<2O$jDE9;d(5lBJ(%haG_CA@j54t8ghx^kfrL{i8K7|b^hWY|NPeK`zw(@#~jyz?Ob))jrI{Lvb$VpO3?{1K*bIckJ6~H>v*3wv_We zP7dYn+_od(aMp1mQtsxh|NN)jEt&Hf1{cft32}L&SFB#$h z-ji!jbO_m7C7m`ku%1CqM})Cox~!b!=yeA+J?7aZzFSVCw~Btco{wn3`M50==d#n_%(p{Ild1;mFrhd z-9e2`bXM~RogN`pDc}CH^o7at^h=Z6T5cenGp)d}wG~U2t@O4$JFm3!fDZ3?{d-nR zX~)BUO}bzjEzg!N;+WAT6zQGgIxg%dI>z+@T&GW&!)A~Yolk=ioqzu@ku#q_FhA{2 z^zemRd&31fR*8|<&L`k_wQ@cor_AvL+OqU`f)XLm&b=tuDjN+ir{%aHYrl%CoN}n5 z?&VR1o-#}UmV&9AS0IWm|7S#za(Kc_l))3N22a*A<hMJV_%8kUNs;lXHLL_7?|lqE$W;%LA81t~_qj)i-Ga^hhVlex zHdCN6DQEL>eFGFDc!o3CMN5rV_Q`*6*G8r(+<>@%dVP4rkvEDO?O@Nj;3@g?aS(;<0~M4Oh>lTX9H zjDg?9z~LzIVE=S0F#X?}%;%Mkz_YOf=jzb9m%S72B3WjyPOnRsO_r@!OqN)dW3+5- zAirUDXDG${7KM<3-MIPG(kY>C($mK>H8f%8#t9l;KPTg}MVfemsE z+Qv89alTE?V|@dtw(fI;di{{PI8FL8IkXc|?}Q zV#tz=;wVT(?e3eW5)N%`q}<^LSjETmVKSE}S7O(P$kJB1(nm6fC45Xj>Ol%~T=SyY zQ5{w~yCnzJWj;mbMm0yd;rfp#x0P06=ftR#NGpX=4p4{d4>#Yaco$Hez_T!(tq!Gq zUn|`LGo=MywZL-=j`%tYC@ru}Sa}QVm=?HssRf=|5jwO8t*gh0JrPmsNhfQ^iJwY4 zm+r|W-;#B6;7SpX<}cspZMnCT8dD5INyMzUNh6aJN01PbvH*TuQ9X1_GUmj?2^O+- z#F6BklMc)}LBx&J7-tw8yZopR*|=aGStpWjqjKY57qJdHTE9;xqh;2*;|MY~d#6Wb zkuD^C0d6D_N5oDY>uvcpPBv~doFE4l?p%Fj^VXxxD?evDS&PsHc2JqDU9cUF;m;F~ zj@%r#I%45aBBsd3orZn+Q%CralsV}nT_j!hWbK?T;%v0abX<-`vwIy3?5Z5al*Xku zSP$?sHp1LEt^#7BA2)=+e5s*(W{;3)U-8(cg%D7 zOM#fQbleHV;+D?-mv;hzORy_}E@9uVm3oIzJRsrtju-q4Wo`sI`hR{SP)MsXS4cfo zu8`8+IoAemkP2x~_F|3(%Do)Uml~1pW7lu6qK?DuFCUYCTK<^4Yw?%Q!aps47Jij# z9mPNU5?TVgO{pDNbRm5}8%uW(O^*vhzEiKrC{IZ&f}yDiGcilLYl0k|C4CQ*>=zfC z^v}!Maok*(Y3gy@r|j~hn=C9wG2@;xGj1N;seDx~)3PVq_eh~X$y{G}d6et3D$Cqw zp-uki>)ZEK6Uu##9WZEJ1UG1KA^KA&_}MEiygK!&zLmA1@?b)pF;Sle(mhkuW9fc5 zQv`>C%Vqyr-DPMs^@(h3t|#qp19z<<)^EF!pCnSkQDIxq2>ue@1-H>w5C^4T|s z1rG$4zETm0hD%>*4b$4RqvJgK2IqP7(zoB(H#pB8%RXhx!qC)S>{UvEd31EWS79IA ztA8o?D!9@HHc6f2ah&c@+$kO4>nkM^ME|m_L1t?!)Ks>HhvKp+`o|jZBe=9N6g6^9 z3;wYNJH`Q`^;f(!as`m?QSL6BG>+3*h1my6vK@W=!GsN&y;p}qc zpNb0G(HqNQc8qKT<@Nj&eQYjw8S>NBjR@HJ!_@`!c?hf4&0XfB))(|94z5FY~{$ zXndJ{Di-9I%E>6RcJ{ZidiED2=z5PODRYk{bT{=H%bU(?ERQ{6*I2sTlQ;`TCWG9_ zWY+G@TdLlh2l=gBoA+gzYxC4HU#hp}G5E@TEZCW4_p#Uq;%=yn@naHjp-QK))0f%R zEI$8-SF5Fv^aqS4c&BxvKt3Ze?Vu|AK<)}1)gS| zeVuwO3!tlhj7-&!!74d7Gg-yI+sy0^mR%tf9oW|Og(eD4pUUuq2{D`&*`LaIpvtFoN`pxa+y<(+NG(mCDa>h7ePHQ zy|LDjs}N&}l7(v|{opP7r&}bSy4)h^Dn%bXanS`TC}AdzOLjX1CzR6i`i2^7=n&!u z>18Idk7A4LV4*3XREvNXa1;{vEv5sgQ4r{#Q~PbC215Ml)Br9?=shivRg z+LB(0@fqU&2KueJCvijNadI?+yqUN>c40gbYd7y(yXBSczxbRbM>nrp$KoPN5Et?1 zv6s~Ywhio}Asj#Hq9Lf;z<$qTUZTk zAyL&9@}Vs#3b*6-+B?Mt2{+fu-lKQcqqPp=x>`lOu1{-?-uavCeFzuZ`hS6&YjLry z9yNDm;3gD-wt<264KC{V=~Q31cq=Pg<(0{2lxuPdiuxZ-Lh-wT2vuY5R!8)RldfIseJ&_SPanz;rK?wi&v)vimp3nwt_Gby*Xf$x zoTYl9z!V)<1tt~0DJ88KQ$AQDr_5D>xMWoxggD-pLp{{cePc1F9+q(4-q%@9m43o~ zKEA2UnA|b5_n4zfra_ zOWl0W5MNvBo94{5vGRVw4DAZ_Q}K(M?8^E*@B?*YP!=@0Bx) zxp@WH9H6=kp_b0%D@(iHh^dalohr}ba9nCAryCUFEf-J;hu;~TD#BWY6G#1~&zhM$D-CYB)J1RQPFwbNF4-Y6%Uq7Uq&{4OEwN?HqGpU*TY2d zzp!|(hX=K~Q;)?9JSx8HVXn!wuy7aHZ*Exd)2I#cgD2l|KXG>cb^NGF0-uSWk=e!n zhM!28fFJYNCwwN{5PlK<(pA$n)D6}p;a4P=>elG?>#n#Bcbn*z<#yDq$n7t^RUe_B zq|esx)_<+Pt(Oh289Erk4f70J4SyO<#t>tMaV>sx@tX0Wu~_+?MPIS4I0nDLQ6S!^ z;90?^!h{NQDjchDrot7I-c->PWa?pxF=d(FHZ3%*HT~dT&pph2r28EAUG87F|LFdw zhuNcsM^}$9k2w4yN3O>vk7FKRc>IOm$XO1w>FmE&;HJ>$~GvBXN ztJ2^~BPvB#T3l&Er6ZNTuJnDSpDNiabCoMr_NnYwIim7AmG@M>RGE6V^9=Kx=(*4H ztmikL-+8%tRrLz;3h@f}%JN#}mGAYF#m!R7Qr8k->1YYDOth@G?6jPuljM-qH5mNLaMz}ZCSO=;RQC_VOfAXEcf6J%cyW~cO1@A zf2hD6yI&I^IG0YN23V_<5JVAA;S1L573 zAdb?o4JA;++uhy_H!SN&Ni$r5;Eub54^ZDL*t=+aLElog4wD10`;7mO%K<;gR`Kx# z{Tq~zpuQ+5y5oA*2-)`ynHL}T(*HoM$7ujIpz;4O8qktj#m2q#S7iQx?2CdT!&Nt& z5XUF84w32xymd3Wcb1Rho@Wnw6zHs$D0A)GC$gKSX|=7CyK!Tyaxt~LJmMVUFniHn z=V%}uK}Q7RD(hZyuV8jC#oPU}9=D>fyIh}8@fSiHlHY@GH`y)X4%3zhW$eTp_jDSO ztHN*JLBDBP-ohq4OLJx-T?Y}GxSFUHwn>trqg0U@QRZ0wGbcylybba2& zJn3eZTNy`e_?3Y8)bzBB3E2sVUyj%?VLK4hPTr=E?i=5i^c3aSsb_QC z^j{O#)Cl}gbhRw%|5tnG0bW&=w(Ye8CpqL$0tN-dfD}QRBqE1)ln#o8C=mLfgAEmR zELa%2inGtD%kJtFzKm5Xb>w{PQDbRCJr~BGz!M&4y zuB3kK3Vn32u09^rMK3Kjh*yetxo%|mni}`}Bc2{RecZJ1)BWdyu}99oKjoG=H|nQ7 zf)~ZhMB?YgYsD(I|8tG^{MFCReRk1ptL}f*4~7!oD0w%2kt$M&Z;yoj82@#=v&!q| zzmxwHA2tm6*$?!*?bn0*UOTjfAFTK7pf8gT`dfys===PyXZ7^6gCVVe-IWq6<$pML z%gdj>y-{^p86E8Pwf3jH+Ob*v*KKM9_Obq7khq`vU_jB=7qwC*qjPRN zKmL_AjOb-)jh*c+^&ENIf6nWLp;$XV{`t3Qv2C#rm5tK-f**T9-(>bpwVLG7 zKqzrL-|p52<`j)yV*Iw+@&13c-}f-N9Q0~K%@AvQZ?;SgA_`?rBbnhsAMn4pLGX7Rf`*3q(9|aap znKNTn{_>@B*K51(Yxk_bap^73518FS)owCb@|IHCAXYkMg`YQO+6eu+!!ZATf7sYz zBS(xJ@z7Y+>d?;14-eK46jbtKr9Nc?@PGGhsgO+NRWc&yYE@8g{G^2T`n%_DTKn(3 zgt4jL?tAtsjWX^ReS1x;>G5mChAX?PYdC%U@_3VPFV_-k6S2DAUM@#9?~C+HH}Sh= zIqnvX-J8t;ImLK0iN$*feFSEVx z$+5qDJHEtz_xPWWj}L?rE9m<8e_{nalvpnRKC5WCJfs2jKVk#^A^VW#>rwtyrX?#$ zyUJyCq_x>++LYBS5ctPX?W(cPUvbxT>N}xQIh%y4Ql+!-}%Cp zUBM9B7^eTk7imuU9!==XFIg6U`R&xtzGW1>NEw>=$}1~3>6dO+T>tw)!O)Lx@kifi ze>lazYV;k$?z`{qTSi>(xAq6VeD}g&%jSW#*M0V@zxsdn_bh#7+oF}Z8~nq;l9^e< zaxZ#dF^vR);-~O=gA7q3|ensG4enLQcOrP4IKGJ`NSCW7H zO^l*fjYEkq?}t=KyqT(3;Y-Cz>mge+UP>R=t5^KyYDTE|)zlO3rG|d{ew{RxZN*-hVcxw>cijN zs`pgX`FKT7-BqmndU&vjR5n2&Txg$AV`Lb)`p&EYn(>Z>xV5rY65BA7tow9k!gMDg*{9dDT^c;<30H)rfi>2XW`I;r0H^alHtuFz#~#4Bgq5l>l>yy}JF zy=wS{zxhY~O?&eW25T+|jQ!QUx9IEep}zjW zPa0|@(6h}$ErOxpOGdn)S6bIBU$WMJ$zOEmwA+HQz$<|}_H}*j0=+!zH&E7>_HURx zWZKZ+<;tp3$)Vpr_{4C3xF6|zRm;1At-l>B!?z%1*1~62`CI(_$8#T_5j?61!H|E` zlw0%e2)1r>sQJFVfyEEMIA*D@?$?yXGlP5E96HoyZ(!!)DKE-t>6jNEUL4%Julb=i ztpj)D-8$ta|7QQzF?T!^Y`w2}^L?!Y58W~5R$ujf_0pxQS1-MJ;J};hT)J9M)1K;j zoU7HCo~o7Ilb>4`%hcmM>0;j;PkRbfi{I8vz4s*b`k(I;@2(2utBUOZ_0M>F_=c&s z%~C&f&`o#s_OJHi_s90Xq|bZ$J+Hk_d)xi~v+tZA>{KuCZr^tw`hfmY%1-UI@LwAF z{Dbp@FE3lT!QZCQ&>g@1&Akt2zI6U1e~-Ut`n*ulTs;SY7bey=Fy9T6{^pNZn6ROLvmy@+q zICib-yS8b@zS579sD$dpKKxSlu#$!nm5K&km2BjycQ5rkn~*zoYOa3kYRU)|_Q*#@ z4%ho`=ja>QePU1h{vqu_d;P<}yyx#18 zb>}?~{ienc|AD-*PY1hS6L?|Bs)t|oclqzF7p%GS1!#zDkf7*h4f2*wCgp@hIeQxaH;uk7I`&Rn#M{S?`aqV*t#hPmSl};P)R8q%a zhi>ztKEk48E#EjnGpIr$0Maj;s7v`@G=?cdmGIXN}O+(Z1WZMWfrc^^IPAb>F^MXM{?= zBWth7s<_G?e=OyAOLf~C`hQlLO;JU>h_~hR*%BpLpGXku-49{iK}uihs2!6p83}_c->5*%7b1XOEUOI3i+t9Wdl) zE^e{u%3!T{S}g1@e>Jdi=$jAi@ME{z&)8k!$NKr7-F~3gTfwhYC0qD64G8pFbXA_Z z+qe6++?6?Du73S^xr|WBU-IOaIzt;Kt$qIaN&Rwi#`U}H zwsC7WWQ6K1$^1Izp8M>}FK_=W?rPtXSuZ0r_l}qE)3(+Fudm;|ZP2?H*BIwNwZs+x z_vxjK2jYD;jSWnhm^)#bA6zi^fuTe1d*H4b_xTS_8#N;sx@Xw1d+vH**1QaV+JtG7 zrc9nVZNdzR)oS+x^a#oyxekf3>yE6TwjPSk;tx zh4{iRRX}<{cU>T~rSFE0o%-~-F2n!Lw0m=h2Saz?|KM$UPYFCG5E zU9*ETbEiDx&kcs2otZy>{>%sO(=YqoopJZWdC%4e&73-7`0!C9AAWe$wA>n@xwB`_ zT`=o`-(>hBA00Vv%-BapKKfffc-KR-=W24q`JcvW24>8CcA4^jZ-=#AF6)-vQOWD} zO7`C01w%Xfbc!c^mb_Zq<4a}6F3`?ifA8kk_uE&lhxF)mb63BUf6aq+wM_8lqlao?pT25Da&Kdeo%Iuln_A59eP&>P&nV#Cb->mhj4 z@dceyVtMg7@dfc!npavQ_juCZQeqvBhxAZ-QiD@{P~__4`SJ5(uLnY>?tNGhJNxO_ zJF8>0|HZr8gX3q9j=wW7Ui*~SwL^MsBA!e4q`!Y%y?7l({ic#MK@SA2b^5P;sn>W# zJIGyK3_ZmgGV#?=|HOxCR2v`KNuN8>v{yr>y&CGDfF-(r>^&*{qQtFmFWJRp7H05e`ci2@tl-jw1{8BR`D~6R`Ek~ z{QNPhk?|Js7Xy#a){FRaG(~AMwt9Ldrg1aw^&gx(T+e`T?1jLS!=AWbJF`RM{70UA zbV6{;=Yeb00y9FJ)gCh@`V$|Y@YuhRqR=TxD!KU}82X9r2cc#6ygG72v2kwqQQ*wO zVf8Qh5W3cZd)|HH4L7Xr@ocx^eGZ{Bd~}L0(LH~rk5q+DS(%&h*eri(P<=7h=1MNg z#ha!0ZE|}pzxkAvz0j@sH%+=(PnOiqThOJ5mvLn(hUAl4Bc%WAD66BiE2ksHmDf?)rMU{?)m%k|KUYVJ ztF9xYrK%1$4Lp*8oZu4$)0WF)|UNPj|;oVUqdmC^sH;*#>pCbe?iyz=^Qm>cXJ za-EY7xY|kQxYSZxT~^W*S5m(K2``ssiD&Fh3l5ISog8V zr6w&>xVx3ko0W#dy##cvFZ~%hD|iDH|INDZn-!)ySIdLi-aEPvbH81AR7LmLw(w}u zb?|PbR!LUwNmmqwlbn|;zjxfZ-j@n@gKL-6Nb&bj{IwK!MAu5nw>Q*c{_fi7+E;m> zt~?n`oRyU38QI7@9TJVqH<{+BqtdW8j)LA}Dii0@6> z>s)dETN#u*b)OCNe;LZa%AxqQGEiA14Uk(Rzb(&CIZMsZ|5gUd|B|OyMe$d$ye~Yg z^F#8zIRC8-N}g7~u5iheft5q?X=Pw#Y*)K?i~IDe)jx~>r><5vUzES@pYWDPmezlD zRXs-jC$5&4Nw*h%UaG9>*zJXJx#xDG7(zlW~p7aoz$1Btk$i!_|PT75dBlA#eL?h+A8hk^sChwE4QC{wRTQhvpQzwTh!jI%_-mOm%Ms2 zrTYWjPnE(KPxuwp1FQdbKh$5VL+2=8txa3oH=7_E$+=qB<~ncD*+XqFsI!&Mc7^fc zw48p$4w)?|PNQs3sbPtc9d$Kser0sF(3!9gXOv6&ozi=k(*1?(&pV3K&H<;LJrv*7%G0Z5 zM=vjolcq|#j~kR%WfiVr;WV>lC(>)_ZmK-IRBrDl&eH8&x}8P#cX-j&?6KLzzGf4p z-<}g;oyHsLv^b47*4eq}T5Q9wJY9#%$KZk z%=TOUn>_AjV<+nwQIyA|$#kCKEY(5JLDOkA_M{CyV;=t^Iq6jT+Ue|?baml=yPu-} z&8_5V`E=1~Zt^euT5gtSb#*4n>}uKKs|#OF$f_Qn;KXI|Nv`mSuElZvR{T#qmruKQ zyKnjdrQHR(a?19kt9`tjRPJPXm%1rY-d5(gs|>hG#$-7+yKWrT&!Hz|{gytP^`9&M zbyMd;7p{5!?)ZWvMdjEvyDs2Q$E$AnEt&z_Y-MLGH!l zUcBbGv|R%RPp}ureR;TUOuf^B&2VB%#yfkz1DhgAaC--u~?ZQfhyM<@DbYVJJMKa}_-FrEgg-p0` zfh*^wiI-Q5N7eJUuoVbffv^<_Tfte_3WTjd*b0(gpq6c6(-dlmFrd_?Y21)5tfX;4 znlxky(_Lp_W$90IXKTY-nmb21kmjmNW12=9CR0l}ndWMvp$;1A!Y=^pf%S2}5Z(Z^ znwI7o!5f23z>C19$Y;W{;1|Q2ftSe6rMc#$C#+FPnri{J#J3f`t>NuSTL;o1)7-W2>)<_Yb@*q&mpXO z?Q-dZ&c5(|Dv>m$*zRHk8b*Snz|r6sa4h%;Iq)c{eGD8AJ`Vm4^uZ^Jop0m zBDe(KrR3x?_;PRs_!77hTm`;D4zC7Z1=oOU$>DY2dL%c18^KNBX7Dv|3-`Vi{yO{( z_%`^P@VDS^!`~r4--U07zXyLGz61UNd?)-v_($-M;k)3w;d|hF(Z3JAAASIS5dI1L z5d2g4VZ#1_d;BB(Gx(q2N661V!~X*REBtTpqwvq+U%_;R@2z z4fmd;rx$6+fn&kEK5#6U*N?FM2{nLp4n%&M+$*Xsqzfx4&nv2TQDDy%JwYnEvvH|X zctpIKo+B0YluXz2CQW)O=?LhIh@ZvN@GQBv5wECFXGvRuFdbf5Vb78_!*ithELR2h zs$exFO^-dzGAstgm`*<@U7-rZtuK+TG#HO4mddW2Tq?T?V4B)OWraE*JWH`uCf>@r z*4JrwQ5g-DrQxu6#ALi73VSwbK3idpThHojg)IRMMG6IR3*l$NLE#h>NhCH?ZOIRnlx0Ce1Xu)w3=cxZZTF< zj7Nl3;SuStrnt6i9@2Uy)xlbnY%Rs!*VU3wK$xXawbUosB8(`Vwe(bt3vtJh&Jb(F%|DiydN#JB~3OE&4tkxG*I(BYQ9EK7Q2lKYkSmujh^gI`-1~W)j(vfM@I7>@*ND`2u>q~)4>^FE;v(I z2MbU~b|WrscBYPOh1_K;;;ycI(}iXu>Z;Wr5!zFxuDce@QJU*&uU#GAsc?CQB@XuCufzMj%hRoDV-rF7PFtrc56rK*jvJ#lpeJAs|S zE?`%%o6=m*X(mE=4cHy`Z1}bC>)<`$R)Y0pyG-6nu%7D;+W5Dg#`WgXPxiB(dH|N5 z5!}~Ea1=Ni90QI8$8nF35OxAM5u5~02B&~i!I@~DMS5m~&w$T@bHKUaJa9g^09*(z z0vCfzz@^;LGWc?E1^5!U5?lqog5+xORd5Zsmb68YSqERw-EF{SBYYElGyFC979_XA zUx&W|-v)mZ{ucag_`BeH;0|yn_z}1Z+ym|<)IRur_yPDq_$Tl~@K51iQ3_vMIjZli z@>Q?fFd0k%%YdmCNelEN^ z{5*IDyarq=O_Dz!{k7n=;dS73;q}mbA=nUX3|<6gg4U0zC+lbFZ)UYf*3Wozc-Y#G z*TQPA*Am_ePMh-Dz$2vRQbM&wrXB9>txVN_w)^d9IqA{jyv|6{;=Hch(dEcoK|Xhb z_as%lkj#P8rqqkJ)Y7J853O7Vkj{ZfUXQjLz`@{+;1IJd-ZV5%2WNn};7p;-&148| z4yrzOuReCKzFKr#bFntpR9_Y^;4Vb+LiuL62Ji;(hT>J#i!%2{Sh+@WX(MjdtPyiZ zjbxot#jSVUNEY&lFh{XBl7-wZtSFbpgtb|a&f;cQ8!J8Ah1R=ktWd{gwQ|_y&+|9-8c~h5T=$SoliapOHb{koxyG-nJrtI@k zakI~vvd@QwwN#p!dTvw|)(0CFju3AOx2I~RVl>}o=(z+8gDt?88XaUh8;fPC4_P3z zJjs-OwzxWioxskZ%`|2@v&@;UJ9sVFL%ESDyJ~tykgAd3C~!151{@2HQ|~>~Jwn?;a3Qz|TnsJ&mx0T{72r$YN^ljp zp44uDZ-j4xZ-&1H-$Dwv!e58K0pA9H6aE(bZTP$3d*BXmC-@P#3)}`$h~ad~PF z)~0MOGec+=+j?HLh4sOvptaL1T1}R_M5)cvJgv=)TTd)YMh z*a@^biY(Vf@n&hvWxP9R?LEucENhl#S&ds?F3a@>`+|efJc6{11V@3R!7<=iaGX+| zrFLYgod8Y*CxMf}Dd1Fa8u3mCXMnk&+1@NSi}cI}p8=l*=YVs;dEk6-0k{xc1TF@b zfXl$;;0o|1a3#13T+98g1J`p$8*twU-vr+be+|BcyVweU9sUM<8~jc9TkyBx?}G1v zJHVabN8m1S54e|5`{4WG2jBN0oVv^ z0yYJ0WRc}vYIRT^5W%<`sz)4>^FE@(FYVztvY z!Yt{zSgmi1(E2$S>-l?BXyclT_576!cC4A?t=(Gxp_%04LhA`MV-2gB%f`jlu$mFJ z8EaV0R8n$LNu|0=c=BGNC+t!2b3t3D(`=@4{u1>ZY6~Np`ME^ewhOOSsxFZ><55HU z1D7a0TVyqCWZhip2?(th*IZ%i3vErUIq7Lm8k#E&T6H7#F!N(!p7BxkU7=4l@c1>nXom-1>xJ zXQQyNGus>1ijwhegt{7+Yd{-?g_UxXu~AsqT?e;OSXeVi7K^PyhMkSP!i>DajJ#}> z%VHmb%!AzT2<~MhI0_sMjseGl;|ez@)Fa&21aKla37ia00jGjC3JW_Mg@v7s!oto* zVPR*Zu&}dHSlHPpEbMF)7IroY3p*Qyg`JJU!p=rvVP~VTu(MHE*e&H=Y!nuDHVO+n z8-<0Pjl#mtMqy!Rqp+}h1}(Vkb~XwN zyS;?6QCQgRhaZ3+gxe@A><+KU2_+X)cSESjP11W2sPL^SR+-VhD82Oy#mp`q;oh&bB= z5OGb>V0!>8#iq^n07Ufsu@qjSyo%^KP(f&O`w`~$Bg$7xRV(FBME7UBJ@OsEj$kLS zGiZANBI;+FPTK;4gE>;4gE>;4gE>;4gE>;4gE>;4gE>;4gE z>;4gE>;4gE>;4h-Y%Sj=f|Ed7_m4PR_m60VXgG~LoDR+abHSOUa~AhC8+-+t|f=pf$Pb= z4ajeVZ-Q@zzXspJy>Ervx_`vkj^c>h2G;`YI9-9O?Ezz@Pdf!n%&#C;0?1Na$e zYyA;tW2%U=wf>0v3*6TFBl;+nx!YQQ#C;C8wf>0v60ZKS_*b-!ui>`VA91$UA91$U zA91$UA7QOO;%u!y;%u!y;%u!y!didCeGmTucU$X^u+|@8tv})=nY(9e{SmJ;+}8Rd zp1tc3@lrrr>yK!ErMcKze?;Q~yH9l~nG+Vd$3qm_N*4d4yo zjo^*pP2d;7o5C~UHn$(qI6`(n;|N(KjU!}HwJu{kZ29lCAa<;l*9wl+(#pQ2%AOYy z?^1H0E%NPfZ*MhO;|@zd){K??h*oOM1#6}ehiSl?Y20D%*29c=wl^ulN=-!L4&$~` z6VbTCct2w4Z&piV5KHGk(rhdH?HMDq*Ek~XI>0+Hr`JI~sdDKEb^<$tU68an(hf?E zp^YFq(AVvtr%!=+Z?F$&t3@5u<2@>zh2(7T8Sq(f4mcN_2hIlXSQ1m6bV1>XaAfIGpDz+IqrRZHhS@BsJ; z_^F}lykR-80%+r@4)oSK&`;|?53Pe{xs_wA?sZU)OSz;Tmth-_a`D>1JD`EGP^~a+ z-N0UGAkQ@mZQA;SwjS5PyMeGbg41xB4$c5`!I{F2lzvBQLPwQj+;zgGlWaql>W=k2 zRD-0aGbP?xGRL&z+U~21`Z>phHcsp!`JqDVO?6S9#9VB)ybFD*F7&3lqPZ(^T@Jq- z&6iVuuE6CA>FKOJur`M62ES6gjo$CEp39Zed_28-LbhjZ;aW=nT9tm>T}Q3H zPW7s`xUC>xr&^ccu2Zcm(A;I3-mvQ--+*Q?jrSB!6`EvEYJN{i9@YL)tCPLZnZvjs zhkZDj@sv%@q2A_b7NS7BBiIS-40ciMIhutq-|nF4&v8AJ!W_kuA2fNz3tgYSaxfjhvR;78yta1Xc-JOF+Ierl+(wqZH20%#?bqxpR0mB!JA z=YciA^DXy08{_17HoD2t^IdtUC%bZwdYD5!%%L9U=n1Z}pdRLEn#vr zH+V1fP$GI3o6i1LE_(i&=Ie>;25>NVBWOFUax@cYu}lZ`){S^BX!AL}sXx6{qXOb) z@p@C=daH!%yWZ5yKG@_wlIg74V577?$n=p+hUqppr z8gEt*uMXP&v3}_3C;N8P^+V5K^k~ncQa+e*?qJ5bgEdOs&Forjp^b9~GtM0>4Qs@0 zoI99t?qJ5bgQabO-a@sx!MnVngnm>a#v>SykC5*c zakJ+mxR(*qf5eSoJU)Uu8cEoZgdIuPk%S#d*pY-CN!XEu9ZA@cgdIiLQG^{u*inQX zMc7e<9YxqtgdIiLQG^{$*wKU?P1w}g1Jc^!2k$DttkD~2Sv^|Qp$B=&v`Nxod z4Ee{9e+>D@kbeyM@yL%yemwHykspuzc;v?;KOXtVk$)Wd$B};=`Nxre9QntQe;oPW zNj^hka_h1Ca3B5z{0aDz@F(F;#2PcM}LZfv^*ipNRZKCO5&4P8Pegtq@{^FCg#0Ar zCm}xx`ANu6LVgnRlaZf{{AA=OBR?7W$;eMeelqe?ke`D56y&EMKLz_#@-vX1f&2{QXCOZV`CR04ks7u9~^P>40(G0%j9i;LZ12?hPFQ;Pb&_Fb3oglkf#*~ z!}*}?Psr1XgQ4wD$kU30q3ut|bGAPrPiqZ^wm%_HYYm3BKOs+R4TkH9-S#KsX$8Sj zvI)K!Zu=AR+*a@n&~_u_xwk>vhmfb00>d5PPSExtmbj4 z3fg{yJomM6^*{`5k3k;03-VNI#%)JI9(xJ$JljQ($Nqsl%_JCa4u+9z2~ukIeyx1l z+g-3jAdejadF&9#Q?J8(ZC5~^=Gcu>j_d)*(|F(9ub1`6^K2G9PqXO8Z-m<(fP9T^ z0>X}9C$KYUqnmtiz(5x5v!0xkoWgDb$7z?I-C&_*};jBfHZy0O?dfv=F_Xpr&pQJ z=q8_DWxhr?rjxSJ=*D+1n|ww$`HXJz8QtVF zx|t>0W&0a!bTb=18~zOZ8ThmCXW?_;bKrB~bK&#g^TZEpq-wK3^QaZ`7_rWi-Ifcf znvdjsLd};7%n2;ar{ zF2;8;zKii)jPG;u&2rDl(sp*w!=H!00Dl4gBK$@868I8nJFJnmrE`h26}Tm6TZ->e z;#!LPGF+A+zYO_h33ud|r@O=s2m+*ZF-!S@yU#@%Z8YWS=0SK({mYv5~nmaSF(WVy9^ zQpANeYFH~P7k5!+OQOt{M42s#GFuX5wj|1INtD@=C@G0*w!}hNu12vZQS3<+dlJQ- zM42s#GFuX5wj|1INtD@=D6=I|W=o>fy{KwasXmPDB?i85Of zWws>BY)O>ak|?t!QS3>S*^(%;B~fNeqRf^=nJtMjTM}iqB+6__l-ZIfvn5exOQOt{ zM42s#GFuX5wj|1INtD@=D6=I|W=o>XmPDB?iBd*U&+JJQdlJQ-M42s#Vo##jlPLBi ziam*9Pomh9D6=I|W=o>XmPDB?i85Of#hyg5CsFK46nhe-ccwf!+LzztN!$L>luTrSD)vIxQ*A=t419bS}j_yIv{uTa}2F6 zZJ^d|pw?|f&qnlYM9)U_Y(&pS^lU`WM)Yh%&qnlYLeD1jY(~#!^lV1YX7p@E&t~*& zM$cySY(~#!^t^_i*U+;CzC~fiP?S$P+K#&Sm1c3}d4XDyJt=l5lwvW*E5!yaR+sA187;PV;?PIikjJA)__A%Oap=}r1cA;$- z+IFFBH`=s=O!uWd6w0^VXxojp-Dum5w%uskgSI_r+k>_}XxoFfz4Fa)dsTv|LM#2f z(v~i?y1G|oCwEB(WT&j$_eoFO?I+ZJLhUEienRah)P6$kC)5E#9U#;}LLDU3K|&oQ z)ImZWB-BAdeL|>D2z7{1hX{3uP=^S0h){9~HOf z%OCOmBfg*E`x(BU;rl0i|Ag-me2<8C)>^FP)t_RwD`v0Z3(J0|&KLfiQtqwd8N>ZsP3tR zJ2ZTUhVRhuUugI*G{n&mM?)M9aWrUei_)OkdZj^gu1Z534RJIaN5gS6e2<3j(eOPQ zzDL9NX!srt-=pDsG<=VSAJFgv8Vb=+h=xKm6r!OJ4TWeZL_;AO3QdF7&Xo^B<%3qx zRf~iwS*@d+dy>K)6{==>rSL6<69cv0KNfylkrW)HyPg)TvBjJ!6k@%5cx9r zmch3SzNvCa*V?=+zff&osJbDP6U+A5%}0@^B|tpeI?CP-gJakcc-r!?&mEfCfR^=7enQ@CoixP5IZO*=&mWnG1O zLtoeu-U|8FaIMkEU28N#&HM|sMkCZnT&Oh~VRul^AaSkH2z%nIy`bWH#tD0aeL?Mi zlZ(dY!V#o)BsdBj4UPfFg4*jY$w$yX0h|a<0w;r0z^UL&G;0k=p|l1h)EbabYd}J+ z0SUDRB-9#^a6YItAaSh$3AF|!)EbabYe2$f;Bs&Us5KzDYYj-KH6YLi{9st+Ts_xpufff81OYaL2lt13dRMhP?ET9Xp52|wTRM|(R>rZ!w}$BWm6*8{b; zQ!Wib?d=rT-cDgAsOP)*#c;j%B(7a{!sc-5xyBBvdmi;%BM9TvbB!a6n~h4-o=>}r zw#42J_x4sgw*Dp5K6xQ+K|4RqMQdR~y?h6zYpi;?uzCp!RmkB^R72tcV4uhy|!LN zV-;F_wNfwCT$XShxB(Zf)XPOH^}?;7R_evIQZLj> zy-+LlLao#bcYs={7uQO?P%HI9t<(#(YelG)dZAY8g<7fiD$1@KYS)TTEA>LN@D)An zF&1ifu~7Spg__F}>Ya393(F-NdkVE{MX0`_upL}0_2QI;jXlM+YelHJEFpPrV^4AI zS`licUZ|CN;fczEEFVsrCP%HJqnZimc{We0)q6;;m6zZJ_p=Pj!W@#&_CCY^- z@>#Z;uYG1(!xfr+K1Xew4IH%v(a`o+RjGX*=Rc(ZD*tH9JHN-wsX*S4%*H^TNUbg6=yBFinPUr)|aSa zD-J@ffC#NtS3z?XTYd1V$YRNba;z#1?YycA6_5?GXS(g8OBI^7YG|v5wrXgrhPG;G ztA@5}Xsd>{bJ2D#+Nz_?_Ti<S(Kuw(4lBj<)J(tB$tw&~_f$GSHTR zwhXjope+M!8EDHuo7U=8ni*)zKwAddYM`wK+G<+QP4?8vy{3B3hSk9=m3U1(>-E(J zN%j?LScH0qMW~e*uO^nfrb=Op-a)cFJYVvuLaRULtBk68=Tm}w3q|(Y;?lQJ zK#QxE^%#W~S1sbIMO^k>lr3_xxM~rXeH}%T#8sQPY7Tq9mh^r29 z)#1MC5LX@IszY3Lh^r29)gi7r#ARPsDDdhKS6$+&OI-S{g52v8m%gZ=^w*PbmeAUD zeenuHt;7kn5+~H?LumD|KBZHi60sgiK>I8$?1hAFK-dO^Z9v!tgl$0B283-u*an1c zNZ5viZA92cgl$CFMucrd*hYkHMA$}zZA{q4gl$6DCWLK5*d~N+Lf9sRZ9>>4guRHc z7ZJ88VVe@RDPfxuwkcto61FK}n-VsYu$hF-qKvXsM#uCM&4mgu*)Hsac|Yx?!Rh9vdA259>2?TiAU>9;pB3cS|n=Zy?;OFwU92rd1s z(cc>VtCB`lNZONauq`xic zZ%g{ylK!@&za8msNBY~5{&u9l9qDgJ`rDEI_SBa4lJD!amwZ5|xnH3@soSfK92QzB zv?rGKibcM}(m|o@JxsGX9TloTXl2)tSUM6*N6E{bxH=)<37wsII&_juf!B#ALTCA= zYe%)^OlNYYGdW{>SPb}xO1#dfb9){Z*U(3e>H5{vC&E0BxD zVtd#MG?p-#e&lLD=|3W_U1UNluYQs@ZqMI-(kx$vI;LHE*6#W%RA-?*-TI@szcgof z{n0!C%>$JF?Ha3?)g6E(9H3Z?TZs%%*uFxugaf2ujnJ&_0NsmR6r;GbE${}SZ6J3s zP?B-EnAII9-?)BT!7H>?e7$`j46A-#FPX#M_0k|0$sE?|zPaB(TsO#ffp-I%e@&=g z>#h%bzgFx*LJh`!FrfycZE(UJZ8ze6Bie36+l^?uiBLD8?IyI{L@0fGSa&o;U!U78 z)K~q4>7c$LBVJ9VFhpTZM&GK{??qU?-L6;y-W`<4Q1AZ2YWls1VTE;s4-{S~e6X;W z@Sz_L3Lh?PA{_q1SHj;G_7jdkeq`ar-ksVXSk1E?gH1f!G1$cWjqpP69&f1R?-j4( z-3OnhbRN^*O0%fbl-omvW>Kf<9?iups_i3^3;8fjyDn3;3)4bPmrQ{-UG4?m3|wa5 zG6R=fTyk;A#l`mg9Mc!oE!0eNajt3=r|&qs{%OFs@Yyr`gwl zbi_5Id_vmpHSt$?RI&wjNb*RLqrRWW|Hq1@PPx`rO6|*jPBPM~bnHf6^@$@9Pr72Y z_>=<9A{B~5zo??H+Y{GQG)tp&Dt3jiaP~F0be)IL~51Ss#9S$c)vwW$lJ6HL0>=qs~pTaFF34NDHXS#N$DXmIvZQM**sS-I=q?~-R{H|TN zM!As)SJm44$;0wWr`5YHy4ty2=K}3_YHU)bs`y%@HgWl7^`yA2SXh-5cly)y#O_mR zvc36*JN0!tr~B3WYIesQYA@6gyIL+#ZmPA3zmw_4ugJCdV)evQP@sIZT3~hWSi%i= zM*FX=THc_ z>kWDx1u82^7bn;J;fYpmb=dv_ip6U2fyBQjk<^u_+4!Uu%jr8e`b(s^2$gHFE!5oF zY9;m_yS0CPtGVbnrmLM+6RozWRpLUvDTU@5DAZS`Pn5xlI-@YMI~G!CIh~{R*lr+L>z{+mRR+ldSLwM?k5BNnAwzbK))yQb*auIo|q)AHGJ-%xIp z-piJ^x_8xe)pqKT#h%D-%M-Oiv#}L~R%gs=)i265s~O5=&I95__ek2!hMY|IDR$;W z(*HRrE@eq2;`@u-x=h7Bx4TL1+}r0weE~!r#hZ7##2S? zKk9?o=o9t#$1x)?*?hN+1xL;hTZ|zAv)d8WS~#qkqzgbgHR zl(t<*C64mx!JVpxLq>aw2m}Nzg4l{5E9LeKdTJj%L+NR{CeCyJJJ%YR+~aop}s9OYK)4n`%8Vt;){OX)8@jF*lkj*_t@q|4lrJU;o4 ziK94tnOiLV#fR>Kdokb81kT&Vi_dM!ffA?1%{eOhkKXC${**kg#Ocy3&+I(&k<=pC zBWX%-S#X(^$?2!%bMcvArLHVfooDAS(^t0ZndHmgRqSf1O{BPN*ON!N{>A?PbJyb7PsW|n zHrPYuqkB%=y|u&C;i<#bMthXmk~s8xhxQflQ~$cU7TRy~bN<>&D5*%WG}q*!|C|dJ zu~hvsa(Qw4Ei?V%%L#uY)pXkL{nXSxzdHKrM|bz(hBYla?Ia|kV zrf-a${#Wv8)arE4Q~tM6Wa6)_J9#ALb&GvYUW2-W#9{Yhzv*m8BDQKudGS$ScTltF z=&n1kqdw`k-yyQYEPa!rqnZ9|rr4{UIFl5<kPtkIvG{rIM#po+Zxiy8ie~ zR(?9==(WNgXE=MwEpa9=sFW6;^3q@L6KASQqU2dtJI?puTj`L4R?_evB0{712t_NR5^ z{AFsdIHP!r6RwupM2fF)Cyy&n`2Ww5;@D5dU5nad_no-=MoLU09T({6U33(;e@}jc z^y8NI>T34Hz81rueir;Yaq~|Sx1PLNjMC!Y>o>iNWxg!>eeX267wR{_?XM=>L%wYh zFJ&I$8;Y*xY`;5h`iuW8cg-4=)?vRUp3r8$#9k#K?cMJU^B(XX^d9ma_J(`E^+tFj zCI9~_T+&ab^G-KRzvh0o*2nJFy4XEh54*2KI{$~lCG}UFR%U5Bt;DM9lr{01>dcdu zT65B++(T)#=7eOLTC;w&OTR*3za!twd&+ytH7_cw(i$Hp>sK~|u1s+)k;X(BTIf^% zs5z%P>DTOwT}{`?KP&4bX+HJOQ%p&U>(oDwnpji=i@eI7&|6OS?Brh}w$o?g60Vjj zCI1qx6-ziJjk14|!BSvpFaWw|oc#$^w}q;$LZwz%7Odb>MmhZj!7`xvR>HS3Smm<9 z!q0UUY$z=JyRKj8{6_q1U3LC2L;RSziRX!bC$95{ksAsNkBk48_!#j*@$VH^BAp^~ zU!)kV-IWse#S`sG4*IjZP1KHrOrqr#uazYv6Xg+f54(p>oWq?RX2TO9(oSmwX(!t1 zP;Za7*W2gq_YQamy-&PD-lyJSi&T3u67k#BVoKNffKJs(z1L@LKR|u7_b%-DFuGPji3^m@J(_jXpQLVe4|wl+?|VDE54@e;hu%lt$KEb)w|xGN z4(Do0Q?hbnkgoPCSf%tO6zj=Mkmj;(lAgI0_07>5?mX>rzD&mw_bVOC++cUT-l@Gw z#~OE=j;Om+$9i{KlE$>P1alLK$d++z|V{fWA z(d|y8HYwm8@&25Y?ES@?>&^4#dkegU-Xd?Y_nh~<_k#CV?{D5w?+35Y``r7&Tjzb_ zed)!$0`DK*pOQ*@-+SLBC3(k^JnvgC=6&WJ_x>x%d4Ko5^1e>8`uM;1`~P+Mda{0M zMb5R=X*Q&~&dxeV=v<=npw45|_2M?uMc*o{LqF;QeNXVJ6Ky8(#F03E37d81|E$%T zbn#)&sl;E>D7zNf#b=D04OaRFS<5vp8zd}qSz+O-4TXhkbbVRZsIKdDU9YS0jpCb% q_-o=@T;k7CWp{hRj%vTk4DDsf)jkyWzj^V$d7+wVf2B{>i~j=)sTR5b literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/font/skycoin_bold_italic.otf b/android/app/src/main/res/font/skycoin_bold_italic.otf new file mode 100644 index 0000000000000000000000000000000000000000..ec11b0fcaacb7ec5d15b52d4c69c732f6f733890 GIT binary patch literal 74740 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 M7XMooKhlf;0}&oTg8%>k literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/font/skycoin_light.otf b/android/app/src/main/res/font/skycoin_light.otf new file mode 100644 index 0000000000000000000000000000000000000000..57fc96f821b985ad582fe5e0abcb29ee98bd62b7 GIT binary patch literal 71664 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 EKWNHr7XSbN literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/font/skycoin_light_italic.otf b/android/app/src/main/res/font/skycoin_light_italic.otf new file mode 100644 index 0000000000000000000000000000000000000000..fcc949c0abb78b9885792b29d552171d9665c8c1 GIT binary patch literal 75260 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 literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/font/skycoin_regular.otf b/android/app/src/main/res/font/skycoin_regular.otf new file mode 100644 index 0000000000000000000000000000000000000000..e3e80f0e48b29fe264995cebf363cf3ab078b911 GIT binary patch literal 67292 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 VXQ$2o*2VwU1$rib31|An{{cZc+K>PM literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/font/skycoin_regular_italic.otf b/android/app/src/main/res/font/skycoin_regular_italic.otf new file mode 100644 index 0000000000000000000000000000000000000000..00694225b6bd6e925d8e663c6f7af89b5746e045 GIT binary patch literal 75060 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 Date: Mon, 3 Aug 2026 18:03:11 +0800 Subject: [PATCH 05/37] =?UTF-8?q?feat(android):=20core=20service=20?= =?UTF-8?q?=E2=80=94=20on-device=20config,=20Connect=20flow,=20log=20viewe?= =?UTF-8?q?r?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SkywireCoreService (specialUse FGS): execs the Go payload as a child process, captures its output to a rotating file, restarts on crash with backoff, stops it with a real SIGTERM via /proc + Os.kill (Android's Process.destroy() is SIGKILL and would skip the visor's graceful shutdown) - First-run config: the payload's own `config gen` (keys never leave the device) + a Kotlin phone profile re-applied on every start — API pinned to 127.0.0.1:8000 with auth, RPC/pty/skywire-tcp/LAN-dmsg off, app-private paths, apps in-proc with autostart off - Local-API client: admin bootstrap with a Keystore-encrypted random password, transparent re-login on 401, CSRF helper - Home: Connect/Disconnect state machine + live visor card (PK, version, uptime, transports, dmsg latency, service health); Disconnect stays reachable while the core is starting or crash-looping - Shared log viewer, three sources: core runtime ring (cursor protocol incl. restart reset), per-app feed, captured process output — the latter works even when the visor won't start - Go: netutil via anet + SKYWIRE_ANDROID_API_LEVEL (Android 11+ blocks the stdlib netlink dump for app UIDs), interface fallback when `ip r` is empty, nil-safe Visor.Ports() for pty-less configs --- android/README.md | 30 ++ android/app/build.gradle.kts | 2 + android/app/src/main/AndroidManifest.xml | 22 +- .../java/com/skycoin/skywire/api/VisorApi.kt | 211 ++++++++++ .../com/skycoin/skywire/api/VisorModels.kt | 118 ++++++ .../com/skycoin/skywire/core/ConfigManager.kt | 227 +++++++++++ .../com/skycoin/skywire/core/CoreState.kt | 32 ++ .../com/skycoin/skywire/core/SecretStore.kt | 116 ++++++ .../skywire/core/SkywireCoreService.kt | 291 ++++++++++++++ .../com/skycoin/skywire/core/SkywirePaths.kt | 65 +++ .../java/com/skycoin/skywire/ui/SkywireApp.kt | 25 +- .../com/skycoin/skywire/ui/home/HomeScreen.kt | 378 +++++++++++++++++- .../skycoin/skywire/ui/home/HomeViewModel.kt | 153 +++++++ .../com/skycoin/skywire/ui/logs/LogModels.kt | 114 ++++++ .../skycoin/skywire/ui/logs/LogViewModel.kt | 197 +++++++++ .../skywire/ui/logs/LogViewerScreen.kt | 275 +++++++++++++ .../skycoin/skywire/ui/navigation/Routes.kt | 5 + android/app/src/main/res/values/strings.xml | 40 ++ .../main/res/xml/network_security_config.xml | 10 + android/gradle/libs.versions.toml | 2 + android/implementation-report.md | 69 ++++ go.mod | 2 +- pkg/netutil/anet_android.go | 43 ++ pkg/netutil/net_linux.go | 45 ++- pkg/netutil/net_native.go | 17 +- pkg/visor/api_services.go | 30 +- 26 files changed, 2490 insertions(+), 29 deletions(-) create mode 100644 android/app/src/main/java/com/skycoin/skywire/api/VisorApi.kt create mode 100644 android/app/src/main/java/com/skycoin/skywire/api/VisorModels.kt create mode 100644 android/app/src/main/java/com/skycoin/skywire/core/ConfigManager.kt create mode 100644 android/app/src/main/java/com/skycoin/skywire/core/CoreState.kt create mode 100644 android/app/src/main/java/com/skycoin/skywire/core/SecretStore.kt create mode 100644 android/app/src/main/java/com/skycoin/skywire/core/SkywireCoreService.kt create mode 100644 android/app/src/main/java/com/skycoin/skywire/core/SkywirePaths.kt create mode 100644 android/app/src/main/java/com/skycoin/skywire/ui/home/HomeViewModel.kt create mode 100644 android/app/src/main/java/com/skycoin/skywire/ui/logs/LogModels.kt create mode 100644 android/app/src/main/java/com/skycoin/skywire/ui/logs/LogViewModel.kt create mode 100644 android/app/src/main/java/com/skycoin/skywire/ui/logs/LogViewerScreen.kt create mode 100644 android/app/src/main/res/xml/network_security_config.xml create mode 100644 pkg/netutil/anet_android.go diff --git a/android/README.md b/android/README.md index 21d839d43c..d35ebbd548 100644 --- a/android/README.md +++ b/android/README.md @@ -85,6 +85,33 @@ 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. + ## Testing & debugging App logs: @@ -93,6 +120,9 @@ App logs: 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): diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 4226b1dee3..9502c601a0 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -67,5 +67,7 @@ dependencies { // 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) debugImplementation(libs.compose.ui.tooling) } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index fac5574abc..1619c98892 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -3,8 +3,13 @@ - + + + + @@ -15,6 +20,7 @@ android:roundIcon="@mipmap/ic_launcher_round" android:label="@string/app_name" android:supportsRtl="true" + android:networkSecurityConfig="@xml/network_security_config" android:theme="@style/Theme.Skywire.Splash"> - + + + + + + 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..fff713df67 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/api/VisorApi.kt @@ -0,0 +1,211 @@ +package com.skycoin.skywire.api + +import android.content.Context +import com.skycoin.skywire.core.SecretStore +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import okhttp3.Cookie +import okhttp3.CookieJar +import okhttp3.HttpUrl +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +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() + + 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 } + + suspend fun summary(): VisorSummary = authedGet("/api/visors/${localPk()}/summary") + + suspend fun dmsg(): List = authedGet("/api/dmsg") + + suspend fun serviceHealth(): List = authedGet("/api/service-health") + + suspend fun runtimeLogs(since: Long): RuntimeLogsDelta = + authedGet("/api/visors/${localPk()}/runtime-logs?since=$since") + + /** + * 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") + } + } + } + + /** 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): okhttp3.Response { + val first = get(path) + if (first.code != 401) return first + first.close() + ensureSession() + return get(path) + } + + private fun get(path: String): okhttp3.Response = + client.newCall(Request.Builder().url("$BASE$path").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 { + private const val BASE = "http://127.0.0.1:8000" + + @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..5c3f556890 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/api/VisorModels.kt @@ -0,0 +1,118 @@ +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 = "", +) + +@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 = "", + @SerialName("public_ip") val publicIp: String = "", +) + +@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, +) + +@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 = "", +) + +@Serializable +data class DmsgServerInfo( + @SerialName("pk") val pk: String = "", + /** Nanoseconds (Go time.Duration). */ + @SerialName("latency") val latencyNs: Long = 0, +) + +/** GET /api/dmsg element — per-session round trip. */ +@Serializable +data class DmsgClientSummary( + @SerialName("public_key") val publicKey: String = "", + @SerialName("server_public_key") val serverPublicKey: String = "", + /** Nanoseconds (Go time.Duration). */ + @SerialName("round_trip") val roundTripNs: Long = 0, +) + +@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(), +) + +@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/ConfigManager.kt b/android/app/src/main/java/com/skycoin/skywire/core/ConfigManager.kt new file mode 100644 index 0000000000..4aab19e114 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/core/ConfigManager.kt @@ -0,0 +1,227 @@ +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.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) { + + 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(): Result = withContext(Dispatchers.IO) { + paths.ensureDirs() + 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() + 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; + * - 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; + * - 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. + */ + private fun applyPhoneProfile() { + 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("")) + "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)) + 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)) + }, + ) + else -> put(key, value) + } + } + putObject("dmsgscp") { put("disabled", JsonPrimitive(true)) } + } + paths.configFile.writeText(json.encodeToString(JsonObject.serializer(), edited)) + } + + /** + * 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() + } + + 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 + } +} + +// --- 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/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/SecretStore.kt b/android/app/src/main/java/com/skycoin/skywire/core/SecretStore.kt new file mode 100644 index 0000000000..3e417f827c --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/core/SecretStore.kt @@ -0,0 +1,116 @@ +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 local-API password: generated once, kept only on this device. + * 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() + + suspend fun apiPassword(): String = mutex.withLock { + val prefs = context.coreDataStore.data.first() + prefs[KEY_API_PASSWORD]?.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_API_PASSWORD] = 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). + */ + 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") + } +} 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..c4c73473f1 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/core/SkywireCoreService.kt @@ -0,0 +1,291 @@ +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.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runInterruptible +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 var runner: Job? = null + + @Volatile private var child: Process? = null + + @Volatile private var stopRequested = false + + override fun onCreate() { + super.onCreate() + paths = SkywirePaths(this) + configManager = ConfigManager(paths) + 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 config = configManager.ensureConfig().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)) + + val pump = launch(Dispatchers.IO) { + process.inputStream.bufferedReader().forEachLine { log.line(it) } + } + val exit = runInterruptible(Dispatchers.IO) { process.waitFor() } + pump.join() + child = 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 { + // 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), + ) + } + } +} + +/** + * 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..7807af2fb1 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/core/SkywirePaths.kt @@ -0,0 +1,65 @@ +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") + + /** 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. + */ +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(), +) 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 index 2d2e4d49f8..3540173c2f 100644 --- a/android/app/src/main/java/com/skycoin/skywire/ui/SkywireApp.kt +++ b/android/app/src/main/java/com/skycoin/skywire/ui/SkywireApp.kt @@ -24,16 +24,20 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp 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.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.settings.SettingsScreen import com.skycoin.skywire.ui.socks.SocksScreen @@ -101,7 +105,15 @@ fun SkywireApp() { startDestination = Routes.HOME, modifier = Modifier.padding(innerPadding), ) { - composable(Routes.HOME) { HomeScreen() } + 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() } composable(Routes.HUB) { HubScreen( @@ -117,6 +129,17 @@ fun SkywireApp() { composable(Routes.VPN) { VpnScreen(onBack = { navController.popBackStack() }) } composable(Routes.DEX) { DexScreen(onBack = { navController.popBackStack() }) } composable(Routes.FLEET) { FleetScreen(onBack = { navController.popBackStack() }) } + + // 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() }, + ) + } } } } 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 index 57502b367c..8decc6a077 100644 --- 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 @@ -1,52 +1,402 @@ 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.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme 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.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.logs.LogSources /** - * Home tab. The core-service work turns the Connect button into the real core - * start (foreground service → poll /api/ping → Disconnected/Starting/Connected) - * and adds the visor-info card below it. For now it is the themed shell. + * 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() { +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), - verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { - Button( - onClick = { /* starts SkywireCoreService once the core service lands */ }, - enabled = false, - shape = CircleShape, - modifier = Modifier.size(160.dp), - ) { - Text(stringResource(R.string.connect), style = MaterialTheme.typography.titleLarge) + 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) + state.summary?.let { summary -> + Spacer(Modifier.height(24.dp)) + VisorInfoCard(state, summary, onOpenLogs) } Spacer(Modifier.height(24.dp)) + } +} + +@Composable +private fun StatusLine(state: HomeUiState) { + val core = state.coreState + val (label, color) = when { + state.connected -> stringResource(R.string.state_connected) to Color(0xFF16A34A) + core is CoreState.Starting || core is CoreState.Running -> + stringResource(R.string.state_starting) to Color(0xFFF59E0B) + core is CoreState.Restarting -> + stringResource(R.string.state_restarting, core.nextAttempt) to Color(0xFFF59E0B) + 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 + + Button( + onClick = { if (showDisconnect) onDisconnect() else onConnect() }, + enabled = !busy, + shape = CircleShape, + colors = if (showDisconnect) { + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } else { + ButtonDefaults.buttonColors() + }, + modifier = Modifier.size(180.dp), + ) { + when { + busy -> CircularProgressIndicator( + modifier = Modifier.size(44.dp), + color = MaterialTheme.colorScheme.primary, + 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, + textAlign = TextAlign.Center, + ) + } + else -> Text( + stringResource(if (showDisconnect) R.string.disconnect else R.string.connect), + style = MaterialTheme.typography.titleLarge, + 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, + ) + TextButton(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, + ) + TextButton(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 + + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + 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, + ) + TextButton(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), + ) + InfoRow( + label = stringResource(R.string.visor_transports), + value = summary.overview.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 dmsgRows = state.dmsg.ifEmpty { null } + if (dmsgRows == null) { + Text("—", style = MaterialTheme.typography.bodyMedium) + } else { + dmsgRows.take(4).forEach { session -> + InfoRow( + label = shortPk(session.serverPublicKey), + value = "${session.roundTripNs / 1_000_000} ms", + 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)) { + Color(0xFF16A34A) + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + } + } +} + +@Composable +private 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( - text = stringResource(R.string.home_placeholder), + label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, + 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), + ) + } +} + +@Composable +private fun SectionDivider() { + HorizontalDivider( + modifier = Modifier.padding(vertical = 10.dp), + color = MaterialTheme.colorScheme.surfaceContainerHighest, + ) +} + +private fun shortPk(pk: String): String = + if (pk.length <= 20) pk else pk.take(10) + "…" + pk.takeLast(8) + +private 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 + val secs = total % 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("${secs}s") } } 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..fc09782002 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/home/HomeViewModel.kt @@ -0,0 +1,153 @@ +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.DmsgClientSummary +import com.skycoin.skywire.api.ServiceHealthEntry +import com.skycoin.skywire.api.VisorApi +import com.skycoin.skywire.api.VisorSummary +import com.skycoin.skywire.core.ConfigManager +import com.skycoin.skywire.core.CoreServiceState +import com.skycoin.skywire.core.CoreState +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.first +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 dmsg: List = emptyList(), + val serviceHealth: List = emptyList(), + val error: String? = null, +) { + 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 live = MutableStateFlow(LiveData()) + private var authResetAttempted = false + + private data class LiveData( + val apiUp: Boolean = false, + val summary: VisorSummary? = null, + val dmsg: List = emptyList(), + val serviceHealth: List = emptyList(), + val error: String? = null, + ) + + val uiState: StateFlow = + combine(CoreServiceState.state, live.asStateFlow()) { core, data -> + HomeUiState( + coreState = core, + apiUp = data.apiUp, + summary = data.summary, + dmsg = data.dmsg, + serviceHealth = data.serviceHealth, + error = data.error, + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), HomeUiState()) + + init { + viewModelScope.launch { + CoreServiceState.state.collectLatest { core -> + live.value = LiveData() + if (core is CoreState.Running) pollWhileRunning() + } + } + } + + 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 { + val summary = api.summary() + val dmsg = runCatching { api.dmsg() }.getOrDefault(emptyList()) + val health = runCatching { api.serviceHealth() }.getOrDefault(emptyList()) + live.value = LiveData( + apiUp = true, + summary = summary, + dmsg = dmsg, + 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. + * + * Launched as an independent viewModelScope job: stopping the core makes + * the state collector cancel [pollWhileRunning] and everything called + * from it, and the recovery must survive that cancellation. + */ + private fun launchAuthRecovery(cause: AuthFailedException) { + if (authResetAttempted) { + live.value = live.value.copy(error = cause.message) + return + } + authResetAttempted = true + val app = getApplication() + viewModelScope.launch { + SkywireCoreService.stop(app) + CoreServiceState.state.first { it is CoreState.Stopped } + ConfigManager(SkywirePaths(app)).deleteUsersDb() + // Give the stopping service instance a beat to finish stopSelf so + // the fresh start command lands on a clean instance. + delay(SERVICE_RESTART_GRACE_MS) + runCatching { SkywireCoreService.start(app) } + .onFailure { live.value = live.value.copy(error = it.message) } + } + } + + private companion object { + const val PING_INTERVAL_MS = 700L + const val REFRESH_INTERVAL_MS = 4_000L + const val SERVICE_RESTART_GRACE_MS = 400L + } +} 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..3f15e7a3e8 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/logs/LogModels.kt @@ -0,0 +1,114 @@ +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-" + + fun app(name: String) = APP_PREFIX + name + fun appName(source: String) = source.removePrefix(APP_PREFIX) + fun isApp(source: String) = source.startsWith(APP_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..6665c443ca --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/logs/LogViewModel.kt @@ -0,0 +1,197 @@ +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(), + 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(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) } + } + 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)) + else -> pollRuntimeLogs() + } + + private suspend fun pollRuntimeLogs() { + // 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) + 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..55ded06c48 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/logs/LogViewerScreen.kt @@ -0,0 +1,275 @@ +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +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.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.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.ui.components.SkyTopBar + +/** + * 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) { + 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) + LogSources.isApp(source) -> LogSources.appName(source) + else -> stringResource(R.string.logs_source_core) +} + +private fun levelColor(level: LogLevel): Color = when (level) { + LogLevel.ERROR, LogLevel.FATAL -> Color(0xFFDC2626) + LogLevel.WARN -> Color(0xFFF59E0B) + LogLevel.INFO -> Color(0xFF0072FF) + 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 index 1812426789..90832e44dc 100644 --- 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 @@ -17,6 +17,11 @@ object Routes { const val DEX = "dex" const val FLEET = "fleet" + /** Shared log viewer; {source} is core, process, or app:. */ + 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) } diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 188c0bf9cd..626d8716fa 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -19,8 +19,48 @@ Coming soon Connect + Disconnect Back + + 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). + + + 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 + Transports + DMSG servers + Service health + Copied + View logs + + + Logs + Core + Process + Follow + Pause + Share + Filter… + No log entries yet. + %1$d lines missed (buffer wrapped) + Core API unreachable — showing nothing new. The Process source works even when the visor won\'t start. + Connect will start the Skywire core — coming soon. SkyChat is coming soon — the chat UI will load here. The native Skycoin wallet is coming soon. 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..3306a8f959 --- /dev/null +++ b/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,10 @@ + + + + + 127.0.0.1 + localhost + + diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 5e0c2274ae..fd4c0e765c 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -12,6 +12,7 @@ splashscreen = "1.2.0" datastore = "1.2.1" okhttp = "5.4.0" kotlinxSerialization = "1.11.0" +coroutines = "1.10.2" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -28,6 +29,7 @@ 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" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/android/implementation-report.md b/android/implementation-report.md index 14f897dd61..dce8450cae 100644 --- a/android/implementation-report.md +++ b/android/implementation-report.md @@ -7,6 +7,75 @@ was actually performed (commands, devices, measured numbers — not intentions). --- +## 2026-08-03 — Core service, on-device config, the Connect screen, log viewer + +**Built:** + +- `core/SkywireCoreService` — `specialUse` foreground service that execs the + Go payload from `nativeLibraryDir` as a child process: rotating capture of + its combined output (`files/skywire/skywire-process.log`), crash-restart + with backoff (1 s → 30 s, reset after a stable minute), graceful stop. +- `core/ConfigManager` — first-run `config gen` run by the payload itself + (keys generated on-device, `-r` keeps them across regens) + the phone + profile applied in Kotlin on every start: API pinned `127.0.0.1:8000` with + auth, `cli_addr` emptied (no RPC), `pty`/`skywire-tcp`/`lan_dmsg_server` + dropped, `dmsgscp` disabled, absolute app-private paths, apps in-proc with + autostart off. +- `core/SecretStore` — random API password satisfying the server policy, + AES-GCM-encrypted via AndroidKeyStore, stored in DataStore. +- `api/VisorApi` — OkHttp client for the local API: `admin` account + bootstrap, transparent re-login on 401 (visor restarts drop sessions), + CSRF helper, summary/dmsg/service-health/runtime-logs/app-logs endpoints + (the app-logs 500 "no new available logs" is treated as an empty page). +- Home: Connect/Disconnect state machine over the service state + live visor + card (PK tap-to-copy, version, uptime, transports, dmsg latencies, service + health) and log-viewer entry points. Disconnect stays reachable while the + core is still coming up or crash-looping. +- `ui/logs/` — one viewer, three sources: core runtime ring (since-cursor + polling incl. reset detection across visor restarts), per-app feed + (RFC3339Nano cursor + boundary-line dedupe), and the captured process + output (UTF-8 byte tail, rotation-aware) — the only source that works when + the visor won't start. Follow/pause, level chips, search, tap-to-copy, + share (capped). +- Manifest: `FOREGROUND_SERVICE_SPECIAL_USE` + `POST_NOTIFICATIONS`; + cleartext allowed for loopback only (`network_security_config.xml`). +- Go core fixes for app-UID Android: `pkg/netutil` enumerates interfaces via + `anet` (Android 11+ denies the stdlib's netlink dump — "netlinkrib: + permission denied"), with the device API level passed as + `SKYWIRE_ANDROID_API_LEVEL` because a CGO-free process cannot detect it; + `DefaultNetworkInterface` falls back to the first routable interface when + `ip r` prints nothing; `Visor.Ports()` no longer panics on configs without + `pty`/`skywire-tcp`/`cli_addr`. + +**Hard-won facts:** + +- `launcher.bin_path` must never point into the install dir: the launcher + MkdirAlls it at startup and the native-library path is read-only *and* + changes on every app update — the visor then aborts ("failed to create + dir … permission denied"). It now points at an app-private dir and the + profile re-pins it on every launch. +- Android's `Process.destroy()` sends SIGKILL, not SIGTERM — the graceful + stop finds the child's pid in `/proc` and delivers a real SIGTERM + (`Os.kill`), verified by "Shutdown complete. Goodbye!" and exit 0. +- An adversarial review pass over the diff surfaced and fixed: Disconnect + unreachable during startup/crash-loop, the auth-recovery job cancelling + itself (it stops the core, which cancels the collector that launched it), + spawn-failure `Failed` state being overwritten by `Stopped`, the + runtime-log cursor silently skipping a restarted visor's lines, and the + log ring polluting itself via per-poll session probes. + +**Verified (fresh install each time, DM-B70104, Android 15, slow LTE):** +config generated on-device in ~1 s (offline, `--nofetch`) with every pin +checked field-by-field on the resulting JSON; Connect → API up in 20–120 s; +card live with all service-health rows OK; `kill -9` of the visor → +respawned in ≤ 4 s → reconnected without user action; `am kill` of the app → +visor survived (same pid) behind the foreground service; Disconnect → +SIGTERM → clean module unwind → exit 0 and the notification cleared; the +viewer live-tailed the core ring during connection and the process capture +during startup. + +--- + ## 2026-08-03 — Brand assets: real logo, Skycoin typeface, designed top bar **Built:** diff --git a/go.mod b/go.mod index 6bc313fd2c..bb6fd1e90c 100644 --- a/go.mod +++ b/go.mod @@ -157,7 +157,7 @@ require ( github.com/sergi/go-diff v1.4.0 // indirect github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923 // indirect github.com/vishvananda/netns v0.0.5 // indirect - github.com/wlynxg/anet v0.0.5 // indirect + github.com/wlynxg/anet v0.0.5 github.com/zyedidia/micro v1.4.1 // indirect go.mongodb.org/mongo-driver/v2 v2.8.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect diff --git a/pkg/netutil/anet_android.go b/pkg/netutil/anet_android.go new file mode 100644 index 0000000000..7562635207 --- /dev/null +++ b/pkg/netutil/anet_android.go @@ -0,0 +1,43 @@ +//go:build android + +// Package netutil pkg/netutil/anet_android.go c0-com-util +package netutil + +import ( + "os" + "strconv" + + "github.com/wlynxg/anet" +) + +// APILevelEnv carries the host app's Build.VERSION.SDK_INT to the visor +// process. +// +// Android 11 (API 30) stopped letting unprivileged processes run the +// RTM_GETLINK netlink dump the Go standard library uses to enumerate network +// interfaces, so net.Interfaces() fails with "route ip+net: netlinkrib: +// permission denied" and everything built on it — the visor overview, the +// address-resolver binds — fails with it. anet avoids that by enumerating +// through RTM_GETADDR instead, but only once it knows the device is on API +// 30+; its own detection calls android_get_device_api_level() through cgo, +// and the shipped payload is built CGO_ENABLED=0. The device API level is +// also not discoverable from a pure-Go app process (/system/build.prop is +// not readable and there is no property-service client without cgo), so the +// host app passes it in. +const APILevelEnv = "SKYWIRE_ANDROID_API_LEVEL" + +// androidFallbackAPILevel is assumed when the env var is absent or unusable. +// anet's RTM_GETADDR path is permitted on every Android version, so treating +// an unknown device as "restricted" is the safe direction: it keeps interface +// enumeration working instead of failing on the majority of devices. +const androidFallbackAPILevel = 30 + +func init() { + level := androidFallbackAPILevel + if raw := os.Getenv(APILevelEnv); raw != "" { + if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 { + level = parsed + } + } + anet.SetAndroidVersion(uint(level)) +} diff --git a/pkg/netutil/net_linux.go b/pkg/netutil/net_linux.go index 8adfdda3b0..c9dcb8fda0 100644 --- a/pkg/netutil/net_linux.go +++ b/pkg/netutil/net_linux.go @@ -7,7 +7,10 @@ package netutil import ( "bytes" "fmt" + "net" "os/exec" + + "github.com/wlynxg/anet" ) const ( @@ -23,6 +26,46 @@ func DefaultNetworkInterface() (string, error) { // just in case outputBytes = bytes.TrimRight(outputBytes, "\n") + if name := string(outputBytes); name != "" { + return name, nil + } + + // Android satisfies the linux build tag but gives an app process no + // routing table to read, so `ip r` prints nothing and exits 0. Fall back + // to the first usable interface. When none qualifies (airplane mode), + // return "" without error — the historical contract of this function, + // which callers handle as "unknown" — rather than failing summaries. + return firstRoutableInterface() +} - return string(outputBytes), nil +func firstRoutableInterface() (string, error) { + ifaces, err := anet.Interfaces() + if err != nil { + return "", fmt.Errorf("error getting network interfaces: %w", err) + } + for _, iface := range ifaces { + if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 { + continue + } + if IsVirtualInterface(iface.Name) { + continue + } + addrs, err := anet.InterfaceAddrsByInterface(&iface) + if err != nil { + continue + } + for _, addr := range addrs { + var ip net.IP + switch v := addr.(type) { + case *net.IPNet: + ip = v.IP + case *net.IPAddr: + ip = v.IP + } + if ip != nil && ip.To4() != nil && ip.IsGlobalUnicast() { + return iface.Name, nil + } + } + } + return "", nil } diff --git a/pkg/netutil/net_native.go b/pkg/netutil/net_native.go index 18a9dbe60b..8f32cf80ce 100644 --- a/pkg/netutil/net_native.go +++ b/pkg/netutil/net_native.go @@ -15,8 +15,17 @@ import ( "io" "net" "net/http" + + "github.com/wlynxg/anet" ) +// Interface enumeration goes through anet, not net, because Android 11+ +// denies unprivileged processes the netlink RIB dump the standard library +// uses: net.Interfaces() there fails with "route ip+net: netlinkrib: +// permission denied" and every caller of these helpers breaks. anet reads +// the same data by other means on Android and is a straight pass-through to +// the standard library everywhere else. + // LocalNetworkInterfaceIPs gets IPs of all local interfaces. func LocalNetworkInterfaceIPs() ([]net.IP, error) { ips, _, err := localNetworkInterfaceIPs("") @@ -34,7 +43,7 @@ func NetworkInterfaceIPs(name string) ([]net.IP, error) { func localNetworkInterfaceIPs(ifcName string) ([]net.IP, []net.IP, error) { var ifcIPs []net.IP - ifaces, err := net.Interfaces() + ifaces, err := anet.Interfaces() if err != nil { return nil, nil, fmt.Errorf("error getting network interfaces: %w", err) } @@ -48,7 +57,7 @@ func localNetworkInterfaceIPs(ifcName string) ([]net.IP, []net.IP, error) { continue // loopback interface } - addrs, err := iface.Addrs() + addrs, err := anet.InterfaceAddrsByInterface(&iface) if err != nil { return nil, nil, fmt.Errorf("error getting addresses for interface %s: %w", iface.Name, err) } @@ -114,7 +123,7 @@ func HasPublicIP() (bool, error) { func LocalAddresses() ([]string, error) { result := make([]string, 0) - ifaces, err := net.Interfaces() + ifaces, err := anet.Interfaces() if err != nil { return nil, err } @@ -129,7 +138,7 @@ func LocalAddresses() ([]string, error) { continue } - addrs, err := iface.Addrs() + addrs, err := anet.InterfaceAddrsByInterface(&iface) if err != nil { continue } diff --git a/pkg/visor/api_services.go b/pkg/visor/api_services.go index 79486397c6..d126c19141 100644 --- a/pkg/visor/api_services.go +++ b/pkg/visor/api_services.go @@ -603,14 +603,25 @@ func (v *Visor) Ports() (map[string]PortDetail, error) { ctx := context.Background() var ports = make(map[string]PortDetail) + // Every optional section really is optional here: mobile-profile configs + // ship without pty/skywire-tcp and with an empty cli_addr, and this + // endpoint must not panic on them. if v.conf.Hypervisor != nil { - ports["hypervisor"] = PortDetail{Port: fmt.Sprint(strings.Split(v.conf.Hypervisor.HTTPAddr, ":")[1]), Type: "TCP"} + ports["hypervisor"] = PortDetail{Port: addrPort(v.conf.Hypervisor.HTTPAddr), Type: "TCP"} } - ports["dmsg_pty"] = PortDetail{Port: fmt.Sprint(v.conf.Pty.DmsgPort), Type: "DMSG"} - ports["cli_addr"] = PortDetail{Port: fmt.Sprint(strings.Split(v.conf.CLIAddr, ":")[1]), Type: "TCP"} - ports["proc_addr"] = PortDetail{Port: fmt.Sprint(strings.Split(v.conf.Launcher.ServerAddr, ":")[1]), Type: "TCP"} - ports["stcp_addr"] = PortDetail{Port: fmt.Sprint(strings.Split(v.conf.STCP.ListeningAddress, ":")[1]), Type: "TCP"} + if v.conf.Pty != nil { + ports["dmsg_pty"] = PortDetail{Port: fmt.Sprint(v.conf.Pty.DmsgPort), Type: "DMSG"} + } + if v.conf.CLIAddr != "" { + ports["cli_addr"] = PortDetail{Port: addrPort(v.conf.CLIAddr), Type: "TCP"} + } + if v.conf.Launcher != nil { + ports["proc_addr"] = PortDetail{Port: addrPort(v.conf.Launcher.ServerAddr), Type: "TCP"} + } + if v.conf.STCP != nil { + ports["stcp_addr"] = PortDetail{Port: addrPort(v.conf.STCP.ListeningAddress), Type: "TCP"} + } if v.arClient != nil { sudphPort := v.arClient.Addresses(ctx) @@ -653,6 +664,15 @@ func (v *Visor) Ports() (map[string]PortDetail, error) { return ports, nil } +// addrPort returns the port part of a host:port address, or "" when the +// address has none — never panics on empty/portless input. +func addrPort(addr string) string { + if i := strings.LastIndex(addr, ":"); i >= 0 { + return addr[i+1:] + } + return "" +} + // SetLogRotationInterval sets log_rotation_interval config of visor func (v *Visor) SetLogRotationInterval(d visorconfig.Duration) error { return v.conf.UpdateLogRotationInterval(d) From 962ec16eb869608c6ef269dc41fda690e5f2f687 Mon Sep 17 00:00:00 2001 From: "MohammadReza P." Date: Tue, 4 Aug 2026 13:30:14 +0800 Subject: [PATCH 06/37] =?UTF-8?q?feat(android):=20SkySOCKS=20screen=20?= =?UTF-8?q?=E2=80=94=20discovery=20list,=20connect,=20expose-port=20The=20?= =?UTF-8?q?first=20app=20screen,=20and=20the=20shape=20the=20others=20reus?= =?UTF-8?q?e:=20list=20servers=20from=20service=20discovery,=20point=20sky?= =?UTF-8?q?socks-client=20at=20one,=20start=20it,=20and=20watch=20what=20i?= =?UTF-8?q?t=20reports=20=E2=80=94=20plus=20a=20Logs=20action=20scoped=20t?= =?UTF-8?q?o=20that=20app.=20No=20Go=20changes;=20every=20endpoint=20alrea?= =?UTF-8?q?dy=20existed.=20-=20ui/socks/:=20server=20list=20(~1050=20entri?= =?UTF-8?q?es=20with=20flag,=20country=20=C2=B7=20region,=20key=20=20=20an?= =?UTF-8?q?d=20version,=20searchable),=20status=20card=20with=20the=20viso?= =?UTF-8?q?r's=20own=20detailed=20=20=20status=20and=20live=20transferred?= =?UTF-8?q?=20bytes,=20and=20the=20"SOCKS5=20for=20other=20apps=20?= =?UTF-8?q?=E2=80=94=20=20=20127.0.0.1:1080"=20helper=20card=20with=20the?= =?UTF-8?q?=20port=20editable=20in=20a=20sheet.=20-=20core/AppPreferences:?= =?UTF-8?q?=20plain=20DataStore=20for=20non-secret=20UI=20state,=20separat?= =?UTF-8?q?e=20=20=20from=20the=20encrypted=20SecretStore.=20Remembers=20t?= =?UTF-8?q?he=20last=20server,=20so=20the=20screen=20=20=20opens=20on=20a?= =?UTF-8?q?=20one-tap=20Reconnect.=20-=20api/VisorApi:=20services(type)=20?= =?UTF-8?q?over=20/api/svc-fetch,=20app(),=20appConnections(),=20=20=20and?= =?UTF-8?q?=20a=20CSRF-signed=20updateApp()=20carrying=20only=20the=20fiel?= =?UTF-8?q?ds=20given.=20Two=20skysocks-client=20flags=20are=20now=20owned?= =?UTF-8?q?=20by=20the=20phone=20profile=20and=20re-pinned=20on=20every=20?= =?UTF-8?q?launch:=20-=20--addr=20host=20forced=20to=20127.0.0.1.=20The=20?= =?UTF-8?q?generated=20":1080"=20listens=20on=20every=20=20=20interface=20?= =?UTF-8?q?=E2=80=94=20on=20a=20phone=20that=20is=20a=20SOCKS5=20proxy=20a?= =?UTF-8?q?ny=20device=20on=20the=20same=20=20=20Wi-Fi=20can=20use.=20The?= =?UTF-8?q?=20port=20stays=20editable=20from=20the=20screen.=20-=20--recon?= =?UTF-8?q?nect=20always=20on.=20A=20cell=20handover=20is=20enough=20to=20?= =?UTF-8?q?lose=20a=20mesh=20route,=20=20=20and=20without=20it=20the=20app?= =?UTF-8?q?=20exits=20when=20its=20route=20group=20dies=20("Liveness=20pro?= =?UTF-8?q?be=20=20=20failed=202x;=20route=20group=20gone"),=20leaving=20a?= =?UTF-8?q?=20dead=20proxy=20until=20the=20user=20=20=20notices.=20Configu?= =?UTF-8?q?ring=20or=20starting=20stops=20the=20app=20first:=20the=20serve?= =?UTF-8?q?r-side=20restart=20on=20a=20pk/args=20change=20races=20the=20ou?= =?UTF-8?q?tgoing=20proc,=20whose=20blocked=20accept()=20returns=20"use=20?= =?UTF-8?q?of=20closed=20network=20connection"=20and=20sticks=20the=20app?= =?UTF-8?q?=20in=20Errored,=20and=20status:1=20on=20an=20already-running?= =?UTF-8?q?=20app=20is=20a=20500.=20Two=20more=20found=20while=20building?= =?UTF-8?q?=20it.=20/api/svc-fetch=20needs=20its=20own=20HTTP=20client=20?= =?UTF-8?q?=E2=80=94=20the=20visor=20dials=20service=20discovery=20over=20?= =?UTF-8?q?dmsg=20with=20a=2015s=20budget=20per=20hop,=20so=20the=20shared?= =?UTF-8?q?=2015s=20call=20timeout=20aborted=20every=20opening=20list=20lo?= =?UTF-8?q?ad.=20And=20the=20view=20model's=20state=20writes=20go=20throug?= =?UTF-8?q?h=20MutableStateFlow.update:=20a=20read-modify-write=20across?= =?UTF-8?q?=20a=20suspension=20point=20let=20the=20DataStore=20read=20clob?= =?UTF-8?q?ber=20the=20core-state=20collector,=20so=20the=20screen=20claim?= =?UTF-8?q?ed=20the=20core=20was=20stopped=20while=20it=20was=20running.?= =?UTF-8?q?=20Verified=20on=20a=20real=20device=20(Android=2015,=20LTE,=20?= =?UTF-8?q?light=20+=20dark):=20list=20loaded,=20connect=20established=20a?= =?UTF-8?q?=20route,=20the=20port=20sheet=20moved=20the=20listener=201080?= =?UTF-8?q?=20=E2=86=92=201085=20=E2=86=92=201080=20with=20the=20app=20sta?= =?UTF-8?q?ying=20connected=20and=20no=20stale=20listener,=20Disconnect=20?= =?UTF-8?q?closed=20it=20and=20kept=20the=20server=20for=20Reconnect,=20an?= =?UTF-8?q?d=20the=20saved=20server=20+=20port=20survived=20an=20app=20rei?= =?UTF-8?q?nstall.=20End=20to=20end=20from=20the=20phone=20itself=20(adb?= =?UTF-8?q?=20shell,=20a=20different=20UID=20=E2=80=94=20the=20same=20reac?= =?UTF-8?q?hability=20a=20third-party=20app=20gets):=20curl=20https://api.?= =?UTF-8?q?ipify.org=20returns=20the=20real=20IP,=20the=20same=20call=20th?= =?UTF-8?q?rough=20--socks5-hostname=20127.0.0.1:1080=20returns=20the=20ex?= =?UTF-8?q?it=20node's.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/README.md | 40 +- .../java/com/skycoin/skywire/api/VisorApi.kt | 134 ++++- .../com/skycoin/skywire/api/VisorModels.kt | 52 ++ .../skycoin/skywire/core/AppPreferences.kt | 31 + .../com/skycoin/skywire/core/ConfigManager.kt | 53 ++ .../java/com/skycoin/skywire/ui/SkywireApp.kt | 9 +- .../skycoin/skywire/ui/socks/SocksModels.kt | 93 +++ .../skycoin/skywire/ui/socks/SocksScreen.kt | 552 +++++++++++++++++- .../skywire/ui/socks/SocksViewModel.kt | 283 +++++++++ android/app/src/main/res/values/strings.xml | 28 +- android/implementation-report.md | 72 +++ 11 files changed, 1333 insertions(+), 14 deletions(-) create mode 100644 android/app/src/main/java/com/skycoin/skywire/core/AppPreferences.kt create mode 100644 android/app/src/main/java/com/skycoin/skywire/ui/socks/SocksModels.kt create mode 100644 android/app/src/main/java/com/skycoin/skywire/ui/socks/SocksViewModel.kt diff --git a/android/README.md b/android/README.md index d35ebbd548..250f1b63bc 100644 --- a/android/README.md +++ b/android/README.md @@ -112,6 +112,33 @@ under a supervisor: `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`. + ## Testing & debugging App logs: @@ -132,6 +159,15 @@ adb forward tcp:1080 tcp:1080 # your desktop browser rides the phone's SOCKS 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. @@ -163,7 +199,9 @@ android/ ├── AndroidManifest.xml ├── java/com/skycoin/skywire/ │ ├── MainActivity.kt # the one Activity: splash → gate → scaffold - │ └── ui/ # theme/, navigation/, components/, + │ ├── 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 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 index fff713df67..cc44891979 100644 --- a/android/app/src/main/java/com/skycoin/skywire/api/VisorApi.kt +++ b/android/app/src/main/java/com/skycoin/skywire/api/VisorApi.kt @@ -6,7 +6,10 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject import okhttp3.Cookie import okhttp3.CookieJar import okhttp3.HttpUrl @@ -61,6 +64,18 @@ class VisorApi(context: Context) { .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() + private val sessionMutex = Mutex() @Volatile private var cachedPk: String? = null @@ -152,6 +167,88 @@ class VisorApi(context: Context) { } } + // --- 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) + } + } + } + + /** + * 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), + * - [status] 1 starts, 0 stops. + * + * Both [pk] and [args] 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, + status: Int? = null, + ): AppState = withContext(Dispatchers.IO) { + val body = buildJsonObject { + pk?.let { put("pk", JsonPrimitive(it)) } + args?.let { put("args", 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) + } + } + /** 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 } @@ -169,17 +266,41 @@ class VisorApi(context: Context) { } } - private suspend fun getWithRelogin(path: String): okhttp3.Response { - val first = get(path) + 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) + return get(path, client) } - private fun get(path: String): okhttp3.Response = + /** + * 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 fun get(path: String, client: OkHttpClient = this.client): okhttp3.Response = client.newCall(Request.Builder().url("$BASE$path").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(), @@ -199,7 +320,12 @@ class VisorApi(context: Context) { }.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" @Volatile private var instance: VisorApi? = null 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 index 5c3f556890..f294e5159a 100644 --- a/android/app/src/main/java/com/skycoin/skywire/api/VisorModels.kt +++ b/android/app/src/main/java/com/skycoin/skywire/api/VisorModels.kt @@ -53,6 +53,58 @@ data class AppState( @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 holds a single connection to its server, so the + * first element is the one the screen renders. + */ +@Serializable +data class AppConnection( + @SerialName("is_alive") val isAlive: Boolean = false, + /** Nanoseconds (Go time.Duration). */ + @SerialName("latency") val latencyNs: Long = 0, + @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, + @SerialName("error") val error: String = "", ) @Serializable 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..ca396882ee --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/core/AppPreferences.kt @@ -0,0 +1,31 @@ +package com.skycoin.skywire.core + +import android.content.Context +import androidx.datastore.preferences.core.edit +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 + } + } +} 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 index 4aab19e114..c112588c57 100644 --- a/android/app/src/main/java/com/skycoin/skywire/core/ConfigManager.kt +++ b/android/app/src/main/java/com/skycoin/skywire/core/ConfigManager.kt @@ -5,6 +5,8 @@ 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 @@ -141,6 +143,7 @@ class ConfigManager(private val paths: SkywirePaths) { key, value.jsonObject.edit { put("bin_path", JsonPrimitive(paths.binDir.absolutePath)) + this["apps"]?.let { apps -> this["apps"] = pinSocksArgs(apps) } }, ) else -> put(key, value) @@ -151,6 +154,53 @@ class ConfigManager(private val paths: SkywirePaths) { paths.configFile.writeText(json.encodeToString(JsonObject.serializer(), edited)) } + /** + * The two skysocks-client flags the phone must own, re-applied on every + * launch. Everything else in the argv — the server key the SkySOCKS + * screen writes, above all — passes through untouched, so this never + * undoes a user's choice. + */ + private fun pinSocksArgs(apps: JsonElement): JsonElement { + val list = apps as? JsonArray ?: return apps + return JsonArray( + list.map { entry -> + val app = entry as? JsonObject ?: return@map entry + if ((app["name"] as? JsonPrimitive)?.content != SOCKS_APP) 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() } + app.edit { this["args"] = JsonPrimitive(phoneSocksArgs(args).joinToString(" ")) } + }, + ) + } + + /** + * - `--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() + val flag = pinned.indexOfFirst { it == "--addr" || it == "-addr" } + when { + flag < 0 || flag + 1 >= pinned.size -> + pinned += listOf("--addr", "$LOOPBACK:$DEFAULT_SOCKS_PORT") + // A malformed value only gets worse from rewriting — leave it + // and let the visor report it. + else -> pinned[flag + 1].substringAfterLast(':').toIntOrNull() + ?.let { port -> pinned[flag + 1] = "$LOOPBACK:$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 @@ -194,6 +244,9 @@ class ConfigManager(private val paths: SkywirePaths) { private companion object { const val MAX_CAPTURE = 256 * 1024 + const val SOCKS_APP = "skysocks-client" + const val LOOPBACK = "127.0.0.1" + const val DEFAULT_SOCKS_PORT = 1080 } } 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 index 3540173c2f..b854465c78 100644 --- a/android/app/src/main/java/com/skycoin/skywire/ui/SkywireApp.kt +++ b/android/app/src/main/java/com/skycoin/skywire/ui/SkywireApp.kt @@ -125,7 +125,14 @@ fun SkywireApp() { composable(Routes.SETTINGS) { SettingsScreen() } // Full-screen routes pushed from the hub — back returns to the hub. - composable(Routes.SOCKS) { SocksScreen(onBack = { navController.popBackStack() }) } + composable(Routes.SOCKS) { + SocksScreen( + onBack = { navController.popBackStack() }, + onOpenLogs = { source -> + navController.navigate(Routes.logs(source)) { launchSingleTop = true } + }, + ) + } composable(Routes.VPN) { VpnScreen(onBack = { navController.popBackStack() }) } composable(Routes.DEX) { DexScreen(onBack = { navController.popBackStack() }) } composable(Routes.FLEET) { FleetScreen(onBack = { navController.popBackStack() }) } 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..110b13ce46 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/socks/SocksModels.kt @@ -0,0 +1,93 @@ +package com.skycoin.skywire.ui.socks + +import com.skycoin.skywire.api.ServiceEntry +import kotlinx.serialization.Serializable + +/** 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, + ) + } +} + +/** + * 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 + } +} + +/** 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) 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 index 3ea06b7bc4..ab5ba7c9ae 100644 --- 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 @@ -1,16 +1,554 @@ 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.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +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.ui.components.AppRouteScaffold +import com.skycoin.skywire.api.AppConnection +import com.skycoin.skywire.api.ServiceEntry +import com.skycoin.skywire.core.CoreState +import com.skycoin.skywire.ui.components.SkyTopBar +import com.skycoin.skywire.ui.logs.LogSources +import java.util.Locale -/** SkySOCKS route — server list / connect / expose-port flow. */ +/** + * 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) { - AppRouteScaffold( - title = stringResource(R.string.app_skysocks), - subtitle = stringResource(R.string.socks_placeholder), - onBack = onBack, +fun SocksScreen( + onBack: () -> Unit, + onOpenLogs: (String) -> Unit, + viewModel: SocksViewModel = viewModel(), +) { + val state by viewModel.uiState.collectAsState() + var portSheetOpen by remember { mutableStateOf(false) } + + Scaffold( + topBar = { + SkyTopBar( + title = stringResource(R.string.app_skysocks), + onBack = onBack, + actions = { + TextButton(onClick = { onOpenLogs(LogSources.app(SocksArgs.APP)) }) { + Text(stringResource(R.string.logs_title)) + } + }, + ) + }, + ) { 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 }) } + + 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 + }, + ) + } +} + +// --- 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.latencyNs.takeIf { it > 0 }?.let { latency -> + InfoRow( + label = stringResource(R.string.socks_latency), + value = "${latency / 1_000_000} 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() + }, + ) + TextButton(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, + ) + TextButton(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(), + ) + } +} + +@Composable +private fun ServerRow( + server: ServiceEntry, + selected: Boolean, + enabled: Boolean, + onClick: () -> Unit, +) { + Card( + colors = CardDefaults.cardColors( + containerColor = if (selected) { + MaterialTheme.colorScheme.secondaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + ), + 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.socks_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, + ) + } + } + } } + +// --- small shared pieces --- + +@Composable +private fun SectionCard(content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) { + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + modifier = Modifier.fillMaxWidth(), + ) { + Column(Modifier.padding(20.dp), content = content) + } +} + +@Composable +private fun InfoRow( + label: String, + value: String, + mono: Boolean = false, + modifier: Modifier = Modifier, +) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + ) { + 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 + }, + textAlign = TextAlign.End, + modifier = Modifier.weight(1f), + ) + } +} + +@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 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]) +} + +private val CONNECTED_GREEN = Color(0xFF16A34A) +private val PENDING_AMBER = Color(0xFFF59E0B) +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..d134b8c175 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/socks/SocksViewModel.kt @@ -0,0 +1,283 @@ +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 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, + 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 { + 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. + * + * 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. + */ + fun connect(server: SavedServer) = action { + 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) } + } + + /** + * 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)) + } + } + + // --- 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) } + } + } + } + + /** + * 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/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 626d8716fa..1c82913a58 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -21,6 +21,8 @@ Connect Disconnect Back + Save + Cancel Skywire core @@ -61,11 +63,35 @@ %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. + Unknown location + Retry + Refresh + Connect will start the Skywire core — coming soon. SkyChat is coming soon — the chat UI will load here. The native Skycoin wallet is coming soon. Identity, config export, app lock and logs are coming soon. - Server list and connect flow are coming soon. Whole-phone VPN with killswitch is coming soon. Market connect and the SkyDEX UI are coming soon. The opt-in Fleet status view is coming soon. diff --git a/android/implementation-report.md b/android/implementation-report.md index dce8450cae..6d025749ed 100644 --- a/android/implementation-report.md +++ b/android/implementation-report.md @@ -7,6 +7,78 @@ was actually performed (commands, devices, measured numbers — not intentions). --- +## 2026-08-04 — SkySOCKS screen: discovery list, connect, expose-port + +**Built:** + +- `ui/socks/` — the first real app screen, and the shape the other app + screens copy: server list → configure → start → observe, with a `Logs` + bar action opening the shared viewer scoped to `skysocks-client`. + - Server list from service discovery, ~1050 entries, each with flag, + country · region, short key and version; search over key/country/ + region/version; pull-free refresh. + - Status card: state, the visor's own detailed status, selected server + (tap-to-copy), live transferred bytes, Connect/Disconnect/Reconnect. + - Helper card: *"SOCKS5 for other apps — 127.0.0.1:1080"*, tap-to-copy, + with the port editable in a bottom sheet. + - Last-used server persisted (`core/AppPreferences.kt`, plain DataStore + — separate from the encrypted `SecretStore`), so the screen opens on + a one-tap Reconnect. +- `api/VisorApi` additions: `services(type)` over `/api/svc-fetch`, + `app(name)`, `appConnections(name)`, and a CSRF-signed `updateApp` that + carries only the fields given (`pk` / `args` / `status`). +- `core/ConfigManager` now also pins two skysocks-client flags on every + launch (see below). + +**Hard-won facts:** + +- The proxy-server query is `/api/svc-fetch?service=sd&path=/api/services + ?type=proxy` (path URL-encoded). SD answers a bare `null`, not `[]`, for + an empty result, and labels the entries `type: "skysocks"` — `proxy` is + the filter, not the returned value. +- `svc-fetch` needs its own HTTP client. The visor dials the service over + DMSG with a 15 s budget *per hop*, and the shared client's 15 s call + timeout aborted at exactly that moment — the first list load failed with + a bare "timeout" every time. It now uses a 50 s client, and the opening + load retries 3× (the API answers well before dmsg has a session). +- `--addr` defaults to `:1080` — **every interface**, i.e. a SOCKS5 proxy + any device on the same Wi-Fi could use. The phone profile forces the + host to loopback and leaves the port alone (the one knob the screen + exposes). `SetAppAddress` is skychat-only, so the port is written by + replacing the whole argv via the `args` field. +- `--reconnect` is now pinned on too. Caught live: a cell-network route + loss ("Liveness probe failed 2x; route group gone") makes the app *exit* + without it — `accept: use of closed network connection`, Errored, dead + proxy until the user notices. With it the client re-dials in place. +- Configure/start must stop the app first. `PUT` with `pk` or `args` + triggers a server-side `RestartApp` that races the outgoing proc, whose + blocked `accept` returns "use of closed network connection" and sticks + the app in Errored; and `status: 1` on an already-running app is a 500 + ("app already started"). The stop is unconditional and its failure + ignored — the polled snapshot can be a poll behind the visor. +- A read-modify-write of the UI state across a suspension point silently + drops concurrent updates: the DataStore read of the last-used server + captured the state *before* it suspended and clobbered the core-state + collector's write, so the screen claimed the core was stopped while it + was running. Every write goes through `MutableStateFlow.update`. +- `AppState.args` is a JSON **array** over the API; the space-joined + string is an on-disk-only rendering (`appConfigOnDisk`). + +**Verified (DM-B70104, Android 15, LTE, light + dark):** list loaded 1052 +entries; tapping a server connected (~10 s to route, up to ~9 min on a bad +cell); `…/connections` drove the live byte counters; the port sheet moved +the listener 1080 → 1085 → 1080 with the app staying Connected and no +stale listener left behind; Disconnect closed the listener and kept the +server for Reconnect; the saved server + port survived an app reinstall +and a core restart. End-to-end, **from the phone itself** (adb shell, a +different UID — same reachability a third-party app has): +`curl https://api.ipify.org` → `5.208.36.94` (real), through +`--socks5-hostname 127.0.0.1:1080` → `182.8.229.155` / `36.95.212.119` +(ID exit) and `158.247.213.146` (KR exit), matching the flag shown for the +selected server. Same fetch from the Mac over `adb forward` → HTTP 200. + +--- + ## 2026-08-03 — Core service, on-device config, the Connect screen, log viewer **Built:** From 89e3114031b67952af5b2ee233671cc0640ba547 Mon Sep 17 00:00:00 2001 From: "MohammadReza P." Date: Tue, 4 Aug 2026 17:04:42 +0800 Subject: [PATCH 07/37] feat(android): primary transport, and the dmsg servers Home never showed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things the phone needs from the visor: an honest list of the dmsg servers it is actually on, and a say in which transport type gets tried first. Home's dmsg list was always empty, and not because of the card. /api/dmsg is the round-trip tracker: to report any visor it dials that visor's dmsgctrl port back over dmsg, and for the local visor that self-dial never lands here — "dmsg error 202 - cannot connect to delegated server" every ~5 minutes in the log — so it returns [] forever. The visor already knows its own sessions and ships them in the summary the card was fetching anyway, so Home reads summary.dmsg_servers: no extra request, one fewer round trip per poll. Rows carry the session carrier always, plus latency only when there is one — that is a separate hourly self-ping, and every row would otherwise read a bare, false "0 ms". Primary transport, dmsg by default on mobile. The knob existed (routing.transport_preference) but only sorted transports that already existed; both paths that CREATE one hardcoded STCPR → SUDPH → DMSG. They now walk the configured order via the new tptypes.PreferredOrder, so the first entry really means "try this first, fall back to the rest", and one order governs both creation and selection. The built-in default reproduces the old sequence exactly — a visor that configures nothing sees no change. The route-setup hook's 20 s STUN wait is now evaluated lazily: it gates SUDPH only, and an order that reaches dmsg or stcpr first must not pay it. - pkg/visor: transport_preference on RouterSettings, GET and PUT. Omitted leaves the order alone, [] reverts to the default, non-empty installs it; unknown names are an error rather than a silent drop, since a dropped typo would leave the caller believing a preference is in force that is not. Applied live and flushed to the config, so no restart either way. - core/TransportPreference: the phone's choice, hoisted to the front of the core's full default order rather than sent as a one-element list — that would flatten every untouched type to "unranked". Written into the config on every launch (the app is the source of truth; the visor persists its own copy on a live change, and this keeps the two from drifting). - ui/components/TransportPreferenceUi: card + sheet, dmsg badged "Recommended on mobile", stcpr and sudph with what they cost. Shared composables — SkyVPN shows the same visor-wide value. The PUT is a read-modify-write and has to be: it applies every field of the settings struct, so a bare body would also send min_hops: 0, which the router reads as routing disabled. Changing the primary while connected re-dials the client — the setting steers route setup, so an established route keeps whatever transport it already rides. That re-dial is why the connect body is now a plain suspend startWith(): re-entering the one-action guard from inside it would cancel the job making the call. Verified on device. Four servers listed right after Connect. With dmsg primary, Reconnect goes straight there — "Dialing transport … via dmsg" 4 s after the tap, transport saved, "Found direct transport to destination (type=dmsg)", route group up ~9 s in, no STCPR or SUDPH attempt and no STUN wait. Switching DMSG → STCPR → DMSG in the sheet logged SetTransportPreference each time and landed in the config immediately with min_hops: 1 intact. go test green for pkg/visor/..., pkg/router/... and pkg/transport/types/... (new coverage for the ordering helper and the readback); mobile-tag build green. --- .../java/com/skycoin/skywire/api/VisorApi.kt | 32 ++- .../com/skycoin/skywire/api/VisorModels.kt | 36 +++- .../com/skycoin/skywire/core/ConfigManager.kt | 21 +- .../skywire/core/SkywireCoreService.kt | 8 +- .../skywire/core/TransportPreference.kt | 67 +++++++ .../ui/components/TransportPreferenceUi.kt | 184 ++++++++++++++++++ .../com/skycoin/skywire/ui/home/HomeScreen.kt | 22 ++- .../skycoin/skywire/ui/home/HomeViewModel.kt | 9 +- .../skycoin/skywire/ui/socks/SocksScreen.kt | 23 +++ .../skywire/ui/socks/SocksViewModel.kt | 82 ++++++-- android/app/src/main/res/values/strings.xml | 14 ++ android/implementation-report.md | 85 ++++++++ pkg/router/router_dial.go | 7 +- pkg/transport/types/preference.go | 31 +++ pkg/transport/types/preference_test.go | 46 +++++ pkg/visor/api_transport.go | 49 ++++- pkg/visor/init_router.go | 93 +++++---- pkg/visor/visorconfig/v1.go | 15 +- 18 files changed, 727 insertions(+), 97 deletions(-) create mode 100644 android/app/src/main/java/com/skycoin/skywire/core/TransportPreference.kt create mode 100644 android/app/src/main/java/com/skycoin/skywire/ui/components/TransportPreferenceUi.kt 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 index cc44891979..11997e8439 100644 --- a/android/app/src/main/java/com/skycoin/skywire/api/VisorApi.kt +++ b/android/app/src/main/java/com/skycoin/skywire/api/VisorApi.kt @@ -138,8 +138,6 @@ class VisorApi(context: Context) { suspend fun summary(): VisorSummary = authedGet("/api/visors/${localPk()}/summary") - suspend fun dmsg(): List = authedGet("/api/dmsg") - suspend fun serviceHealth(): List = authedGet("/api/service-health") suspend fun runtimeLogs(since: Long): RuntimeLogsDelta = @@ -249,6 +247,36 @@ class VisorApi(context: Context) { } } + // --- 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. + * + * Read-modify-write, and it has to be: the PUT applies every field of + * the settings struct, so sending the preference alone would also send + * `min_hops: 0` — which the router reads as *routing disabled* — along + * with a zeroed mux_routes. + */ + suspend fun setTransportPreference(order: List): RouterSettings = + withContext(Dispatchers.IO) { + val body = json.encodeToString( + RouterSettings.serializer(), + routerSettings().copy(transportPreference = order), + ) + putWithRelogin("/api/visors/${localPk()}/router-settings", body).use { resp -> + if (!resp.isSuccessful) { + throw IOException( + "router settings update failed (${resp.code}): ${errorBody(resp)}", + ) + } + decode(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 } 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 index f294e5159a..8a6ccfcec6 100644 --- a/android/app/src/main/java/com/skycoin/skywire/api/VisorModels.kt +++ b/android/app/src/main/java/com/skycoin/skywire/api/VisorModels.kt @@ -115,20 +115,42 @@ data class HealthInfo( @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). */ + /** + * 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 /api/dmsg element — per-session round trip. */ +/** + * 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 DmsgClientSummary( - @SerialName("public_key") val publicKey: String = "", - @SerialName("server_public_key") val serverPublicKey: String = "", - /** Nanoseconds (Go time.Duration). */ - @SerialName("round_trip") val roundTripNs: Long = 0, +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 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 index c112588c57..e4b74a3d26 100644 --- a/android/app/src/main/java/com/skycoin/skywire/core/ConfigManager.kt +++ b/android/app/src/main/java/com/skycoin/skywire/core/ConfigManager.kt @@ -34,7 +34,7 @@ class ConfigManager(private val paths: SkywirePaths) { * idempotent) so the security-relevant pins survive any config rewrite * the visor itself performs at runtime. */ - suspend fun ensureConfig(): Result = withContext(Dispatchers.IO) { + suspend fun ensureConfig(transportPrimary: String): Result = withContext(Dispatchers.IO) { paths.ensureDirs() if (!paths.visorBinary.canExecute()) { return@withContext Result.failure( @@ -53,7 +53,7 @@ class ConfigManager(private val paths: SkywirePaths) { } } try { - applyPhoneProfile() + applyPhoneProfile(transportPrimary) Result.success(paths.configFile) } catch (e: Exception) { // A file that exists but does not parse would otherwise crash-loop @@ -106,9 +106,14 @@ class ConfigManager(private val paths: SkywirePaths) { * - 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. + * 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. */ - private fun applyPhoneProfile() { + private fun applyPhoneProfile(transportPrimary: String) { val root = json.parseToJsonElement(paths.configFile.readText()).jsonObject val edited = buildJsonObject { for ((key, value) in root) { @@ -146,6 +151,14 @@ class ConfigManager(private val paths: SkywirePaths) { this["apps"]?.let { apps -> this["apps"] = pinSocksArgs(apps) } }, ) + "routing" -> put( + key, + value.jsonObject.edit { + this["transport_preference"] = JsonArray( + TransportPreference.order(transportPrimary).map(::JsonPrimitive), + ) + }, + ) else -> put(key, value) } } 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 index c4c73473f1..0b386e8aa4 100644 --- a/android/app/src/main/java/com/skycoin/skywire/core/SkywireCoreService.kt +++ b/android/app/src/main/java/com/skycoin/skywire/core/SkywireCoreService.kt @@ -20,6 +20,7 @@ import kotlinx.coroutines.Job 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 java.io.File @@ -42,6 +43,7 @@ class SkywireCoreService : Service() { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private lateinit var paths: SkywirePaths private lateinit var configManager: ConfigManager + private lateinit var prefs: AppPreferences private var runner: Job? = null @Volatile private var child: Process? = null @@ -52,6 +54,7 @@ class SkywireCoreService : Service() { super.onCreate() paths = SkywirePaths(this) configManager = ConfigManager(paths) + prefs = AppPreferences(this) createChannel() } @@ -87,7 +90,10 @@ class SkywireCoreService : Service() { CoreServiceState.mutableState.value = CoreState.Starting(attempt = 1) val log = ProcessLog(paths.processLogFile) try { - val config = configManager.ensureConfig().getOrElse { err -> + val primary = TransportPreference.sanitize( + prefs.string(TransportPreference.PREF_KEY).first(), + ) + val config = configManager.ensureConfig(primary).getOrElse { err -> log.line("=== config generation failed ===") log.line(err.message ?: "unknown error") CoreServiceState.mutableState.value = 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/ui/components/TransportPreferenceUi.kt b/android/app/src/main/java/com/skycoin/skywire/ui/components/TransportPreferenceUi.kt new file mode 100644 index 0000000000..c47e62cfd4 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/components/TransportPreferenceUi.kt @@ -0,0 +1,184 @@ +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.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +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, +) { + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + modifier = Modifier.fillMaxWidth(), + ) { + Column(Modifier.padding(20.dp)) { + 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), + ) + TextButton(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/home/HomeScreen.kt b/android/app/src/main/java/com/skycoin/skywire/ui/home/HomeScreen.kt index 8decc6a077..19e5b1b3a2 100644 --- 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 @@ -299,14 +299,23 @@ private fun VisorInfoCard( color = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(Modifier.height(4.dp)) - val dmsgRows = state.dmsg.ifEmpty { null } - if (dmsgRows == null) { + val dmsgServers = summary.dmsgServers + if (dmsgServers.isEmpty()) { Text("—", style = MaterialTheme.typography.bodyMedium) } else { - dmsgRows.take(4).forEach { session -> + 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(session.serverPublicKey), - value = "${session.roundTripNs / 1_000_000} ms", + 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, ) } @@ -384,6 +393,9 @@ private fun SectionDivider() { ) } +/** The visor keeps a handful of dmsg sessions; the card shows the first few. */ +private const val MAX_DMSG_ROWS = 4 + private fun shortPk(pk: String): String = if (pk.length <= 20) pk else pk.take(10) + "…" + pk.takeLast(8) 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 index fc09782002..2aac184adf 100644 --- 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 @@ -4,7 +4,6 @@ import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.skycoin.skywire.api.AuthFailedException -import com.skycoin.skywire.api.DmsgClientSummary import com.skycoin.skywire.api.ServiceHealthEntry import com.skycoin.skywire.api.VisorApi import com.skycoin.skywire.api.VisorSummary @@ -30,7 +29,6 @@ data class HomeUiState( /** The local API answered /api/ping. */ val apiUp: Boolean = false, val summary: VisorSummary? = null, - val dmsg: List = emptyList(), val serviceHealth: List = emptyList(), val error: String? = null, ) { @@ -51,7 +49,6 @@ class HomeViewModel(app: Application) : AndroidViewModel(app) { private data class LiveData( val apiUp: Boolean = false, val summary: VisorSummary? = null, - val dmsg: List = emptyList(), val serviceHealth: List = emptyList(), val error: String? = null, ) @@ -62,7 +59,6 @@ class HomeViewModel(app: Application) : AndroidViewModel(app) { coreState = core, apiUp = data.apiUp, summary = data.summary, - dmsg = data.dmsg, serviceHealth = data.serviceHealth, error = data.error, ) @@ -97,13 +93,14 @@ class HomeViewModel(app: Application) : AndroidViewModel(app) { 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 dmsg = runCatching { api.dmsg() }.getOrDefault(emptyList()) val health = runCatching { api.serviceHealth() }.getOrDefault(emptyList()) live.value = LiveData( apiUp = true, summary = summary, - dmsg = dmsg, serviceHealth = health, ) } catch (e: AuthFailedException) { 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 index ab5ba7c9ae..1f66c84a3e 100644 --- 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 @@ -59,6 +59,8 @@ import com.skycoin.skywire.api.AppConnection import com.skycoin.skywire.api.ServiceEntry import com.skycoin.skywire.core.CoreState 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.logs.LogSources import java.util.Locale @@ -76,6 +78,7 @@ fun SocksScreen( ) { val state by viewModel.uiState.collectAsState() var portSheetOpen by remember { mutableStateOf(false) } + var transportSheetOpen by remember { mutableStateOf(false) } Scaffold( topBar = { @@ -97,6 +100,15 @@ fun SocksScreen( ) { 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) } @@ -123,6 +135,17 @@ fun SocksScreen( }, ) } + + if (transportSheetOpen) { + TransportPreferenceSheet( + current = state.transportPrimary, + onDismiss = { transportSheetOpen = false }, + onSelect = { type -> + viewModel.setTransportPrimary(type) + transportSheetOpen = false + }, + ) + } } // --- status --- 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 index d134b8c175..66575de672 100644 --- 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 @@ -10,6 +10,7 @@ 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 kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -33,6 +34,8 @@ data class SocksUiState( 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, @@ -100,6 +103,12 @@ class SocksViewModel(app: Application) : AndroidViewModel(app) { 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 { @@ -127,27 +136,8 @@ class SocksViewModel(app: Application) : AndroidViewModel(app) { viewModelScope.launch { loadServers() } } - /** - * Point the app at [server] and make sure it runs. - * - * 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. - */ - fun connect(server: SavedServer) = action { - 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) } - } + /** 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 @@ -188,8 +178,58 @@ class SocksViewModel(app: Application) : AndroidViewModel(app) { } } + /** + * 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 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 1c82913a58..3a4a2faf3b 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -88,6 +88,20 @@ Retry Refresh + + 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. + Connect will start the Skywire core — coming soon. SkyChat is coming soon — the chat UI will load here. The native Skycoin wallet is coming soon. diff --git a/android/implementation-report.md b/android/implementation-report.md index 6d025749ed..5efe89130c 100644 --- a/android/implementation-report.md +++ b/android/implementation-report.md @@ -7,6 +7,91 @@ was actually performed (commands, devices, measured numbers — not intentions). --- +## 2026-08-04 — DMSG-servers card fixed; primary transport is now a choice + +**Built:** + +- **Home / DMSG servers** — the list was empty on every run. It was read + from `GET /api/dmsg`, which is the *round-trip tracker*: to report a + visor it must dial that visor's dmsgctrl port back over dmsg, and for + the local visor that self-dial never lands on this phone. It now reads + `dmsg_servers` out of the summary the card already fetches — the visor + answers that from its own live session list, no network involved. Each + row shows the session protocol always, plus latency when there is one. + `VisorApi.dmsg()` and the `DmsgClientSummary` DTO are gone with it. +- **Primary transport** (`core/TransportPreference.kt`, + `ui/components/TransportPreferenceUi.kt`) — a card on SkySOCKS with a + sheet to pick which transport type the visor reaches for FIRST; the + others stay behind it as fallbacks. Visor-wide, not per-app, so SkyVPN + will show the same value from the same two composables. **dmsg is the + phone default.** + - Stored in `AppPreferences`, written into `routing.transport_preference` + by the phone profile on every launch, and applied live (no restart) + through `PUT …/router-settings` when the core is up. Changing it while + the client runs re-dials it, since the setting only steers route setup. +- **Go core** — one order now governs both halves of "which transport": + - `tptypes.PreferredOrder(...)` / `PreferenceOrder()` (new) sort a + candidate list by the configured order; + - the route-setup hook (`getRouteSetupHooks`) and `EnsureDirectTransport` + both used a hardcoded STCPR → SUDPH → DMSG; they now walk + `PreferredOrder`. The built-in default reproduces the old sequence + exactly, so nothing changes for a visor that configures nothing. + - `RouterSettings` gained `transport_preference` (GET reports the order + in force; PUT applies + persists; `null` = leave alone, `[]` = revert + to default, unknown names rejected rather than silently dropped). + - The SUDPH gate — up to a 20 s wait on the STUN client — is now + evaluated lazily, only when SUDPH is actually the next type to try. + dmsg-first would otherwise still have paid it. + +**Hard-won facts:** + +- **`/api/dmsg` is empty forever on a phone, by design of what it is.** + Caught live: `GET /api/dmsg → 200, bytes=3` (`[]`) next to a 2375-byte + summary, with `dtm.establishTracker … client_pk=03704809…(self) error= + "dmsg error 202 - cannot connect to delegated server"` repeating every + ~5 min. The summary's `dmsg_servers` had entries the whole time + (`Measuring DMSG server latencies via self-ping servers=2`). +- `DMSGServerInfo.latency` is a self-ping the visor runs 5 s after dmsg + comes up and then **hourly**, so a server that joined since the last + pass has `latency: 0`. On LTE the session set churns constantly (4 → 3 + servers between two screenshots), so most rows have no latency. Rows + render `tcp · 1182 ms` when measured and `tcp` when not — never a + fabricated "0 ms". +- **`PUT …/router-settings` applies every field of the struct**, so a + caller changing one knob must read-modify-write. Sending only + `transport_preference` would also send `min_hops: 0`, and the router + reads 0 as *routing disabled*. Verified the round trip leaves + `min_hops: 1` intact. +- The transport-preference sheet is the first sheet tall enough to reach + the gesture bar: it needs `navigationBarsPadding()`, and even then the + trailing Cancel row was clipped — dropped it (selection applies and + dismisses; scrim/swipe/back cancel). + +**Verified** live on DM-B70104 (Android 15, LTE, dark): + +- Home now lists the dmsg servers after Connect — 4 rows, one with + `1182 ms`, the rest with their carrier. +- Config written at launch carries + `"transport_preference": ["dmsg","stcpr","squicr","sudph","stcp","webrtc","swsr","swtr"]`, + and the visor logs `Applied configured transport preference order`. +- SkySOCKS Reconnect with dmsg primary, from the log: dial at `08:07:23.6` + → `Dialing transport to 03b8ec58… via dmsg` at `:27.4` → + `saved transport … type(dmsg)` at `:29.2` → + `Found direct transport to destination … (type=dmsg)` at `:29.9` → + route group up at `:32.6`. **No STCPR/SUDPH attempt, no STUN wait.** + Screen went Connected with bytes flowing. +- Live switch DMSG → STCPR → DMSG: each logged + `SetTransportPreference: [...]` and landed in the config file + immediately, `min_hops` untouched. +- `go test ./pkg/visor/... ./pkg/router/... ./pkg/transport/types/...` + green; `go build` with the mobile tags green. + +**Left open:** the dmsg self-dial that fails with error 202 is the same +one behind both the empty `/api/dmsg` and the missing latencies. Worth a +look on its own; nothing on the phone depends on it now. + +--- + ## 2026-08-04 — SkySOCKS screen: discovery list, connect, expose-port **Built:** diff --git a/pkg/router/router_dial.go b/pkg/router/router_dial.go index b9e7078c49..e3a8f05e5e 100644 --- a/pkg/router/router_dial.go +++ b/pkg/router/router_dial.go @@ -129,8 +129,13 @@ func (r *router) DialRoutes( // direct transport is already known to TPD). After this, isTpdExist(rPK) is // true → baseMinHops downgrades to 1, and UseExistingTpOnly (set alongside // EnsureDirectTransport) bypasses the route-finder → a 1-hop direct dial. + // Creation order follows the configured transport preference + // (routing.transport_preference), same as the order the route/transport + // SELECTION paths use — one knob decides "which type first" everywhere. + // Unset preference = the built-in default = stcpr, sudph, dmsg, exactly + // as this list was hardcoded. if opts != nil && opts.EnsureDirectTransport && !r.isTpdExist(rPK) { - for _, nt := range []tptypes.Type{tptypes.STCPR, tptypes.SUDPH, tptypes.DMSG} { + for _, nt := range tptypes.PreferredOrder(tptypes.STCPR, tptypes.SUDPH, tptypes.DMSG) { if _, sErr := r.tm.SaveTransport(ctx, rPK, nt, transport.LabelAutomatic); sErr == nil { log.WithField("tp_type", nt).WithField("remote", rPK). Debug("--direct: created direct transport on demand") diff --git a/pkg/transport/types/preference.go b/pkg/transport/types/preference.go index 9d4316bf76..69b571211c 100644 --- a/pkg/transport/types/preference.go +++ b/pkg/transport/types/preference.go @@ -2,6 +2,7 @@ package tptypes import ( + "sort" "sync/atomic" ) @@ -38,6 +39,36 @@ func TypePreference(t Type) int { return len(*order) } +// PreferenceOrder returns the active order — the one SetPreferenceOrder +// installed, or the built-in default when none was. The result is a copy; +// mutating it does not affect the active order. Callers surfacing the +// setting (the router-settings API, a UI) use this rather than assuming +// the default. +func PreferenceOrder() []Type { + order := preferenceOrder.Load() + if order == nil { + order = &defaultPreference + } + out := make([]Type, len(*order)) + copy(out, *order) + return out +} + +// PreferredOrder returns candidates sorted by TypePreference, most-preferred +// first, without modifying the input. It is the single way to answer "which +// of these types do we try first" — whether picking between transports that +// already exist or deciding which one to create — so one configured order +// governs every such decision. Types absent from the active order keep their +// relative position at the end (the sort is stable). +func PreferredOrder(candidates ...Type) []Type { + out := make([]Type, len(candidates)) + copy(out, candidates) + sort.SliceStable(out, func(i, j int) bool { + return TypePreference(out[i]) < TypePreference(out[j]) + }) + return out +} + // SetPreferenceOrder overrides the active transport-type preference order. // Pass nil or an empty slice to revert to the built-in default. Unknown // or unspecified types implicitly sort after listed types. diff --git a/pkg/transport/types/preference_test.go b/pkg/transport/types/preference_test.go index b9123e7326..dba193cadf 100644 --- a/pkg/transport/types/preference_test.go +++ b/pkg/transport/types/preference_test.go @@ -97,6 +97,52 @@ func TestWSWTLegacyAlias(t *testing.T) { } } +// TestPreferredOrder covers the helper the transport-CREATION paths use to +// decide which type to dial first: default order, a configured override +// (the phone's "dmsg primary"), and non-mutation of the caller's slice. +func TestPreferredOrder(t *testing.T) { + SetPreferenceOrder(nil) + + got := PreferredOrder(DMSG, SUDPH, STCPR) + if got[0] != STCPR || got[1] != SUDPH || got[2] != DMSG { + t.Fatalf("default order: got %v, want [stcpr sudph dmsg]", got) + } + + defer SetPreferenceOrder(nil) + SetPreferenceOrder([]Type{DMSG, STCPR, SUDPH}) + in := []Type{STCPR, SUDPH, DMSG} + got = PreferredOrder(in...) + if got[0] != DMSG || got[1] != STCPR || got[2] != SUDPH { + t.Fatalf("dmsg-primary order: got %v, want [dmsg stcpr sudph]", got) + } + if in[0] != STCPR || in[1] != SUDPH || in[2] != DMSG { + t.Fatalf("PreferredOrder mutated its input: %v", in) + } +} + +// TestPreferenceOrderReadback confirms the getter answers the active order — +// the default when none was set, and the override afterwards — since the +// router-settings API reports it to callers as "what is in force". +func TestPreferenceOrderReadback(t *testing.T) { + SetPreferenceOrder(nil) + if got := PreferenceOrder(); len(got) != len(defaultPreference) || got[0] != STCPR { + t.Fatalf("default readback: got %v", got) + } + + defer SetPreferenceOrder(nil) + want := []Type{DMSG, STCPR} + SetPreferenceOrder(want) + got := PreferenceOrder() + if len(got) != 2 || got[0] != DMSG || got[1] != STCPR { + t.Fatalf("override readback: got %v, want %v", got, want) + } + // The copy must not alias the stored order. + got[0] = STCP + if PreferenceOrder()[0] != DMSG { + t.Fatal("PreferenceOrder returned an aliased slice") + } +} + // TestSetPreferenceOrderSortsTransports verifies a configured override actually // reorders a slice the way the router's dial logic relies on. func TestSetPreferenceOrderSortsTransports(t *testing.T) { diff --git a/pkg/visor/api_transport.go b/pkg/visor/api_transport.go index 7863b0faca..278a5658c6 100644 --- a/pkg/visor/api_transport.go +++ b/pkg/visor/api_transport.go @@ -26,6 +26,16 @@ type RouterSettings struct { ExistingTPOnly bool `json:"existing_tp_only"` MuxRoutes int `json:"mux_routes"` MinHops uint16 `json:"min_hops"` + // TransportPreference is the transport-type priority order: which type + // is tried first when a transport has to be created, and which existing + // one a route rides when several reach the same peer. GET always answers + // the full order in effect. On PUT: + // omitted / null — leave the order unchanged; + // [] — revert to the built-in default; + // non-empty — that order, most-preferred first (unlisted types + // sort after it). + // Persisted to routing.transport_preference, so it survives a restart. + TransportPreference []string `json:"transport_preference,omitempty"` } // GetRouterSettings returns the current runtime values of the four @@ -41,11 +51,17 @@ func (v *Visor) GetRouterSettings() (RouterSettings, error) { if err != nil { return RouterSettings{}, err } + order := types.PreferenceOrder() + preference := make([]string, 0, len(order)) + for _, t := range order { + preference = append(preference, string(t)) + } return RouterSettings{ - ForceLocalRoutes: v.router.GetForceLocalRoutes(), - ExistingTPOnly: v.router.GetExistingTPOnly(), - MuxRoutes: v.router.GetMuxRoutes(), - MinHops: hops, + ForceLocalRoutes: v.router.GetForceLocalRoutes(), + ExistingTPOnly: v.router.GetExistingTPOnly(), + MuxRoutes: v.router.GetMuxRoutes(), + MinHops: hops, + TransportPreference: preference, }, nil } @@ -70,6 +86,31 @@ func (v *Visor) SetRouterSettings(s RouterSettings) error { if err := v.SetMinHops(s.MinHops); err != nil { return err } + if s.TransportPreference != nil { + if err := v.SetTransportPreference(s.TransportPreference); err != nil { + return err + } + } + return nil +} + +// SetTransportPreference installs the transport-type priority order and +// persists it to routing.transport_preference. An empty slice reverts to +// the built-in default. Every name must be a known transport type — a +// silently-dropped typo would leave the caller believing a preference is +// in force that isn't. +func (v *Visor) SetTransportPreference(order []string) error { + parsed := types.ParsePreferenceOrder(order) + if len(parsed) != len(order) { + return fmt.Errorf("unknown transport type in preference order %v (known: %v)", + order, types.Known()) + } + types.SetPreferenceOrder(parsed) + v.conf.Routing.TransportPreference = order + if err := v.conf.Flush(); err != nil { + v.log.WithError(err).Warn("Failed to persist transport_preference to config") + } + v.log.Infof("SetTransportPreference: %v", order) return nil } diff --git a/pkg/visor/init_router.go b/pkg/visor/init_router.go index efac549c3b..dd23b0f070 100644 --- a/pkg/visor/init_router.go +++ b/pkg/visor/init_router.go @@ -68,22 +68,21 @@ func getRouteSetupHooks(ctx context.Context, v *Visor, log *logging.Logger) []ro // continue with route creation return dmsgFallback() } - // try to establish direct connection to rPK (single hop) using SUDPH or STCPR - trySTCPR := false - trySUDPH := false - + // What the peer advertises to the address resolver. dmsg needs no + // advertisement — every visor is reachable over it. + advertised := make(map[types.Type]bool, len(transports)) for _, trans := range transports { - nType := types.Type(trans) - if nType == types.STCPR { - trySTCPR = true - continue - } + advertised[types.Type(trans)] = true + } - // Wait until the stun client is ready — but bounded. An - // unbounded <-v.stun.ready blocks direct-transport setup for this - // peer forever when STUN never becomes ready (no reachable STUN - // server), wedging automatic transport establishment. Same wedge - // class as the AR/service-discovery paths. + // SUDPH additionally needs a ready STUN client and a NAT type that + // can hole-punch. Evaluated LAZILY: the wait below costs up to 20 s, + // and a preference order that reaches dmsg or stcpr first must not + // pay it. (Bounded — an unbounded <-v.stun.ready blocks direct- + // transport setup for this peer forever when STUN never becomes + // ready, wedging automatic transport establishment. Same wedge class + // as the AR/service-discovery paths.) + sudphUsable := func() (bool, error) { stunReady := false select { case <-v.stun.ready: @@ -91,49 +90,59 @@ func getRouteSetupHooks(ctx context.Context, v *Visor, log *logging.Logger) []ro case <-time.After(20 * time.Second): log.Warn("STUN not ready within 20s; skipping SUDPH for this peer") case <-ctx.Done(): - return ctx.Err() + return false, ctx.Err() + } + // Without a ready STUN client we can't determine NAT type; + // skip SUDPH rather than nil-deref v.stun.client. + if !stunReady || v.stun.client == nil { + return false, nil } + switch v.stun.client.NATType { + case stun.NATSymmetric, stun.NATSymmetricUDPFirewall, + stun.NATError, stun.NATUnknown, stun.NATBlocked: + return false, nil + } + return true, nil + } - // skip SUDPH if NAT type prevents it (symmetric NAT, firewall, or STUN failure) - if nType == types.SUDPH { - // Without a ready STUN client we can't determine NAT type; - // skip SUDPH rather than nil-deref v.stun.client. - if !stunReady || v.stun.client == nil { + // Try each type in the configured preference order + // (routing.transport_preference), stopping at the first one that + // establishes: the operator's primary transport, then the others. + // Unset preference = the built-in default = stcpr, then sudph, then + // dmsg — the order this was hardcoded to. A phone that pins dmsg + // first skips the STCPR/SUDPH dialing a NATed mobile link rarely + // wins, and the ~20 s STUN wait with it. + var lastErr error + for _, nType := range types.PreferredOrder(types.STCPR, types.SUDPH, types.DMSG) { + switch nType { + case types.STCPR: + if !advertised[types.STCPR] { continue } - switch v.stun.client.NATType { - case stun.NATSymmetric, stun.NATSymmetricUDPFirewall, - stun.NATError, stun.NATUnknown, stun.NATBlocked: + case types.SUDPH: + if !advertised[types.SUDPH] { + continue + } + usable, err := sudphUsable() + if err != nil { + return err + } + if !usable { continue } } - trySUDPH = true - } - - // trying to establish direct connection to rPK using STCPR - if trySTCPR { - err := retrier.Do(ctx, func() error { - _, err := tm.SaveTransport(ctx, rPK, types.STCPR, transport.LabelAutomatic) - return err - }) - if err == nil { - return nil - } - log.Debugf("Establishing automatic STCPR transport failed.") - } - // trying to establish direct connection to rPK using SUDPH - if trySUDPH { err := retrier.Do(ctx, func() error { - _, err := tm.SaveTransport(ctx, rPK, types.SUDPH, transport.LabelAutomatic) + _, err := tm.SaveTransport(ctx, rPK, nType, transport.LabelAutomatic) return err }) if err == nil { return nil } - log.Debugf("Establishing automatic SUDPH transport failed.") + log.Debugf("Establishing automatic %s transport failed.", nType) + lastErr = err } - return dmsgFallback() + return lastErr }, } } diff --git a/pkg/visor/visorconfig/v1.go b/pkg/visor/visorconfig/v1.go index 6d981401d5..cec99f71dc 100644 --- a/pkg/visor/visorconfig/v1.go +++ b/pkg/visor/visorconfig/v1.go @@ -581,10 +581,17 @@ type Routing struct { // MuxRoutes is the number of parallel routes to establish per connection. // 0 or 1 = single route (default), >1 = route multiplexing across transports. MuxRoutes int `json:"mux_routes,omitempty"` - // TransportPreference is the priority order applied when multiple transports - // exist between the same edges. Earlier entries are preferred. Valid values: - // "stcpr", "sudph", "stcp", "dmsg". If empty, the built-in default order - // (stcpr > sudph > stcp > dmsg) is used. Types not listed here sort last. + // TransportPreference is the transport-type priority order, most-preferred + // first. It decides both which existing transport a route rides when + // several reach the same peer, and which type the visor tries to CREATE + // first when a route needs a transport that isn't there yet — so the first + // entry is the primary transport and the rest are the fallbacks, in order. + // Valid values are the names in tptypes.Known() ("stcpr", "squicr", + // "sudph", "stcp", "webrtc", "swsr", "swtr", "dmsg"; the pre-rename + // "quic"/"ws"/"wt" are accepted too). If empty, the built-in default order + // (stcpr > squicr > sudph > stcp > webrtc > swsr > swtr > dmsg) is used. + // Types not listed here sort last. Settable at runtime, without a restart, + // via PUT /api/visors/{pk}/router-settings. TransportPreference []string `json:"transport_preference,omitempty"` // EnableCascadeRouteSetup opts INTO the source-driven cascade route-setup From 05257180fe818dece9bdbf1343ab48ad1ed204f4 Mon Sep 17 00:00:00 2001 From: "MohammadReza P." Date: Tue, 4 Aug 2026 23:02:10 +0800 Subject: [PATCH 08/37] =?UTF-8?q?feat(android):=20Chat=20tab=20=E2=80=94?= =?UTF-8?q?=20skychat=20embedded,=20gated,=20and=20made=20to=20fit=20a=20p?= =?UTF-8?q?hone?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Chat tab is skychat's own web UI in a WebView, not a native rewrite: one chat codebase, and the phone layout is a breakpoint inside it rather than a second UI. Opening the tab is what starts the app — autostart is off for every app on the phone — so the screen waits for the visor API, starts skychat, polls its own listener until it answers, and only then creates the WebView. Pointing it at a port nothing is listening on replaces the chat with Chromium's error page and needs a manual reload. skychat now runs password-gated on the phone, and that is the part worth reading. Android has no per-app network namespace: a listener on 127.0.0.1 is reachable by every app holding INTERNET, and skychat's surface is the whole account — history, contacts, sending — so left open it would be readable and writable by anything installed, with no prompt. The phone profile turns on the gate skychat already ships. A second device-local secret is hashed the way commands/auth.go verifies (:, 16-byte salt) into /skychat-password, written by ConfigManager *before* the app is ever started so there is no window in which the surface is open, and the WebView answers the challenge in onReceivedHttpAuthRequest — Chromium then reuses the credential for every subresource, XHR and the SSE stream. The record is re-checked rather than blindly rewritten: the salt is read back and the stored secret re-hashed against it, so a rotated keystore rewrites the file instead of the app 401-ing against its own gate, and a running skychat holding a stale file is stopped and started once (that reloads --password-file) before the failure is reported. --portless is stripped from the argv: the visor's /skychat/proxy mount serves the same handler under a path prefix, but every fetch in the page is root-absolute (/history, /sse, /message), so the UI cannot run there and the port is the only way in. A WebView left at the default wrap_content breaks every percentage height in the page. Compose measures it with an AT_MOST spec and Chromium then treats the layout viewport height as indefinite: measured live, innerHeight was 777 while getComputedStyle(html).height and body were both 0px. Every flex column collapsed — the conversation list rendered nothing below the tabs and the composer rode up under the header instead of sitting at the bottom. MATCH_PARENT layout params is the whole fix, and it is load-bearing, not cosmetic. WebView also needs MODIFY_AUDIO_SETTINGS, not just RECORD_AUDIO. With RECORD_AUDIO granted, getUserMedia still failed with a bare "NotReadableError: Could not start audio source"; logcat named it — "cr_media: Requires MODIFY_AUDIO_SETTINGS and RECORD_AUDIO. No audio device will be available for recording". Install-time permission, no prompt, and without it neither voice nor video messages can record. - ui/chat/: ChatViewModel brings the surface up and hands the screen a URL; ChatWebView holds the Android half. Android back maps to the page's history, which already pushes an entry when a conversation opens and answers popstate by returning to the list — so back closes the chat and back again leaves the tab, with no second mechanism. Top-bar overflow carries Reload and Logs, the shared viewer scoped to skychat. - Uploads go through onShowFileChooser → StartActivityForResult, and the callback is answered on cancel too: an unanswered file chooser leaves the page's input dead for good. Downloads are not left to the page — WebView ignores the download attribute and would render an image or a video *in place of the chat*, so same-origin main-frame navigations become DownloadManager jobs carrying the gate's Authorization header (it fetches in its own process with no session of ours), and anything off-origin goes to the browser. - core/SkychatProfile owns the argv pins and the password record; api/SkychatApi is the client for skychat's own surface (the credential and a readiness probe), separate from VisorApi on :8000. - The app scaffold consumes its window insets so the Chat WebView's imePadding does not count the navigation bar twice — once in the scaffold padding and once in the keyboard inset it is measured from. 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. The toast reserved 100vw - 360px for a sidebar that is not beside anything in one pane, leaving every toast a ~46px column of wrapped words. And the image lightbox had no zoom of its own while the app — rightly — keeps page zoom off, so a received photo could not be looked at: it now pinches, double-taps and drags to pan. The message field is a textarea rather than a single-line input, one row growing to three and then scrolling with its scrollbar hidden; Enter sends where there is a real keyboard and inserts a newline on a touch device, where it is the only way to write a second line. Two sizing traps in that: scrollHeight is content+padding and the field is border-box, so the border has to be added back or every row is 2px short and grows a scrollbar it does not need; and sizing the composer while it is hidden — it is, until a conversation opens — measures 0 and pins it shut until the first keystroke. Verified on device against a desktop visor, with DevTools attached over adb forward for the measurements. Text both ways; a 96 KB PNG sent (✓✓) and a 196 KB PNG plus a 3.2 MB MP3 received, rendering inline with a working player. list → chat → Android back → list with the unread badge intact. getUserMedia({audio}) OK and {video,audio} → video=1 audio=1, both NotReadableError before the permission fix. Composer 42px at rest with msgForm overflow 0 at a 406px viewport (was 62px); toast 383px on one 40px row. Lightbox double-tap on a received screenshot → scale 2.5 anchored on the tap, a synthetic two-finger pinch 1 → 3. Attach opens documentsui and cancelling returns with the conversation still open; overflow → Logs live-tails skychat. go test ./cmd/apps/skychat/... green. Left open: one send arrived three times on the desktop and one reply twice on the phone, not reproduced since — the log around it is route- group churn between the two visors ("Failed to send periodic SACK … transport is not set up", a new route group per message), the shape of a retransmit after an unacked send. Nothing in the embed sends twice: the page holds one EventSource and ignores the server's history replay. Worth chasing with a reproduction. Leaving the tab destroys the WebView, so returning reloads the page and loses which conversation was open — the WebView has to be Activity-scoped, since an application-context one crashes on the composer'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 + } + } + + BackHandler(enabled = canGoBack) { webView?.goBack() } + + Scaffold( + topBar = { + SkyTopBar( + title = stringResource(R.string.app_skychat), + actions = { + IconButton(onClick = { menuOpen = true }) { + Icon( + Icons.Default.MoreVert, + contentDescription = stringResource(R.string.chat_menu), + ) + } + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + DropdownMenuItem( + text = { Text(stringResource(R.string.chat_reload)) }, + onClick = { + menuOpen = false + pageError = null + webView?.reload() + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.logs_title)) }, + onClick = { + menuOpen = false + onOpenLogs(LogSources.app(SkychatProfile.APP)) + }, + ) + } + }, + ) + }, + // 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 }, + 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) + } + webView = view + } + }, + update = { view -> + val target = state.url + if (target != null && target != loadedUrl) { + loadedUrl = target + view.loadUrl(target) + } + }, + onRelease = { view -> + // A live SSE stream survives the composable otherwise. + ChatWebView.release(view) + webView = null + loadedUrl = null + canGoBack = 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)) + TextButton(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..10b0553224 --- /dev/null +++ b/android/app/src/main/java/com/skycoin/skywire/ui/chat/ChatWebView.kt @@ -0,0 +1,256 @@ +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 + +/** + * 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, + 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()) + } + + 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 + } + } + + /** + * 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/home/HomeViewModel.kt b/android/app/src/main/java/com/skycoin/skywire/ui/home/HomeViewModel.kt index 2aac184adf..742f308b47 100644 --- 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 @@ -10,6 +10,7 @@ import com.skycoin.skywire.api.VisorSummary 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 @@ -133,7 +134,7 @@ class HomeViewModel(app: Application) : AndroidViewModel(app) { viewModelScope.launch { SkywireCoreService.stop(app) CoreServiceState.state.first { it is CoreState.Stopped } - ConfigManager(SkywirePaths(app)).deleteUsersDb() + ConfigManager(SkywirePaths(app), SecretStore(app)).deleteUsersDb() // Give the stopping service instance a beat to finish stopSelf so // the fresh start command lands on a clean instance. delay(SERVICE_RESTART_GRACE_MS) diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 3a4a2faf3b..dea0934312 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -102,8 +102,14 @@ 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. + Connect will start the Skywire core — coming soon. - SkyChat is coming soon — the chat UI will load here. The native Skycoin wallet is coming soon. Identity, config export, app lock and logs are coming soon. Whole-phone VPN with killswitch is coming soon. diff --git a/android/implementation-report.md b/android/implementation-report.md index 5efe89130c..6900c00115 100644 --- a/android/implementation-report.md +++ b/android/implementation-report.md @@ -7,6 +7,134 @@ was actually performed (commands, devices, measured numbers — not intentions). --- +## 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. + - `` → ` @@ -3370,11 +3443,15 @@

Banned

-
`; @@ -6913,6 +6979,18 @@

Banned

}); document.title = totalUnread > 0 ? `(${totalUnread}) Skychat` : 'Skychat'; + + // The host app's hub card needs this number when no page is open to + // compute it, so every change is reported and the server carries it + // forward from there — see /unread on the Go side. + if (this._postedUnread !== totalUnread) { + this._postedUnread = totalUnread; + fetch('/unread', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ unread: totalUnread }) + }).catch(() => {}); + } } sendMessage(el) { @@ -7921,57 +7999,102 @@

Banned

}); } - fetch('/send-file', { method: 'POST', body: form }) - .then(res => { - if (!res.ok) { - message.file.status = 'failed'; - this._patchStatusTick(message); - const what = note ? 'Could not save that file' : 'File send failed'; - res.text().then(t => this.showToast(`${what}: ${t}`, 'error')); - if (!isGroup) this.saveChat(destination); - return; - } - // 'stored' is a note's whole lifecycle: kept here, offered to - // nobody, so there is no delivery to confirm afterwards. - message.file.status = note ? 'stored' : 'sent'; - this._patchStatusTick(message); - this.showToast(note - ? (opts.savedToast || 'Saved.') - : (opts.sentToast || 'File sent.')); - // The response carries the server-assigned file id so a re-request - // can locate the bytes and the id matches the feed reference other - // members see. It also carries file_url — the copy the visor kept - // of what we just sent. - res.json().then(j => { - if (j && j.id) message.file.id = j.id; - // Re-point the bubble at that served copy: what it is showing now - // is an object URL, which dies with the page, so without this a - // clip we recorded comes back as a bare file card after a reload. - // Images are left alone — they persist as their own thumbnail - // data URL (above), which survives even if the copy is pruned. - if (j && j.file_url && !this.isImageFile(message.file)) { - message.file.url = j.file_url; - message.file.local = false; // a served URL persists — don't strip on save - } - // Same race as a text bubble: the peer's read receipt for this - // transfer can land before we learn its id. - this._claimOrphanStatus(destination, message, message.file.id); - if (!isGroup) this.saveChat(destination); - }).catch(() => { if (!isGroup) this.saveChat(destination); }); - }) - .catch(e => { + // XHR rather than fetch for the one thing fetch cannot do: upload + // progress events. The bubble reads "2.1 MB / 10 MB" and fills as + // the frames actually leave, which for a big clip on a slow uplink + // is the difference between "sending" and "stuck". + const xhr = new XMLHttpRequest(); + xhr.open('POST', '/send-file'); + xhr.upload.onprogress = (e) => { + if (e.lengthComputable) this._uploadProgress(message, e.loaded, e.total); + }; + xhr.onload = () => { + if (xhr.status < 200 || xhr.status >= 300) { + this._uploadProgress(message, null); message.file.status = 'failed'; this._patchStatusTick(message); - this.showToast(e.message, 'error'); - }); + const what = note ? 'Could not save that file' : 'File send failed'; + this.showToast(`${what}: ${xhr.responseText}`, 'error'); + if (!isGroup) this.saveChat(destination); + return; + } + // The full total, then the fade — "sent" deserves one whole frame. + this._uploadProgress(message, file.size, file.size, { fade: true }); + // 'stored' is a note's whole lifecycle: kept here, offered to + // nobody, so there is no delivery to confirm afterwards. + message.file.status = note ? 'stored' : 'sent'; + this._patchStatusTick(message); + this.showToast(note + ? (opts.savedToast || 'Saved.') + : (opts.sentToast || 'File sent.')); + // The response carries the server-assigned file id so a re-request + // can locate the bytes and the id matches the feed reference other + // members see. It also carries file_url — the copy the visor kept + // of what we just sent. + let j = null; + try { j = JSON.parse(xhr.responseText); } catch (e) { /* non-JSON reply */ } + if (j && j.id) message.file.id = j.id; + // Re-point the bubble at that served copy: what it is showing now + // is an object URL, which dies with the page, so without this a + // clip we recorded comes back as a bare file card after a reload. + // Images are left alone — they persist as their own thumbnail + // data URL (above), which survives even if the copy is pruned. + if (j && j.file_url && !this.isImageFile(message.file)) { + message.file.url = j.file_url; + message.file.local = false; // a served URL persists — don't strip on save + } + // Same race as a text bubble: the peer's read receipt for this + // transfer can land before we learn its id. + this._claimOrphanStatus(destination, message, message.file.id); + if (!isGroup) this.saveChat(destination); + }; + xhr.onerror = () => { + this._uploadProgress(message, null); + message.file.status = 'failed'; + this._patchStatusTick(message); + this.showToast('File send failed: network error', 'error'); + }; + xhr.send(form); } + // _uploadProgress paints "2.1 MB / 10 MB" under a sending bubble, + // creating the element on the first event (and re-creating it if a + // re-render swept it away). loaded == null removes it — a failed send + // shows its failure through the status tick, not a stale counter. + // {fade: true} lets the final full reading linger, then dissolve. + _uploadProgress(message, loaded, total, fx = {}) { + if (message.mid == null) return; + const row = document.querySelector(`.message-row[data-mid="${message.mid}"]`); + if (!row) return; + let el = row.querySelector('.upload-progress'); + if (loaded == null) { + if (el) el.remove(); + return; + } + if (!el) { + el = document.createElement('div'); + el.className = 'upload-progress'; + (row.querySelector('.message-bubble') || row).appendChild(el); + } + el.textContent = `${this.humanSize(loaded)} / ${this.humanSize(total)}`; + if (fx.fade) { + el.classList.add('done'); + setTimeout(() => el.remove(), Chat.uploadFadeMs); + } + } + + /** How long the final "10 MB / 10 MB" lingers before dissolving. */ + static get uploadFadeMs() { return 1400; } + // requestFile asks a file's original sender to re-send it (backfill) — - // e.g. after the local copy was pruned or on a fresh device. Params come - // from the message's file descriptor via data-* attributes (DOM-read, so - // no interpolation/injection). + // e.g. after the local copy was pruned or on a fresh device. The + // element form survives for any markup still carrying data-*; the menu + // path calls _requestFile directly. requestFile(el) { - const from = el.dataset.from, fid = el.dataset.fid, fname = el.dataset.fname || ''; + this._requestFile(el.dataset.from, el.dataset.fid, el.dataset.fname || ''); + } + + _requestFile(from, fid, fname) { if (!from || !fid) return; fetch('/request-file', { method: 'POST', @@ -9824,6 +9947,11 @@

Banned

// the next page arrives before the reader gets to the top of this one. static get groupHistoryChunk() { return 50; } + // The owner's wire-level liveness probe. The server side filters these + // everywhere it can, but a store or ring written by an older build can + // still serve them — and a probe rendered as a message reads as spam. + static get heartbeatMarker() { return '__skychat_group_heartbeat__'; } + // _histState returns the paging state for a group, creating it on first // use. cursor is the ts_nano of the OLDEST row we hold — the exclusive // bound for the next page. done means the server has told us there is @@ -9860,6 +9988,7 @@

Banned

// Skip messages this device deleted-for-me (groups reload from // history, so the local hide lives in hiddenGroup, not the cache). if (this._isGroupHidden(id, tsNano != null ? String(tsNano) : '')) return; + if (m.text === Chat.heartbeatMarker) return; // persisted by an older build const sender = String(m.sender_pk || '').toLowerCase(); const senderIsMe = sender === this.myPK; // A file-reference row (file_id set) becomes a media bubble. @@ -10026,6 +10155,7 @@

Banned

handleGroupSSE(data) { const gid = data.group_id; if (!gid) return; + if (data.message === Chat.heartbeatMarker) return; // liveness probe, not chat const sender = String(data.sender || '').toLowerCase(); const tsNano = data.ts_nano != null ? data.ts_nano : null; if (this.myPK && sender === this.myPK) { diff --git a/pkg/skychat/group/session.go b/pkg/skychat/group/session.go index c5008b7362..97b69127b0 100644 --- a/pkg/skychat/group/session.go +++ b/pkg/skychat/group/session.go @@ -2708,6 +2708,12 @@ func (s *Session) ReplayHistoryThrough(handler MessageHandler, cap int) { msg.Ciphertext = nil msg.Nonce = nil } + // The live path drops heartbeats in onUpdate; replay must not + // resurrect them as chat. Skipped before the cap window so a + // heartbeat-heavy feed cannot displace real history within it. + if IsHeartbeat(msg) { + continue + } out = append(out, decoded{path: l.path, msg: msg}) } if len(out) == 0 { diff --git a/pkg/visor/group.go b/pkg/visor/group.go index e75e6cdb24..f4391ebc24 100644 --- a/pkg/visor/group.go +++ b/pkg/visor/group.go @@ -1171,7 +1171,20 @@ func (v *Visor) GroupHistoryPage(args GroupHistoryPageArgs) ([]GroupMessage, err if hist == nil { return nil, ErrGroupHistoryDisabled } - return hist.ListGroupBefore(args.GroupID, args.Before, args.Limit) + msgs, err := hist.ListGroupBefore(args.GroupID, args.Before, args.Limit) + if err != nil { + return nil, err + } + // Stores written by builds that predate the inbox's heartbeat filter + // carry the owner's liveness probes as ordinary rows; a page may come + // back short of Limit, which readers already tolerate. + kept := msgs[:0] + for _, m := range msgs { + if m.Text != skychatgroup.HeartbeatMarker { + kept = append(kept, m) + } + } + return kept, nil } // GroupHistoryPageArgs is the RPC input for GroupHistoryPage. @@ -1365,6 +1378,12 @@ func (g *groupInbox) setManager(mgr *skychatgroup.Manager) { } func (g *groupInbox) deliver(groupID string, senderPK cipher.PubKey, msg skychatgroup.Message) { + // The filter IsHeartbeat was exposed for: the inbox is the user-facing + // surface, and a heartbeat that slips past the session layer must not + // reach subscribers, the ring, history, or last_message_at. + if skychatgroup.IsHeartbeat(msg) { + return + } gm := GroupMessage{ GroupID: groupID, SenderPK: senderPK, From e08ceb2de9cc4aa4ab70ad016e20cd1ecf7a7e53 Mon Sep 17 00:00:00 2001 From: "MohammadReza P." Date: Fri, 7 Aug 2026 22:16:25 +0800 Subject: [PATCH 34/37] =?UTF-8?q?=20=20feat(wallet):=20Ethereum=20?= =?UTF-8?q?=E2=80=94=20native=20ETH=20and=20ERC-20=20tokens,=20USDT=20buil?= =?UTF-8?q?t=20in?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - wallet-core grows an eth package: Keccak-256, an RLP encoder, EIP-55 addresses off the existing BIP 32 tree (m/44'/60'/0'/0/i), and EIP-1559 type-2 build/sign — the existing low-S recoverable signature IS the Ethereum form, its recovery id doubling as yParity. transfer/balanceOf call data covers the whole ERC-20 surface the wallet needs. - EthRpcClient speaks JSON-RPC for state and broadcast, and the keyless etherscan-style Blockscout API for history — plain RPC cannot list an address's transactions. - EthWalletCore serves both the native coin and any token from one class. Amounts stay in the seam's 64 bits: ETH is carried in gwei and a token in its own decimals capped at nine; wei only exists inside the core as BigInteger. Token sends without gas ETH fail with the new WalletException.InsufficientGas, in words that name the actual problem. - App: ETH and USDT ship built in; the add-coin screen gains a fiber/ERC-20 toggle for user-added tokens (contract checksum-validated). A gas fee card that shows rather than asks, ETH/token review branches (no total row where amount and fee are different currencies), history fees always labelled ETH. - Verified: Keccak/EIP-55/derivation/RLP reference vectors, the EIP-155 worked example reproduced byte-for-byte under RFC 6979, a type-2 sign-and-recover round trip, and opt-in live tests parsing production publicnode + Blockscout answers (balance, history, balanceOf, tokentx). Full wallet-core suite and assembleDebug green. A funded on-device send/receive round trip is still owed. --- .../skycoin/skywire/ui/wallet/WalletManage.kt | 63 +++- .../skycoin/skywire/ui/wallet/WalletScreen.kt | 11 +- .../skycoin/skywire/ui/wallet/WalletSend.kt | 106 +++++- .../com/skycoin/skywire/ui/wallet/WalletUi.kt | 6 +- .../skywire/ui/wallet/WalletViewModel.kt | 16 + .../skycoin/skywire/wallet/WalletModels.kt | 66 +++- .../skywire/wallet/WalletRepository.kt | 56 ++- android/app/src/main/res/values/strings.xml | 24 +- android/implementation-report.md | 56 +++ .../kotlin/com/skycoin/wallet/WalletCore.kt | 3 + .../com/skycoin/wallet/eth/EthCrypto.kt | 82 +++++ .../com/skycoin/wallet/eth/EthRpcClient.kt | 209 +++++++++++ .../kotlin/com/skycoin/wallet/eth/EthTxn.kt | 86 +++++ .../com/skycoin/wallet/eth/EthWalletCore.kt | 334 ++++++++++++++++++ .../main/kotlin/com/skycoin/wallet/eth/Rlp.kt | 44 +++ .../kotlin/com/skycoin/wallet/EthLiveTest.kt | 71 ++++ .../com/skycoin/wallet/EthVectorsTest.kt | 183 ++++++++++ 17 files changed, 1384 insertions(+), 32 deletions(-) create mode 100644 android/wallet-core/src/main/kotlin/com/skycoin/wallet/eth/EthCrypto.kt create mode 100644 android/wallet-core/src/main/kotlin/com/skycoin/wallet/eth/EthRpcClient.kt create mode 100644 android/wallet-core/src/main/kotlin/com/skycoin/wallet/eth/EthTxn.kt create mode 100644 android/wallet-core/src/main/kotlin/com/skycoin/wallet/eth/EthWalletCore.kt create mode 100644 android/wallet-core/src/main/kotlin/com/skycoin/wallet/eth/Rlp.kt create mode 100644 android/wallet-core/src/test/kotlin/com/skycoin/wallet/EthLiveTest.kt create mode 100644 android/wallet-core/src/test/kotlin/com/skycoin/wallet/EthVectorsTest.kt 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 index 3c46521380..622fed02e9 100644 --- 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 @@ -33,6 +33,7 @@ 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 @@ -468,7 +469,11 @@ fun WalletRevealScreen( } } -/** Add a fiber coin: name + ticker + node URL. */ +/** What the add screen can add: a fiber chain, or an ERC-20 on Ethereum. */ +private enum class AddCoinKind { FIBER, ERC20 } + +/** Add a fiber coin (name + ticker + node URL) or an ERC-20 token + * (name + ticker + contract + decimals). */ @Composable fun WalletAddCoinScreen( viewModel: WalletViewModel, @@ -476,9 +481,12 @@ fun WalletAddCoinScreen( 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 node by remember { mutableStateOf("") } + var contract by remember { mutableStateOf("") } + var decimals by remember { mutableStateOf("18") } val snackbar = remember { SnackbarHostState() } LaunchedEffect(state.message) { @@ -495,18 +503,45 @@ fun WalletAddCoinScreen( .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(R.string.wallet_add_coin_body), + 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 } - LabeledField(stringResource(R.string.wallet_add_coin_node), node, stringResource(R.string.wallet_add_coin_node_hint)) { node = it } + 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 = { viewModel.addFiberCoin(name, ticker, node, onDone = onAdded) }, - enabled = name.isNotBlank() && ticker.isNotBlank() && node.isNotBlank(), + onClick = { + if (kind == AddCoinKind.FIBER) { + viewModel.addFiberCoin(name, ticker, node, onDone = onAdded) + } else { + viewModel.addErc20Token(name, ticker, contract, decimals, 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) @@ -518,6 +553,24 @@ fun WalletAddCoinScreen( } } +@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)) { 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 index a7cc7a002c..bb24a90792 100644 --- 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 @@ -360,10 +360,11 @@ private fun BalanceHeader(state: WalletUiState) { } } Text( - text = if (coin.kind == CoinKind.BTC) { - stringResource(R.string.wallet_btc_sub, snapshot?.spendableOutputs ?: 0) - } else { - stringResource(R.string.wallet_hours_sub, hoursText(snapshot?.hours ?: 0uL)) + 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, @@ -527,6 +528,8 @@ private fun CoinSheet( 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, 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 index 96cc55c3a4..4f0bbcaa45 100644 --- 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 @@ -123,8 +123,12 @@ fun WalletSendScreen( onValueChange = { new -> viewModel.updateSend { it.copy(to = new, planError = null) } }, placeholder = { Text( - if (coin.kind == CoinKind.BTC) stringResource(R.string.wallet_send_to_hint_btc) - else stringResource(R.string.wallet_send_to_hint_fiber, coin.name), + 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, @@ -211,8 +215,12 @@ fun WalletSendScreen( } } Text( - text = if (coin.kind == CoinKind.BTC) stringResource(R.string.wallet_max_note_btc) - else stringResource(R.string.wallet_max_note_fiber, coin.ticker), + 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), @@ -222,10 +230,10 @@ fun WalletSendScreen( stringResource(R.string.wallet_fee), modifier = Modifier.padding(top = 26.dp), ) - if (coin.kind == CoinKind.BTC) { - BtcFeeCard(viewModel) - } else { - FiberFeeCard(viewModel) + when (coin.kind) { + CoinKind.BTC -> BtcFeeCard(viewModel) + CoinKind.ETH, CoinKind.ERC20 -> EthFeeCard(viewModel) + else -> FiberFeeCard(viewModel) } send.planError?.let { err -> @@ -345,6 +353,60 @@ private fun FiberFeeCard(viewModel: WalletViewModel) { } } +/** + * 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) { @@ -508,6 +570,34 @@ private fun ReviewSheet( ) 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 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 index 61f4a0db19..923bcc330f 100644 --- 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 @@ -59,12 +59,16 @@ fun CoinSpec.amountText(units: ULong): String = fun hoursText(hours: ULong): String = Amounts.groupThousands(hours.toString()) -/** Fee line for a history row / detail: burned hours or BTC. */ +/** 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)) } 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 index b442008894..31295d6477 100644 --- 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 @@ -179,6 +179,22 @@ class WalletViewModel(app: Application) : AndroidViewModel(app) { onDone() } + fun addErc20Token( + name: String, + ticker: String, + contract: String, + decimals: 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) + repo.setSelectedCoin(spec.id) + onDone() + } + // --- create / restore --- /** Begin the create flow for the selected coin: fresh phrase + quiz picks. */ 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 index 01dd2f1a3c..7187551433 100644 --- a/android/app/src/main/java/com/skycoin/skywire/wallet/WalletModels.kt +++ b/android/app/src/main/java/com/skycoin/skywire/wallet/WalletModels.kt @@ -2,13 +2,14 @@ package com.skycoin.skywire.wallet import kotlinx.serialization.Serializable -/** Which family a coin belongs to — the two protocols this wallet speaks. */ -enum class CoinKind { SKY_FIBER, BTC } +/** 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 and BTC ship built in; fiber coins are - * added by the user with a name, a ticker and their node's address — every - * fiber chain runs the same daemon and differs only in where it lives. + * 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( @@ -20,12 +21,33 @@ data class CoinSpec( /** %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, ) { - /** Base-unit exponent: droplets are 10⁻⁶, satoshis 10⁻⁸. */ - val exponent: Int get() = if (kind == CoinKind.BTC) 8 else 6 + /** + * 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() = if (kind == CoinKind.BTC) 8 else 3 + 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( @@ -46,6 +68,34 @@ data class CoinSpec( 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 } } 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 index 8be9359d6a..0b721418b5 100644 --- a/android/app/src/main/java/com/skycoin/skywire/wallet/WalletRepository.kt +++ b/android/app/src/main/java/com/skycoin/skywire/wallet/WalletRepository.kt @@ -8,6 +8,8 @@ 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 @@ -42,11 +44,17 @@ class WalletRepository private constructor(private val context: Context) { // --- coins --- fun coins(): Flow> = seeds.store.data.map { prefs -> - val fiber = prefs[KEY_FIBER_COINS]?.let { + // 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 fiber coins in the order added, BTC last. - listOf(CoinSpec.SKY) + fiber + listOf(CoinSpec.BTC) + // SKY first, user fiber coins 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 } @@ -63,6 +71,37 @@ class WalletRepository private constructor(private val context: Context) { kind = CoinKind.SKY_FIBER, nodeUrl = url, ) + 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): 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, + ) + 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() @@ -71,12 +110,21 @@ class WalletRepository private constructor(private val context: Context) { ListSerializer(CoinSpec.serializer()), current + spec, ) } - return 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 --- diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index ff29d17da7..34adde94bf 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -402,9 +402,13 @@ Skycoin mainnet · native Fiber coin Bitcoin mainnet - Add a fiber coin + 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 @@ -440,6 +444,7 @@ To %1$s address bc1… or 1… address + 0x… address Paste Scan a QR code Amount @@ -447,6 +452,8 @@ 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 @@ -460,6 +467,11 @@ ~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 + brand-blue background layer. 44x33dp keeps the logo's 4:3 box fully + inside the 66dp safe circle (half-diagonal 27.5dp), so no launcher + mask or parallax scale ever crowds the cloud against the edge. --> SOCKS5 proxy Swap · SKY / BTC Rooms and messages - SKY · fiber coins · BTC + SKY · Fibercoin · BTC · ETH Visors you run elsewhere Encrypted calls Reconnecting @@ -397,10 +397,10 @@ Choose a coin Choose a coin Search %1$d coins - Skycoin mainnet · native - Fiber coin + Fibercoin Bitcoin mainnet Ethereum mainnet ERC-20 token on Ethereum @@ -475,7 +475,7 @@ Review You are about to send + Fibercoin is its own network — never conflate them. --> to %1$s on the %2$s network. Nothing is signed until you confirm. Amount Miner fee at %1$d sat/vB @@ -547,14 +547,17 @@ Hide the phrase Add a coin or token - Fiber coin + Fibercoin ERC-20 token - Every fiber coin 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 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 @@ -579,7 +582,7 @@ 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 fiber coins 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. + 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. diff --git a/android/implementation-report.md b/android/implementation-report.md index 00b8fb9b7b..d0189d674f 100644 --- a/android/implementation-report.md +++ b/android/implementation-report.md @@ -7,6 +7,61 @@ was actually performed (commands, devices, measured numbers — not intentions). --- +## 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 From 0730e356e480a077bb70924b219423f808df5dee Mon Sep 17 00:00:00 2001 From: "MohammadReza P." Date: Sat, 8 Aug 2026 05:22:41 +0800 Subject: [PATCH 36/37] =?UTF-8?q?fix(mobile):=20SkyDEX=20follows=20the=20a?= =?UTF-8?q?pp's=20theme,=20and=20three=20things=20about=20its=20chrome=20T?= =?UTF-8?q?he=20trading=20UI=20is=20a=20vendored=20single-theme=20page=20?= =?UTF-8?q?=E2=80=94=20dark=20navy,=20six=20custom=20properties=20on=20:ro?= =?UTF-8?q?ot=20=E2=80=94=20so=20on=20a=20phone=20set=20to=20Light=20it=20?= =?UTF-8?q?arrived=20as=20the=20one=20dark=20screen=20in=20the=20app.=20It?= =?UTF-8?q?=20now=20follows=20the=20app's=20resolved=20theme,=20after=20th?= =?UTF-8?q?e=20user's=20own=20Light/Dark=20override,=20the=20same=20way=20?= =?UTF-8?q?the=20embedded=20chat=20does.=20Theme.=20An=20injected=20styles?= =?UTF-8?q?heet=20re-points=20the=20page's=20own=20tokens=20under=20a=20cl?= =?UTF-8?q?ass=20this=20side=20controls:=20dark=20keeps=20the=20page's=20d?= =?UTF-8?q?esign=20on=20the=20app's=20ground=20(one=20token,=20so=20the=20?= =?UTF-8?q?document=20behind=20the=20native=20header=20is=20the=20app's=20?= =?UTF-8?q?navy),=20light=20is=20the=20palette=20from=20ui/theme/Theme.kt.?= =?UTF-8?q?=20Every=20hard-coded=20translucent=20dark=20in=20the=20page's?= =?UTF-8?q?=20CSS=20is=20white-on-navy=20arithmetic=20that=20turns=20to=20?= =?UTF-8?q?mud=20on=20white,=20so=20each=20is=20re-based=20on=20ink=20or?= =?UTF-8?q?=20the=20brand=20=E2=80=94=20inputs,=20the=20trade=20legs,=20ad?= =?UTF-8?q?dress=20boxes,=20the=20progress=20track,=20the=20shadows=20tune?= =?UTF-8?q?d=20for=20a=20dark=20ground.=20Two=20exceptions=20are=20called?= =?UTF-8?q?=20out=20where=20they=20are=20made:=20--sky-white=20is=20the=20?= =?UTF-8?q?ink=20token=20and=20the=20primary=20button=20fills=20with=20bra?= =?UTF-8?q?nd=20blue,=20so=20that=20button=20keeps=20white=20text=20on=20l?= =?UTF-8?q?ight;=20and=20the=20trade=20builder's=20amount=20stays=20transp?= =?UTF-8?q?arent=20so=20it=20reads=20as=20a=20figure=20on=20its=20panel,?= =?UTF-8?q?=20not=20a=20field.=20color-scheme=20flips=20with=20the=20rest,?= =?UTF-8?q?=20so=20scrollbars=20and=20select=20popups=20follow.=20Committe?= =?UTF-8?q?d=20from=20onPageCommitVisible,=20so=20a=20light=20load=20never?= =?UTF-8?q?=20flashes=20navy,=20and=20re-applied=20live=20when=20the=20the?= =?UTF-8?q?me=20changes=20with=20the=20page=20open.=20The=20platform's=20J?= =?UTF-8?q?S=20dialogs=20go=20with=20it.=20window.confirm=20is=20what=20gu?= =?UTF-8?q?ards=20cancelling=20a=20listing,=20and=20an=20Activity=20theme?= =?UTF-8?q?=20cannot=20see=20a=20choice=20that=20lives=20in=20a=20composit?= =?UTF-8?q?ion=20local=20=E2=80=94=20so=20the=20half=20is=20now=20named=20?= =?UTF-8?q?explicitly=20and=20a=20confirm=20no=20longer=20lands=20as=20a?= =?UTF-8?q?=20dark=20slab=20over=20a=20light=20page.=20Cards/List,=20where?= =?UTF-8?q?=20it=20means=20something.=20The=20switch=20was=20a=20bar=20of?= =?UTF-8?q?=20its=20own=20above=20every=20tab,=20including=20Settings,=20w?= =?UTF-8?q?hich=20is=20a=20form=20with=20nothing=20to=20switch.=20It=20now?= =?UTF-8?q?=20appears=20only=20where=20there=20are=20rows=20to=20read=20?= =?UTF-8?q?=E2=80=94=20the=20test=20is=20the=20DOM's,=20not=20a=20list=20o?= =?UTF-8?q?f=20tab=20names=20=E2=80=94=20and=20rides=20in=20the=20heading?= =?UTF-8?q?=20that=20owns=20them:=20the=20market's=20"Available=20products?= =?UTF-8?q?",=20every=20other=20tab's=20own=20title.=20One=20line,=20switc?= =?UTF-8?q?h=20on=20the=20right,=20no=20separate=20row=20of=20chrome.=20Cl?= =?UTF-8?q?ear=20history=20moves=20to=20Settings.=20Beside=20the=20list=20?= =?UTF-8?q?it=20wipes,=20it=20was=20one=20mis-tap=20from=20the=20tab=20you?= =?UTF-8?q?=20just=20opened;=20it=20belongs=20with=20the=20other=20things?= =?UTF-8?q?=20you=20set=20once.=20The=20two=20tabs=20are=20separate=20Reac?= =?UTF-8?q?t=20screens,=20so=20it=20is=20re-offered=20rather=20than=20move?= =?UTF-8?q?d=20=E2=80=94=20same=20localStorage=20key,=20same=20confirm,=20?= =?UTF-8?q?same=20action.=20Verified=20on=20an=20emulator=20against=20a=20?= =?UTF-8?q?live=20market:=20light=20and=20dark=20across=20Market,=20Histor?= =?UTF-8?q?y=20and=20Settings,=20the=20confirm=20dialog,=20and=20the=20cle?= =?UTF-8?q?ar=20itself.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/skycoin/skywire/ui/dex/DexScreen.kt | 15 +- .../com/skycoin/skywire/ui/dex/DexWebView.kt | 267 +++++++++++++++++- .../com/skycoin/skywire/ui/theme/Theme.kt | 6 +- android/implementation-report.md | 84 ++++++ 4 files changed, 354 insertions(+), 18 deletions(-) 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 index 65ae2ae815..67e8413309 100644 --- 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 @@ -36,6 +36,7 @@ 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 @@ -60,6 +61,7 @@ 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 /** @@ -90,6 +92,16 @@ fun DexScreen( // 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 — @@ -139,10 +151,11 @@ fun DexScreen( view.webViewClient = DexWebView.client( baseUrl = { url.value }, password = { password.value }, + isDark = { dark.value }, onError = { pageError = it }, onHistoryChanged = { canGoBack = it }, ) - view.webChromeClient = DexWebView.chromeClient() + view.webChromeClient = DexWebView.chromeClient(isDark = { dark.value }) webView = view } }, 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 index 80309a8758..6da1474f81 100644 --- 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 @@ -74,6 +74,7 @@ internal object DexWebView { fun client( baseUrl: () -> String?, password: () -> String?, + isDark: () -> Boolean, onError: (String) -> Unit, onHistoryChanged: (Boolean) -> Unit = {}, ): WebViewClient = object : WebViewClient() { @@ -115,10 +116,21 @@ internal object DexWebView { 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") applyPhoneStyles(view) + if (url != "about:blank") { + applyTheme(view, isDark()) + applyPhoneStyles(view) + } } override fun onReceivedError( @@ -133,6 +145,110 @@ internal object DexWebView { } } + /** + * 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. * @@ -213,12 +329,33 @@ internal object DexWebView { } } - /* The switch rides at the top of .content, which React rebuilds on - every tab change — so it is (re)inserted from enhance(), the same - way the data-labels are re-applied. */ - function ensureViewbar() { + /* 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 || content.querySelector('.sky-viewbar')) return; + 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 = @@ -228,10 +365,74 @@ internal object DexWebView { var b = e.target.closest('button[data-view]'); if (b) setMode(b.dataset.view); }); - content.insertBefore(bar, content.firstChild); + 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; @@ -286,7 +487,13 @@ internal object DexWebView { } function enhance() { - ensureViewbar(); + // 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]; @@ -391,12 +598,33 @@ internal object DexWebView { .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. */ + 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. */ - .sky-viewbar { display: flex; justify-content: flex-end; margin: 0 0 0.7rem; } + /* 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; @@ -614,7 +842,18 @@ internal object DexWebView { * this, **Cancel silently does nothing**, which on a screen holding * escrowed coins is the worst possible way to fail. */ - fun chromeClient(): WebChromeClient = object : WebChromeClient() { + 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, @@ -622,7 +861,7 @@ internal object DexWebView { message: String, result: JsResult, ): Boolean { - AlertDialog.Builder(view.context) + builder(view.context) .setMessage(message) .setPositiveButton(android.R.string.ok) { _, _ -> result.confirm() } .setNegativeButton(android.R.string.cancel) { _, _ -> result.cancel() } @@ -637,7 +876,7 @@ internal object DexWebView { message: String, result: JsResult, ): Boolean { - AlertDialog.Builder(view.context) + builder(view.context) .setMessage(message) .setPositiveButton(android.R.string.ok) { _, _ -> result.confirm() } .setOnCancelListener { result.confirm() } 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 index 44cf0952e7..2b44708430 100644 --- 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 @@ -141,9 +141,9 @@ private val SkywireShapes = Shapes( * 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 SkyChat page is the - * one caller today: it has a matching pair of themes and no way to know which - * one the app is in. + * 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 } diff --git a/android/implementation-report.md b/android/implementation-report.md index d0189d674f..0e7a9014ac 100644 --- a/android/implementation-report.md +++ b/android/implementation-report.md @@ -7,6 +7,90 @@ 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:** From 090fcc77c47bd1de2083c0bb631252320aa3c9ec Mon Sep 17 00:00:00 2001 From: "MohammadReza P." Date: Sun, 9 Aug 2026 22:13:47 +0800 Subject: [PATCH 37/37] =?UTF-8?q?fix(dmsg):=20publish=20the=20first=20clie?= =?UTF-8?q?nt=20entry=20even=20when=20the=20read=20shows=20no=20delta=20Ni?= =?UTF-8?q?ne=20e2e=20tests=20failed=20on=20the=20same=20precondition=20?= =?UTF-8?q?=E2=80=94=20"visor=20visor-a=20did=20not=20appear=20in=20DMSG?= =?UTF-8?q?=20discovery=20with=20delegated=20servers=20within=202m0s"=20?= =?UTF-8?q?=E2=80=94=20and=20the=20suite=20then=20blew=20its=2045-minute?= =?UTF-8?q?=20budget=20on=20the=20waiting.=20The=20visors=20were=20healthy?= =?UTF-8?q?;=20they=20were=20simply=20not=20in=20discovery.=20visor-a=20bo?= =?UTF-8?q?ots=20at=2008:58:42=20and=20its=20first=20"Updating=20entry."?= =?UTF-8?q?=20is=20at=2009:03:43,=20exactly=20one=20UpdateInterval=20later?= =?UTF-8?q?,=20with=20nothing=20in=20between=20and=20no=20error=20logged.?= =?UTF-8?q?=20The=20first-session=20publish=20was=20being=20skipped=20sile?= =?UTF-8?q?ntly.=20updateClientEntry=20reads=20the=20current=20entry=20and?= =?UTF-8?q?=20writes=20only=20on=20a=20delta,=20but=20a=20visor's=20disc?= =?UTF-8?q?=20client=20is=20a=20registering=20fallback=20whose=20reads=20r?= =?UTF-8?q?esolve=20direct-first,=20and=20the=20visor=20seeds=20its=20OWN?= =?UTF-8?q?=20pk=20into=20that=20direct=20client,=20where=20GetAllEntries?= =?UTF-8?q?=20synthesizes=20a=20self-entry=20listing=20every=20configured?= =?UTF-8?q?=20server=20as=20delegated.=20The=20read=20describes=20local=20?= =?UTF-8?q?config,=20not=20what=20dmsg-discovery=20holds.=20With=20a=20sin?= =?UTF-8?q?gle=20dmsg=20server=20=E2=80=94=20every=20e2e=20run=20=E2=80=94?= =?UTF-8?q?=20that=20synthetic=20set=20equals=20the=20live=20session=20set?= =?UTF-8?q?,=20so=20the=20delta=20was=20empty=20on=20the=20very=20first=20?= =?UTF-8?q?publish=20and=20the=20client=20stayed=20unregistered=20until=20?= =?UTF-8?q?the=20periodic=20tick.=20In=20the=20log,=20visor-a's=20own=20pk?= =?UTF-8?q?=20never=20once=20falls=20through=20to=20HTTP=20discovery=20whi?= =?UTF-8?q?le=20other=20keys=20do=20so=2097=20times.=20updateClientEntry?= =?UTF-8?q?=20now=20forces=20the=20write=20when=20it=20has=20never=20publi?= =?UTF-8?q?shed=20and=20has=20a=20session=20to=20announce.=20Steady-state?= =?UTF-8?q?=20suppression=20is=20unchanged:=20an=20unchanged=20set=20befor?= =?UTF-8?q?e=20the=20entry=20is=20due=20still=20writes=20nothing,=20which?= =?UTF-8?q?=20is=20what=20keeps=20reconnect=20storms=20off=20the=20discove?= =?UTF-8?q?ry.=20SetDiscoveryClients=20already=20clears=20lastPushedSrvPKs?= =?UTF-8?q?=20for=20the=20same=20reason,=20so=20nil=20is=20the=20establish?= =?UTF-8?q?ed=20"never=20published=20here"=20signal=20rather=20than=20a=20?= =?UTF-8?q?new=20one.=20The=20e2e=20helper's=20freshness=20bound=20moves?= =?UTF-8?q?=20with=20it.=20A=20flat=2060s=20was=20calibrated=20to=20client?= =?UTF-8?q?s=20inheriting=20the=20server's=20DefaultUpdateInterval;=20agai?= =?UTF-8?q?nst=20the=20client's=20own=20five-minute=20cadence=20it=20rejec?= =?UTF-8?q?ted=20a=20good=20entry=20four=20minutes=20out=20of=20five.=20It?= =?UTF-8?q?=20now=20derives=20from=20that=20cadence,=20and=20the=20restart?= =?UTF-8?q?=20test=20=E2=80=94=20which=20does=20need=20proof=20of=20a=20re?= =?UTF-8?q?-registration=20=E2=80=94=20pins=20its=20cutoff=20to=20the=20re?= =?UTF-8?q?start=20itself=20instead=20of=20"now=20=E2=88=92=2060s",=20whic?= =?UTF-8?q?h=20had=20been=20landing=20before=20the=20restart=20it=20was=20?= =?UTF-8?q?verifying.=20The=20publish=20stall=20predates=20the=20cadence?= =?UTF-8?q?=20change=20and=20cost=20a=20rebooted=20visor=20minutes=20of=20?= =?UTF-8?q?being=20undialable;=20the=20five-minute=20interval=20only=20wid?= =?UTF-8?q?ened=20it=20past=20what=20the=20tests=20would=20wait.=20A=20tes?= =?UTF-8?q?t=20pins=20the=20invariant,=20and=20fails=20without=20the=20fix?= =?UTF-8?q?.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/integration/env_test.go | 41 ++++++- internal/integration/restart_test.go | 7 +- pkg/dmsg/dmsg/client_entry_publish_test.go | 129 +++++++++++++++++++++ pkg/dmsg/dmsg/entity_common.go | 31 ++++- 4 files changed, 196 insertions(+), 12 deletions(-) create mode 100644 pkg/dmsg/dmsg/client_entry_publish_test.go diff --git a/internal/integration/env_test.go b/internal/integration/env_test.go index 007491bc38..e4099a162e 100644 --- a/internal/integration/env_test.go +++ b/internal/integration/env_test.go @@ -28,6 +28,7 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/net/proxy" + "github.com/skycoin/skywire/pkg/dmsg/dmsg" "github.com/skycoin/skywire/pkg/logging" "github.com/skycoin/skywire/pkg/routing" "github.com/skycoin/skywire/pkg/servicedisc" @@ -1376,14 +1377,42 @@ func (env *TestEnv) CheckServicesDmsgReachable(timeout time.Duration) error { return nil } +const ( + // dmsgClientPublishInterval mirrors the client cadence set in dmsg.Config.Ensure. + dmsgClientPublishInterval = dmsg.DefaultUpdateInterval * 5 + + // dmsgEntryMaxAge bounds how old a client's discovery entry may be and + // still count as "this visor is registered". A dmsg client publishes as + // soon as its first session is up and then only refreshes on the cadence + // above, so a healthy, settled visor's entry is routinely minutes old — its + // age says nothing about whether the visor is reachable. The bound exists + // solely to reject an entry left behind by a visor that is no longer + // running, so it tracks the publish cadence with one interval of slack for + // a missed tick. + // + // It was previously a flat 60s, which matched the cadence back when clients + // inherited the SERVER's DefaultUpdateInterval. Once clients moved to their + // own 5-minute cadence, that bound rejected a perfectly good entry for four + // minutes out of every five and every test gated on this helper timed out + // against a deployment that was working. + dmsgEntryMaxAge = 2 * dmsgClientPublishInterval +) + // WaitForDmsgDiscoveryEntry waits for a visor's entry to appear in DMSG discovery -// with delegated servers and a recent timestamp. After a graceful visor halt, the old -// entry is deregistered, so this waits for a fresh registration from the restarted visor. +// with delegated servers, published recently enough to belong to the running +// visor rather than to a dead predecessor. +// +// Callers that have just restarted a visor and need proof of a re-registration +// want WaitForFreshDmsgDiscoveryEntry, which pins the cutoff to the restart. func (env *TestEnv) WaitForDmsgDiscoveryEntry(visor string, timeout time.Duration) error { - // Reject entries older than 60s to avoid matching stale entries that - // weren't cleaned up (e.g. if graceful halt failed) - notBefore := time.Now().Add(-60 * time.Second) - return env.waitForDmsgDiscoveryEntryAfter(visor, timeout, notBefore) + return env.waitForDmsgDiscoveryEntryAfter(visor, timeout, time.Now().Add(-dmsgEntryMaxAge)) +} + +// WaitForFreshDmsgDiscoveryEntry waits for a registration published after +// `since` — the moment the visor was restarted — so a stale entry carried over +// from the previous process can never satisfy the wait. +func (env *TestEnv) WaitForFreshDmsgDiscoveryEntry(visor string, timeout time.Duration, since time.Time) error { + return env.waitForDmsgDiscoveryEntryAfter(visor, timeout, since) } func (env *TestEnv) waitForDmsgDiscoveryEntryAfter(visor string, timeout time.Duration, notBefore time.Time) error { diff --git a/internal/integration/restart_test.go b/internal/integration/restart_test.go index 83d02d1742..a6c311c102 100644 --- a/internal/integration/restart_test.go +++ b/internal/integration/restart_test.go @@ -142,12 +142,15 @@ func TestRestart(t *testing.T) { } // Restart visor containers (ContainerRestart polls until containers are running) + restartedAt := time.Now() require.NoError(t, env.ContainerRestart(tc.restartList...)) - // Wait for DMSG to be ready on all restarted visors + // Wait for DMSG to be ready on all restarted visors. The cutoff is the + // restart itself: anything published before that belongs to the process + // we just killed, however recent it looks. for _, visor := range tc.restartList { t.Logf("Waiting for DMSG to be ready on %s", visor) - if err := env.WaitForDmsgDiscoveryEntry(visor, 30*time.Second); err != nil { + if err := env.WaitForFreshDmsgDiscoveryEntry(visor, 30*time.Second, restartedAt); err != nil { t.Logf("Warning: DMSG not ready on %s: %v", visor, err) } t.Logf("DMSG ready on %s", visor) diff --git a/pkg/dmsg/dmsg/client_entry_publish_test.go b/pkg/dmsg/dmsg/client_entry_publish_test.go new file mode 100644 index 0000000000..0e3e2a1e79 --- /dev/null +++ b/pkg/dmsg/dmsg/client_entry_publish_test.go @@ -0,0 +1,129 @@ +// Package dmsg pkg/dmsg/dmsg/client_entry_publish_test.go c1-net-dmsg +package dmsg + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/skycoin/skywire/pkg/cipher" + "github.com/skycoin/skywire/pkg/dmsg/disc" + "github.com/skycoin/skywire/pkg/logging" +) + +// selfEntryDisc answers Entry() for the client's own PK with an entry that +// ALREADY lists delegated servers, and counts the writes it receives. +// +// This is the shape a visor actually sees: its disc client is a registering +// fallback whose reads resolve direct-first, and the visor seeds its own PK +// into that direct client, where direct.GetAllEntries synthesizes a self-entry +// listing every configured server as delegated. The read therefore describes +// local config, not what dmsg-discovery holds. +type selfEntryDisc struct { + disc.APIClient // unused methods panic if ever called + + mx sync.Mutex + selfEntry *disc.Entry + puts int + posts int +} + +func (d *selfEntryDisc) Entry(_ context.Context, _ cipher.PubKey) (*disc.Entry, error) { + d.mx.Lock() + defer d.mx.Unlock() + return d.selfEntry, nil +} + +func (d *selfEntryDisc) PutEntry(_ context.Context, _ cipher.SecKey, _ *disc.Entry) error { + d.mx.Lock() + defer d.mx.Unlock() + d.puts++ + return nil +} + +func (d *selfEntryDisc) PostEntry(_ context.Context, _ *disc.Entry) error { + d.mx.Lock() + defer d.mx.Unlock() + d.posts++ + return nil +} + +func (d *selfEntryDisc) writes() (posts, puts int) { + d.mx.Lock() + defer d.mx.Unlock() + return d.posts, d.puts +} + +// TestUpdateClientEntry_FirstPublishIsNotSkipped is the regression guard for a +// fleet-wide registration stall: a client whose discovery READ already reports +// the delegated servers it was about to publish must still publish, once, +// because the read is not proof that the discovery ever received it. +// +// The failure it pins: with a single dmsg server, the synthetic self-entry the +// direct client serves lists exactly the one server the live session is on, so +// the read-modify-write found "no delta" on the very first publish and returned +// without writing. The client then stayed absent from dmsg-discovery until the +// periodic tick (5 min for clients) — long enough that every e2e test gated on +// "visor appears in DMSG discovery" timed out, and long enough in production +// that a rebooted visor was undialable for minutes. +func TestUpdateClientEntry_FirstPublishIsNotSkipped(t *testing.T) { + pk, sk := cipher.GenerateKeyPair() + srvPK, _ := cipher.GenerateKeyPair() + + dc := &selfEntryDisc{ + // The delegated set the client is about to publish — already present. + selfEntry: disc.NewClientEntry(pk, 1, []cipher.PubKey{srvPK}), + } + + c := new(EntityCommon) + c.init(pk, sk, dc, logging.MustGetLogger("test"), time.Minute*5) + c.sessions[srvPK] = &SessionCommon{rPK: srvPK} + + // initilizeClientEntry records an update before any session exists, so the + // periodic timer is NOT due when the first session lands. Without that the + // due-timer alone would force the write and hide the bug. + c.recordUpdate() + _, due := c.updateIsDue() + require.False(t, due, "test setup: the first publish must race a not-due timer") + + done := make(chan struct{}) + require.NoError(t, c.updateClientEntry(context.Background(), done, "visor")) + + _, puts := dc.writes() + require.Equal(t, 1, puts, "first publish must reach the discovery even when the read shows no delta") + + // Steady state is unchanged: same server set, still not due, no second write. + // This is the guard that keeps reconnect storms off the discovery. + require.NoError(t, c.updateClientEntry(context.Background(), done, "visor")) + _, puts = dc.writes() + require.Equal(t, 1, puts, "an unchanged server set must not republish before the entry is due") + + // A change in the delegated set publishes again immediately. + srv2PK, _ := cipher.GenerateKeyPair() + c.sessions[srv2PK] = &SessionCommon{rPK: srv2PK} + require.NoError(t, c.updateClientEntry(context.Background(), done, "visor")) + _, puts = dc.writes() + require.Equal(t, 2, puts, "a changed server set must republish") +} + +// TestUpdateClientEntry_NoSessionsDoesNotForcePublish keeps the first-publish +// override narrow: it is for a client that has a delegated set to announce, not +// for one whose update loop ticks before any session exists. Publishing an +// empty delegated set would advertise an unreachable client. +func TestUpdateClientEntry_NoSessionsDoesNotForcePublish(t *testing.T) { + pk, sk := cipher.GenerateKeyPair() + + dc := &selfEntryDisc{selfEntry: disc.NewClientEntry(pk, 1, nil)} + + c := new(EntityCommon) + c.init(pk, sk, dc, logging.MustGetLogger("test"), time.Minute*5) + c.recordUpdate() + + require.NoError(t, c.updateClientEntry(context.Background(), make(chan struct{}), "visor")) + posts, puts := dc.writes() + require.Zero(t, puts) + require.Zero(t, posts) +} diff --git a/pkg/dmsg/dmsg/entity_common.go b/pkg/dmsg/dmsg/entity_common.go index 40091fe393..8f81ef4c13 100644 --- a/pkg/dmsg/dmsg/entity_common.go +++ b/pkg/dmsg/dmsg/entity_common.go @@ -781,6 +781,13 @@ func (c *EntityCommon) updateClientEntry(ctx context.Context, done chan struct{} return nil } + // This client has never published a delegated-server set of its own, and it + // now has one to publish: force the write through even if the read-modify- + // write below finds a matching set already on record. See the mustPublish + // argument of updateClientEntryOnEndpoint for why "already matching" is not + // the same as "already published". + mustPublish := lastPushed == nil && len(srvPKs) > 0 + var firstErr error anyOK := false // Re-check shutdown immediately before the publish IO. The work above @@ -796,7 +803,7 @@ func (c *EntityCommon) updateClientEntry(ctx context.Context, done chan struct{} return nil } for _, ep := range endpoints { - if updateErr := c.updateClientEntryOnEndpoint(ctx, ep, clientType, srvPKs); updateErr != nil { + if updateErr := c.updateClientEntryOnEndpoint(ctx, ep, clientType, srvPKs, mustPublish); updateErr != nil { if firstErr == nil { firstErr = updateErr } @@ -820,7 +827,22 @@ func (c *EntityCommon) updateClientEntry(ctx context.Context, done chan struct{} // updateClientEntryOnEndpoint runs the read-modify-write registration // cycle against a single discovery endpoint. -func (c *EntityCommon) updateClientEntryOnEndpoint(ctx context.Context, ep *discoveryEndpoint, clientType string, srvPKs []cipher.PubKey) error { +// +// mustPublish forces the PUT even when the entry read back already carries the +// delegated servers we would write. The read is not proof of publication: a +// visor's disc client is a registering fallback whose READS resolve +// direct-first (pkg/dmsg/dmsgclient/fallback_disc.go), and the visor seeds its +// OWN pk into that direct client (pkg/visor/init_dmsg.go), where +// direct.GetAllEntries synthesizes a self-entry listing every CONFIGURED server +// as delegated. So the entry compared against here can be a local fiction that +// the real dmsg-discovery has never seen. When the configured server set +// happens to equal the live session set — a single-server deployment, i.e. +// every e2e run — sameSrvPKs was true on the very first publish and the client +// silently skipped it, staying absent from discovery until the periodic tick +// (UpdateInterval, 5 min for clients) came round. Callers pass mustPublish for +// the first publish only, so the steady-state suppression that keeps reconnect +// storms off the discovery is unaffected. +func (c *EntityCommon) updateClientEntryOnEndpoint(ctx context.Context, ep *discoveryEndpoint, clientType string, srvPKs []cipher.PubKey, mustPublish bool) error { entry, err := ep.Client.Entry(ctx, c.pk) if err != nil { // Only register a fresh sequence-0 entry when the entry is @@ -853,8 +875,9 @@ func (c *EntityCommon) updateClientEntryOnEndpoint(ctx context.Context, ep *disc // Whether the client's CURRENT delegated servers is the same as what would be advertised. sameSrvPKs := cipher.SamePubKeys(srvPKs, entry.Client.DelegatedServers) - // No update is needed if delegated servers has no delta, and an entry update is not due. - if _, due := c.updateIsDue(); sameSrvPKs && !due { + // No update is needed if delegated servers has no delta, an entry update is + // not due, and this is not the client's first publish. + if _, due := c.updateIsDue(); sameSrvPKs && !due && !mustPublish { return nil }