Merge pull request #362 from OffbandMesh/dev

release: promote dev to main for 1.2.0 (first production promotion)
main v1.2.0
Strycher 15 hours ago committed by GitHub
commit 4ab744ddfe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,75 @@
#!/usr/bin/env bash
# Release-notes gate. Verifies that every human-authored release text exists and
# is well-formed for the version currently in pubspec.yaml. Run BOTH on the cut
# PR (so it goes red before merge) and as the first job of the tag workflow (a
# cheap backstop before build/sign).
#
# rc.2 and rc.3 shipped with only a pubspec bump and no notes. This makes that
# state impossible: no notes, no release.
#
# Exit 0 = all present and valid. Exit 1 = a hard failure (prints every problem
# before exiting, so one run surfaces all of them, not one at a time).
#
# #342, epic #312.
set -uo pipefail
PLAY_MAX=500
fail=0
note() { printf ' %s\n' "$1"; }
bad() { printf '::error::%s\n' "$1"; fail=1; }
# --- version from pubspec (strip the +build suffix) -------------------------
VERSION=$(grep -E '^version:' pubspec.yaml | head -1 | sed 's/version:[[:space:]]*//' | sed 's/+.*//' | tr -d '[:space:]')
if [ -z "${VERSION}" ]; then
bad "Could not read a version from pubspec.yaml."
exit 1
fi
echo "Release gate for version: ${VERSION}"
echo
# --- 1. CHANGELOG has a section for this version ----------------------------
# Matches a markdown heading containing the version, e.g. "## [1.1.2-rc.4]".
if grep -qE "^#+.*\[?${VERSION//./\\.}\]?" CHANGELOG.md; then
note "CHANGELOG.md: section for ${VERSION} present"
else
bad "CHANGELOG.md has no section for ${VERSION}. Add one before releasing."
fi
# --- 2. GitHub release notes exist and are non-empty ------------------------
RN="release-notes/${VERSION}.md"
if [ -s "${RN}" ]; then
note "release-notes: ${RN} present"
else
bad "${RN} is missing or empty. This becomes the GitHub release body."
fi
# --- 3. Play 'What's New' exists and is within the length limit -------------
PLAY="play/${VERSION}.txt"
if [ ! -s "${PLAY}" ]; then
bad "${PLAY} is missing or empty. Required for the Play Console 'What's new'."
else
# Unicode-aware count (wc -m), matching Google's per-character limit.
LEN=$(wc -m < "${PLAY}" | tr -d '[:space:]')
if [ "${LEN}" -gt "${PLAY_MAX}" ]; then
bad "${PLAY} is ${LEN} chars; Play limit is ${PLAY_MAX}. Trim it."
else
note "play: ${PLAY} present (${LEN}/${PLAY_MAX} chars)"
fi
fi
# --- 4. Discord announcement exists and is non-empty ------------------------
DISCORD="discord/${VERSION}.md"
if [ -s "${DISCORD}" ]; then
note "discord: ${DISCORD} present"
else
bad "${DISCORD} is missing or empty. Paste-ready community announcement."
fi
echo
if [ "${fail}" -ne 0 ]; then
echo "Release gate FAILED for ${VERSION}. Fix the above before tagging."
exit 1
fi
echo "Release gate passed for ${VERSION}."

@ -18,7 +18,7 @@ jobs:
java-version: "17"
- uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version: "3.44.1"
cache: true
- name: Cache Gradle
uses: actions/cache@v4
@ -30,7 +30,12 @@ jobs:
restore-keys: |
${{ runner.os }}-gradle-
- run: flutter pub get
- run: flutter build apk --release --no-pub
- run: dart run build_runner build --delete-conflicting-outputs
- name: Write dart_defines.json
env:
GIPHY_API_KEY: ${{ secrets.GIPHY_API_KEY }}
run: jq -n --arg k "$GIPHY_API_KEY" '{GIPHY_API_KEY:$k}' > dart_defines.json
- run: flutter build apk --release --no-pub --dart-define-from-file=dart_defines.json
- name: Upload APK
uses: actions/upload-artifact@v4
with:
@ -45,9 +50,10 @@ jobs:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version: "3.44.1"
cache: true
- run: flutter pub get
- run: dart run build_runner build --delete-conflicting-outputs
- run: flutter build ios --release --no-codesign --no-pub
linux:
@ -56,12 +62,17 @@ jobs:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version: "3.44.1"
cache: true
- name: Install Linux build deps
run: sudo apt-get update && sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev
- run: flutter pub get
- run: flutter build linux --release --no-pub
- run: dart run build_runner build --delete-conflicting-outputs
- name: Write dart_defines.json
env:
GIPHY_API_KEY: ${{ secrets.GIPHY_API_KEY }}
run: jq -n --arg k "$GIPHY_API_KEY" '{GIPHY_API_KEY:$k}' > dart_defines.json
- run: flutter build linux --release --no-pub --dart-define-from-file=dart_defines.json
- name: Upload Linux bundle
uses: actions/upload-artifact@v4
with:
@ -76,9 +87,10 @@ jobs:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version: "3.44.1"
cache: true
- run: flutter pub get
- run: dart run build_runner build --delete-conflicting-outputs
- run: flutter build macos --release --no-pub
web:
@ -87,10 +99,15 @@ jobs:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version: "3.44.1"
cache: true
- run: flutter pub get
- run: flutter build web --release --no-pub
- run: dart run build_runner build --delete-conflicting-outputs
- name: Write dart_defines.json
env:
GIPHY_API_KEY: ${{ secrets.GIPHY_API_KEY }}
run: jq -n --arg k "$GIPHY_API_KEY" '{GIPHY_API_KEY:$k}' > dart_defines.json
- run: flutter build web --release --no-pub --dart-define-from-file=dart_defines.json
- name: Upload web build
uses: actions/upload-artifact@v4
with:
@ -105,10 +122,19 @@ jobs:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version: "3.44.1"
cache: true
- run: flutter pub get
- run: flutter build windows --release --no-pub
- run: dart run build_runner build --delete-conflicting-outputs
# Windows runner defaults to PowerShell; jq is not guaranteed there,
# so build the JSON with ConvertTo-Json, which escapes correctly.
- name: Write dart_defines.json
env:
GIPHY_API_KEY: ${{ secrets.GIPHY_API_KEY }}
run: |
@{ GIPHY_API_KEY = $env:GIPHY_API_KEY } | ConvertTo-Json -Compress |
Set-Content -Path dart_defines.json -Encoding utf8 -NoNewline
- run: flutter build windows --release --no-pub --dart-define-from-file=dart_defines.json
- name: Upload Windows build
uses: actions/upload-artifact@v4
with:

@ -0,0 +1,94 @@
name: Configure offband.app domains
# One-time (idempotent) setup: attaches custom domains to the offband-app
# Pages project and creates the proxied CNAME records.
#
# offband.app -> offband-app.pages.dev (production branch)
# dev.offband.app -> dev.offband-app.pages.dev (dev branch alias)
#
# The dev record deliberately targets the BRANCH ALIAS rather than the
# project root. Cloudflare's documented behaviour: a custom domain CNAME'd
# to <project>.pages.dev always serves production, so pointing dev at
# dev.<project>.pages.dev is what makes it serve the dev branch. This only
# works with Cloudflare-managed DNS and a PROXIED record; an unproxied or
# external record silently falls through to production.
#
# Manual trigger only. Domains do not need reconfiguring on every deploy,
# and this touches DNS.
#
# Mirrors OffbandMesh/offband-site/.github/workflows/configure-domains.yml,
# which is the working reference for offband.org.
#
# NOTE: offband.org belongs to a DIFFERENT Pages project (offband-site) in
# the same Cloudflare account. This workflow only ever touches the
# offband.app zone. See #339.
on:
workflow_dispatch:
permissions:
contents: read
jobs:
domains:
runs-on: ubuntu-latest
env:
API: https://api.cloudflare.com/client/v4
ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
PAGES_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
DNS_TOKEN: ${{ secrets.CLOUDFLARE_DNS_ZONE_TOKEN }}
PROJECT: offband-app
ZONE: offband.app
steps:
- name: Attach custom domains and create DNS records
run: |
set -euo pipefail
ZID=$(curl -s -H "Authorization: Bearer $DNS_TOKEN" \
"$API/zones?name=$ZONE" | jq -r '.result[0].id // empty')
if [ -z "$ZID" ]; then
echo "::error::Could not resolve zone id for $ZONE. Check CLOUDFLARE_DNS_ZONE_TOKEN has Zone:Read."
exit 1
fi
echo "zone $ZONE -> ${ZID:0:6}…"
# name -> CNAME target. Apex serves production; dev serves the
# dev branch alias. Cloudflare flattens the apex CNAME.
attach() {
NAME="$1"; TARGET="$2"
echo "── $NAME -> $TARGET ──"
REC=$(curl -s -H "Authorization: Bearer $DNS_TOKEN" \
"$API/zones/$ZID/dns_records?type=CNAME&name=$NAME" \
| jq -r '.result[0].id // empty')
BODY=$(jq -n --arg n "$NAME" --arg c "$TARGET" \
'{type:"CNAME",name:$n,content:$c,proxied:true}')
if [ -n "$REC" ]; then
curl -s -X PUT -H "Authorization: Bearer $DNS_TOKEN" \
-H "Content-Type: application/json" \
"$API/zones/$ZID/dns_records/$REC" --data "$BODY" \
| jq -r 'if .success then " DNS: updated" else " DNS ERR: " + (.errors|tostring) end'
else
curl -s -X POST -H "Authorization: Bearer $DNS_TOKEN" \
-H "Content-Type: application/json" \
"$API/zones/$ZID/dns_records" --data "$BODY" \
| jq -r 'if .success then " DNS: created" else " DNS ERR: " + (.errors|tostring) end'
fi
curl -s -X POST -H "Authorization: Bearer $PAGES_TOKEN" \
-H "Content-Type: application/json" \
"$API/accounts/$ACCOUNT_ID/pages/projects/$PROJECT/domains" \
--data "$(jq -n --arg n "$NAME" '{name:$n}')" \
| jq -r 'if .success then " Pages: attached (" + (.result.status // "pending") + ")" else " Pages: " + (.errors|tostring) end'
}
attach "$ZONE" "${PROJECT}.pages.dev"
attach "dev.$ZONE" "dev.${PROJECT}.pages.dev"
curl -s -X PATCH -H "Authorization: Bearer $DNS_TOKEN" \
-H "Content-Type: application/json" \
"$API/zones/$ZID/settings/always_use_https" --data '{"value":"on"}' \
| jq -r 'if .success then "AlwaysHTTPS: on" else "AlwaysHTTPS (skip): " + (.errors|tostring) end'
echo "configure-web-domains complete"

@ -0,0 +1,112 @@
name: Deploy web (Cloudflare Pages)
# Publishes the Flutter web client to Cloudflare Pages.
#
# push to main -> --branch=main -> PRODUCTION -> offband.app
# push to dev -> --branch=dev -> dev tier -> dev.offband.app
#
# WHY THE TRIGGER LIST IS THE SAFETY MECHANISM:
# Direct-upload Pages projects cannot configure production branch controls
# in the Cloudflare dashboard, so the "never auto-deploy arbitrary branches
# to prod" requirement (#122) is enforced HERE, by the trigger scope. Adding
# a branch to `on.push.branches` is what grants it deploy rights. Do not add
# one casually, and never add `pull_request`.
#
# DO NOT enable Cloudflare's dashboard "Connect to Git" for this project. It
# creates a second, git-integrated project that races with this workflow.
# Deploys are Actions direct-upload only, matching offband-site.
#
# The marketing site (offband.org) is a SEPARATE Pages project in the same
# account, deployed from OffbandMesh/offband-site. Nothing here touches it.
#
# See #339, epic #312.
on:
push:
branches:
- main
- dev
workflow_dispatch:
concurrency:
# Serialise per target so two pushes cannot race the same environment,
# but let dev and main deploy independently.
group: deploy-web-${{ github.ref_name }}
cancel-in-progress: true
permissions:
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Pinned to the bench toolchain, same as every other workflow (#331).
- uses: subosito/flutter-action@v2
with:
flutter-version: "3.44.1"
cache: true
- run: flutter pub get
- run: dart run build_runner build --delete-conflicting-outputs
# Same mechanism as build.yml (#331). build_pipe's build_command in
# pubspec.yaml passes --dart-define-from-file, so this file must exist
# before the build runs or the GIF picker ships dead.
- name: Write dart_defines.json
env:
GIPHY_API_KEY: ${{ secrets.GIPHY_API_KEY }}
run: jq -n --arg k "$GIPHY_API_KEY" '{GIPHY_API_KEY:$k}' > dart_defines.json
# build_pipe, NOT a plain `flutter build web`. It appends ?v=<pubspec
# version> to the bootstrap/JS files, which is what stops browsers
# serving stale code after a deploy. A plain flutter build has no cache
# busting and would leave users on old bundles.
- name: Build web (versioned, cache-busted)
run: dart run build_pipe:build
- name: Verify build output
run: |
set -eu
test -f build/web/index.html || { echo "::error::build/web/index.html missing"; exit 1; }
COUNT=$(find build/web -type f | wc -l)
BIGGEST=$(find build/web -type f -printf '%s\n' | sort -rn | head -1)
echo "files: ${COUNT}, largest: $((BIGGEST / 1048576)) MB"
# Cloudflare Pages hard limits: 25 MiB per asset, 20000 files.
# Fail here with a clear message rather than mid-upload.
if [ "${BIGGEST}" -gt 26214400 ]; then
echo "::error::An asset exceeds the Cloudflare Pages 25 MiB limit."
find build/web -type f -size +25M -printf ' %s %p\n'
exit 1
fi
if [ "${COUNT}" -gt 20000 ]; then
echo "::error::More than 20000 files; exceeds the Cloudflare Pages limit."
exit 1
fi
grep -q 'v=' build/web/index.html \
&& echo "cache-busting query params present" \
|| echo "::warning::no ?v= found in index.html; check build_pipe config"
- name: Deploy to Cloudflare Pages
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
set -eu
npx --yes wrangler@3 pages project create offband-app \
--production-branch=main \
|| echo "project already exists, continuing"
npx --yes wrangler@3 pages deploy build/web \
--project-name=offband-app \
--branch="${GITHUB_REF_NAME}" \
--commit-dirty=true
- name: Report target
run: |
if [ "${GITHUB_REF_NAME}" = "main" ]; then
echo "Deployed PRODUCTION -> https://offband.app"
else
echo "Deployed dev tier -> https://dev.offband.app"
fi

@ -1,41 +0,0 @@
name: Deploy to Cloudflare Workers
# Manual-only. This workflow was inherited from upstream zjs81 and deploys the web
# build to *its* Cloudflare account; this fork has no Cloudflare secrets, so the old
# `push: tags: ['*']` trigger failed on every tag (missing CLOUDFLARE_API_TOKEN).
# Disabled the auto-trigger to stop the per-tag failures (chore #120). The job is
# preserved for the deliberate Offband web-app launch (Feature #121 / Epic #122),
# which will define the proper trigger scope + secrets.
on:
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: 'stable'
# Match local development version which provides Dart 3.11.0
flutter-version: '3.41.2'
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Get dependencies
run: flutter pub get
- name: Build Web
run: bun run build
- name: Deploy to Cloudflare
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: deploy

@ -17,11 +17,14 @@ jobs:
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
flutter-version: "3.44.1"
- name: Install dependencies
run: flutter pub get
- name: Generate code (drift / build_runner)
run: dart run build_runner build --delete-conflicting-outputs
- name: Analyze code
run: flutter analyze --fatal-infos --fatal-warnings

@ -0,0 +1,27 @@
name: Release gate
# Fails a PR whose pubspec version lacks any of the four human-authored release
# texts (CHANGELOG section, release-notes, play blurb <=500 chars, discord).
#
# This runs on EVERY PR so a release-cut PR goes red before merge if a note is
# missing. It is cheap and platform-agnostic. The tag workflow runs the same
# script as its first job (a backstop before build/sign).
#
# rc.2 and rc.3 shipped with only a pubspec bump. This makes that unmergeable.
#
# #342, epic #312.
on:
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Check release texts for the pubspec version
run: bash .github/scripts/release-gate.sh

@ -0,0 +1,304 @@
name: Release (signed, all platforms)
# Produces a COMPLETE, published release from a `v*` tag: signed Android APK +
# AAB, Windows, and Linux, attached to a GitHub release whose body is the
# human-authored release notes. This is what makes a partial release (the
# rc.2/rc.3 failure) impossible.
#
# SECURITY: this repo is PUBLIC and the `android` job has the signing keystore,
# the single most irreplaceable credential in the project. If it leaks the app
# can never be updated again on any channel.
#
# - NEVER add a `pull_request` trigger. A PR (incl. from a fork) must never
# run a job that can read the keystore.
# - Only the `android` job references the `release-signing` Environment, so
# ONLY it receives the keystore secrets, and only after the required
# reviewer approves. The windows/linux/release jobs never see them.
# - Keystore written to disk only for the build, deleted with `if: always()`.
# - Signing values passed via env:, never on a command line, never echoed.
#
# Debug-signed artifacts for day-to-day PR testing still come from build.yml
# and are unaffected. See #330 / #342, epic #312.
on:
push:
tags:
- "v*"
workflow_dispatch:
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: read
jobs:
# Cheap backstop: the same gate that runs on the cut PR, re-run here so a tag
# can never publish without the four release texts. Fails in seconds, before
# any expensive build.
gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Release-notes gate
run: bash .github/scripts/release-gate.sh
android:
needs: gate
runs-on: ubuntu-latest
environment: release-signing # required-reviewer gate; do not remove
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: "temurin"
java-version: "17"
- uses: subosito/flutter-action@v2
with:
flutter-version: "3.44.1"
cache: true
- name: Cache Gradle
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('android/gradle/wrapper/gradle-wrapper.properties', 'android/build.gradle', 'android/settings.gradle', 'android/app/build.gradle', 'pubspec.lock') }}
restore-keys: |
${{ runner.os }}-gradle-
# PKCS12 keystore (despite the .jks extension) has one password for both
# store and key. See #330.
- name: Decode signing keystore
env:
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
run: |
set -eu
if [ -z "${KEYSTORE_BASE64}" ] || [ -z "${KEYSTORE_PASSWORD}" ]; then
echo "::error::Signing secrets are not available to this run."
exit 1
fi
printf '%s' "${KEYSTORE_BASE64}" | base64 -d > android/app/release.jks
SIZE=$(stat -c%s android/app/release.jks)
if [ "${SIZE}" -lt 1000 ]; then
echo "::error::Decoded keystore is ${SIZE} bytes; expected ~2.8 KB. Secret is malformed."
exit 1
fi
umask 077
cat > android/key.properties <<'PROPS'
storeFile=release.jks
keyAlias=meshcore
PROPS
{
printf 'storePassword=%s\n' "${KEYSTORE_PASSWORD}"
printf 'keyPassword=%s\n' "${KEYSTORE_PASSWORD}"
} >> android/key.properties
echo "Keystore decoded (${SIZE} bytes), key.properties written."
- run: flutter pub get
- run: dart run build_runner build --delete-conflicting-outputs
- name: Write dart_defines.json
env:
GIPHY_API_KEY: ${{ secrets.GIPHY_API_KEY }}
run: jq -n --arg k "$GIPHY_API_KEY" '{GIPHY_API_KEY:$k}' > dart_defines.json
- name: Build signed APK
run: flutter build apk --release --no-pub --dart-define-from-file=dart_defines.json
- name: Build signed AAB
run: flutter build appbundle --release --no-pub --dart-define-from-file=dart_defines.json
# A green build does NOT prove release signing: build.gradle.kts silently
# falls back to the debug config when key.properties is absent (#111). Read
# the cert off the APK and fail on the debug key or any mismatch.
- name: Verify APK signing certificate
env:
EXPECTED_SHA256: e7da8cd5bf22ac3eda7cb954b8b120a18b6c4382d1b6e6fdd204ddedddaf5488
run: |
set -eu
APK=build/app/outputs/flutter-apk/app-release.apk
BUILD_TOOLS=$(ls -d "${ANDROID_HOME}"/build-tools/* | sort -V | tail -1)
CERTS=$("${BUILD_TOOLS}/apksigner" verify --print-certs "${APK}")
echo "--- apksigner --print-certs output ---"
echo "${CERTS}"
echo "--------------------------------------"
if echo "${CERTS}" | grep -qi "CN=Android Debug"; then
echo "::error::APK is DEBUG-SIGNED. key.properties not picked up; see #111."
exit 1
fi
# Extract the fingerprint by shape, not by label: only the cert's
# SHA-256 digest is 64 hex chars (SHA-1 is 40, MD5 is 32), so this is
# unambiguous and independent of how a given apksigner version phrases
# the line. The label-based awk parse broke on the runner's apksigner
# (#345 first live run: label differed, ACTUAL came back empty).
ACTUAL=$(echo "${CERTS}" | grep -ioE '[0-9a-f]{64}' | head -1 | tr 'A-F' 'a-f')
if [ -z "${ACTUAL}" ]; then
echo "::error::Could not read a SHA-256 fingerprint from the APK (see output above)."
exit 1
fi
if [ "${ACTUAL}" != "${EXPECTED_SHA256}" ]; then
echo "::error::Signing cert MISMATCH. expected ${EXPECTED_SHA256} actual ${ACTUAL}"
exit 1
fi
echo "APK signing certificate verified: ${ACTUAL}"
# The AAB is the artifact that goes to Play, so verify IT directly rather
# than trusting the APK as a proxy. apksigner cannot read an AAB; keytool
# can, and reports the same SHA-256 fingerprint. Same pin as the APK.
# (Gemini review, #342.)
- name: Verify AAB signing certificate
env:
EXPECTED_SHA256: e7da8cd5bf22ac3eda7cb954b8b120a18b6c4382d1b6e6fdd204ddedddaf5488
run: |
set -eu
AAB=build/app/outputs/bundle/release/app-release.aab
# keytool comes from the JDK that setup-java put on PATH in this job.
echo "--- keytool -printcert -jarfile output ---"
keytool -printcert -jarfile "${AAB}" | grep -iE 'Owner|SHA256' || true
echo "------------------------------------------"
# Strip the label, keep only hex (robust to spacing/case), lowercase.
AAB_SHA=$(keytool -printcert -jarfile "${AAB}" \
| grep -i 'SHA256:' | head -1 \
| sed 's/.*SHA256://' | tr -cd '0-9a-fA-F' | tr 'A-F' 'a-f')
if [ -z "${AAB_SHA}" ]; then
echo "::error::Could not read a SHA-256 from the AAB signing cert (see output above)."
exit 1
fi
if [ "${AAB_SHA}" != "${EXPECTED_SHA256}" ]; then
echo "::error::AAB signing cert MISMATCH. expected ${EXPECTED_SHA256} actual ${AAB_SHA}"
echo "::error::This AAB would be rejected by Play App Signing or break cross-channel updates."
exit 1
fi
echo "AAB signing certificate verified: ${AAB_SHA}"
- name: Stage signed artifacts
run: |
mkdir -p dist
cp build/app/outputs/flutter-apk/app-release.apk dist/
cp build/app/outputs/bundle/release/app-release.aab dist/
- uses: actions/upload-artifact@v4
with:
name: release-android
path: dist/*
if-no-files-found: error
retention-days: 7
- name: Remove keystore material
if: always()
run: |
rm -f android/app/release.jks android/key.properties dart_defines.json
echo "Keystore material removed."
windows:
needs: gate
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
flutter-version: "3.44.1"
cache: true
- run: flutter pub get
- run: dart run build_runner build --delete-conflicting-outputs
- name: Write dart_defines.json
env:
GIPHY_API_KEY: ${{ secrets.GIPHY_API_KEY }}
run: |
@{ GIPHY_API_KEY = $env:GIPHY_API_KEY } | ConvertTo-Json -Compress |
Set-Content -Path dart_defines.json -Encoding utf8 -NoNewline
- run: flutter build windows --release --no-pub --dart-define-from-file=dart_defines.json
- name: Zip Windows build
run: Compress-Archive -Path build/windows/x64/runner/Release/* -DestinationPath offband-windows-x64.zip
- uses: actions/upload-artifact@v4
with:
name: release-windows
path: offband-windows-x64.zip
if-no-files-found: error
retention-days: 7
linux:
needs: gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
flutter-version: "3.44.1"
cache: true
- name: Install Linux build deps
run: sudo apt-get update && sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev
- run: flutter pub get
- run: dart run build_runner build --delete-conflicting-outputs
- name: Write dart_defines.json
env:
GIPHY_API_KEY: ${{ secrets.GIPHY_API_KEY }}
run: jq -n --arg k "$GIPHY_API_KEY" '{GIPHY_API_KEY:$k}' > dart_defines.json
- run: flutter build linux --release --no-pub --dart-define-from-file=dart_defines.json
- name: Tar Linux bundle
run: tar -czf offband-linux-x64.tar.gz -C build/linux/x64/release/bundle .
- uses: actions/upload-artifact@v4
with:
name: release-linux
path: offband-linux-x64.tar.gz
if-no-files-found: error
retention-days: 7
# Creates the release ONLY after all platform builds (and the signing cert
# check) have passed, so a signing failure yields no release rather than an
# empty one. Published directly: the notes were reviewed in the cut PR.
release:
needs: [android, windows, linux]
runs-on: ubuntu-latest
permissions:
contents: write # create the release + upload assets
steps:
- uses: actions/checkout@v4
- name: Resolve version + notes
id: v
run: |
set -eu
VERSION=$(grep -E '^version:' pubspec.yaml | head -1 | sed 's/version:[[:space:]]*//' | sed 's/+.*//' | tr -d '[:space:]')
NOTES="release-notes/${VERSION}.md"
test -s "${NOTES}" || { echo "::error::${NOTES} missing at release time"; exit 1; }
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "notes=${NOTES}" >> "$GITHUB_OUTPUT"
- uses: actions/download-artifact@v4
with:
path: incoming
- name: Collect assets
run: |
mkdir -p out
cp incoming/release-android/* out/
cp incoming/release-windows/* out/
cp incoming/release-linux/* out/
echo "Assets for the release:"
ls -la out/
# Draft-first so publication is ATOMIC: the release is not visible to
# anyone until every asset is uploaded. Creating-then-uploading would
# leave a public release with missing assets if an upload stalls or the
# runner dies mid-way — the exact partial-release failure this pipeline
# exists to prevent. `gh release create --help` documents this pattern.
# The final `--draft=false` is applied in BOTH branches so a run retried
# after a mid-way death still ends published, never stuck as a draft.
- name: Create GitHub release (draft → upload → publish)
env:
GH_TOKEN: ${{ github.token }}
NOTES: ${{ steps.v.outputs.notes }}
VERSION: ${{ steps.v.outputs.version }}
run: |
set -eu
TAG="${GITHUB_REF_NAME}"
if gh release view "${TAG}" >/dev/null 2>&1; then
# Retry / re-run: refresh assets and notes, then ensure published.
gh release upload "${TAG}" out/* --clobber
gh release edit "${TAG}" --notes-file "${NOTES}" --prerelease --draft=false
else
gh release create "${TAG}" \
--draft \
--title "Offband Meshcore ${VERSION}" \
--notes-file "${NOTES}" \
--prerelease
gh release upload "${TAG}" out/*
gh release edit "${TAG}" --draft=false
fi
echo "Release ${TAG} published with $(ls out | wc -l) assets."

1
.gitignore vendored

@ -30,7 +30,6 @@ migrate_working_dir/
.flutter-plugins-dependencies
.pub-cache/
.pub/
pubspec.lock
/build/
/coverage/
# fvm project files

@ -2,6 +2,108 @@
All notable changes to Offband Meshcore. Pre-releases are tagged `-beta.N` / `-rc.N`.
## [1.2.0] - 2026-07-21
A major uplift: the storage engine moved to a real database, the app got a new
navigation shell, and GIFs are now shareable across MeshCore clients. First
release cut end-to-end through CI (signed, all platforms).
### Storage
- **Bulk data (messages, contacts, channels, and more) now lives in a drift /
SQLite database** instead of individual SharedPreferences entries. Existing
data is **migrated automatically on first launch** and preserved, not
discarded (#335, #355, #348).
- The database is stored in the app-support directory, so it is not caught up in
OneDrive/Documents sync on desktop (#335).
- Message history is kept in full; only the in-memory window is bounded, so
reopening a conversation no longer risks losing older messages (#343).
- Storage failures that used to fail silently now surface (#306, #333).
### Navigation
- **New app shell with a hamburger nav drawer that can be pinned open** (#292).
- The channel list lives in the drawer and is switchable from inside a channel;
channels swap in place so a pinned panel stays put (#289).
- Map layer toggles moved into the nav panel; Disconnect and Settings moved into
the panel footer (#291, #290).
- Contacts gained a prebuilt filter rail, including a Sensors type (#307, #308).
- Back-button behavior fixed: back now backgrounds the app instead of killing
it, and no longer drops a user off a connected radio (#291).
### GIFs
- GIFs are now shared as a **Giphy URL** instead of the Offband-only `g:<code>`,
so other MeshCore clients can open them (#282).
- Pasted Giphy / Tenor URLs render inline, behind a trusted-host allowlist
(#283, #284).
### Other features
- Desktop window position and size are remembered across launches (#349).
- Reach a contact's settings from its long-press menu (#351).
- FEM LNA toggle in Radio Settings and Offband capabilities shown in Device Info,
gated on firmware support (#304).
- Message arrival time (rxTime) is logged and persisted, distinct from the
claimed send time (#285).
### Fixes
- You can no longer accidentally block your own node (#250).
- Path length decodes per the firmware contract (width-aware), and path
diagnostics report units truthfully (#309, #298).
- Connecting no longer rewrites the entire preferences file per channel (#306).
### Under the hood
- Offband is now available as a web app at offband.app, and every release is
built, signed, and published across Android, Windows, Linux, and web through CI.
## [1.1.2-rc.3] - 2026-07-17
Combined entry for rc.2 (versionCode 59) and rc.3 (versionCode 60). Both shipped
to the Play closed test with only a version bump; this backfills the changelog
they should have carried.
### Added
- Block / mute system: block a contact or a channel sender from contacts, DMs,
discovery, or a channel message; blocked DMs and adverts are suppressed with a
visible marker, and a name-only block promotes to the sender's public key on
the next advert or DM and ages out after 30 days. Managed from Settings →
Blocked. Synced to firmware on connect where the radio supports it (#168#180,
#172#174, #169, #170).
- Per-channel notification modes: a 3-way selector (all / mentions / mute) on the
Channels screen, applied at the notification gate and stored keyed to the
channel PSK (#260, #261, #262).
- Path trace and topology: width-matched path tracing, a topology service and
readout, RX ingestion, and a route-builder UX that groups hops by hash width
rather than raw bytes (#150, #186, #156, #155, #224).
- Channel QR: share a channel as a scannable QR and scan one to add it, unified
with the community scanner and using the reference-app format (#163).
- Own device public key is now full, selectable, and one-tap copyable (#234).
- Reply-to chip shown above a reply-gif, with inline `@[Name]` mention chips
(#235).
- GPS refresh falls back to stock-firmware self-telemetry when the fork-specific
query is unavailable, chosen by firmware capability (#123).
- Queue-sync diagnostics: a file log of queue-drain activity plus a manual
force-drain action (#51).
### Fixed
- USB open no longer wedges ESP32-C6 boards: the DTR open pulse is gated by USB
vendor ID, so it fires only for nRF52 reconnect and is skipped for chips it
would reset into ROM download mode (#248).
- Outgoing DMs to a blocked contact are dropped at the send path (#252).
- Contact path-length byte decodes at the device's hash width instead of being
read as a raw byte count (#224).
- Stale channel slot history is cleared on channel reassignment, and the message
store's device-key scope is aligned (#193, #194).
- Enter selects the highlighted emoji or mention in autocomplete (#238).
- Manual path entry honors the configured path-hash width (#155).
### Changed
- The fork-only GPS query is gated on firmware capability, so stock and
non-Offband radios degrade gracefully instead of erroring (#123, #144).
## [1.1.2-rc.1] - 2026-06-28
### Added

@ -2,13 +2,32 @@ package com.meshcore.meshcore_open
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity() {
private val usbFunctions by lazy { MeshcoreUsbFunctions(this) }
private val appLifecycleChannelName = "meshcore_open/app_lifecycle"
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
usbFunctions.configureFlutterEngine(flutterEngine)
MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
appLifecycleChannelName
).setMethodCallHandler { call, result ->
when (call.method) {
// Send the task to the background WITHOUT finishing the
// activity. SystemNavigator.pop() calls finish(), which tears
// down the Flutter engine and drops the radio connection, so
// reopening the app lands on a disconnected radio.
"moveTaskToBack" -> {
result.success(moveTaskToBack(true))
}
else -> result.notImplemented()
}
}
}
override fun onDestroy() {

@ -0,0 +1,13 @@
**Offband Meshcore 1.1.2-rc.3 is out** 📡
Big one this round:
🚫 **Block & mute** — block contacts or channel senders from just about anywhere (contacts, DMs, discovery, a channel message). Blocked stuff is hidden with a marker, name-only blocks upgrade to a real key when the sender shows up again, and there's a Blocked screen in Settings to manage it all. Syncs to the radio where firmware supports it.
🔔 **Per-channel notifications** — set each channel to all / mentions only / muted.
🗺️ **Path trace + topology** — width-matched traces, a topology readout, and a route builder that groups hops properly. Plus share/add channels by QR.
🔧 Fixes: USB no longer wedges some ESP32-C6 boards, DMs to a blocked contact actually stop sending, and more radios report GPS without flooding the link.
Download for your platform below. Full changelog in the repo.

@ -0,0 +1,20 @@
🚀 **Offband Meshcore 1.2.0 is here, and it's our biggest release yet.**
A ground-up interface redesign, a real database under the hood, GIFs that work across every MeshCore client, and hands-on control of your radio's RF front end. This is also the first Offband release built, signed, and shipped to every platform automatically.
🎨 **A brand-new interface.** The headline feature. There's a new **left rail** you open from the hamburger button and can **pin open**, so it lives right alongside your chat instead of covering it. Your channels live in the rail and switch **in place**, so you never lose your spot. Map toggles, Disconnect, and Settings all found sensible new homes, and the back button finally behaves.
🗄️ **Your data on solid ground.** Messages, contacts, and channels now live in a real **SQLite database** instead of scattered preference files. Your existing data migrates automatically on first launch, nothing is thrown away, and full message history is preserved. On desktop it no longer fights with OneDrive/Documents sync.
🎞️ **GIFs everyone can see.** GIFs now send as a **Giphy link** instead of an Offband-only code, so people on **stock MeshCore and other clients can tap and watch them** too. Pasted **Giphy and Tenor** links render inline.
📡 **Take command of your radio's front end.** On supported hardware, you can now toggle **FEM / LNA** right from Radio Settings, and Device Info reads out your radio's Offband capabilities.
⚡ Plus a **snappier app** from the new storage engine, **desktop window position** remembered between launches, contact settings from the long-press menu, deeper mesh diagnostics, and you can't accidentally block your own node anymore.
**📥 Download for your platform:** https://github.com/OffbandMesh/meshcore-client/releases/tag/v1.2.0
🌐 **Or run it right in your browser, no install:** https://offband.app
🔗 Project home: https://offband.org
📝 Full changelog is in the release notes at the link above.
❤️ **A few of you have asked how to chip in, so I've put up a donation page:** https://offband.org/donate. Offband is free and open source and always will be. Anything there just helps keep the project going, and helps cover getting it onto the iOS App Store. Thank you all for being part of this.

@ -0,0 +1,14 @@
# Discord announcement
One file per release: `discord/<version>.md`, e.g. `discord/1.1.2-rc.4.md`.
## Style
Casual, paste-ready community announcement. What landed, in plain excited-but-honest
terms, with the download link. Emoji fine. This is the message the owner posts to Discord.
**The owner pastes this into Discord manually.** CI validates the file exists and is
non-empty; it never posts. Posting to a community is an external, human-triggered action.
The release gate (`scripts/release-gate.sh`) fails the build if this file is missing or
empty for the version being tagged.

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

@ -22,6 +22,7 @@ import '../helpers/time_anomaly.dart';
import '../helpers/cyr2lat.dart';
import '../helpers/smaz.dart';
import '../helpers/cayenne_lpp.dart';
import '../helpers/path_helper.dart';
import '../services/app_debug_log_service.dart';
import '../services/ble_debug_log_service.dart';
import '../services/linux_ble_error_classifier.dart';
@ -281,6 +282,7 @@ class MeshCoreConnector extends ChangeNotifier {
String? _firmwareVersion;
String? _deviceModel;
int? _offbandCaps;
bool? _femLnaEnabled;
int _pathHashByteWidth = 1;
CompanionRadioStats? _latestRadioStats;
Stopwatch? _airtimeBumpStopwatch;
@ -491,6 +493,36 @@ class MeshCoreConnector extends ChangeNotifier {
int get pathHashByteWidth => _pathHashByteWidth;
/// Compact path rendering for logs: hop count, applied width, the bytes we
/// actually hold versus the bytes that hop count implies, and the hex.
///
/// Captures previously logged only a length, which made it impossible to tell
/// a single 2-byte hop from two 1-byte hops. That is the exact question
/// #240/#279 turn on.
///
/// [pathLenField] is the firmware path-len field's low 6 bits, which is a HOP
/// COUNT, not a byte length: firmware `src/Packet.h:79-84` defines
/// `getPathByteLen() == getPathHashCount() * getPathHashSize()`. Logging the
/// held byte count next to the implied one makes the #309 decode truncation
/// self-evident in any capture, without the device present.
///
/// One line, existing log sites only, no new per-frame logging. (#298)
/// [pathHashWidth] must be the width the path was CAPTURED at (each Contact
/// carries its own), not the connected device's current global width. Using
/// the global one falsely flags a complete 1-byte-width path as TRUNCATED
/// when the client is connected to a 2-byte device. (#309, Gemini review)
String _pathDiag(List<int> pathBytes, int pathLenField, int pathHashWidth) {
if (pathLenField < 0) return 'flood';
final w = pathHashWidth < 1 ? 1 : pathHashWidth;
final expectedBytes = pathLenField * w;
final hex = pathBytes.isEmpty
? 'none'
: PathHelper.formatPathHex(pathBytes, w);
final short = pathBytes.length < expectedBytes ? ' TRUNCATED' : '';
return 'hops=$pathLenField w=$w '
'bytes=${pathBytes.length}/$expectedBytes$short [$hex]';
}
CompanionRadioStats? get latestRadioStats => _latestRadioStats;
bool get supportsCompanionRadioStats => (_firmwareVerCode ?? 0) >= 8;
@ -558,6 +590,48 @@ class MeshCoreConnector extends ChangeNotifier {
/// True when the radio's block store hit `MAX_BLOCKED_KEYS` (32) and rejected
/// an ADD (ok=0). Local block still applies; those keys just aren't portable.
bool get blockOffloadStoreFull => _blockOffloadStoreFull;
/// Whether this specific radio can control its external FEM LNA (#304).
///
/// Gated on the capability BIT alone firmware derives it from a runtime FEM
/// probe, so it is a per-unit answer: two Heltec V4s can legitimately disagree
/// depending on the fitted chip, and `rak3401` reports false by design (its
/// SKY66122 gates LNA and PA off one line, so a toggle would kill TX).
/// Never shortcut this to a model or version check.
bool get supportsOffbandFemLna => firmwareSupportsOffbandFemLna(_offbandCaps);
/// Current FEM LNA state as last reported by the radio, or null if unknown
/// (pre-v16 firmware). Always reflects hardware truth, never a local guess.
bool? get femLnaEnabled => _femLnaEnabled;
/// Ask the radio to enable or bypass its FEM LNA. No-op unless the capability
/// bit is set, so a non-capable radio never sees `0xC3` traffic. State is
/// updated from the reply, not optimistically.
Future<void> setFemLna(bool enabled) async {
if (!isConnected || !supportsOffbandFemLna) return;
await sendFrame(buildOffbandFemLnaSetFrame(enabled));
}
/// Fallback read; device-info (offset 83) is the primary source on connect.
Future<void> requestFemLnaState() async {
if (!isConnected || !supportsOffbandFemLna) return;
await sendFrame(buildOffbandFemLnaGetFrame());
}
/// `[0xC3][sub][value]` the value is the post-apply hardware state, so it is
/// adopted verbatim rather than assuming a SET took effect (#304).
void _handleOffbandFemLnaReply(Uint8List frame) {
final reply = parseOffbandFemLnaReply(frame);
if (reply == null) return;
if (_femLnaEnabled == reply.enabled) return;
_femLnaEnabled = reply.enabled;
appLogger.info(
'FEM LNA now ${reply.enabled ? 'enabled' : 'bypassed'}',
tag: 'Connector',
);
notifyListeners();
}
Map<String, String>? get currentCustomVars => _currentCustomVars;
int? get batteryMillivolts => _batteryMillivolts;
int? get storageUsedKb => _storageUsedKb;
@ -625,15 +699,36 @@ class MeshCoreConnector extends ChangeNotifier {
if (messages == null) return;
final removed = messages.remove(message);
if (!removed) return;
await _messageStore.saveMessages(contactKeyHex, messages);
// Explicit delete: saveMessages now merges and never removes (#343).
await _messageStore.removeMessage(contactKeyHex, message);
notifyListeners();
}
/// Aggregate cost of the per-contact conversation load, which runs once per
/// contact during a pull. It is fired unawaited from the contact handler, so
/// it escapes the frame-handler timing and has to be measured here.
int _convLoadCount = 0;
int _convLoadStoreMs = 0;
int _convLoadMergeMs = 0;
Future<void> _loadMessagesForContact(String contactKeyHex) async {
if (_loadedConversationKeys.contains(contactKeyHex)) return;
_loadedConversationKeys.add(contactKeyHex);
final storeWatch = Stopwatch()..start();
final allMessages = await _messageStore.loadMessages(contactKeyHex);
storeWatch.stop();
final mergeWatch = Stopwatch()..start();
_convLoadCount++;
_convLoadStoreMs += storeWatch.elapsedMilliseconds;
if (_convLoadCount % 25 == 0) {
appLogger.info(
'conversation loads: $_convLoadCount contacts, '
'store=${_convLoadStoreMs}ms(WALL, spans await - includes event-loop '
'queueing, NOT pure work) merge=${_convLoadMergeMs}ms(sync work)',
tag: 'Perf',
);
}
if (allMessages.isNotEmpty) {
// Keep only the most recent N messages in memory to bound memory usage
final windowedMessages = allMessages.length > _messageWindowSize
@ -674,6 +769,8 @@ class MeshCoreConnector extends ChangeNotifier {
_conversations[contactKeyHex] = windowedMergedMessages;
notifyListeners();
}
mergeWatch.stop();
_convLoadMergeMs += mergeWatch.elapsedMilliseconds;
}
String _messageMergeKey(Message message) {
@ -737,7 +834,10 @@ class MeshCoreConnector extends ChangeNotifier {
if (messages == null) return;
final removed = messages.remove(message);
if (!removed) return;
await _channelMessageStore.saveChannelMessages(channelIndex, messages);
// Explicit delete path: saveChannelMessages now MERGES and never removes
// (#343), so deletion must go through removeChannelMessage or the message
// would be resurrected on the next save.
await _channelMessageStore.removeChannelMessage(channelIndex, message);
notifyListeners();
}
@ -1117,10 +1217,17 @@ class MeshCoreConnector extends ChangeNotifier {
_contacts
..clear()
..addAll(cached);
for (final contact in cached) {
_ensureContactSmazSettingLoaded(contact.publicKeyHex);
_ensureContactCyr2LatSettingLoaded(contact.publicKeyHex);
}
// Load every contact's settings without notifying per contact, then
// notify once. Per-contact notification rebuilt the whole tree 2x per
// contact (600 rebuilds for 300 contacts), and each rebuild walks the
// contact list, which is what stalled the UI for ~44s on connect.
await Future.wait([
for (final contact in cached) ...[
_ensureContactSmazSettingLoaded(contact.publicKeyHex, notify: false),
_ensureContactCyr2LatSettingLoaded(contact.publicKeyHex, notify: false),
],
]);
notifyListeners();
}
Future<void> _loadDiscoveredContactCache() async {
@ -3165,11 +3272,15 @@ class MeshCoreConnector extends ChangeNotifier {
}
}
/// Pushes a path to the device. [hopCount] is a HOP count and [hashWidth] the
/// bytes per hop hash; both are needed because the wire path_len byte packs
/// them together, and sending a bare count mislabels the width. (#309)
Future<void> setContactPath(
Contact contact,
Uint8List customPath,
int pathLen,
) async {
int hopCount, {
int hashWidth = 1,
}) async {
// Serialize path operations to prevent interleaved async calls from
// leaving in-memory state inconsistent with the device.
final prev = _pathOpLock;
@ -3183,7 +3294,8 @@ class MeshCoreConnector extends ChangeNotifier {
buildUpdateContactPathFrame(
contact.publicKey,
customPath,
pathLen,
hopCount,
hashWidth: hashWidth,
type: contact.type,
flags: contact.flags,
name: contact.name,
@ -3198,8 +3310,12 @@ class MeshCoreConnector extends ChangeNotifier {
(c) => c.publicKeyHex == contact.publicKeyHex,
);
if (idx != -1) {
// pathLength is a HOP count. This wrote customPath.length (a BYTE
// count), which silently redefined the field's unit after any path
// set and doubled it at 2-byte width. (#309)
_contacts[idx] = _contacts[idx].copyWith(
pathLength: customPath.length,
pathLength: hopCount,
pathHashWidth: hashWidth,
path: customPath,
);
notifyListeners();
@ -3245,6 +3361,7 @@ class MeshCoreConnector extends ChangeNotifier {
latestContact.publicKey,
latestContact.path,
latestContact.pathLength,
hashWidth: latestContact.pathHashWidth,
type: latestContact.type,
flags: updatedFlags,
name: latestContact.name,
@ -3588,6 +3705,7 @@ class MeshCoreConnector extends ChangeNotifier {
contact.publicKey,
contact.path,
contact.pathLength,
hashWidth: contact.pathHashWidth,
type: contact.type,
flags: contact.flags,
name: contact.name,
@ -4257,7 +4375,64 @@ class MeshCoreConnector extends ChangeNotifier {
}
}
/// Any frame handler that occupies the UI isolate long enough to drop
/// frames is a bug: it stalls rendering, so progress bars freeze and input
/// stops responding while sync appears to do nothing and then finish at
/// once. Logged rather than assumed, so the offending code is named.
static const Duration _slowHandlerThreshold = Duration(milliseconds: 100);
/// Cumulative synchronous time per frame code. A single handler under the
/// slow threshold can still dominate if it runs hundreds of times, which a
/// per-call threshold cannot see.
final Map<int, int> _frameCodeMicros = {};
final Map<int, int> _frameCodeCount = {};
int _frameTotalMicros = 0;
void _handleFrame(List<int> data) {
final handlerWatch = Stopwatch()..start();
_handleFrameInner(data);
handlerWatch.stop();
if (data.isEmpty) return;
final code = data[0];
final micros = handlerWatch.elapsedMicroseconds;
_frameCodeMicros[code] = (_frameCodeMicros[code] ?? 0) + micros;
_frameCodeCount[code] = (_frameCodeCount[code] ?? 0) + 1;
_frameTotalMicros += micros;
if (handlerWatch.elapsed > _slowHandlerThreshold) {
appLogger.info(
'slow frame handler: code=$code blocked UI for '
'${handlerWatch.elapsedMilliseconds}ms',
tag: 'Perf',
);
}
// Periodic cumulative report: names the code that owns the most isolate
// time even when no single call is slow.
if (_frameTotalMicros > 2000000) {
final ranked = _frameCodeMicros.entries.toList()
..sort((a, b) => b.value.compareTo(a.value));
final top = ranked
.take(4)
.map(
(e) =>
'code=${e.key} ${(e.value / 1000).round()}ms'
'/${_frameCodeCount[e.key]}calls',
)
.join(' ');
appLogger.info(
'frame handler cumulative (${(_frameTotalMicros / 1000).round()}ms '
'total sync): $top',
tag: 'Perf',
);
_frameTotalMicros = 0;
_frameCodeMicros.clear();
_frameCodeCount.clear();
}
}
void _handleFrameInner(List<int> data) {
if (data.isEmpty) return;
_lastRxTime = DateTime.now();
@ -4281,6 +4456,9 @@ class MeshCoreConnector extends ChangeNotifier {
case cmdOffbandBlock:
_handleOffbandBlockFrame(frame);
break;
case cmdOffbandFemLna:
_handleOffbandFemLnaReply(frame);
break;
case respCodeSelfInfo:
debugPrint('Got SELF_INFO');
_handleSelfInfo(frame);
@ -4554,16 +4732,10 @@ class MeshCoreConnector extends ChangeNotifier {
_channelStore.setPublicKeyHex = selfPublicKeyHex;
_unreadStore.setPublicKeyHex = selfPublicKeyHex;
// Now that we have self info, we can load all the persisted data for this node
_loadChannelOrder();
loadContactCache();
loadChannelSettings();
loadCachedChannels();
// Load persisted channel messages
loadAllChannelMessages();
loadUnreadState();
_loadDiscoveredContactCache();
// Now that we have self info, we can load all the persisted data for this
// node. Each step is timed: this sequence has stalled the UI isolate for
// ~40s on a large store, and the timings say which step is responsible.
unawaited(_timedStartupLoad());
_awaitingSelfInfo = false;
_selfInfoRetryTimer?.cancel();
@ -4574,6 +4746,26 @@ class MeshCoreConnector extends ChangeNotifier {
_maybeStartInitialChannelSync();
}
Future<void> _timedStartupLoad() async {
Future<void> step(String name, Future<void> Function() body) async {
final sw = Stopwatch()..start();
await body();
sw.stop();
appLogger.info(
'startup-load $name took ${sw.elapsedMilliseconds}ms',
tag: 'Perf',
);
}
await step('channelOrder', () async => _loadChannelOrder());
await step('contactCache', loadContactCache);
await step('channelSettings', () => loadChannelSettings());
await step('cachedChannels', loadCachedChannels);
await step('channelMessages', () => loadAllChannelMessages());
await step('unreadState', loadUnreadState);
await step('discoveredContacts', _loadDiscoveredContactCache);
}
/// Extract the additive `offband_caps` byte from a device-info reply.
///
/// Offset verified against firmware MyMesh.cpp: the reply tail is three
@ -4585,6 +4777,14 @@ class MeshCoreConnector extends ChangeNotifier {
static int? parseOffbandCaps(Uint8List frame) =>
frame.length >= 83 ? frame[82] : null;
/// FEM LNA state byte, appended immediately after the caps byte in device-info
/// v16+ (firmware #298). Appended **unconditionally** including on
/// non-capable boards, where it reads 0 so the byte's presence indicates
/// firmware version, not capability. The capability BIT is what decides
/// whether to render the control. Null on v15 and older (shorter frame).
static bool? parseFemLnaState(Uint8List frame) =>
frame.length >= 84 ? frame[83] != femLnaBypass : null;
void _handleDeviceInfo(Uint8List frame) {
if (frame.length < 4) return;
if (_shouldGateInitialChannelSync) {
@ -4599,7 +4799,8 @@ class MeshCoreConnector extends ChangeNotifier {
_firmwareVersion = info.version;
_deviceModel = info.model;
_appDebugLogService?.info(
'Device info: ${infoStrings.isEmpty ? '(no strings)' : infoStrings.join(' · ')}',
'Device info: ${infoStrings.isEmpty ? '(no strings)' : infoStrings.join(' · ')}'
' (frameLen=${frame.length})',
tag: 'Device',
);
@ -4608,15 +4809,42 @@ class MeshCoreConnector extends ChangeNotifier {
_clientRepeat = frame[80] != 0;
}
// Path hash mode v10+ (byte 81): width = mode + 1 byte(s) per hop
final priorPathHashByteWidth = _pathHashByteWidth;
if (frame.length >= 82) {
final mode = (frame[81] & 0xFF).clamp(0, 2);
_pathHashByteWidth = mode + 1;
} else {
_pathHashByteWidth = 1;
}
// A short frame silently downgrades a known-good width to 1 (#240). Log the
// frame length and the before/after width so any capture shows whether that
// happened, without needing the device. (#298)
final widthChanged = _pathHashByteWidth != priorPathHashByteWidth;
final widthNote =
'Path hash width: $priorPathHashByteWidth -> $_pathHashByteWidth '
'(device-info frame len=${frame.length}'
'${frame.length < 82 ? ', SHORT: no byte 81, forced to 1' : ''})';
if (widthChanged) {
_appDebugLogService?.warn(widthNote, tag: 'Device');
} else {
_appDebugLogService?.info(widthNote, tag: 'Device');
}
// Offband config capability v14+ (byte 82). Extracted + bounds-checked in a
// testable helper; offset verified against firmware (see parseOffbandCaps).
_offbandCaps = parseOffbandCaps(frame);
// FEM LNA state rides one byte past the caps byte on v16+ (#304). Primary
// read on connect a 0xC3 GET is only the fallback.
_femLnaEnabled = parseFemLnaState(frame);
// Capability-gated features are invisible when a bit is clear, which looks
// identical to a bug. Log the raw inputs so "the toggle didn't appear" can
// be told apart from "this radio says it can't". (#304)
_appDebugLogService?.info(
'Offband caps=0x${(_offbandCaps ?? 0).toRadixString(16).padLeft(2, '0')} '
'verCode=${_firmwareVerCode ?? 0} frameLen=${frame.length} '
'femLnaByte=${_femLnaEnabled == null ? 'absent' : (_femLnaEnabled! ? '1' : '0')} '
'femCapable=$supportsOffbandFemLna blockCapable=$supportsOffbandBlock',
tag: 'Device',
);
// Caps just landed; (re)evaluate GPS polling in case a `gps=1` custom-var
// frame arrived before this device-info reply set support. (#144)
_reconcileGpsPolling();
@ -4877,6 +5105,28 @@ class MeshCoreConnector extends ChangeNotifier {
return physicsMax;
}
/// Coalesces notifications during a bulk contact pull.
///
/// Outside a pull this notifies immediately, preserving live-update
/// behaviour for adverts arriving one at a time.
DateTime? _lastContactPullNotify;
static const Duration _contactPullNotifyInterval = Duration(
milliseconds: 250,
);
void _notifyContactPullThrottled() {
if (!_isLoadingContacts) {
notifyListeners();
return;
}
final now = DateTime.now();
final last = _lastContactPullNotify;
if (last == null || now.difference(last) >= _contactPullNotifyInterval) {
_lastContactPullNotify = now;
notifyListeners();
}
}
void _handleContact(Uint8List frame, {bool isContact = true}) {
final contactTmp = Contact.fromFrame(frame);
if (contactTmp != null) {
@ -4918,7 +5168,7 @@ class MeshCoreConnector extends ChangeNotifier {
: contact.lastMessageAt;
appLogger.info(
'Refreshing contact ${contact.name}: devicePath=${contact.pathLength}, existingOverride=${existing.pathOverride}',
'Refreshing contact ${contact.name}: devicePath=${_pathDiag(contact.path, contact.pathLength, contact.pathHashWidth)}, existingOverride=${existing.pathOverride}',
tag: 'Connector',
);
@ -4933,7 +5183,7 @@ class MeshCoreConnector extends ChangeNotifier {
);
appLogger.info(
'After merge: pathOverride=${_contacts[existingIndex].pathOverride}, devicePath=${_contacts[existingIndex].pathLength}',
'After merge: pathOverride=${_contacts[existingIndex].pathOverride}, devicePath=${_pathDiag(_contacts[existingIndex].path, _contacts[existingIndex].pathLength, _contacts[existingIndex].pathHashWidth)}',
tag: 'Connector',
);
} else {
@ -4944,7 +5194,7 @@ class MeshCoreConnector extends ChangeNotifier {
isContact) {
_contacts.add(contact);
appLogger.info(
'Added new contact ${contact.name}: pathLen=${contact.pathLength}',
'Added new contact ${contact.name}: pathLen=${_pathDiag(contact.path, contact.pathLength, contact.pathHashWidth)}',
tag: 'Connector',
);
} else {
@ -4964,7 +5214,14 @@ class MeshCoreConnector extends ChangeNotifier {
_pathHistoryService!.handlePathUpdated(contact);
}
notifyListeners();
// During a bulk pull this fired once per contact, and each notification
// is a synchronous full-tree rebuild that itself walks the contact list.
// Measured at ~19ms per contact, which never tripped a per-call slow
// threshold but dominated total isolate time. The channel handler
// already guards its notify with _isLoadingChannels; this is the same
// guard, throttled rather than suppressed so the sync progress bar still
// advances while the pull runs.
_notifyContactPullThrottled();
// Show notification for new contact (advertisement)
if (isNewContact && _appSettingsService != null) {
@ -5032,7 +5289,7 @@ class MeshCoreConnector extends ChangeNotifier {
);
appLogger.info(
'After merge: pathOverride=${_contacts[existingIndex].pathOverride}, devicePath=${_contacts[existingIndex].pathLength}',
'After merge: pathOverride=${_contacts[existingIndex].pathOverride}, devicePath=${_pathDiag(_contacts[existingIndex].path, _contacts[existingIndex].pathLength, _contacts[existingIndex].pathHashWidth)}',
tag: 'Connector',
);
} else {
@ -5497,22 +5754,32 @@ class MeshCoreConnector extends ChangeNotifier {
return frame.sublist(prefixOffset, prefixOffset + prefixLen);
}
void _ensureContactSmazSettingLoaded(String contactKeyHex) {
/// [notify] is false for bulk loads, which notify once at the end instead.
/// Notifying per contact rebuilds the whole tree once per contact, and each
/// rebuild itself walks the contact list, so a large address book turns this
/// into O(contacts^2) work on the UI isolate.
Future<void> _ensureContactSmazSettingLoaded(
String contactKeyHex, {
bool notify = true,
}) async {
if (_contactSmazEnabled.containsKey(contactKeyHex)) return;
_contactSettingsStore.loadSmazEnabled(contactKeyHex).then((enabled) {
final enabled = await _contactSettingsStore.loadSmazEnabled(contactKeyHex);
if (_contactSmazEnabled[contactKeyHex] == enabled) return;
_contactSmazEnabled[contactKeyHex] = enabled;
notifyListeners();
});
if (notify) notifyListeners();
}
void _ensureContactCyr2LatSettingLoaded(String contactKeyHex) {
Future<void> _ensureContactCyr2LatSettingLoaded(
String contactKeyHex, {
bool notify = true,
}) async {
if (_contactCyr2LatEnabled.containsKey(contactKeyHex)) return;
_contactSettingsStore.loadCyr2LatEnabled(contactKeyHex).then((enabled) {
final enabled = await _contactSettingsStore.loadCyr2LatEnabled(
contactKeyHex,
);
if (_contactCyr2LatEnabled[contactKeyHex] == enabled) return;
_contactCyr2LatEnabled[contactKeyHex] = enabled;
notifyListeners();
});
if (notify) notifyListeners();
}
void _ensureContactCyr2LatProfileLoaded(String contactKeyHex) {
@ -7154,7 +7421,7 @@ class MeshCoreConnector extends ChangeNotifier {
: existing.lastMessageAt;
appLogger.info(
'Refreshing contact ${existing.name}: devicePath=${existing.pathLength}, existingOverride=${existing.pathOverride}',
'Refreshing contact ${existing.name}: devicePath=${_pathDiag(existing.path, existing.pathLength, existing.pathHashWidth)}, existingOverride=${existing.pathOverride}',
tag: 'Connector',
);
@ -7180,7 +7447,7 @@ class MeshCoreConnector extends ChangeNotifier {
_updateDirectRepeater(_contacts[existingIndex], snr, path);
appLogger.info(
'After merge: pathOverride=${_contacts[existingIndex].pathOverride}, devicePath=${_contacts[existingIndex].pathLength}',
'After merge: pathOverride=${_contacts[existingIndex].pathOverride}, devicePath=${_pathDiag(_contacts[existingIndex].path, _contacts[existingIndex].pathLength, _contacts[existingIndex].pathHashWidth)}',
tag: 'Connector',
);
}

@ -263,6 +263,54 @@ const int respCodeOffbandGps = 0xC1;
/// Request frame for [cmdOffbandGps] a bare 1-byte command, no payload. (#135)
Uint8List buildOffbandGpsRequestFrame() => Uint8List.fromList([cmdOffbandGps]);
// --- Offband FEM LNA command (0xC3) capability-gated. Heltec V4 external
// FEM LNA control; firmware counterpart OffbandMesh/meshcore-firmware#298.
//
// Deliberately a fork-private command rather than an extra byte on the stock
// CMD_SET_OTHER_PARAMS (38): that frame is shared with upstream MeshCore and is
// sent to every radio regardless of fork, so widening it would perturb stock
// firmware. Nothing here is emitted unless the capability bit is set. (#304)
const int cmdOffbandFemLna = 0xC3;
const int offbandFemLnaSet = 0x01;
const int offbandFemLnaGet = 0x02;
/// `0` = FEM LNA bypassed, `1` = enabled. Firmware default is enabled.
const int femLnaBypass = 0x00;
const int femLnaEnabled = 0x01;
Uint8List buildOffbandFemLnaSetFrame(bool enabled) => Uint8List.fromList([
cmdOffbandFemLna,
offbandFemLnaSet,
enabled ? femLnaEnabled : femLnaBypass,
]);
Uint8List buildOffbandFemLnaGetFrame() =>
Uint8List.fromList([cmdOffbandFemLna, offbandFemLnaGet]);
/// Reply to a `0xC3` request: `[0xC3][sub][value]`.
///
/// The value is the **post-apply hardware state, not an echo of the request**
/// (firmware #298 as-built): if the FEM ever refused a write, this reports the
/// truth rather than confirming a change that didn't take. Always render from
/// this value; never assume the written value stuck.
///
/// Error replies are never 0xC3-prefixed, so they don't reach this parser:
/// malformed `[respCodeErr][errCodeIllegalArg]`; a request to a non-capable
/// board `[respCodeErr][errCodeUnsupportedCmd]` (unreachable when gated on
/// the capability bit, but firmware answers it defensively).
class OffbandFemLnaReply {
const OffbandFemLnaReply(this.subType, this.value);
final int subType;
final int value;
bool get enabled => value != femLnaBypass;
}
OffbandFemLnaReply? parseOffbandFemLnaReply(Uint8List frame) {
if (frame.length < 3 || frame[0] != cmdOffbandFemLna) return null;
return OffbandFemLnaReply(frame[1], frame[2]);
}
// --- Offband block command (0xC2) capability-gated; see
// docs/architecture/block-contract-as-built.md §8. Firmware as-built PR #247. ---
const int cmdOffbandBlock = 0xC2;
@ -276,6 +324,12 @@ const int offbandBlockClear = 0x04;
/// app must recognise the 2-byte error frame and not wait for a 0xC2 echo.
const int errCodeIllegalArg = 6;
/// `ERR_CODE_UNSUPPORTED_CMD` returned for an Offband command the connected
/// board can't service (e.g. a `0xC3` FEM LNA request to a board without FEM
/// control). Unreachable when the capability bit is respected; firmware answers
/// it defensively against a stale or mis-gated client. (#304)
const int errCodeUnsupportedCmd = 1;
Uint8List buildOffbandBlockAddFrame(Uint8List pubKey) =>
Uint8List.fromList([cmdOffbandBlock, offbandBlockAdd, ...pubKey]);
Uint8List buildOffbandBlockRemoveFrame(Uint8List pubKey) =>
@ -319,6 +373,21 @@ bool firmwareSupportsOffbandGps(int? offbandCaps) => offbandCaps != null;
/// `FIRMWARE_VER_CODE >= 15`; absent app-only mode (no sync, no firmware drop).
const int offbandCapBlock = 0x02;
/// `OFFBAND_CAP_FEM_LNA` bit (bit 2) in the `offband_caps` byte: this radio can
/// control its external FEM LNA (firmware #298).
///
/// PROVISIONAL firmware owns the caps byte and has not yet confirmed 0x04 as
/// free. Do not ship against this without that confirmation (#304).
///
/// Gate on the BIT ONLY, never on model or version: firmware derives it at
/// runtime from the auto-detected FEM chip (KCT8103L vs GC1109), so it is a
/// per-unit answer two Heltec V4s can legitimately disagree, and other
/// FEM-bearing boards report false today.
const int offbandCapFemLna = 0x04;
bool firmwareSupportsOffbandFemLna(int? offbandCaps) =>
offbandCaps != null && (offbandCaps & offbandCapFemLna) != 0;
bool firmwareSupportsOffbandBlock(int? offbandCaps, int? firmwareVerCode) =>
offbandCaps != null &&
(offbandCaps & offbandCapBlock) != 0 &&
@ -514,24 +583,50 @@ int readInt32LE(Uint8List data, int offset) {
return val;
}
// Path-length byte from the firmware. Low 6 bits = the path length field
// (a BYTE count of the hop-hash array, 0-63); high 2 bits carry an optional
// hash-size mode hint (0..2 -> 1..3 bytes/hop) that is not reliably populated,
// so the device's configured hash width (MeshCoreConnector.pathHashByteWidth)
// is authoritative for slicing the path. realHopCount() converts the byte
// length to a true hop count at that width. (#112)
// TX counterpart: buildSetPathHashModeFrame (CMD_SET_PATH_HASH_MODE).
// Path-length byte from the firmware. This is a PACKED field, and both halves
// are authoritative the path is self-describing on the wire:
//
// high 2 bits = hash size - 1 (0..2 -> 1..3 bytes per hop hash)
// low 6 bits = hash COUNT (the number of HOPS, 0-63)
// byte length = count * size
//
// Verified against firmware `src/Packet.h:79-84`:
// getPathHashSize() == (path_len >> 6) + 1
// getPathHashCount() == path_len & 63
// getPathByteLen() == getPathHashCount() * getPathHashSize()
// setPathHashSizeAndCount(sz, n) { path_len = ((sz - 1) << 6) | (n & 63); }
//
// The high bits are set deliberately by that setter, so the per-path width
// travels with the path. The companion contact frame carries this same encoded
// byte verbatim: `Packet::copyPath()` returns path_len unchanged
// (`src/Packet.cpp:32-35`) into `ContactInfo.out_path_len`
// (`src/helpers/BaseChatMesh.cpp:319`), which `writeContactRespFrame` emits
// as-is (`examples/companion_radio/MyMesh.cpp:205-212`).
//
// A prior comment here claimed the low 6 bits were a BYTE count and that the
// high bits were "not reliably populated". Both were wrong, and #222 plus the
// Contact decode were built on them: the app read `count` bytes where firmware
// meant `count` hops, keeping half of every path at 2-byte width. (#309)
//
// TX counterparts: buildSetPathHashModeFrame (CMD_SET_PATH_HASH_MODE) sets the
// device-wide default width; encodePathLen() packs a per-path value to send.
int pathHopCount(int pathLenRaw) => pathLenRaw & 0x3F;
int pathHashSizeBytes(int pathLenRaw) => ((pathLenRaw >> 6) & 0x03) + 1;
/// Real hop count from the firmware path byte-length and the device hash width.
/// `pathHopCount` returns the path BYTE length, so at a 2-byte hash width a
/// single 2-byte hop reports 2 divide by the width to get the true hop count.
/// Null (unknown) and negative (flood) sentinels pass through unchanged. (#112)
int? realHopCount(int? rawByteLen, int hashWidth) {
if (rawByteLen == null || rawByteLen < 0) return rawByteLen;
final w = hashWidth < 1 ? 1 : hashWidth;
return rawByteLen ~/ w;
/// Byte length of the hop-hash array described by a raw path-length byte.
int pathByteLength(int pathLenRaw) =>
pathHopCount(pathLenRaw) * pathHashSizeBytes(pathLenRaw);
/// Packs a hop count and per-hop hash width into the firmware path-length byte.
///
/// Mirrors firmware `Packet::setPathHashSizeAndCount`. Sending a bare hop count
/// (mode bits 00) tells the radio "1-byte hashes", so a 2-byte path routed that
/// way is read one byte per hop and goes to nodes that were never on the route.
/// Width is clamped to 1..3; mode 3 is reserved by firmware
/// (`isValidPathLen` rejects hash_size == 4). (#309)
int encodePathLen(int hopCount, int hashWidth) {
final w = hashWidth.clamp(1, 3);
return ((w - 1) << 6) | (hopCount & 0x3F);
}
/// Readable strings from a RESP_CODE_DEVICE_INFO frame: build date, model, and
@ -824,10 +919,19 @@ Uint8List buildResetPathFrame(Uint8List pubKey) {
// Build CMD_ADD_UPDATE_CONTACT frame to set custom path
// Format: [cmd][pub_key x32][type][flags][path_len][path x64][name x32][Lat? x4, Lon? x4][timestamp? x4]
//
// [hopCount] is a HOP count and [hashWidth] the bytes per hop hash; the two are
// packed into the single wire path_len byte via encodePathLen().
//
// This previously wrote the count raw, leaving the mode bits 00 which tells
// the radio "1-byte hashes". On a 2-byte net that handed the firmware 2-byte
// hash data labelled as 1-byte hops, so it routed to nodes that were never on
// the route. That is the send-side half of #240's misrouting. (#309)
Uint8List buildUpdateContactPathFrame(
Uint8List pubKey,
Uint8List path,
int pathLen, {
int hopCount, {
int hashWidth = 1,
int type = 1, // ADV_TYPE_CHAT
int flags = 0,
String name = '',
@ -840,7 +944,8 @@ Uint8List buildUpdateContactPathFrame(
writer.writeBytes(pubKey);
writer.writeByte(type);
writer.writeByte(flags);
writer.writeByte(pathLen);
// Negative = flood sentinel, passed through as the firmware's 0xFF.
writer.writeByte(hopCount < 0 ? 0xFF : encodePathLen(hopCount, hashWidth));
writer.writeBytesPadded(path, maxPathSize);

@ -31,7 +31,14 @@ class GifHelper {
final pageMatch = RegExp(
r'^(?:https?:\/\/)?giphy\.com\/gifs\/(?:[^/?]*-)?([A-Za-z0-9_]+)\/?$',
).firstMatch(trimmed);
return pageMatch?.group(1);
if (pageMatch != null) {
return pageMatch.group(1);
}
// The CDN form people actually paste out of a browser. (#283)
final cdnMatch = RegExp(
r'^(?:https?:\/\/)?i\.giphy\.com\/([A-Za-z0-9_-]+)\.(?:gif|webp)$',
).firstMatch(trimmed);
return cdnMatch?.group(1);
}
/// Encode a GIF as a Giphy page URL, a format parseGif() also parses.
@ -43,4 +50,31 @@ class GifHelper {
static String encodeGif(String gifId) {
return 'https://giphy.com/gifs/$gifId';
}
/// Tenor's own CDN, the only Tenor form that is directly renderable.
///
/// A `tenor.com/view/<slug>-<id>` page URL is deliberately NOT matched: the
/// modern CDN path uses an opaque hash that cannot be derived from the page
/// id, so resolving one needs the Tenor API. Such a link stays plain text
/// rather than silently rendering the wrong image. (#283)
static final RegExp _tenorMedia = RegExp(
r'^(?:https?:\/\/)?(?:media|c)\.tenor\.com\/[A-Za-z0-9_\-\/]+\.(?:gif|webp)$',
);
/// The URL that renders [text], or null if it is not a GIF we will display.
///
/// Rendering is restricted to an allowlist of curated GIF platforms (Giphy
/// and Tenor). Anything off-list returns null and stays a plain tap-to-open
/// link: auto-fetching an arbitrary URL leaks the viewer's IP and enables
/// tracking-pixel abuse, and inline-rendering unmoderated hosts is a malware
/// and inappropriate-content vector. Widening the allowlist is a deliberate
/// decision, not a convenience. (#283)
static String? resolveGifUrl(String text) {
final gifId = parseGif(text);
if (gifId != null) {
return 'https://media.giphy.com/media/$gifId/giphy.gif';
}
final trimmed = text.trim().replaceFirst(RegExp(r'^@\[[^\]]+\]\s+'), '');
return _tenorMedia.hasMatch(trimmed) ? trimmed : null;
}
}

@ -236,6 +236,7 @@
"settings_infoStatus": "Status",
"settings_infoFirmware": "Firmware",
"settings_infoModel": "Model",
"settings_infoOffbandCaps": "Offband capabilities",
"settings_infoBattery": "Battery",
"settings_infoPublicKey": "Public Key",
"settings_publicKeyCopied": "Public key copied",
@ -253,6 +254,8 @@
"settings_txPowerInvalid": "Invalid TX power (0-22 dBm)",
"settings_clientRepeat": "Off-Grid Repeat",
"settings_clientRepeatSubtitle": "Allow this device to repeat mesh packets for others",
"settings_femLna": "Receive amplifier (FEM LNA)",
"settings_femLnaSubtitle": "Boosts receive sensitivity. Leave on unless you are troubleshooting a strong nearby signal.",
"settings_clientRepeatFreqWarning": "Off-grid repeat requires 433, 869, or 918 MHz frequency",
"settings_error": "Error: {message}",
"@settings_error": {
@ -973,6 +976,7 @@
"map_repeaters": "Repeaters",
"map_otherNodes": "Other Nodes",
"map_showOverlaps": "Repeater Key Overlaps",
"map_alwaysShowNames": "Always show names",
"map_keyPrefix": "Key Prefix",
"map_filterByKeyPrefix": "Filter by key prefix",
"map_publicKeyPrefix": "Public key prefix",

@ -1174,6 +1174,12 @@ abstract class AppLocalizations {
/// **'Model'**
String get settings_infoModel;
/// No description provided for @settings_infoOffbandCaps.
///
/// In en, this message translates to:
/// **'Offband capabilities'**
String get settings_infoOffbandCaps;
/// No description provided for @settings_infoBattery.
///
/// In en, this message translates to:
@ -1276,6 +1282,18 @@ abstract class AppLocalizations {
/// **'Allow this device to repeat mesh packets for others'**
String get settings_clientRepeatSubtitle;
/// No description provided for @settings_femLna.
///
/// In en, this message translates to:
/// **'Receive amplifier (FEM LNA)'**
String get settings_femLna;
/// No description provided for @settings_femLnaSubtitle.
///
/// In en, this message translates to:
/// **'Boosts receive sensitivity. Leave on unless you are troubleshooting a strong nearby signal.'**
String get settings_femLnaSubtitle;
/// No description provided for @settings_clientRepeatFreqWarning.
///
/// In en, this message translates to:
@ -3394,6 +3412,12 @@ abstract class AppLocalizations {
/// **'Repeater Key Overlaps'**
String get map_showOverlaps;
/// No description provided for @map_alwaysShowNames.
///
/// In en, this message translates to:
/// **'Always show names'**
String get map_alwaysShowNames;
/// No description provided for @map_keyPrefix.
///
/// In en, this message translates to:

@ -590,6 +590,9 @@ class AppLocalizationsBg extends AppLocalizations {
@override
String get settings_infoModel => 'Model';
@override
String get settings_infoOffbandCaps => 'Offband capabilities';
@override
String get settings_infoBattery => 'Батерия';
@ -642,6 +645,13 @@ class AppLocalizationsBg extends AppLocalizations {
String get settings_clientRepeatSubtitle =>
'Позволете на това устройство да предава пакети към мрежата за други устройства.';
@override
String get settings_femLna => 'Receive amplifier (FEM LNA)';
@override
String get settings_femLnaSubtitle =>
'Boosts receive sensitivity. Leave on unless you are troubleshooting a strong nearby signal.';
@override
String get settings_clientRepeatFreqWarning =>
'За повторение извън мрежата са необходими честоти от 433, 869 или 918 MHz.';
@ -1880,6 +1890,9 @@ class AppLocalizationsBg extends AppLocalizations {
@override
String get map_showOverlaps => 'Покриване на ключа на повтаряча';
@override
String get map_alwaysShowNames => 'Always show names';
@override
String get map_keyPrefix => 'Префикс на ключа';

@ -585,6 +585,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get settings_infoModel => 'Model';
@override
String get settings_infoOffbandCaps => 'Offband capabilities';
@override
String get settings_infoBattery => 'Akku';
@ -637,6 +640,13 @@ class AppLocalizationsDe extends AppLocalizations {
String get settings_clientRepeatSubtitle =>
'Ermöglichen Sie diesem Gerät, Mesh-Pakete für andere zu wiederholen.';
@override
String get settings_femLna => 'Receive amplifier (FEM LNA)';
@override
String get settings_femLnaSubtitle =>
'Boosts receive sensitivity. Leave on unless you are troubleshooting a strong nearby signal.';
@override
String get settings_clientRepeatFreqWarning =>
'Die Kommunikation ohne Stromversorgung erfordert Frequenzen von 433, 869 oder 918 MHz.';
@ -1877,6 +1887,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get map_showOverlaps => 'Überlappungen der Repeater-Taste';
@override
String get map_alwaysShowNames => 'Always show names';
@override
String get map_keyPrefix => 'Schlüsselpräfix';

@ -575,6 +575,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get settings_infoModel => 'Model';
@override
String get settings_infoOffbandCaps => 'Offband capabilities';
@override
String get settings_infoBattery => 'Battery';
@ -627,6 +630,13 @@ class AppLocalizationsEn extends AppLocalizations {
String get settings_clientRepeatSubtitle =>
'Allow this device to repeat mesh packets for others';
@override
String get settings_femLna => 'Receive amplifier (FEM LNA)';
@override
String get settings_femLnaSubtitle =>
'Boosts receive sensitivity. Leave on unless you are troubleshooting a strong nearby signal.';
@override
String get settings_clientRepeatFreqWarning =>
'Off-grid repeat requires 433, 869, or 918 MHz frequency';
@ -1844,6 +1854,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get map_showOverlaps => 'Repeater Key Overlaps';
@override
String get map_alwaysShowNames => 'Always show names';
@override
String get map_keyPrefix => 'Key Prefix';

@ -586,6 +586,9 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get settings_infoModel => 'Model';
@override
String get settings_infoOffbandCaps => 'Offband capabilities';
@override
String get settings_infoBattery => 'Batería';
@ -638,6 +641,13 @@ class AppLocalizationsEs extends AppLocalizations {
String get settings_clientRepeatSubtitle =>
'Permita que este dispositivo repita los paquetes de red para otros usuarios.';
@override
String get settings_femLna => 'Receive amplifier (FEM LNA)';
@override
String get settings_femLnaSubtitle =>
'Boosts receive sensitivity. Leave on unless you are troubleshooting a strong nearby signal.';
@override
String get settings_clientRepeatFreqWarning =>
'Para la comunicación fuera de la red, se requiere una frecuencia de 433, 869 o 918 MHz.';
@ -1875,6 +1885,9 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get map_showOverlaps => 'Superposiciones de tecla repetidora';
@override
String get map_alwaysShowNames => 'Always show names';
@override
String get map_keyPrefix => 'Prefijo de clave';

@ -590,6 +590,9 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get settings_infoModel => 'Model';
@override
String get settings_infoOffbandCaps => 'Offband capabilities';
@override
String get settings_infoBattery => 'Batterie';
@ -642,6 +645,13 @@ class AppLocalizationsFr extends AppLocalizations {
String get settings_clientRepeatSubtitle =>
'Permettez à cet appareil de répéter les paquets de données pour les autres.';
@override
String get settings_femLna => 'Receive amplifier (FEM LNA)';
@override
String get settings_femLnaSubtitle =>
'Boosts receive sensitivity. Leave on unless you are troubleshooting a strong nearby signal.';
@override
String get settings_clientRepeatFreqWarning =>
'Pour les transmissions hors réseau, il est nécessaire d\'utiliser les fréquences de 433, 869 ou 918 MHz.';
@ -1885,6 +1895,9 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get map_showOverlaps => 'Chevauchement de la touche répétitive';
@override
String get map_alwaysShowNames => 'Always show names';
@override
String get map_keyPrefix => 'Préfixe clé';

@ -588,6 +588,9 @@ class AppLocalizationsHu extends AppLocalizations {
@override
String get settings_infoModel => 'Model';
@override
String get settings_infoOffbandCaps => 'Offband capabilities';
@override
String get settings_infoBattery => 'Akku';
@ -642,6 +645,13 @@ class AppLocalizationsHu extends AppLocalizations {
String get settings_clientRepeatSubtitle =>
'Engedje, hogy ez a eszköz mások számára is ismételje a hálózati csomagokat.';
@override
String get settings_femLna => 'Receive amplifier (FEM LNA)';
@override
String get settings_femLnaSubtitle =>
'Boosts receive sensitivity. Leave on unless you are troubleshooting a strong nearby signal.';
@override
String get settings_clientRepeatFreqWarning =>
'A hálózat nélküli kommunikációhoz 433, 869 vagy 918 MHz frekvenciát igényel.';
@ -1886,6 +1896,9 @@ class AppLocalizationsHu extends AppLocalizations {
@override
String get map_showOverlaps => 'Az ismétlő kulcsok ütköznek';
@override
String get map_alwaysShowNames => 'Always show names';
@override
String get map_keyPrefix => 'Kulcsfontosságú előtag';

@ -588,6 +588,9 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get settings_infoModel => 'Model';
@override
String get settings_infoOffbandCaps => 'Offband capabilities';
@override
String get settings_infoBattery => 'Batteria';
@ -640,6 +643,13 @@ class AppLocalizationsIt extends AppLocalizations {
String get settings_clientRepeatSubtitle =>
'Permetti a questo dispositivo di ripetere i pacchetti di rete per gli altri.';
@override
String get settings_femLna => 'Receive amplifier (FEM LNA)';
@override
String get settings_femLnaSubtitle =>
'Boosts receive sensitivity. Leave on unless you are troubleshooting a strong nearby signal.';
@override
String get settings_clientRepeatFreqWarning =>
'Per la comunicazione fuori rete, è necessario utilizzare frequenze di 433, 869 o 918 MHz.';
@ -1878,6 +1888,9 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get map_showOverlaps => 'Sovrapposizioni della chiave ripetitore';
@override
String get map_alwaysShowNames => 'Always show names';
@override
String get map_keyPrefix => 'Prefisso Chiave';

@ -561,6 +561,9 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get settings_infoModel => 'Model';
@override
String get settings_infoOffbandCaps => 'Offband capabilities';
@override
String get settings_infoBattery => 'バッテリー';
@ -613,6 +616,13 @@ class AppLocalizationsJa extends AppLocalizations {
String get settings_clientRepeatSubtitle =>
'このデバイスが、他のデバイスに対してメッシュパケットを繰り返し送信できるようにする。';
@override
String get settings_femLna => 'Receive amplifier (FEM LNA)';
@override
String get settings_femLnaSubtitle =>
'Boosts receive sensitivity. Leave on unless you are troubleshooting a strong nearby signal.';
@override
String get settings_clientRepeatFreqWarning =>
'オフグリッドでの再送には、433MHz、869MHz、または918MHzの周波数が必要です。';
@ -1801,6 +1811,9 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get map_showOverlaps => 'リピーターキーの重複';
@override
String get map_alwaysShowNames => 'Always show names';
@override
String get map_keyPrefix => '主要なプレフィックス';

@ -561,6 +561,9 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get settings_infoModel => 'Model';
@override
String get settings_infoOffbandCaps => 'Offband capabilities';
@override
String get settings_infoBattery => '배터리';
@ -613,6 +616,13 @@ class AppLocalizationsKo extends AppLocalizations {
String get settings_clientRepeatSubtitle =>
'이 장치가 다른 사람들을 위해 메시 패킷을 반복하도록 허용합니다.';
@override
String get settings_femLna => 'Receive amplifier (FEM LNA)';
@override
String get settings_femLnaSubtitle =>
'Boosts receive sensitivity. Leave on unless you are troubleshooting a strong nearby signal.';
@override
String get settings_clientRepeatFreqWarning =>
'오프그리드(무전력) 시스템 재연결에는 433MHz, 869MHz, 또는 918MHz 주파수가 필요합니다.';
@ -1797,6 +1807,9 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get map_showOverlaps => '반복 키 중복';
@override
String get map_alwaysShowNames => 'Always show names';
@override
String get map_keyPrefix => '핵심 접두사';

@ -582,6 +582,9 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get settings_infoModel => 'Model';
@override
String get settings_infoOffbandCaps => 'Offband capabilities';
@override
String get settings_infoBattery => 'Batterij';
@ -634,6 +637,13 @@ class AppLocalizationsNl extends AppLocalizations {
String get settings_clientRepeatSubtitle =>
'Laat dit apparaat de berichten van andere apparaten doorsturen.';
@override
String get settings_femLna => 'Receive amplifier (FEM LNA)';
@override
String get settings_femLnaSubtitle =>
'Boosts receive sensitivity. Leave on unless you are troubleshooting a strong nearby signal.';
@override
String get settings_clientRepeatFreqWarning =>
'Om een signaal buiten het netwerk te versturen, zijn frequenties van 433, 869 of 918 MHz vereist.';
@ -1864,6 +1874,9 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get map_showOverlaps => 'Herhalingssleutel overlapt';
@override
String get map_alwaysShowNames => 'Always show names';
@override
String get map_keyPrefix => 'Prefix sleutel';

@ -590,6 +590,9 @@ class AppLocalizationsPl extends AppLocalizations {
@override
String get settings_infoModel => 'Model';
@override
String get settings_infoOffbandCaps => 'Offband capabilities';
@override
String get settings_infoBattery => 'Bateria';
@ -643,6 +646,13 @@ class AppLocalizationsPl extends AppLocalizations {
String get settings_clientRepeatSubtitle =>
'Pozwól temu urządzeniu powtarzać pakiety danych dla innych urządzeń.';
@override
String get settings_femLna => 'Receive amplifier (FEM LNA)';
@override
String get settings_femLnaSubtitle =>
'Boosts receive sensitivity. Leave on unless you are troubleshooting a strong nearby signal.';
@override
String get settings_clientRepeatFreqWarning =>
'Powtórka poza siecią wymaga częstotliwości 433, 869 lub 918 MHz.';
@ -1890,6 +1900,9 @@ class AppLocalizationsPl extends AppLocalizations {
@override
String get map_showOverlaps => 'Nakładające się klucze przekaźników';
@override
String get map_alwaysShowNames => 'Always show names';
@override
String get map_keyPrefix => 'Prefiks klucza';

@ -588,6 +588,9 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get settings_infoModel => 'Model';
@override
String get settings_infoOffbandCaps => 'Offband capabilities';
@override
String get settings_infoBattery => 'Bateria';
@ -640,6 +643,13 @@ class AppLocalizationsPt extends AppLocalizations {
String get settings_clientRepeatSubtitle =>
'Permita que este dispositivo repita pacotes de rede para outros dispositivos.';
@override
String get settings_femLna => 'Receive amplifier (FEM LNA)';
@override
String get settings_femLnaSubtitle =>
'Boosts receive sensitivity. Leave on unless you are troubleshooting a strong nearby signal.';
@override
String get settings_clientRepeatFreqWarning =>
'A repetição fora da rede requer frequências de 433, 869 ou 918 MHz.';
@ -1875,6 +1885,9 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get map_showOverlaps => 'Sobreposições da Chave Repeater';
@override
String get map_alwaysShowNames => 'Always show names';
@override
String get map_keyPrefix => 'Prefixo Chave';

@ -587,6 +587,9 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get settings_infoModel => 'Model';
@override
String get settings_infoOffbandCaps => 'Offband capabilities';
@override
String get settings_infoBattery => 'Батарея';
@ -640,6 +643,13 @@ class AppLocalizationsRu extends AppLocalizations {
String get settings_clientRepeatSubtitle =>
'Позвольте этому устройству повторять пакеты данных для других устройств.';
@override
String get settings_femLna => 'Receive amplifier (FEM LNA)';
@override
String get settings_femLnaSubtitle =>
'Boosts receive sensitivity. Leave on unless you are troubleshooting a strong nearby signal.';
@override
String get settings_clientRepeatFreqWarning =>
'Для работы в режиме \"без подключения к сети\" требуется частота 433, 869 или 918 МГц.';
@ -1879,6 +1889,9 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get map_showOverlaps => 'Перекрытия ключа повтора';
@override
String get map_alwaysShowNames => 'Always show names';
@override
String get map_keyPrefix => 'Префикс ключа';

@ -582,6 +582,9 @@ class AppLocalizationsSk extends AppLocalizations {
@override
String get settings_infoModel => 'Model';
@override
String get settings_infoOffbandCaps => 'Offband capabilities';
@override
String get settings_infoBattery => 'Batéria';
@ -634,6 +637,13 @@ class AppLocalizationsSk extends AppLocalizations {
String get settings_clientRepeatSubtitle =>
'Umožnite, aby toto zariadenie opakovávalo siete pre ostatných.';
@override
String get settings_femLna => 'Receive amplifier (FEM LNA)';
@override
String get settings_femLnaSubtitle =>
'Boosts receive sensitivity. Leave on unless you are troubleshooting a strong nearby signal.';
@override
String get settings_clientRepeatFreqWarning =>
'Použitie off-grid systému vyžaduje frekvencie 433, 869 alebo 918 MHz.';
@ -1866,6 +1876,9 @@ class AppLocalizationsSk extends AppLocalizations {
@override
String get map_showOverlaps => 'Prekrývanie opakovača kľúča';
@override
String get map_alwaysShowNames => 'Always show names';
@override
String get map_keyPrefix => 'Päťciferné predpona';

@ -579,6 +579,9 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get settings_infoModel => 'Model';
@override
String get settings_infoOffbandCaps => 'Offband capabilities';
@override
String get settings_infoBattery => 'Baterija';
@ -631,6 +634,13 @@ class AppLocalizationsSl extends AppLocalizations {
String get settings_clientRepeatSubtitle =>
'Omogočite temu naprave, da ponavlja paketne sporočila za druge.';
@override
String get settings_femLna => 'Receive amplifier (FEM LNA)';
@override
String get settings_femLnaSubtitle =>
'Boosts receive sensitivity. Leave on unless you are troubleshooting a strong nearby signal.';
@override
String get settings_clientRepeatFreqWarning =>
'Za ponovni prenos na brezžični način so potrebne frekvence 433, 869 ali 918 MHz.';
@ -1861,6 +1871,9 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get map_showOverlaps => 'Prekrivanje ključa ponovnega predvajanja';
@override
String get map_alwaysShowNames => 'Always show names';
@override
String get map_keyPrefix => 'Predpona ključa';

@ -577,6 +577,9 @@ class AppLocalizationsSv extends AppLocalizations {
@override
String get settings_infoModel => 'Model';
@override
String get settings_infoOffbandCaps => 'Offband capabilities';
@override
String get settings_infoBattery => 'Batteri';
@ -629,6 +632,13 @@ class AppLocalizationsSv extends AppLocalizations {
String get settings_clientRepeatSubtitle =>
'Låt enheten repetera nätpaket för andra användare.';
@override
String get settings_femLna => 'Receive amplifier (FEM LNA)';
@override
String get settings_femLnaSubtitle =>
'Boosts receive sensitivity. Leave on unless you are troubleshooting a strong nearby signal.';
@override
String get settings_clientRepeatFreqWarning =>
'För att kunna kommunicera utanför elnätet krävs frekvenserna 433, 869 eller 918 MHz.';
@ -1854,6 +1864,9 @@ class AppLocalizationsSv extends AppLocalizations {
@override
String get map_showOverlaps => 'Repeater-nyckelöverlappningar';
@override
String get map_alwaysShowNames => 'Always show names';
@override
String get map_keyPrefix => 'Nyckelprefix';

@ -585,6 +585,9 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get settings_infoModel => 'Model';
@override
String get settings_infoOffbandCaps => 'Offband capabilities';
@override
String get settings_infoBattery => 'Батарея';
@ -637,6 +640,13 @@ class AppLocalizationsUk extends AppLocalizations {
String get settings_clientRepeatSubtitle =>
'Дозвольте цьому пристрою повторювати пакети даних для інших пристроїв.';
@override
String get settings_femLna => 'Receive amplifier (FEM LNA)';
@override
String get settings_femLnaSubtitle =>
'Boosts receive sensitivity. Leave on unless you are troubleshooting a strong nearby signal.';
@override
String get settings_clientRepeatFreqWarning =>
'Повтор без підключення до мережі вимагає частоти 433, 869 або 918 МГц.';
@ -1874,6 +1884,9 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get map_showOverlaps => 'Перекриття ключів ретрансляторів';
@override
String get map_alwaysShowNames => 'Always show names';
@override
String get map_keyPrefix => 'Префікс ключа';

@ -553,6 +553,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get settings_infoModel => 'Model';
@override
String get settings_infoOffbandCaps => 'Offband capabilities';
@override
String get settings_infoBattery => '电池';
@ -604,6 +607,13 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get settings_clientRepeatSubtitle => '允许此设备重复发送网状数据包给其他设备';
@override
String get settings_femLna => 'Receive amplifier (FEM LNA)';
@override
String get settings_femLnaSubtitle =>
'Boosts receive sensitivity. Leave on unless you are troubleshooting a strong nearby signal.';
@override
String get settings_clientRepeatFreqWarning =>
'离网重复通信需要使用 433、869 或 918 兆赫兹的频率。';
@ -1768,6 +1778,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get map_showOverlaps => '重复键重叠';
@override
String get map_alwaysShowNames => 'Always show names';
@override
String get map_keyPrefix => '关键字前缀';

@ -27,6 +27,8 @@ import 'services/ui_view_state_service.dart';
import 'services/timeout_prediction_service.dart';
import 'services/observer_config_service.dart';
import 'services/block_service.dart';
import 'services/window_geometry_service.dart';
import 'storage/drift/blob_store.dart';
import 'storage/prefs_manager.dart';
import 'utils/app_logger.dart';
@ -36,9 +38,20 @@ void main() async {
// Initialize SharedPreferences cache
await PrefsManager.initialize();
// Move bulk data (message history, contacts) out of SharedPreferences into
// drift (#335). Must run after prefs are up and BEFORE any store reads, so
// no code sees a half-migrated state. Idempotent: a no-op once done.
//
// Deliberately awaited: the alternative is stores racing the migration, and
// this is the failure mode that produced #333.
await BlobStore.instance.migrateFromPrefs();
// Start always-on file logging (#97); no-op on web.
await FileLogService.instance.init();
// Restore the desktop window's saved position/size (#349); no-op off desktop.
await WindowGeometryService.instance.initialize();
// Initialize services
final storage = StorageService();
final connector = MeshCoreConnector();

@ -112,6 +112,9 @@ class AppSettings {
final bool mapShowChatNodes;
final bool mapShowOtherNodes;
final bool mapShowOverlaps;
/// Show node names at any zoom, instead of only past the zoom threshold.
final bool mapAlwaysShowNames;
final double mapTimeFilterHours; // 0 = all time
final bool mapKeyPrefixEnabled;
final String mapKeyPrefix;
@ -188,6 +191,7 @@ class AppSettings {
this.mapShowChatNodes = true,
this.mapShowOtherNodes = true,
this.mapShowOverlaps = false,
this.mapAlwaysShowNames = false,
this.mapTimeFilterHours = 0, // Default to all time
this.mapKeyPrefixEnabled = false,
this.mapKeyPrefix = '',
@ -252,6 +256,7 @@ class AppSettings {
'map_show_chat_nodes': mapShowChatNodes,
'map_show_other_nodes': mapShowOtherNodes,
'map_show_overlaps': mapShowOverlaps,
'map_always_show_names': mapAlwaysShowNames,
'map_time_filter_hours': mapTimeFilterHours,
'map_key_prefix_enabled': mapKeyPrefixEnabled,
'map_key_prefix': mapKeyPrefix,
@ -338,6 +343,7 @@ class AppSettings {
mapShowChatNodes: json['map_show_chat_nodes'] as bool? ?? true,
mapShowOtherNodes: json['map_show_other_nodes'] as bool? ?? true,
mapShowOverlaps: json['map_show_overlaps'] as bool? ?? false,
mapAlwaysShowNames: json['map_always_show_names'] as bool? ?? false,
mapTimeFilterHours:
(json['map_time_filter_hours'] as num?)?.toDouble() ?? 0,
mapKeyPrefixEnabled: json['map_key_prefix_enabled'] as bool? ?? false,
@ -462,6 +468,7 @@ class AppSettings {
bool? mapShowChatNodes,
bool? mapShowOtherNodes,
bool? mapShowOverlaps,
bool? mapAlwaysShowNames,
double? mapTimeFilterHours,
bool? mapKeyPrefixEnabled,
String? mapKeyPrefix,
@ -510,6 +517,7 @@ class AppSettings {
mapShowChatNodes: mapShowChatNodes ?? this.mapShowChatNodes,
mapShowOtherNodes: mapShowOtherNodes ?? this.mapShowOtherNodes,
mapShowOverlaps: mapShowOverlaps ?? this.mapShowOverlaps,
mapAlwaysShowNames: mapAlwaysShowNames ?? this.mapAlwaysShowNames,
mapTimeFilterHours: mapTimeFilterHours ?? this.mapTimeFilterHours,
mapKeyPrefixEnabled: mapKeyPrefixEnabled ?? this.mapKeyPrefixEnabled,
mapKeyPrefix: mapKeyPrefix ?? this.mapKeyPrefix,

@ -8,8 +8,22 @@ class Contact {
final String name;
final int type;
final int flags;
final int pathLength; // -1 = flood, 0+ = direct hops (from device)
final Uint8List path; // Path bytes from device
/// Hop count for [path]: -1 = flood, 0+ = number of hops.
///
/// This is the firmware path-len field's low 6 bits, which firmware defines
/// as a hash COUNT, not a byte length (`src/Packet.h:79-84`). It was
/// previously treated as a byte count, which truncated every path at widths
/// above 1. (#309)
final int pathLength;
/// Bytes per hop hash in [path] (1..3), from the path-len byte's high 2 bits.
///
/// Carried per-path rather than read from the connector's global width, so a
/// stored path can never be re-sliced at a width it was not captured at.
final int pathHashWidth;
final Uint8List path; // Path bytes from device (pathLength * pathHashWidth)
final int?
pathOverride; // User's path override: -1 = force flood, null = auto
final Uint8List? pathOverrideBytes; // User's path override bytes
@ -28,6 +42,7 @@ class Contact {
required this.type,
this.flags = 0,
required this.pathLength,
this.pathHashWidth = 1,
required this.path,
this.pathOverride,
this.pathOverrideBytes,
@ -80,6 +95,7 @@ class Contact {
int? type,
int? flags,
int? pathLength,
int? pathHashWidth,
Uint8List? path,
int? pathOverride,
Uint8List? pathOverrideBytes,
@ -98,6 +114,7 @@ class Contact {
type: type ?? this.type,
flags: flags ?? this.flags,
pathLength: pathLength ?? this.pathLength,
pathHashWidth: pathHashWidth ?? this.pathHashWidth,
path: path ?? this.path,
pathOverride: clearPathOverride
? null
@ -133,8 +150,8 @@ class Contact {
return parts.join(',');
}
/// Default grouping uses legacy single-byte hop hash width.
String get pathIdList => pathFormattedIdList(pathHashSize);
/// Groups by this path's own captured width, not a global or legacy default.
String get pathIdList => pathFormattedIdList(pathHashWidth);
String get shortPubKeyHex {
return "<${publicKeyHex.substring(0, 8)}...${publicKeyHex.substring(publicKeyHex.length - 8)}>";
@ -166,14 +183,20 @@ class Contact {
final type = reader.readByte();
final flags = reader.readByte();
final pathLen = reader.readByte();
// The firmware path-len byte packs a hash-mode hint in the high 2 bits and
// the path BYTE length in the low 6 (#222). Decode before use: a direct
// node at 2-byte mode sends 0x40, whose raw value would otherwise read as
// 64 hops and pull 64 junk bytes into the path. 0xFF stays the flood
// sentinel; the device-configured pathHashByteWidth converts bytes->hops
// at display time.
final byteLen = pathLen == 0xFF ? -1 : pathHopCount(pathLen);
final safePathLen = byteLen > 0 ? byteLen : 0;
// The firmware path-len byte packs hash size in the high 2 bits and hash
// COUNT (hops) in the low 6; the byte length is count * size
// (`src/Packet.h:79-84`). 0xFF stays the flood sentinel.
//
// This previously read `count` BYTES, so at 2-byte width it kept half of
// every path and discarded the rest the truncation behind #240's failed
// repeater logins. The width is taken from the path itself rather than the
// connector's global width, so a path is always sliced at the width it was
// captured at. (#309)
final isFlood = pathLen == 0xFF;
final hopCount = isFlood ? -1 : pathHopCount(pathLen);
final hashWidth = isFlood ? 1 : pathHashSizeBytes(pathLen);
final byteLen = isFlood ? 0 : (hopCount * hashWidth);
final safePathLen = byteLen.clamp(0, maxPathSize);
final pathBytes = reader.readBytes(maxPathSize).sublist(0, safePathLen);
final name = reader.readCStringGreedy(maxNameSize);
@ -218,7 +241,9 @@ class Contact {
name: name.isEmpty ? 'Unknown' : name,
type: type,
flags: flags,
pathLength: byteLen, // decoded low-6-bit byte length; -1 = flood (#222)
pathLength:
hopCount, // hop count from the low 6 bits; -1 = flood (#309)
pathHashWidth: hashWidth, // bytes/hop from the high 2 bits (#309)
path: pathBytes,
latitude: lat,
longitude: lon,

@ -29,6 +29,8 @@ import '../services/chat_text_scale_service.dart';
import '../services/translation_service.dart';
import '../utils/emoji_utils.dart';
import '../utils/route_transitions.dart';
import 'settings_screen.dart';
import '../utils/dialog_utils.dart';
import '../widgets/app_shell.dart';
import '../widgets/channel_drawer_list.dart';
import '../widgets/mention_autocomplete.dart';
@ -287,6 +289,11 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
);
}
Future<void> _disconnect(BuildContext context) async {
final connector = context.read<MeshCoreConnector>();
await showDisconnectDialog(context, connector);
}
/// Bottom-bar navigation out of an open channel.
///
/// Tapping Channels returns to the channel list. Tapping Contacts or Map
@ -351,6 +358,13 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
currentChannelIndex: _currentChannel.index,
onChannelSelected: _switchChannel,
),
// Present here too: the footer is app-level, so it belongs on every
// screen carrying the panel, not just the primary views.
onDisconnect: () => _disconnect(context),
onSettings: () => Navigator.push(
context,
MaterialPageRoute(builder: (context) => const SettingsScreen()),
),
appBarBuilder: (context, pinned) => AppBar(
// Pinned: the panel is already docked, so no hamburger is needed.
// Unpinned: this is a pushed route, so Scaffold would put a back arrow
@ -642,8 +656,8 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
final enableTracing = settingsService.settings.enableMessageTracing;
final hashWidth = context.read<MeshCoreConnector>().pathHashByteWidth;
final isOutgoing = message.isOutgoing;
final gifId = GifHelper.parseGif(message.text);
final gifReplyName = gifId != null
final gifUrl = GifHelper.resolveGifUrl(message.text);
final gifReplyName = gifUrl != null
? TranslatedMessageContent.leadingReplyName(message.text)
: null;
final poi = parseMarkerText(message.text);
@ -689,7 +703,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
? (_) => _showMessageActions(message)
: null,
child: Container(
padding: gifId != null
padding: gifUrl != null
? const EdgeInsets.all(4)
: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
constraints: BoxConstraints(
@ -706,7 +720,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
children: [
if (!isOutgoing) ...[
Padding(
padding: gifId != null
padding: gifUrl != null
? const EdgeInsets.only(
left: 8,
top: 4,
@ -722,7 +736,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
),
),
),
if (gifId == null) const SizedBox(height: 4),
if (gifUrl == null) const SizedBox(height: 4),
],
if (poi != null)
_buildPoiMessage(
@ -732,7 +746,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
textScale,
message.senderName,
)
else if (gifId != null)
else if (gifUrl != null)
Column(
crossAxisAlignment: isOutgoing
? CrossAxisAlignment.end
@ -752,8 +766,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: GifMessage(
url:
'https://media.giphy.com/media/$gifId/giphy.gif',
url: gifUrl,
backgroundColor: Colors.transparent,
fallbackTextColor: isOutgoing
? Theme.of(context)
@ -797,7 +810,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
if (enableTracing && displayPath.isNotEmpty) ...[
const SizedBox(height: 2),
Padding(
padding: gifId != null
padding: gifUrl != null
? const EdgeInsets.symmetric(horizontal: 8)
: EdgeInsets.zero,
child: Text(
@ -1161,8 +1174,8 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
child: ValueListenableBuilder<TextEditingValue>(
valueListenable: _textController,
builder: (context, value, child) {
final gifId = GifHelper.parseGif(value.text);
if (gifId != null) {
final gifUrl = GifHelper.resolveGifUrl(value.text);
if (gifUrl != null) {
return Focus(
autofocus: true,
onKeyEvent: (node, event) {
@ -1181,8 +1194,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: GifMessage(
url:
'https://media.giphy.com/media/$gifId/giphy.gif',
url: gifUrl,
backgroundColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
@ -1530,8 +1542,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
}
Widget _buildHopBadge(BuildContext context, ChannelMessage message) {
final hashWidth = context.read<MeshCoreConnector>().pathHashByteWidth;
final hops = realHopCount(message.hopCount, hashWidth) ?? 0;
// message.hopCount is already a hop count (path-len low 6 bits). It used to
// be divided by the device width, which halved it at 2-byte width. (#309)
final hops = message.hopCount ?? 0;
final color = Theme.of(context).brightness == Brightness.dark
? Colors.grey[400]
: Colors.grey[600];

@ -43,9 +43,11 @@ class ChannelMessagePathScreen extends StatelessWidget {
final hashWidth = connector.pathHashByteWidth;
final hops = _buildPathHops(primaryPath, connector, l10n, hashWidth);
final hasHopDetails = primaryPath.isNotEmpty;
// Observed = bytes we hold / this path's width. Claimed = the hop count
// the path-len byte already carries; dividing it again halved it. (#309)
final observedLabel = _formatObservedHops(
primaryPath.length ~/ hashWidth,
realHopCount(message.hopCount, hashWidth),
primaryPath.length ~/ message.pathHashSize,
message.hopCount,
l10n,
);
final extraPaths = _otherPaths(primaryPath, message.pathVariants);
@ -121,7 +123,6 @@ class ChannelMessagePathScreen extends StatelessWidget {
Widget _buildSummaryCard(BuildContext context, {String? observedLabel}) {
final l10n = context.l10n;
final hashWidth = context.read<MeshCoreConnector>().pathHashByteWidth;
return Card(
child: Padding(
padding: const EdgeInsets.all(12),
@ -145,7 +146,7 @@ class ChannelMessagePathScreen extends StatelessWidget {
),
_buildDetailRow(
l10n.channelPath_pathLabelTitle,
_formatPathLabel(realHopCount(message.hopCount, hashWidth), l10n),
_formatPathLabel(message.hopCount, l10n),
),
if (observedLabel != null)
_buildDetailRow(l10n.channelPath_observedLabel, observedLabel),

@ -97,11 +97,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
return const SizedBox.shrink();
}
final allowBack = !connector.isConnected;
return PopScope(
canPop: allowBack,
child: AppShell(
return AppShell(
selectedIndex: 1,
onDestinationSelected: (index) => _handleQuickSwitch(index, context),
contactsUnreadCount: connector.getTotalContactsUnreadCount(),
@ -109,24 +105,22 @@ class _ChannelsScreenState extends State<ChannelsScreen>
drawerContent: ChannelDrawerList(
onChannelSelected: (channel) => _openChannel(context, channel),
),
onDisconnect: () => _disconnect(context),
onSettings: () => Navigator.push(
context,
MaterialPageRoute(builder: (context) => const SettingsScreen()),
),
appBar: AppBar(
title: AppBarTitle(context.l10n.channels_title),
centerTitle: true,
bottom: const SyncProgressAppBarBottom(),
actions: [
// Disconnect and Settings moved to the panel footer (#290); only
// the screen-level Communities entry remains, and it already only
// appears once a community has been joined.
if (_communities.isNotEmpty)
PopupMenuButton(
itemBuilder: (context) => [
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.logout, color: Colors.red),
const SizedBox(width: 8),
Text(context.l10n.common_disconnect),
],
),
onTap: () => _disconnect(context),
),
if (_communities.isNotEmpty)
PopupMenuItem(
child: Row(
children: [
@ -137,21 +131,6 @@ class _ChannelsScreenState extends State<ChannelsScreen>
),
onTap: () => _showManageCommunitiesDialog(context),
),
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.settings),
const SizedBox(width: 8),
Text(context.l10n.settings_title),
],
),
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const SettingsScreen(),
),
),
),
],
icon: const Icon(Icons.more_vert),
),
@ -342,7 +321,6 @@ class _ChannelsScreenState extends State<ChannelsScreen>
tooltip: context.l10n.channels_addChannel,
child: const Icon(Icons.add),
),
),
);
}

@ -32,6 +32,7 @@ import '../services/chat_text_scale_service.dart';
import '../services/path_history_service.dart';
import '../services/translation_service.dart';
import '../widgets/chat_zoom_wrapper.dart';
import '../widgets/contact_settings_dialog.dart';
import '../widgets/elements_ui.dart';
import '../widgets/mention_autocomplete.dart';
import '../helpers/emoji_shortcodes.dart';
@ -621,8 +622,8 @@ class _ChatScreenState extends State<ChatScreen> {
child: ValueListenableBuilder<TextEditingValue>(
valueListenable: _textController,
builder: (context, value, child) {
final gifId = GifHelper.parseGif(value.text);
if (gifId != null) {
final gifUrl = GifHelper.resolveGifUrl(value.text);
if (gifUrl != null) {
return Focus(
autofocus: true,
onKeyEvent: (node, event) {
@ -641,8 +642,7 @@ class _ChatScreenState extends State<ChatScreen> {
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: GifMessage(
url:
'https://media.giphy.com/media/$gifId/giphy.gif',
url: gifUrl,
backgroundColor:
colorScheme.surfaceContainerHighest,
fallbackTextColor: colorScheme.onSurface
@ -1274,15 +1274,11 @@ class _ChatScreenState extends State<ChatScreen> {
return context.l10n.chat_hopsForced(contact.pathOverride!);
}
// Use device's path. pathLength is a BYTE length; divide by the device's
// configured hash width to get the true hop count (#222).
// Use device's path. pathLength is a HOP count straight from the path-len
// byte's low 6 bits; it used to be divided by the device width, which
// halved it at 2-byte width. (#309)
if (contact.pathLength < 0) return context.l10n.chat_floodAuto;
final hops =
realHopCount(
contact.pathLength,
context.read<MeshCoreConnector>().pathHashByteWidth,
) ??
0;
final hops = contact.pathLength;
if (hops == 0) return context.l10n.chat_direct;
return context.l10n.chat_hopsCount(hops);
}
@ -1354,166 +1350,7 @@ class _ChatScreenState extends State<ChatScreen> {
}
void _showContactSettings(BuildContext context) {
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final appSettingsService = Provider.of<AppSettingsService>(
context,
listen: false,
);
connector.ensureContactSmazSettingLoaded(widget.contact.publicKeyHex);
connector.ensureContactCyr2LatSettingLoaded(widget.contact.publicKeyHex);
final contact = widget.contact;
bool smazEnabled = connector.isContactSmazEnabled(contact.publicKeyHex);
bool cyr2latEnabled = connector.isContactCyr2LatEnabled(
contact.publicKeyHex,
);
String? selectedCyr2LatProfileId = connector.getContactCyr2LatProfileId(
contact.publicKeyHex,
);
bool teleBaseEnabled = contact.teleBaseEnabled;
bool teleLocEnabled = contact.teleLocEnabled;
bool teleEnvEnabled = contact.teleEnvEnabled;
showDialog(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setDialogState) => AlertDialog(
title: Text(context.l10n.contact_settings),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (contact.hasLocation) ...[
_buildInfoRow(
context.l10n.chat_location,
'${contact.latitude?.toStringAsFixed(4)}, ${contact.longitude?.toStringAsFixed(4)}',
),
const Divider(height: 8),
],
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(context.l10n.channels_smazCompression),
subtitle: Text(context.l10n.chat_compressOutgoingMessages),
value: smazEnabled,
onChanged: (value) {
connector.setContactSmazEnabled(
contact.publicKeyHex,
value,
);
connector.setContactCyr2LatEnabled(
contact.publicKeyHex,
false,
);
setDialogState(() {
smazEnabled = value;
if (smazEnabled) {
cyr2latEnabled = false;
}
});
},
),
const Divider(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(context.l10n.channels_cyr2latCompression),
subtitle: Text(context.l10n.channels_cyr2latCompressionDscr),
value: cyr2latEnabled,
onChanged: (value) {
connector.setContactCyr2LatEnabled(
contact.publicKeyHex,
value,
);
connector.setContactSmazEnabled(
contact.publicKeyHex,
false,
);
setDialogState(() {
cyr2latEnabled = value;
if (cyr2latEnabled) {
smazEnabled = false;
}
});
},
),
if (cyr2latEnabled) ...[
Padding(
padding: const EdgeInsets.fromLTRB(0, 8, 0, 8),
child: DropdownButtonFormField<String>(
initialValue: selectedCyr2LatProfileId,
decoration: InputDecoration(
labelText:
context.l10n.channels_cyr2latSettingsSubheading,
border: const OutlineInputBorder(),
),
items: appSettingsService.settings.cyr2latProfiles.map((
profile,
) {
return DropdownMenuItem(
value: profile.id,
child: Text(profile.name),
);
}).toList(),
onChanged: (value) {
connector.setContactCyr2LatProfileId(
contact.publicKeyHex,
value,
);
setDialogState(() {
selectedCyr2LatProfileId = value;
});
},
),
),
],
const Divider(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(context.l10n.contact_teleBase),
subtitle: Text(context.l10n.contact_teleBaseSubtitle),
value: teleBaseEnabled,
onChanged: (value) {
setDialogState(() => teleBaseEnabled = value);
},
),
const Divider(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(context.l10n.contact_teleLoc),
subtitle: Text(context.l10n.contact_teleLocSubtitle),
value: teleLocEnabled,
onChanged: (value) {
setDialogState(() => teleLocEnabled = value);
},
),
const Divider(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(context.l10n.contact_teleEnv),
subtitle: Text(context.l10n.contact_teleEnvSubtitle),
value: teleEnvEnabled,
onChanged: (value) {
setDialogState(() => teleEnvEnabled = value);
},
),
],
),
),
actions: [
TextButton(
onPressed: () {
connector.setContactFlags(
contact,
teleBase: teleBaseEnabled,
teleLoc: teleLocEnabled,
teleEnv: teleEnvEnabled,
);
Navigator.pop(context);
},
child: Text(context.l10n.common_close),
),
],
),
),
);
showContactSettingsDialog(context, widget.contact);
}
Widget _buildInfoRow(String label, String value) {
@ -1870,7 +1707,7 @@ class _MessageBubble extends StatelessWidget {
final enableTracing = settingsService.settings.enableMessageTracing;
final isOutgoing = message.isOutgoing;
final colorScheme = Theme.of(context).colorScheme;
final gifId = GifHelper.parseGif(message.text);
final gifUrl = GifHelper.resolveGifUrl(message.text);
final poi = parseMarkerText(message.text);
final isFailed = message.status == MessageStatus.failed;
final bubbleColor = isFailed
@ -1919,7 +1756,7 @@ class _MessageBubble extends StatelessWidget {
],
Flexible(
child: Container(
padding: gifId != null
padding: gifUrl != null
? const EdgeInsets.all(4)
: const EdgeInsets.symmetric(
horizontal: 12,
@ -1937,7 +1774,7 @@ class _MessageBubble extends StatelessWidget {
children: [
if (!isOutgoing) ...[
Padding(
padding: gifId != null
padding: gifUrl != null
? const EdgeInsets.only(
left: 8,
top: 4,
@ -1953,7 +1790,7 @@ class _MessageBubble extends StatelessWidget {
),
),
),
if (gifId == null) const SizedBox(height: 4),
if (gifUrl == null) const SizedBox(height: 4),
],
if (poi != null)
_buildPoiMessage(
@ -1978,14 +1815,13 @@ class _MessageBubble extends StatelessWidget {
)
: null,
)
else if (gifId != null)
else if (gifUrl != null)
Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: GifMessage(
url:
'https://media.giphy.com/media/$gifId/giphy.gif',
url: gifUrl,
backgroundColor: Colors.transparent,
fallbackTextColor: textColor.withValues(
alpha: 0.7,
@ -2057,7 +1893,7 @@ class _MessageBubble extends StatelessWidget {
if (isOutgoing && message.retryCount > 0) ...[
const SizedBox(height: 4),
Padding(
padding: gifId != null
padding: gifUrl != null
? const EdgeInsets.symmetric(horizontal: 8)
: EdgeInsets.zero,
child: Text(
@ -2078,7 +1914,7 @@ class _MessageBubble extends StatelessWidget {
],
const SizedBox(height: 4),
Padding(
padding: gifId != null
padding: gifUrl != null
? const EdgeInsets.only(
left: 8,
right: 8,

@ -29,6 +29,7 @@ import '../widgets/empty_state.dart';
import '../widgets/blocked_badge.dart';
import '../widgets/app_shell.dart';
import '../widgets/contact_filter_rail.dart';
import '../widgets/contact_settings_dialog.dart';
import '../widgets/path_selection_dialog.dart';
import '../widgets/repeater_login_dialog.dart';
import '../widgets/room_login_dialog.dart';
@ -317,15 +318,17 @@ class _ContactsScreenState extends State<ContactsScreen>
return const SizedBox.shrink();
}
final allowBack = !connector.isConnected;
return PopScope(
canPop: allowBack,
child: AppShell(
return AppShell(
selectedIndex: 0,
onDestinationSelected: (index) => _handleQuickSwitch(index, context),
contactsUnreadCount: connector.getTotalContactsUnreadCount(),
channelsUnreadCount: connector.getTotalChannelsUnreadCount(),
drawerContent: const ContactFilterRail(),
onDisconnect: () => _disconnect(context, connector),
onSettings: () => Navigator.push(
context,
MaterialPageRoute(builder: (context) => const SettingsScreen()),
),
appBar: AppBar(
title: AppBarTitle(context.l10n.contacts_title),
bottom: const SyncProgressAppBarBottom(),
@ -387,18 +390,10 @@ class _ContactsScreenState extends State<ContactsScreen>
],
icon: const Icon(Icons.connect_without_contact),
),
// Disconnect and Settings moved to the panel footer (#290).
// Discovered contacts is screen-level and stays here.
PopupMenuButton(
itemBuilder: (context) => [
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.logout, color: Colors.red),
const SizedBox(width: 8),
Text(context.l10n.common_disconnect),
],
),
onTap: () => _disconnect(context, connector),
),
PopupMenuItem(
child: Row(
children: [
@ -414,28 +409,12 @@ class _ContactsScreenState extends State<ContactsScreen>
),
),
),
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.settings),
const SizedBox(width: 8),
Text(context.l10n.settings_title),
],
),
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const SettingsScreen(),
),
),
),
],
icon: const Icon(Icons.more_vert),
),
],
),
body: _buildContactsBody(context, connector),
),
);
}
@ -1274,6 +1253,16 @@ class _ContactsScreenState extends State<ContactsScreen>
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Same destination as the chat ellipsis -> Contact settings (#351).
// First item, so the destructive Block tile stays separated below.
ListTile(
leading: const Icon(Icons.settings_outlined),
title: Text(context.l10n.contact_settings),
onTap: () {
Navigator.pop(sheetContext);
showContactSettingsDialog(context, contact);
},
),
// Blocking your own node is never meaningful (#250).
if (!isSelf)
ListTile(

@ -24,6 +24,7 @@ import '../services/map_tile_cache_service.dart';
import '../utils/contact_search.dart';
import '../utils/route_transitions.dart';
import '../widgets/app_shell.dart';
import '../widgets/map_layer_panel.dart';
import '../widgets/sync_progress_overlay.dart';
import '../icons/los_icon.dart';
import 'channels_screen.dart';
@ -398,7 +399,8 @@ class _MapScreenState extends State<MapScreen> {
// Re center map after removed markers have loaded
if (!_hasInitializedMap && _removedMarkersLoaded) {
_hasInitializedMap = true;
_showNodeLabels = initialZoom >= _labelZoomThreshold;
_showNodeLabels =
settings.mapAlwaysShowNames || initialZoom >= _labelZoomThreshold;
if (hasMapContent) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
@ -408,16 +410,17 @@ class _MapScreenState extends State<MapScreen> {
}
}
final allowBack = !connector.isConnected;
return PopScope(
canPop: allowBack,
child: AppShell(
return AppShell(
selectedIndex: 2,
onDestinationSelected: (index) =>
_handleQuickSwitch(index, context),
onDestinationSelected: (index) => _handleQuickSwitch(index, context),
contactsUnreadCount: connector.getTotalContactsUnreadCount(),
channelsUnreadCount: connector.getTotalChannelsUnreadCount(),
drawerContent: const MapLayerPanel(),
onDisconnect: () => _disconnect(context, connector),
onSettings: () => Navigator.push(
context,
MaterialPageRoute(builder: (context) => const SettingsScreen()),
),
appBar: AppBar(
title: AppBarTitle(context.l10n.map_title),
centerTitle: true,
@ -472,36 +475,6 @@ class _MapScreenState extends State<MapScreen> {
},
tooltip: context.l10n.map_lineOfSight,
),
PopupMenuButton(
itemBuilder: (context) => [
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.logout, color: Colors.red),
const SizedBox(width: 8),
Text(context.l10n.common_disconnect),
],
),
onTap: () => _disconnect(context, connector),
),
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.settings),
const SizedBox(width: 8),
Text(context.l10n.settings_title),
],
),
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const SettingsScreen(),
),
),
),
],
icon: const Icon(Icons.more_vert),
),
],
),
body: Stack(
@ -561,7 +534,9 @@ class _MapScreenState extends State<MapScreen> {
);
},
onPositionChanged: (camera, hasGesture) {
final shouldShow = camera.zoom >= _labelZoomThreshold;
final shouldShow =
settings.mapAlwaysShowNames ||
camera.zoom >= _labelZoomThreshold;
if (shouldShow != _showNodeLabels && mounted) {
setState(() {
_showNodeLabels = shouldShow;
@ -599,12 +574,14 @@ class _MapScreenState extends State<MapScreen> {
if (!settings.mapShowOverlaps)
..._buildGuessedMarker(
guessedLocations,
showLabels: _showNodeLabels,
showLabels:
settings.mapAlwaysShowNames || _showNodeLabels,
),
..._buildMarkers(
contactsWithLocation,
settings,
showLabels: _showNodeLabels,
showLabels:
settings.mapAlwaysShowNames || _showNodeLabels,
),
...sharedMarkers.map(_buildSharedMarker),
if (connector.selfLatitude != null &&
@ -629,9 +606,7 @@ class _MapScreenState extends State<MapScreen> {
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(
alpha: 0.3,
),
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 4,
offset: const Offset(0, 2),
),
@ -683,7 +658,6 @@ class _MapScreenState extends State<MapScreen> {
tooltip: context.l10n.map_filterNodes,
child: const Icon(Icons.filter_list),
),
),
);
},
);

@ -25,6 +25,11 @@ class ScannerScreen extends StatefulWidget {
class _ScannerScreenState extends State<ScannerScreen> {
bool _changedNavigation = false;
/// Re-entrancy guard for routing into the app. Distinct from
/// [_changedNavigation], which records that we entered at least once and
/// gates the disconnect-on-dispose cleanup.
bool _routingIntoApp = false;
late final MeshCoreConnector _connector;
late final VoidCallback _connectionListener;
BluetoothAdapterState _bluetoothState = BluetoothAdapterState.unknown;
@ -40,14 +45,28 @@ class _ScannerScreenState extends State<ScannerScreen> {
if (_connector.state == MeshCoreConnectionState.disconnected) {
_changedNavigation = false;
} else if (_connector.state == MeshCoreConnectionState.connected &&
_connector.activeTransport == MeshCoreTransportType.bluetooth &&
isCurrentRoute &&
!_changedNavigation) {
!_routingIntoApp) {
// Route in whenever this screen is showing while connected, not just
// on the first connect. Otherwise landing back here with a live
// connection is a dead end: Connect does nothing, because there is
// nothing left to connect.
//
// Deliberately transport-agnostic. This was bluetooth-only, which left
// the same dead end for USB and TCP users, who cannot connect their way
// out of it either. The first-connect path for those transports is not
// affected: their own screen sits on top while connecting, so
// isCurrentRoute is false here and they still navigate themselves.
_changedNavigation = true;
_routingIntoApp = true;
if (mounted) {
Navigator.of(context).push(
Navigator.of(context)
.push(
MaterialPageRoute(builder: (context) => const ChannelsScreen()),
);
)
.then((_) {
if (mounted) _routingIntoApp = false;
});
}
}
};

@ -403,6 +403,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
_buildInfoRow(l10n.settings_infoFirmware, firmwareVersion),
if (deviceModel != null)
_buildInfoRow(l10n.settings_infoModel, deviceModel),
// Capability-gated controls vanish silently when a bit is clear,
// which is indistinguishable from a bug. Surface the raw inputs so
// "missing feature" can be diagnosed without enabling logging. (#304)
if (connector.offbandCaps != null)
_buildInfoRow(
l10n.settings_infoOffbandCaps,
'0x${connector.offbandCaps!.toRadixString(16).padLeft(2, '0')}'
' (v${connector.firmwareVerCode ?? 0})'
'${connector.supportsOffbandFemLna ? ' · FEM LNA' : ''}'
'${connector.supportsOffbandBlock ? ' · block' : ''}',
),
_buildBatteryInfoRow(context, connector),
if (connector.selfName != null)
_buildInfoRow(l10n.settings_nodeName, connector.selfName!),
@ -2192,6 +2203,23 @@ class _RadioSettingsFormState extends State<_RadioSettingsForm> {
contentPadding: EdgeInsets.zero,
),
],
// Only this radio's own FEM probe decides whether this appears — never
// model or version (#304). Deliberately not mirrored into local state:
// firmware returns post-apply hardware truth, so the switch renders
// what the radio reports rather than what we asked for.
if (widget.connector.supportsOffbandFemLna) ...[
const SizedBox(height: 16),
ListenableBuilder(
listenable: widget.connector,
builder: (context, _) => SwitchListTile(
title: Text(l10n.settings_femLna),
subtitle: Text(l10n.settings_femLnaSubtitle),
value: widget.connector.femLnaEnabled ?? true,
onChanged: (value) => widget.connector.setFemLna(value),
contentPadding: EdgeInsets.zero,
),
),
],
const SizedBox(height: 16),
SizedBox(
width: double.infinity,

@ -76,6 +76,10 @@ class AppSettingsService extends ChangeNotifier {
await updateSettings(_settings.copyWith(mapShowOverlaps: value));
}
Future<void> setMapAlwaysShowNames(bool value) async {
await updateSettings(_settings.copyWith(mapAlwaysShowNames: value));
}
Future<void> setMapTimeFilterHours(double value) async {
await updateSettings(_settings.copyWith(mapTimeFilterHours: value));
}

@ -4,6 +4,7 @@ import 'dart:ui';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter/foundation.dart';
import '../helpers/gif_helper.dart';
import '../helpers/reaction_helper.dart';
import '../l10n/app_localizations.dart';
import '../utils/platform_info.dart';
@ -154,7 +155,9 @@ class NotificationService {
if (reaction != null) {
return 'Reacted ${reaction.emoji}';
}
if (RegExp(r'^g:[A-Za-z0-9_-]+$').hasMatch(trimmed)) {
// resolveGifUrl, not parseGif: the tray must summarise exactly the set the
// chat renders inline, or an allowlisted Tenor GIF shows as a raw URL. (#283)
if (GifHelper.resolveGifUrl(trimmed) != null) {
return 'Sent a GIF';
}
return text;

@ -0,0 +1,150 @@
import 'dart:async';
import 'dart:convert';
import 'dart:ui';
import 'package:screen_retriever/screen_retriever.dart';
import 'package:window_manager/window_manager.dart';
import '../storage/prefs_manager.dart';
import '../utils/app_logger.dart';
import '../utils/platform_info.dart';
/// Persists and restores the desktop window's position and size (#349).
///
/// The stock Flutter desktop runner never saved geometry, so the window always
/// reopened at a default frame. This service saves the frame on move/resize and
/// restores it on launch.
///
/// The restore is clamped against the currently-connected displays: a window
/// last placed on a monitor that is now unplugged must not reopen off-screen
/// where the user cannot reach it. If the saved frame does not overlap any
/// display enough to grab, it is discarded and the window opens at the default.
///
/// Desktop only (window_manager has no mobile/web backend); a no-op elsewhere.
class WindowGeometryService with WindowListener {
WindowGeometryService._();
static final WindowGeometryService instance = WindowGeometryService._();
static const _prefsKey = 'window_geometry';
static const _minWidth = 400.0;
static const _minHeight = 300.0;
// A window counts as reachable if at least this much of it overlaps a
// display on both axes, so a sliver still on screen is recoverable.
static const _minVisible = 80.0;
Timer? _saveDebounce;
/// Call once in main() before runApp, after PrefsManager.initialize().
Future<void> initialize() async {
if (!PlatformInfo.isDesktop) return;
await windowManager.ensureInitialized();
await windowManager.waitUntilReadyToShow(null, () async {
await _restore();
await windowManager.show();
await windowManager.focus();
});
windowManager.addListener(this);
// Intercept close so the final geometry is saved even if the user moves
// then closes inside the debounce window; onWindowClose does the save and
// then destroys the window (Gemini review).
await windowManager.setPreventClose(true);
}
Future<void> _restore() async {
final raw = PrefsManager.instance.getString(_prefsKey);
if (raw == null || raw.isEmpty) return;
Rect saved;
try {
final m = jsonDecode(raw) as Map<String, dynamic>;
saved = Rect.fromLTWH(
(m['x'] as num).toDouble(),
(m['y'] as num).toDouble(),
(m['w'] as num).toDouble(),
(m['h'] as num).toDouble(),
);
} catch (e) {
// SAFELANE 6: never swallow. A corrupt value falls through to the default.
appLogger.error(
'Failed to decode saved window geometry; using default: $e',
tag: 'Window',
);
return;
}
if (saved.width < _minWidth || saved.height < _minHeight) return;
if (!await _isReachable(saved)) {
appLogger.info(
'Saved window geometry is off-screen (monitor likely unplugged); '
'opening at the default position instead.',
tag: 'Window',
);
return;
}
await windowManager.setBounds(saved);
}
/// True when [saved] overlaps some connected display's visible area by at
/// least [_minVisible] on both axes.
Future<bool> _isReachable(Rect saved) async {
final displays = await screenRetriever.getAllDisplays();
for (final d in displays) {
// Use the visible pair when BOTH are known, else the total pair. Mixing a
// visible origin with a total size makes a rect offset from the real
// area (Gemini review). visiblePosition can be negative for a monitor
// left of / above the primary, which intersect handles correctly.
final Rect display;
if (d.visiblePosition != null && d.visibleSize != null) {
display = d.visiblePosition! & d.visibleSize!;
} else {
display = Offset.zero & d.size;
}
final overlap = saved.intersect(display);
// intersect() can return negative width/height when the rects miss; only
// a genuine overlap on both axes counts.
if (overlap.width >= _minVisible && overlap.height >= _minVisible) {
return true;
}
}
return false;
}
void _scheduleSave() {
_saveDebounce?.cancel();
_saveDebounce = Timer(const Duration(milliseconds: 400), _save);
}
Future<void> _save() async {
try {
final b = await windowManager.getBounds();
await PrefsManager.instance.setString(
_prefsKey,
jsonEncode({'x': b.left, 'y': b.top, 'w': b.width, 'h': b.height}),
);
} catch (e) {
appLogger.error('Failed to save window geometry: $e', tag: 'Window');
}
}
@override
void onWindowMoved() => _scheduleSave();
@override
void onWindowResized() => _scheduleSave();
/// Fires because setPreventClose(true) intercepted the close. Save the final
/// geometry, release resources, then actually close the window.
@override
Future<void> onWindowClose() async {
_saveDebounce?.cancel();
try {
await _save();
} finally {
windowManager.removeListener(this);
await windowManager.destroy();
}
}
}

@ -5,7 +5,7 @@ import 'package:meshcore_open/utils/app_logger.dart';
import '../models/channel_message.dart';
import '../models/translation_support.dart';
import '../helpers/smaz.dart';
import 'prefs_manager.dart';
import 'drift/blob_store.dart';
class ChannelMessageStore {
static const String _keyPrefix = 'channel_messages_';
@ -32,11 +32,25 @@ class ChannelMessageStore {
String _indexKey(int channelIndex) => '$keyFor$channelIndex';
/// Active storage key: PSK identity when known, else the slot index.
///
/// The index fallback is legitimate before a channel list has loaded, but it
/// reads a DIFFERENT key: if history was already migrated to the PSK key, the
/// caller gets an empty list that is indistinguishable from data loss.
/// SAFELANE 6 - this must never be silent. Falling back where a resolver
/// exists means the channel list was not ready, which is the #333 race.
String _storageKey(int channelIndex) {
final pskHex = channelPskResolver?.call(channelIndex);
if (pskHex != null && pskHex.isNotEmpty) {
return '$keyFor$_pskMarker$pskHex';
}
if (channelPskResolver != null) {
appLogger.warn(
'Channel $channelIndex has no PSK yet; falling back to the slot-index '
'key. Any history already migrated to the PSK key will read as EMPTY. '
'This means the channel list was not loaded first (#333).',
tag: 'Storage',
);
}
return _indexKey(channelIndex);
}
@ -51,9 +65,89 @@ class ChannelMessageStore {
);
return;
}
final prefs = PrefsManager.instance;
final jsonList = messages.map((msg) => _messageToJson(msg)).toList();
await prefs.setString(_storageKey(channelIndex), jsonEncode(jsonList));
// Merge into the persisted full history rather than overwriting it. The
// in-memory list is windowed to the most recent N for memory, so a plain
// overwrite would truncate the store to N and erode old history (#343).
// Upsert by identity: keep older persisted messages, add new ones, and let
// the in-memory copy win so edits/reactions/status updates are captured.
// Deletion has its own path (removeChannelMessage) so this never
// resurrects a message the user deleted.
final key = _storageKey(channelIndex);
final blobs = BlobStore.instance;
// Serialise the whole read-modify-write against other saves/deletes on this
// key so two concurrent saves cannot clobber each other (Gemini review).
await blobs.synchronized(key, () async {
final byKey = <String, ChannelMessage>{};
final existing = await blobs.readWithPrefsFallback(key);
if (existing != null && existing.isNotEmpty) {
try {
for (final e in jsonDecode(existing) as List<dynamic>) {
final m = _messageFromJson(e as Map<String, dynamic>);
byKey[_mergeKey(m)] = m;
}
} catch (e) {
// SAFELANE 6: never merge into an empty base and truncate silently.
appLogger.error(
'Failed to decode existing channel $channelIndex history before '
'merge; aborting save to avoid truncation: $e',
tag: 'Storage',
);
return;
}
}
for (final m in messages) {
byKey[_mergeKey(m)] = m;
}
final merged = byKey.values.toList()
..sort((a, b) => a.timestamp.compareTo(b.timestamp));
await blobs.write(key, jsonEncode(merged.map(_messageToJson).toList()));
});
}
/// Stable identity for merge/dedupe. messageId when present, else a composite
/// that distinguishes distinct messages that share no id.
String _mergeKey(ChannelMessage m) {
if (m.messageId.isNotEmpty) return 'id:${m.messageId}';
// Include the sender: two different senders can post identical text at the
// same timestamp without a messageId, and would otherwise collide and lose
// one (Gemini review, 2026-07-20).
final sender = m.senderKey == null
? ''
: m.senderKey!.map((b) => b.toRadixString(16)).join();
return 'x:$sender:${m.packetHash ?? ''}:'
'${m.timestamp.millisecondsSinceEpoch}:${m.text}';
}
/// Removes a single message from the persisted history. The explicit delete
/// path (#343): save merges and never removes, so deletion cannot go through
/// save.
Future<void> removeChannelMessage(
int channelIndex,
ChannelMessage message,
) async {
if (publicKeyHex.isEmpty) return;
final key = _storageKey(channelIndex);
final blobs = BlobStore.instance;
await blobs.synchronized(key, () async {
final existing = await blobs.readWithPrefsFallback(key);
if (existing == null || existing.isEmpty) return;
final List<dynamic> raw;
try {
raw = jsonDecode(existing) as List<dynamic>;
} catch (e) {
appLogger.error(
'Failed to decode channel $channelIndex history for delete: $e',
tag: 'Storage',
);
return;
}
final target = _mergeKey(message);
final kept = raw
.map((e) => _messageFromJson(e as Map<String, dynamic>))
.where((m) => _mergeKey(m) != target)
.toList();
await blobs.write(key, jsonEncode(kept.map(_messageToJson).toList()));
});
}
/// Load messages for a specific channel
@ -64,9 +158,11 @@ class ChannelMessageStore {
);
return [];
}
final prefs = PrefsManager.instance;
final blobs = BlobStore.instance;
final key = _storageKey(channelIndex);
String? jsonString = prefs.getString(key);
// Bulk data lives in drift (#335); the fallback covers an unmigrated key
// and logs loudly if it fires.
String? jsonString = await blobs.readWithPrefsFallback(key);
// One-time migration into the PSK-identity key. Only runs when the PSK is
// known (key != index key). Adopts pre-#194 history keyed by slot index
@ -79,14 +175,37 @@ class ChannelMessageStore {
_indexKey(channelIndex),
'$_keyPrefix$channelIndex', // pre-device-scoping, unscoped index key
]) {
final legacy = prefs.getString(legacyKey);
// Legacy keys may sit in either backend depending on when this
// install last ran, so check both.
final legacy = await blobs.readWithPrefsFallback(legacyKey);
if (legacy != null && legacy.isNotEmpty) {
appLogger.info(
'Migrating channel messages $legacyKey -> $key (PSK-keyed, #194)',
);
await prefs.setString(key, legacy);
await prefs.remove(legacyKey);
jsonString = legacy;
// Under the key lock, and MERGE rather than overwrite: a save may
// have landed on the PSK key between the read above and here, and a
// blind write would clobber it (Gemini review). Union keeps both the
// adopted legacy history and any freshly-saved message.
jsonString = await blobs.synchronized(key, () async {
final byKey = <String, ChannelMessage>{};
for (final srcJson in [await blobs.read(key), legacy]) {
if (srcJson == null || srcJson.isEmpty) continue;
try {
for (final e in jsonDecode(srcJson) as List<dynamic>) {
final m = _messageFromJson(e as Map<String, dynamic>);
byKey[_mergeKey(m)] = m;
}
} catch (_) {
// Skip an undecodable source rather than aborting the adoption.
}
}
final merged = byKey.values.toList()
..sort((a, b) => a.timestamp.compareTo(b.timestamp));
final encoded = jsonEncode(merged.map(_messageToJson).toList());
await blobs.write(key, encoded);
return encoded;
});
await blobs.deleteEverywhere(legacyKey);
break;
}
}
@ -108,17 +227,16 @@ class ChannelMessageStore {
/// history gone, and setChannel's reuse-clear (#193) needs any stale
/// slot-index history gone so it can't be migrated onto the new occupant.
Future<void> clearChannelMessages(int channelIndex) async {
final prefs = PrefsManager.instance;
await prefs.remove(_storageKey(channelIndex));
await prefs.remove(_indexKey(channelIndex));
final blobs = BlobStore.instance;
await blobs.deleteEverywhere(_storageKey(channelIndex));
await blobs.deleteEverywhere(_indexKey(channelIndex));
}
/// Clear all channel messages
Future<void> clearAllChannelMessages() async {
final prefs = PrefsManager.instance;
final keys = prefs.getKeys().where((k) => k.startsWith(keyFor)).toList();
for (var key in keys) {
await prefs.remove(key);
final blobs = BlobStore.instance;
for (final key in await blobs.keysWithPrefix(keyFor)) {
await blobs.deleteEverywhere(key);
}
}

@ -29,13 +29,15 @@ class ChannelOrderStore {
String? jsonString = prefs.getString(keyFor);
if (jsonString == null || jsonString.isEmpty) {
// Attempt migration from legacy unscoped key on first load
// Only remove the legacy key when it actually exists: every prefs
// mutation rewrites the whole file on Windows.
final legacyJsonString = prefs.getString(_keyPrefix);
prefs.remove(_keyPrefix);
if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
appLogger.info(
'Migrating channel order from legacy key $_keyPrefix to scoped key $keyFor',
);
await prefs.setString(keyFor, legacyJsonString);
await prefs.remove(_keyPrefix);
jsonString = legacyJsonString;
}
}

@ -25,13 +25,18 @@ class ChannelSettingsStore {
bool? enabled = prefs.getBool(key);
if (enabled == null) {
// Attempt migration from legacy unscoped key on first load
// Only touch storage when a legacy key actually exists. This ran
// unconditionally, and on Windows shared_preferences re-serialises and
// rewrites the WHOLE prefs file on every mutation, so removing a key
// that was never there still cost a full multi-MB write. Repeated per
// channel/contact on connect, that blocked the UI isolate for ~38s.
enabled = prefs.getBool(oldKey);
prefs.remove(oldKey);
if (enabled != null) {
appLogger.info(
'Migrating channel settings from legacy key $oldKey to scoped key $key',
);
await prefs.setBool(key, enabled);
await prefs.remove(oldKey);
}
}
return enabled ?? false;

@ -22,13 +22,15 @@ class ChannelStore {
String? jsonString = prefs.getString(keyFor);
if (jsonString == null || jsonString.isEmpty) {
// Attempt migration from legacy unscoped key on first load
// Only remove the legacy key when it actually exists: every prefs
// mutation rewrites the whole file on Windows.
final legacyJsonString = prefs.getString(_keyPrefix);
prefs.remove(_keyPrefix);
if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
appLogger.info(
'Migrating channel messages from legacy key $_keyPrefix to scoped key $keyFor',
);
await prefs.setString(keyFor, legacyJsonString);
await prefs.remove(_keyPrefix);
jsonString = legacyJsonString;
}
}
@ -45,7 +47,11 @@ class ChannelStore {
return jsonList
.map((entry) => _fromJson(entry as Map<String, dynamic>))
.toList();
} catch (_) {
} catch (e) {
// SAFELANE 6: never swallow. A decode failure here is
// indistinguishable from 'no data' to the caller, which reads
// to the user as data loss.
appLogger.error('Failed to decode channels: $e', tag: 'Storage');
return [];
}
}

@ -28,13 +28,15 @@ class CommunityStore {
String? jsonString = prefs.getString(keyFor);
if (jsonString == null || jsonString.isEmpty) {
// Attempt migration from legacy unscoped key on first load
// Only remove the legacy key when it actually exists: every prefs
// mutation rewrites the whole file on Windows.
final legacyJsonString = prefs.getString(_keyPrefix);
prefs.remove(_keyPrefix);
if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
appLogger.info(
'Migrating communities from legacy key $_keyPrefix to scoped key $keyFor',
);
await prefs.setString(keyFor, legacyJsonString);
await prefs.remove(_keyPrefix);
jsonString = legacyJsonString;
}
}
@ -105,7 +107,11 @@ class CommunityStore {
final communities = await loadCommunities();
try {
return communities.firstWhere((c) => c.id == communityId);
} catch (_) {
} catch (e) {
// SAFELANE 6: never swallow. A decode failure here is
// indistinguishable from 'no data' to the caller, which reads
// to the user as data loss.
appLogger.error('Failed to decode communities: $e', tag: 'Storage');
return null;
}
}
@ -116,7 +122,11 @@ class CommunityStore {
final communities = await loadCommunities();
try {
return communities.firstWhere((c) => c.communityId == cid);
} catch (_) {
} catch (e) {
// SAFELANE 6: never swallow. A decode failure here is
// indistinguishable from 'no data' to the caller, which reads
// to the user as data loss.
appLogger.error('Failed to decode communities: $e', tag: 'Storage');
return null;
}
}

@ -2,14 +2,16 @@ import 'dart:convert';
import 'dart:typed_data';
import '../models/contact.dart';
import 'prefs_manager.dart';
import '../utils/app_logger.dart';
import 'drift/blob_store.dart';
class ContactDiscoveryStore {
static const String _keyPrefix = 'discovered_contacts';
Future<List<Contact>> loadContacts() async {
final prefs = PrefsManager.instance;
final jsonStr = prefs.getString(_keyPrefix);
// Bulk data lives in drift (#335), not SharedPreferences. The fallback
// covers a key the migration has not moved yet and logs loudly if it fires.
final jsonStr = await BlobStore.instance.readWithPrefsFallback(_keyPrefix);
if (jsonStr == null) return [];
try {
@ -17,15 +19,19 @@ class ContactDiscoveryStore {
return jsonList
.map((entry) => _fromJson(entry as Map<String, dynamic>))
.toList();
} catch (_) {
} catch (e) {
// SAFELANE 6: a decode failure is not "no data".
appLogger.error(
'Failed to decode discovered contacts: $e',
tag: 'Storage',
);
return [];
}
}
Future<void> saveContacts(List<Contact> contacts) async {
final prefs = PrefsManager.instance;
final jsonList = contacts.map(_toJson).toList();
await prefs.setString(_keyPrefix, jsonEncode(jsonList));
await BlobStore.instance.write(_keyPrefix, jsonEncode(jsonList));
}
Map<String, dynamic> _toJson(Contact contact) {

@ -21,13 +21,15 @@ class ContactGroupStore {
String? jsonString = prefs.getString(keyFor);
if (jsonString == null || jsonString.isEmpty) {
// Attempt migration from legacy unscoped key on first load
// Only remove the legacy key when it actually exists: every prefs
// mutation rewrites the whole file on Windows.
final legacyJsonString = prefs.getString(_keyPrefix);
prefs.remove(_keyPrefix);
if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
appLogger.info(
'Migrating channel messages from legacy key $_keyPrefix to scoped key $keyFor',
);
await prefs.setString(keyFor, legacyJsonString);
await prefs.remove(_keyPrefix);
jsonString = legacyJsonString;
}
}

@ -25,13 +25,18 @@ class ContactSettingsStore {
bool? enabled = prefs.getBool(key);
if (enabled == null) {
// Attempt migration from legacy unscoped key on first load
// Only touch storage when a legacy key actually exists. This ran
// unconditionally, and on Windows shared_preferences re-serialises and
// rewrites the WHOLE prefs file on every mutation, so removing a key
// that was never there still cost a full multi-MB write. Repeated per
// channel/contact on connect, that blocked the UI isolate for ~38s.
enabled = prefs.getBool(oldKey);
prefs.remove(oldKey);
if (enabled != null) {
appLogger.info(
'Migrating contact settings from legacy key $oldKey to scoped key $key',
);
await prefs.setBool(key, enabled);
await prefs.remove(oldKey);
}
}
return prefs.getBool(key) ?? false;

@ -3,6 +3,7 @@ import 'dart:typed_data';
import '../models/contact.dart';
import '../utils/app_logger.dart';
import 'drift/blob_store.dart';
import 'prefs_manager.dart';
class ContactStore {
@ -19,23 +20,27 @@ class ContactStore {
appLogger.warn('Public key hex is not set. Cannot load contacts.');
return [];
}
final prefs = PrefsManager.instance;
String? jsonString = prefs.getString(keyFor);
// Bulk data lives in drift (#335). The fallback covers a key the migration
// has not moved yet, and logs loudly if it fires.
final blobs = BlobStore.instance;
String? jsonString = await blobs.readWithPrefsFallback(keyFor);
if (jsonString == null || jsonString.isEmpty) {
// Attempt migration from legacy unscoped key on first load
final legacyJsonString = prefs.getString(_keyPrefix);
prefs.remove(_keyPrefix);
if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
// Pre-device-scoping data still sits under the legacy unscoped key in
// SharedPreferences. Only touch prefs when it actually exists: an
// unconditional remove costs a full-file rewrite on Windows and a
// 5 MiB-capped synchronous write on web (#306).
final prefs = PrefsManager.instance;
final legacy = prefs.get(_keyPrefix);
if (legacy is String && legacy.isNotEmpty) {
appLogger.info(
'Migrating contacts from legacy key $_keyPrefix to scoped key $keyFor',
'Migrating contacts from legacy key $_keyPrefix to $keyFor (drift)',
);
await prefs.setString(keyFor, legacyJsonString);
jsonString = legacyJsonString;
await blobs.write(keyFor, legacy);
await prefs.remove(_keyPrefix);
jsonString = legacy;
}
}
if (jsonString == null || jsonString.isEmpty) {
jsonString = prefs.getString(keyFor);
}
if (jsonString == null || jsonString.isEmpty) {
return [];
}
@ -55,9 +60,8 @@ class ContactStore {
appLogger.warn('Public key hex is not set. Cannot save contacts.');
return;
}
final prefs = PrefsManager.instance;
final jsonList = contacts.map(_toJson).toList();
await prefs.setString(keyFor, jsonEncode(jsonList));
await BlobStore.instance.write(keyFor, jsonEncode(jsonList));
}
Map<String, dynamic> _toJson(Contact contact) {
@ -67,6 +71,7 @@ class ContactStore {
'type': contact.type,
'flags': contact.flags,
'pathLength': contact.pathLength,
'pathHashWidth': contact.pathHashWidth,
'path': base64Encode(contact.path),
'pathOverride': contact.pathOverride,
'pathOverrideBytes': contact.pathOverrideBytes != null
@ -88,13 +93,31 @@ class ContactStore {
final lastSeenMs = json['lastSeen'] as int? ?? 0;
final lastMessageMs = json['lastMessageAt'] as int?;
final lastModifiedMs = json['lastModified'] as int?;
final isLegacyPathRecord =
!json.containsKey('pathHashWidth') &&
((json['pathLength'] as int? ?? -1) > 0);
if (isLegacyPathRecord) {
appLogger.info(
'Dropping pre-#309 path for contact ${json['name']} '
'(decoded with the old count-as-bytes rule, so truncated); '
'reverting to flood until the device re-supplies it',
);
}
return Contact(
publicKey: Uint8List.fromList(base64Decode(json['publicKey'] as String)),
name: json['name'] as String? ?? 'Unknown',
type: json['type'] as int? ?? 0,
flags: json['flags'] as int? ?? 0,
pathLength: json['pathLength'] as int? ?? -1,
path: json['path'] != null
// Records written before #309 have no 'pathHashWidth' and their path was
// decoded with the old count-as-bytes rule, so at any width above 1 the
// stored bytes are a truncated fragment. Truncated bytes cannot be
// recovered by reinterpreting them, so a legacy record's path is dropped
// and the contact reverts to flood until the radio re-supplies it (the
// device refreshes contacts routinely, so this self-heals). Ben's call:
// re-fetch rather than keep a path we know is wrong. (#309)
pathLength: isLegacyPathRecord ? -1 : (json['pathLength'] as int? ?? -1),
pathHashWidth: json['pathHashWidth'] as int? ?? 1,
path: (!isLegacyPathRecord && json['path'] != null)
? Uint8List.fromList(base64Decode(json['path'] as String))
: Uint8List(0),
pathOverride: json['pathOverride'] as int?,

@ -0,0 +1,322 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import '../../utils/app_logger.dart';
import '../prefs_manager.dart';
import 'offband_database.dart';
/// Bulk-data store backed by drift, replacing SharedPreferences for anything
/// large (#335).
///
/// Why this exists: SharedPreferences is a settings store. On Windows every
/// mutation re-encodes and rewrites the WHOLE file synchronously, and on web it
/// is backed by `localStorage`, which is capped at 5 MiB per origin and is also
/// synchronous. This install holds ~5.2 MB of bulk data, so the web build
/// cannot function at all today and Windows stalls for tens of seconds (#306).
///
/// Each key becomes one row, so a write touches one row rather than the entire
/// store.
class BlobStore {
BlobStore(this._db);
final OffbandDatabase _db;
static OffbandDatabase? _sharedDb;
static BlobStore? _override;
/// Process-wide instance. The database must be opened once; opening it twice
/// is an error on the web backends.
static BlobStore get instance =>
_override ?? BlobStore(_sharedDb ??= OffbandDatabase());
/// Test seam: point the singleton at an in-memory database.
@visibleForTesting
static void overrideForTest(BlobStore store) => _override = store;
@visibleForTesting
static void clearTestOverride() => _override = null;
/// Key families that hold bulk data. Everything else stays in
/// SharedPreferences, which is what it is for.
static const List<String> migratedPrefixes = [
'channel_messages_',
'messages_',
'contacts',
'discovered_contacts',
];
static bool isBulkKey(String key) => migratedPrefixes.any(key.startsWith);
/// Per-key operation chain. Merge-on-save and the legacy-key migration are
/// read-modify-write sequences with an await gap; two of them racing on the
/// same key would let the second clobber the first and silently lose
/// messages (Gemini review, 2026-07-20). Every RMW on a key runs through
/// [synchronized], which serialises operations per key while leaving
/// different keys concurrent.
final Map<String, Future<void>> _keyChains = {};
/// Serialises [action] against other synchronized actions on the same [key].
Future<T> synchronized<T>(String key, Future<T> Function() action) {
final prior = _keyChains[key] ?? Future<void>.value();
final result = prior.then((_) => action());
// Next op waits for this one; swallow errors so one failure does not wedge
// the chain for the key.
_keyChains[key] = result.then((_) {}, onError: (_) {});
return result;
}
/// Reads a bulk key, falling back to SharedPreferences if drift does not
/// have it.
///
/// Belt and braces for the switchover: if migration ever missed a key, the
/// data must still be reachable rather than silently reading as empty, which
/// is exactly how #333 presented. The fallback is LOUD, because a fallback
/// that fires in normal operation means the migration is incomplete and
/// somebody needs to know.
Future<String?> readWithPrefsFallback(String key) async {
final fromDrift = await read(key);
if (fromDrift != null) return fromDrift;
final raw = PrefsManager.instance.get(key);
if (raw is! String || raw.isEmpty) return null;
appLogger.warn(
'Blob read for $key fell back to SharedPreferences: it is NOT in drift. '
'Migration is incomplete for this key; serving the prefs copy.',
tag: 'Storage',
);
return raw;
}
Future<String?> read(String key) async {
final row = await (_db.select(
_db.storedBlobs,
)..where((t) => t.key.equals(key))).getSingleOrNull();
return row?.value;
}
Future<void> write(String key, String value) async {
await _db
.into(_db.storedBlobs)
.insertOnConflictUpdate(
StoredBlobsCompanion.insert(key: key, value: value),
);
}
/// Keys beginning with [prefix], across BOTH backends.
///
/// Callers that clear a family of keys must see prefs-resident keys too, or
/// a pre-migration copy survives the clear and reappears through the read
/// fallback.
Future<List<String>> keysWithPrefix(String prefix) async {
// Filtered in Dart rather than SQL: the table holds tens of rows, so the
// cost is irrelevant and it avoids depending on a version-specific LIKE
// API for a pinned dependency.
final rows = await _db.select(_db.storedBlobs).get();
return {
...rows.map((r) => r.key).where((k) => k.startsWith(prefix)),
...PrefsManager.instance.getKeys().where((k) => k.startsWith(prefix)),
}.toList();
}
/// Removes a key from BOTH backends, so a clear cannot be undone by a
/// leftover prefs copy surfacing through the fallback.
Future<void> deleteEverywhere(String key) async {
await delete(key);
await PrefsManager.instance.remove(key);
}
Future<void> delete(String key) async {
await (_db.delete(_db.storedBlobs)..where((t) => t.key.equals(key))).go();
}
/// Moves bulk keys out of SharedPreferences into drift.
///
/// Ordering is deliberate and non-negotiable: **write, verify by reading
/// back, and only then remove the source.** #333 was caused by a storage path
/// that chose a key silently and made 566 real messages read as empty; a
/// migration that deleted before verifying could do that permanently rather
/// than cosmetically.
///
/// Idempotent: keys already migrated are skipped, so re-running is a no-op
/// rather than a duplicate or an overwrite of newer data.
///
/// Every outcome is logged (SAFELANE 6). A failure never silently drops a
/// key: the source is left intact and the error surfaces.
Future<MigrationReport> migrateFromPrefs() async {
final prefs = PrefsManager.instance;
final report = MigrationReport();
final bulkKeys = prefs.getKeys().where(isBulkKey).toList();
if (bulkKeys.isEmpty) {
appLogger.info(
'Blob migration: nothing to migrate (already done or fresh install)',
tag: 'Storage',
);
return report;
}
appLogger.info(
'Blob migration: ${bulkKeys.length} key(s) to move out of prefs',
tag: 'Storage',
);
for (final key in bulkKeys) {
try {
// Type-check rather than calling getString directly: getString THROWS
// on a non-string value, which would be counted as a migration failure
// and cry wolf. A non-string under a bulk prefix is simply not bulk
// data.
final raw = prefs.get(key);
if (raw is! String || raw.isEmpty) {
report.skipped++;
continue;
}
final source = raw;
final existing = await read(key);
if (existing != null) {
// Drift already holds this key. Do NOT discard the prefs copy: a
// build that writes to SharedPreferences (a non-drift build, or any
// in-between test build) accumulates NEW messages there, and throwing
// them away silently gaps the history across test cycles (#355). Union
// the prefs copy into drift by message identity, keeping drift's live
// entries, then verify before removing the source.
final merged = _mergeBulk(existing, source);
if (merged == null) {
// Not a JSON list (e.g. a scalar under a bulk prefix): drift's copy
// stands, nothing to union. Safe to drop the prefs duplicate.
report.alreadyPresent++;
await prefs.remove(key);
continue;
}
if (merged == existing) {
// Prefs added nothing new; drift is already a superset.
report.alreadyPresent++;
await prefs.remove(key);
continue;
}
await write(key, merged);
final readBack = await read(key);
if (readBack != merged) {
report.failed++;
appLogger.error(
'Blob merge FAILED for $key: wrote ${merged.length} chars, '
'read back ${readBack?.length ?? "null"}. Prefs copy left intact.',
tag: 'Storage',
);
continue;
}
await prefs.remove(key);
report.merged++;
continue;
}
await write(key, source);
// Verify BEFORE removing the source. Length is compared rather than
// full equality to keep a multi-MB comparison cheap while still
// catching truncation, which is the realistic corruption here.
final readBack = await read(key);
if (readBack == null || readBack.length != source.length) {
report.failed++;
appLogger.error(
'Blob migration FAILED for $key: wrote ${source.length} chars, '
'read back ${readBack?.length ?? "null"}. Source left intact.',
tag: 'Storage',
);
continue;
}
await prefs.remove(key);
report.migrated++;
report.bytes += source.length;
} catch (e) {
report.failed++;
appLogger.error(
'Blob migration FAILED for $key: $e. Source left intact.',
tag: 'Storage',
);
}
}
final level = report.failed > 0 ? 'WITH FAILURES' : 'ok';
appLogger.info(
'Blob migration complete ($level): ${report.migrated} moved, '
'${report.merged} merged, '
'${report.alreadyPresent} already present, ${report.skipped} skipped, '
'${report.failed} failed, '
'${(report.bytes / 1024 / 1024).toStringAsFixed(2)} MB',
tag: 'Storage',
);
if (report.failed > 0) {
appLogger.error(
'Blob migration left ${report.failed} key(s) in SharedPreferences. '
'No data was lost, but those keys still carry the old cost.',
tag: 'Storage',
);
}
return report;
}
/// Unions the [prefs] copy of a bulk key into the [drift] copy by element
/// identity, keeping drift's entries where both hold the same one.
///
/// All bulk families store a JSON list of objects (messages keyed by
/// `messageId`, contacts by `publicKey`); identity falls back to the object's
/// canonical form so an id-less element is never dropped. Order is drift's
/// list followed by the prefs-only elements, which mirrors how the stores
/// append on save.
///
/// Returns null when either side is not a JSON list of objects (a scalar
/// under a bulk prefix), signalling the caller to leave drift's copy as-is.
/// Returns the drift JSON unchanged when prefs contributes nothing new.
static String? _mergeBulk(String drift, String prefs) {
final List<dynamic> driftList;
final List<dynamic> prefsList;
try {
final d = jsonDecode(drift);
final p = jsonDecode(prefs);
if (d is! List || p is! List) return null;
driftList = d;
prefsList = p;
} catch (_) {
return null;
}
String idOf(dynamic e) {
if (e is Map) {
final id = e['messageId'];
if (id is String && id.isNotEmpty) return 'm:$id';
final pk = e['publicKey'];
if (pk is String && pk.isNotEmpty) return 'p:$pk';
}
// No stable id: fall back to the element's canonical JSON so distinct
// elements stay distinct and true duplicates collapse.
return 'j:${jsonEncode(e)}';
}
final seen = <String>{for (final e in driftList) idOf(e)};
final result = List<dynamic>.from(driftList);
for (final e in prefsList) {
if (seen.add(idOf(e))) result.add(e);
}
if (result.length == driftList.length) return drift;
return jsonEncode(result);
}
}
/// Mutable tally of a migration run; surfaced in the log and used by tests.
class MigrationReport {
int migrated = 0;
int merged = 0;
int alreadyPresent = 0;
int skipped = 0;
int failed = 0;
int bytes = 0;
bool get hadFailures => failed > 0;
}

@ -0,0 +1,177 @@
import 'dart:io';
import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import '../../utils/app_logger.dart';
part 'offband_database.g.dart';
/// Bulk data store (#335). Replaces SharedPreferences for message history,
/// contacts and discovered contacts.
///
/// SharedPreferences is a settings store, and using it for bulk data is what
/// causes #306: on Windows every mutation re-encodes and rewrites the entire
/// file synchronously, and on web it is backed by `localStorage`, which is
/// capped at 5 MiB per origin and is also synchronous. This install already
/// holds ~7 MB, so the web build cannot function at all today.
///
/// drift is used because it is the only option verified to cover all six
/// targets: native SQLite on Android, iOS, macOS, Windows and Linux, and
/// SQLite compiled to WebAssembly on web, where it stores through OPFS or
/// IndexedDB and runs in a worker rather than on the UI thread.
///
/// Settings stay in SharedPreferences. Only bulk data moves here.
@DriftDatabase(tables: [StoredBlobs])
class OffbandDatabase extends _$OffbandDatabase {
OffbandDatabase([QueryExecutor? executor])
: super(executor ?? _defaultExecutor());
static const String _dbName = 'offband_store';
/// `drift_flutter` selects the platform backend: native SQLite on desktop
/// and mobile, WASM on web. `web:` names the assets that must be shipped for
/// the web build (`sqlite3.wasm`, `drift_worker.js`).
static QueryExecutor _defaultExecutor() {
return driftDatabase(
name: _dbName,
// Native default is getApplicationDocumentsDirectory(), which on Windows
// is the user's Documents folder — redirected into OneDrive on most
// machines. A live SQLite file syncing to OneDrive risks lock contention
// and corruption, and it is the wrong place for app data. Use the
// application-support dir instead (%APPDATA% on Windows), where
// SharedPreferences already lives. path_provider has no web
// implementation, so this only applies off web; web ignores
// databaseDirectory and uses OPFS/IndexedDB.
native: DriftNativeOptions(
databaseDirectory: _appSupportDatabaseDirectory,
),
web: DriftWebOptions(
sqlite3Wasm: Uri.parse('sqlite3.wasm'),
// Filename matches the asset published by the drift release, which is
// `drift_worker.js` not the `drift_worker.dart.js` the older docs
// name. Both assets are taken from the SAME drift release so they are
// built against each other.
driftWorker: Uri.parse('drift_worker.js'),
),
);
}
/// Resolves the application-support directory, and relocates a database left
/// in the old documents location by an earlier build.
///
/// Builds before this fix opened the DB under getApplicationDocumentsDirectory
/// (OneDrive on Windows). Existing installs already have data there, so this
/// moves the file - and its `-wal` / `-shm` sidecars - to the new location on
/// first run, once, before drift opens it. Never deletes the source without a
/// successful move; a failed move leaves the old file so no data is stranded.
static Future<Directory> _appSupportDatabaseDirectory() async {
final support = await getApplicationSupportDirectory();
final newPath = p.join(support.path, '$_dbName.sqlite');
if (!File(newPath).existsSync()) {
try {
final docs = await getApplicationDocumentsDirectory();
final oldPath = p.join(docs.path, '$_dbName.sqlite');
if (File(oldPath).existsSync() && oldPath != newPath) {
// Copy ALL files first, verify, and only then delete the sources.
// Deleting per-file mid-loop (Gemini review) could strand the main
// .sqlite in one place and its -wal in another if a later copy
// failed, corrupting the DB. Copy-all-then-delete-all makes a
// partial failure recoverable: the original stays intact and the
// half-written destination is cleaned up.
final suffixes = ['', '-wal', '-shm'];
final present = suffixes
.where((s) => File('$oldPath$s').existsSync())
.toList();
try {
for (final s in present) {
File('$oldPath$s').copySync('$newPath$s');
}
// Verify every copy exists with a matching size before deleting.
for (final s in present) {
final src = File('$oldPath$s');
final dst = File('$newPath$s');
if (!dst.existsSync() || dst.lengthSync() != src.lengthSync()) {
throw StateError('copy verification failed for $newPath$s');
}
}
for (final s in present) {
File('$oldPath$s').deleteSync();
}
appLogger.info(
'Relocated drift DB out of the documents dir (OneDrive on '
'Windows) into the app-support dir: $oldPath -> $newPath',
tag: 'Storage',
);
} catch (e) {
// Roll back any partial destination so drift does not open a
// half-copied DB; the original in the documents dir is untouched.
for (final s in present) {
final dst = File('$newPath$s');
if (dst.existsSync()) {
try {
dst.deleteSync();
} catch (_) {}
}
}
rethrow;
}
}
} catch (e) {
// Relocation is best-effort. On failure the ORIGINAL DB is left intact
// in the documents dir (the destination was rolled back), so no data is
// lost - drift opens a fresh DB at the support location and the user's
// history remains recoverable from the old path. Loud, never silent.
appLogger.error(
'Failed to relocate the drift DB from the documents dir: $e. The '
'original at the old path is intact; a fresh DB will open at the '
'app-support location. Recover the old DB manually if needed.',
tag: 'Storage',
);
}
}
return support;
}
@override
int get schemaVersion => 1;
/// Step-1 gate for #335: proves the database opens, writes and reads back on
/// the current platform. Deliberately trivial - no user data is involved, so
/// this can be run on every target before any migration is written.
Future<bool> verifyReadWrite() async {
const probeKey = '__offband_probe__';
final probeValue = 'ok-${DateTime.now().microsecondsSinceEpoch}';
await into(storedBlobs).insertOnConflictUpdate(
StoredBlobsCompanion.insert(key: probeKey, value: probeValue),
);
final row = await (select(
storedBlobs,
)..where((t) => t.key.equals(probeKey))).getSingleOrNull();
await (delete(storedBlobs)..where((t) => t.key.equals(probeKey))).go();
return row?.value == probeValue;
}
}
/// One row per stored blob, replacing one SharedPreferences key each.
///
/// Deliberately key/value for the first migration: it maps 1:1 onto the
/// existing store interfaces, so callers do not change and the migration is
/// verifiable row-for-row against the old keys. Relational tables for messages
/// and contacts are a later step, once this is proven and querying is wanted.
class StoredBlobs extends Table {
TextColumn get key => text()();
TextColumn get value => text()();
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
@override
Set<Column> get primaryKey => {key};
}

@ -4,6 +4,7 @@ import '../models/message.dart';
import '../models/translation_support.dart';
import '../helpers/smaz.dart';
import '../utils/app_logger.dart';
import 'drift/blob_store.dart';
import 'prefs_manager.dart';
class MessageStore {
@ -23,10 +24,69 @@ class MessageStore {
appLogger.warn('Public key hex is not set. Cannot save messages.');
return;
}
final prefs = PrefsManager.instance;
// Merge into the persisted full history rather than overwriting it (#343).
// The in-memory list is windowed for memory, so a plain overwrite would
// truncate the store. Upsert by identity; deletion is explicit
// (removeMessage).
final key = '$keyFor$contactKeyHex';
final blobs = BlobStore.instance;
await blobs.synchronized(key, () async {
final byKey = <String, Message>{};
final existing = await blobs.readWithPrefsFallback(key);
if (existing != null && existing.isNotEmpty) {
try {
for (final e in jsonDecode(existing) as List<dynamic>) {
final m = _messageFromJson(e as Map<String, dynamic>);
byKey[_mergeKey(m)] = m;
}
} catch (e) {
appLogger.error(
'Failed to decode existing DM history before merge; aborting save '
'to avoid truncation: $e',
tag: 'Storage',
);
return;
}
}
for (final m in messages) {
byKey[_mergeKey(m)] = m;
}
final merged = byKey.values.toList()
..sort((a, b) => a.timestamp.compareTo(b.timestamp));
await blobs.write(key, jsonEncode(merged.map(_messageToJson).toList()));
});
}
String _mergeKey(Message m) {
if (m.messageId.isNotEmpty) return 'id:${m.messageId}';
return 'x:${m.senderKeyHex}:${m.timestamp.millisecondsSinceEpoch}:${m.text}';
}
/// Explicit delete path (#343): save merges and never removes.
Future<void> removeMessage(String contactKeyHex, Message message) async {
if (publicKeyHex.isEmpty) return;
final key = '$keyFor$contactKeyHex';
final jsonList = messages.map(_messageToJson).toList();
await prefs.setString(key, jsonEncode(jsonList));
final existing = await BlobStore.instance.readWithPrefsFallback(key);
if (existing == null || existing.isEmpty) return;
final List<dynamic> raw;
try {
raw = jsonDecode(existing) as List<dynamic>;
} catch (e) {
appLogger.error(
'Failed to decode DM history for delete: $e',
tag: 'Storage',
);
return;
}
final target = _mergeKey(message);
final kept = raw
.map((e) => _messageFromJson(e as Map<String, dynamic>))
.where((m) => _mergeKey(m) != target)
.toList();
await BlobStore.instance.write(
key,
jsonEncode(kept.map(_messageToJson).toList()),
);
}
Future<List<Message>> loadMessages(String contactKeyHex) async {
@ -34,24 +94,30 @@ class MessageStore {
appLogger.warn('Public key hex is not set. Cannot load messages.');
return [];
}
final prefs = PrefsManager.instance;
final key = '$keyFor$contactKeyHex';
final oldKey = '$_keyPrefix$contactKeyHex';
String? jsonString = prefs.getString(key);
// Bulk data lives in drift (#335); fallback covers an unmigrated key and
// logs loudly if it fires.
final blobs = BlobStore.instance;
String? jsonString = await blobs.readWithPrefsFallback(key);
if (jsonString == null || jsonString.isEmpty) {
// Attempt migration from legacy unscoped key on first load
final legacyJsonString = prefs.getString(oldKey);
prefs.remove(oldKey);
if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
// Only touch prefs when the legacy key actually exists. An unconditional
// remove here ran once per contact and cost a full-file rewrite each
// time on Windows (#306).
final prefs = PrefsManager.instance;
final legacy = prefs.get(oldKey);
if (legacy is String && legacy.isNotEmpty) {
appLogger.info(
'Migrating messages from legacy key $oldKey to scoped key $key',
'Migrating messages from legacy key $oldKey to $key (drift)',
);
await prefs.setString(key, legacyJsonString);
jsonString = legacyJsonString;
await blobs.write(key, legacy);
await prefs.remove(oldKey);
jsonString = legacy;
}
}
if (jsonString == null || jsonString.isEmpty) {
jsonString = prefs.getString(keyFor);
jsonString = await blobs.readWithPrefsFallback(keyFor);
}
if (jsonString == null || jsonString.isEmpty) {
return [];
@ -70,9 +136,11 @@ class MessageStore {
appLogger.warn('Public key hex is not set. Cannot clear messages.');
return;
}
final prefs = PrefsManager.instance;
final key = '$keyFor$contactKeyHex';
await prefs.remove(key);
// Clear both backends: drift is authoritative, but a pre-migration prefs
// copy must not survive a clear and reappear via the read fallback.
await BlobStore.instance.delete(key);
await PrefsManager.instance.remove(key);
}
Map<String, dynamic> _messageToJson(Message msg) {

@ -35,13 +35,15 @@ class UnreadStore {
String? jsonString = prefs.getString(keyFor);
if (jsonString == null || jsonString.isEmpty) {
// Attempt migration from legacy unscoped key on first load
// Only remove the legacy key when it actually exists: every prefs
// mutation rewrites the whole file on Windows.
final legacyJsonString = prefs.getString(_keyPrefix);
prefs.remove(_keyPrefix);
if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
appLogger.info(
'Migrating channel messages from legacy key $_keyPrefix to scoped key $keyFor',
);
await prefs.setString(keyFor, legacyJsonString);
await prefs.remove(_keyPrefix);
jsonString = legacyJsonString;
}
}
@ -55,7 +57,11 @@ class UnreadStore {
try {
final json = jsonDecode(jsonString) as Map<String, dynamic>;
return json.map((key, value) => MapEntry(key, value as int));
} catch (_) {
} catch (e) {
// SAFELANE 6: never swallow. A decode failure here is
// indistinguishable from 'no data' to the caller, which reads
// to the user as data loss.
appLogger.error('Failed to decode unread state: $e', tag: 'Storage');
return {};
}
}

@ -0,0 +1,33 @@
import 'package:flutter/services.dart';
import 'platform_info.dart';
/// Sends the app to the background without tearing it down.
///
/// `SystemNavigator.pop()` is NOT this: on Android it calls `finish()` on the
/// activity, which destroys the Flutter engine and drops the radio connection,
/// so reopening the app finds itself disconnected. `moveTaskToBack` leaves the
/// activity alive and simply hands focus back to whatever was in front before.
class AppBackgrounder {
static const MethodChannel _channel = MethodChannel(
'meshcore_open/app_lifecycle',
);
/// Returns true when the app was actually backgrounded.
///
/// Android only. Desktop windows are closed by their own chrome and iOS
/// forbids programmatic backgrounding, so elsewhere this is a no-op and the
/// caller should leave the press unhandled rather than act on it.
static Future<bool> moveToBackground() async {
if (!PlatformInfo.isAndroid) return false;
try {
return await _channel.invokeMethod<bool>('moveTaskToBack') ?? false;
} on PlatformException catch (_) {
// Surface nothing to the user: the fallback is simply that back does
// nothing at the root, which is the pre-existing behaviour.
return false;
} on MissingPluginException catch (_) {
return false;
}
}
}

@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../l10n/l10n.dart';
import '../services/ui_view_state_service.dart';
import '../utils/app_backgrounder.dart';
import 'quick_switch_bar.dart';
/// Shared shell for the primary views (Contacts / Channels / Map).
@ -11,7 +13,7 @@ import 'quick_switch_bar.dart';
/// dockable pane that can be pinned open on wide ones.
///
/// The bottom bar stays a bottom bar at every width; it never becomes a rail.
class AppShell extends StatelessWidget {
class AppShell extends StatefulWidget {
static const double wideBreakpoint = 720;
static const double _drawerWidth = 300;
@ -36,6 +38,12 @@ class AppShell extends StatelessWidget {
/// List content for the active view. Null renders an empty drawer body.
final Widget? drawerContent;
/// App-level actions, pinned to the bottom of the panel. These are not
/// contextual, which is why they were duplicated in three screens' overflow
/// menus before. Screen-level actions stay in their own overflow menu.
final VoidCallback? onDisconnect;
final VoidCallback? onSettings;
const AppShell({
super.key,
required this.body,
@ -45,15 +53,63 @@ class AppShell extends StatelessWidget {
this.appBarBuilder,
this.floatingActionButton,
this.drawerContent,
this.onDisconnect,
this.onSettings,
this.contactsUnreadCount = 0,
this.channelsUnreadCount = 0,
});
@override
State<AppShell> createState() => _AppShellState();
}
class _AppShellState extends State<AppShell> {
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
/// System back, in priority order:
/// 1. an open drawer closes,
/// 2. on a detail screen, pop back to the list it came from,
/// 3. on a primary view, send the app to the background so Android
/// returns to the home screen or the previous app.
///
/// Step 3 must NOT pop, even though the route below can be popped. The
/// primary views sit on top of the scanner, so popping would dump a
/// connected user back onto the radio-connect list. Reaching the scanner is
/// what Disconnect is for, not what Back is for.
///
/// A primary view is one carrying the bottom bar; a detail screen (a channel
/// chat) has no [selectedIndex] and is genuinely pushed.
Future<void> _handleBack() async {
final scaffold = _scaffoldKey.currentState;
if (scaffold?.isDrawerOpen ?? false) {
scaffold!.closeDrawer();
return;
}
final isPrimaryView = widget.selectedIndex != null;
final navigator = Navigator.of(context);
if (!isPrimaryView && navigator.canPop()) {
navigator.pop();
return;
}
// Background, do NOT finish. SystemNavigator.pop() would call finish() on
// the activity, tearing down the Flutter engine and dropping the radio
// connection, so reopening the app would show a disconnected radio.
await AppBackgrounder.moveToBackground();
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (didPop) return;
_handleBack();
},
child: LayoutBuilder(
builder: (context, constraints) {
final isWide = constraints.maxWidth >= wideBreakpoint;
final isWide = constraints.maxWidth >= AppShell.wideBreakpoint;
final pinned =
isWide && context.watch<UiViewStateService>().navDrawerPinned;
@ -61,32 +117,34 @@ class AppShell extends StatelessWidget {
? _buildPinned(context)
: _buildTransient(context, isWide);
},
),
);
}
/// Null on detail screens, which show the panel but no bottom bar.
Widget? _bottomBar() {
final index = selectedIndex;
final onSelected = onDestinationSelected;
final index = widget.selectedIndex;
final onSelected = widget.onDestinationSelected;
if (index == null || onSelected == null) return null;
return SafeArea(
top: false,
child: QuickSwitchBar(
selectedIndex: index,
onDestinationSelected: onSelected,
contactsUnreadCount: contactsUnreadCount,
channelsUnreadCount: channelsUnreadCount,
contactsUnreadCount: widget.contactsUnreadCount,
channelsUnreadCount: widget.channelsUnreadCount,
),
);
}
PreferredSizeWidget? _appBar(BuildContext context, bool pinned) {
return appBarBuilder?.call(context, pinned) ?? appBar;
return widget.appBarBuilder?.call(context, pinned) ?? widget.appBar;
}
/// Wide + pinned: the panel is laid out beside the body, not overlaid.
Widget _buildPinned(BuildContext context) {
return Scaffold(
key: _scaffoldKey,
appBar: _appBar(context, true),
body: SafeArea(
top: false,
@ -94,15 +152,20 @@ class AppShell extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
width: _drawerWidth,
child: _NavPanel(isWide: true, content: drawerContent),
width: AppShell._drawerWidth,
child: _NavPanel(
isWide: true,
content: widget.drawerContent,
onDisconnect: widget.onDisconnect,
onSettings: widget.onSettings,
),
),
const VerticalDivider(width: 1),
Expanded(child: body),
Expanded(child: widget.body),
],
),
),
floatingActionButton: floatingActionButton,
floatingActionButton: widget.floatingActionButton,
bottomNavigationBar: _bottomBar(),
);
}
@ -111,13 +174,19 @@ class AppShell extends StatelessWidget {
/// the hamburger into the app bar automatically.
Widget _buildTransient(BuildContext context, bool isWide) {
return Scaffold(
key: _scaffoldKey,
appBar: _appBar(context, false),
drawer: Drawer(
width: _drawerWidth,
child: _NavPanel(isWide: isWide, content: drawerContent),
width: AppShell._drawerWidth,
child: _NavPanel(
isWide: isWide,
content: widget.drawerContent,
onDisconnect: widget.onDisconnect,
onSettings: widget.onSettings,
),
),
body: body,
floatingActionButton: floatingActionButton,
body: widget.body,
floatingActionButton: widget.floatingActionButton,
bottomNavigationBar: _bottomBar(),
);
}
@ -128,8 +197,15 @@ class AppShell extends StatelessWidget {
class _NavPanel extends StatelessWidget {
final bool isWide;
final Widget? content;
final VoidCallback? onDisconnect;
final VoidCallback? onSettings;
const _NavPanel({required this.isWide, this.content});
const _NavPanel({
required this.isWide,
this.content,
this.onDisconnect,
this.onSettings,
});
@override
Widget build(BuildContext context) {
@ -164,6 +240,51 @@ class _NavPanel extends StatelessWidget {
),
if (isWide) const Divider(height: 1),
Expanded(child: content ?? const SizedBox.shrink()),
if (onDisconnect != null || onSettings != null) ...[
const Divider(height: 1),
_Footer(onDisconnect: onDisconnect, onSettings: onSettings),
],
],
),
);
}
}
/// App-level actions pinned to the bottom of the panel.
class _Footer extends StatelessWidget {
final VoidCallback? onDisconnect;
final VoidCallback? onSettings;
const _Footer({this.onDisconnect, this.onSettings});
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final colors = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.fromLTRB(8, 6, 8, 8),
child: Row(
children: [
if (onDisconnect != null)
Expanded(
child: TextButton.icon(
// Kept visually distinct: this drops the radio connection,
// and it was a red menu entry before the move.
style: TextButton.styleFrom(foregroundColor: colors.error),
icon: const Icon(Icons.logout, size: 18),
label: Text(l10n.common_disconnect),
onPressed: onDisconnect,
),
),
if (onSettings != null)
Expanded(
child: TextButton.icon(
icon: const Icon(Icons.settings, size: 18),
label: Text(l10n.settings_title),
onPressed: onSettings,
),
),
],
),
);

@ -0,0 +1,180 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart';
import '../models/contact.dart';
import '../services/app_settings_service.dart';
/// The per-contact settings dialog (Smaz/Cyr2Lat compression + telemetry
/// grants). Shared so it can be opened from the chat ellipsis AND the contacts
/// long-press menu without duplicating the body (#351).
void showContactSettingsDialog(BuildContext context, Contact contact) {
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final appSettingsService = Provider.of<AppSettingsService>(
context,
listen: false,
);
connector.ensureContactSmazSettingLoaded(contact.publicKeyHex);
connector.ensureContactCyr2LatSettingLoaded(contact.publicKeyHex);
bool smazEnabled = connector.isContactSmazEnabled(contact.publicKeyHex);
bool cyr2latEnabled = connector.isContactCyr2LatEnabled(contact.publicKeyHex);
String? selectedCyr2LatProfileId = connector.getContactCyr2LatProfileId(
contact.publicKeyHex,
);
bool teleBaseEnabled = contact.teleBaseEnabled;
bool teleLocEnabled = contact.teleLocEnabled;
bool teleEnvEnabled = contact.teleEnvEnabled;
showDialog(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setDialogState) => AlertDialog(
title: Text(context.l10n.contact_settings),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (contact.hasLocation) ...[
_infoRow(
context.l10n.chat_location,
'${contact.latitude?.toStringAsFixed(4)}, ${contact.longitude?.toStringAsFixed(4)}',
),
const Divider(height: 8),
],
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(context.l10n.channels_smazCompression),
subtitle: Text(context.l10n.chat_compressOutgoingMessages),
value: smazEnabled,
onChanged: (value) {
connector.setContactSmazEnabled(contact.publicKeyHex, value);
connector.setContactCyr2LatEnabled(
contact.publicKeyHex,
false,
);
setDialogState(() {
smazEnabled = value;
if (smazEnabled) {
cyr2latEnabled = false;
}
});
},
),
const Divider(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(context.l10n.channels_cyr2latCompression),
subtitle: Text(context.l10n.channels_cyr2latCompressionDscr),
value: cyr2latEnabled,
onChanged: (value) {
connector.setContactCyr2LatEnabled(
contact.publicKeyHex,
value,
);
connector.setContactSmazEnabled(contact.publicKeyHex, false);
setDialogState(() {
cyr2latEnabled = value;
if (cyr2latEnabled) {
smazEnabled = false;
}
});
},
),
if (cyr2latEnabled) ...[
Padding(
padding: const EdgeInsets.fromLTRB(0, 8, 0, 8),
child: DropdownButtonFormField<String>(
initialValue: selectedCyr2LatProfileId,
decoration: InputDecoration(
labelText:
context.l10n.channels_cyr2latSettingsSubheading,
border: const OutlineInputBorder(),
),
items: appSettingsService.settings.cyr2latProfiles.map((
profile,
) {
return DropdownMenuItem(
value: profile.id,
child: Text(profile.name),
);
}).toList(),
onChanged: (value) {
connector.setContactCyr2LatProfileId(
contact.publicKeyHex,
value,
);
setDialogState(() {
selectedCyr2LatProfileId = value;
});
},
),
),
],
const Divider(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(context.l10n.contact_teleBase),
subtitle: Text(context.l10n.contact_teleBaseSubtitle),
value: teleBaseEnabled,
onChanged: (value) {
setDialogState(() => teleBaseEnabled = value);
},
),
const Divider(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(context.l10n.contact_teleLoc),
subtitle: Text(context.l10n.contact_teleLocSubtitle),
value: teleLocEnabled,
onChanged: (value) {
setDialogState(() => teleLocEnabled = value);
},
),
const Divider(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(context.l10n.contact_teleEnv),
subtitle: Text(context.l10n.contact_teleEnvSubtitle),
value: teleEnvEnabled,
onChanged: (value) {
setDialogState(() => teleEnvEnabled = value);
},
),
],
),
),
actions: [
TextButton(
onPressed: () {
connector.setContactFlags(
contact,
teleBase: teleBaseEnabled,
teleLoc: teleLocEnabled,
teleEnv: teleEnvEnabled,
);
Navigator.pop(context);
},
child: Text(context.l10n.common_close),
),
],
),
),
);
}
Widget _infoRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 80,
child: Text(label, style: TextStyle(color: Colors.grey[600])),
),
Expanded(child: SelectableText(value)),
],
),
);
}

@ -0,0 +1,106 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../l10n/l10n.dart';
import '../services/app_settings_service.dart';
/// Map layer toggles for the nav panel.
///
/// The Map view has no list to show, so its panel carries the layer controls
/// that otherwise live in a modal behind the floating button. Pinned on a wide
/// screen these stay visible and the map updates underneath as they are
/// flipped, instead of a dialog covering the thing being filtered.
///
/// The same settings back both surfaces, so a change here shows in the dialog
/// and vice versa.
class MapLayerPanel extends StatelessWidget {
const MapLayerPanel({super.key});
@override
Widget build(BuildContext context) {
final service = context.watch<AppSettingsService>();
final settings = service.settings;
final l10n = context.l10n;
final theme = Theme.of(context);
return ListView(
padding: EdgeInsets.zero,
children: [
_sectionHeader(theme, l10n.map_nodeTypes),
_toggle(
label: l10n.map_chatNodes,
icon: Icons.person_outline,
value: settings.mapShowChatNodes,
onChanged: service.setMapShowChatNodes,
),
_toggle(
label: l10n.map_repeaters,
icon: Icons.cell_tower,
value: settings.mapShowRepeaters,
onChanged: service.setMapShowRepeaters,
),
_toggle(
label: l10n.map_otherNodes,
icon: Icons.help_outline,
value: settings.mapShowOtherNodes,
onChanged: service.setMapShowOtherNodes,
),
const Divider(height: 1),
_toggle(
label: l10n.map_alwaysShowNames,
icon: Icons.label_outline,
value: settings.mapAlwaysShowNames,
onChanged: service.setMapAlwaysShowNames,
),
const Divider(height: 1),
_sectionHeader(theme, l10n.map_filterNodes),
_toggle(
label: l10n.map_showDiscoveryContacts,
icon: Icons.person_search,
value: settings.mapShowDiscoveryContacts,
onChanged: service.setMapShowDiscoveryContacts,
),
_toggle(
label: l10n.map_showGuessedLocations,
icon: Icons.location_searching,
value: settings.mapShowGuessedLocations,
onChanged: service.setMapShowGuessedLocations,
),
_toggle(
label: l10n.map_showOverlaps,
icon: Icons.layers,
value: settings.mapShowOverlaps,
onChanged: service.setMapShowOverlaps,
),
],
);
}
Widget _sectionHeader(ThemeData theme, String label) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Text(
label,
style: theme.textTheme.labelMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w700,
),
),
);
}
Widget _toggle({
required String label,
required IconData icon,
required bool value,
required ValueChanged<bool> onChanged,
}) {
return SwitchListTile(
dense: true,
secondary: Icon(icon, size: 20),
title: Text(label, maxLines: 2, overflow: TextOverflow.ellipsis),
value: value,
onChanged: onChanged,
);
}
}

@ -11,6 +11,7 @@ import '../services/storage_service.dart';
import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
import '../utils/app_logger.dart';
import '../helpers/path_helper.dart';
import 'path_management_dialog.dart';
class RepeaterLoginDialog extends StatefulWidget {
@ -115,9 +116,19 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
);
final timeoutSeconds = (timeoutMs / 1000).ceil();
final timeout = Duration(milliseconds: timeoutMs + 2000);
// `selection.hopCount` is ambiguous by construction: a device-discovered
// path carries a HOP count, a user override carries a BYTE count (#279).
// Do not assert hops here. Log the raw field, the width, and the actual
// byte length so a capture discriminates them:
// bytes == field -> byte-count semantics (override)
// bytes == field * w -> hop-count semantics (device)
// (#298)
final routingWidth = _connector.pathHashByteWidth;
final selectionLabel = selection.useFlood
? 'flood'
: '${selection.hopCount} hops';
: 'field=${selection.hopCount} w=$routingWidth '
'bytes=${selection.pathBytes.length} '
'[${PathHelper.formatPathHex(selection.pathBytes, routingWidth)}]';
appLogger.info('Login routing: $selectionLabel', tag: 'RepeaterLogin');
bool? loginResult;
bool isAdmin = false;

@ -11,6 +11,7 @@ import '../services/storage_service.dart';
import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
import '../utils/app_logger.dart';
import '../helpers/path_helper.dart';
import '../helpers/snack_bar_builder.dart';
import 'path_management_dialog.dart';
@ -111,9 +112,19 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
);
final timeoutSeconds = (timeoutMs / 1000).ceil();
final timeout = Duration(milliseconds: timeoutMs + 2000);
// `selection.hopCount` is ambiguous by construction: a device-discovered
// path carries a HOP count, a user override carries a BYTE count (#279).
// Do not assert hops here. Log the raw field, the width, and the actual
// byte length so a capture discriminates them:
// bytes == field -> byte-count semantics (override)
// bytes == field * w -> hop-count semantics (device)
// (#298)
final routingWidth = _connector.pathHashByteWidth;
final selectionLabel = selection.useFlood
? 'flood'
: '${selection.hopCount} hops';
: 'field=${selection.hopCount} w=$routingWidth '
'bytes=${selection.pathBytes.length} '
'[${PathHelper.formatPathHex(selection.pathBytes, routingWidth)}]';
appLogger.info('Login routing: $selectionLabel', tag: 'RoomLogin');
bool? loginResult;
bool isAdmin = false;

@ -1,7 +1,6 @@
{
"name": "meshcore-open",
"scripts": {
"build": "dart run build_pipe:build",
"deploy": "bun x wrangler deploy"
"build": "dart run build_pipe:build"
}
}

@ -0,0 +1,9 @@
What's new in 1.1.2-rc.3
- Block or mute contacts and channel senders, with a Blocked management screen
- Per-channel notifications: all, mentions only, or muted
- Better path tracing and mesh topology readout
- Share and add channels by QR code
- Fix: USB no longer wedges some ESP32 boards on connect
- Fix: messages to a blocked contact are no longer sent
- More radios report GPS position reliably

@ -0,0 +1,8 @@
What's new in 1.2.0. A major update.
- Your data now lives in a real database. Existing messages and contacts migrate automatically on first launch, nothing lost.
- New navigation drawer you can pin open, with the channel list built in and in-place channel switching.
- GIFs share as links now, so people on other MeshCore apps can see them.
- Desktop remembers its window size and position.
- Reach contact settings from the long-press menu.
- Back-button, path decoding, and connect-speed fixes.

@ -0,0 +1,16 @@
# Play Console "What's new"
One file per release: `play/<version>.txt`, e.g. `play/1.1.2-rc.4.txt`.
**Hard limit: 500 Unicode characters.** The release gate (`scripts/release-gate.sh`)
fails the build if this file is missing, empty, or over 500 chars — so the limit is caught
here, not as a Console rejection at upload time.
## Style
Plain language for end users. No issue numbers, no internal terms. Not promotional
(Google's Metadata policy forbids "free", "#1", "best", etc. and ALL-CAPS except a brand
name).
**The owner pastes this into Play Console manually.** CI validates the file; it never
uploads. Play distribution is an external, human-triggered action.

File diff suppressed because it is too large Load Diff

@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.1.2-rc.3+60
version: 1.2.0+61
environment:
sdk: ^3.9.2
@ -62,7 +62,7 @@ dependencies:
url_launcher: ^6.3.0 # Launch URLs in system browser
flutter_linkify: ^6.0.0 # Auto-detect and linkify URLs in text
gpx: ^2.3.0
path_provider: ^2.1.5
path_provider: ^2.1.6
share_plus: ^12.0.1
build_pipe: ^0.3.1
material_symbols_icons: ^4.2906.0
@ -73,6 +73,13 @@ dependencies:
ml_dataframe: ^1.0.0
llamadart: '>=0.6.8 <0.7.0'
flutter_langdetect: ^0.0.1
# PINNED (#335): web ships version-matched sqlite3.wasm + drift_worker.js
# from the drift release. Bump deliberately, then re-download both assets.
drift: 2.34.2
drift_flutter: 0.3.1
path: ^1.9.1
window_manager: ^0.5.2
screen_retriever: ^0.2.2
hooks:
user_defines:
@ -95,6 +102,8 @@ dev_dependencies:
# rules and activating additional ones.
flutter_lints: ^6.0.0
flutter_launcher_icons: ^0.14.4
drift_dev: 2.34.0
build_runner: ^2.15.1
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
@ -163,7 +172,7 @@ build_pipe:
platforms:
web:
build:
build_command: flutter build web --release --pwa-strategy=none
build_command: flutter build web --release --pwa-strategy=none --dart-define-from-file=dart_defines.json
# Strongly recommended: disables the default service worker which often causes more cache headaches
add_version_query_param: true
# This is the key flag! It appends ?v=<your pubspec version> to bootstrap/JS files

@ -0,0 +1,19 @@
## Offband Meshcore 1.1.2-rc.3
This release brings a full **block / mute system**, richer **path tracing and topology**, and **per-channel notification control**, along with a batch of connectivity and UX fixes. It combines the rc.2 and rc.3 closed-test builds.
### Blocking and muting
You can now block a contact or a channel sender from contacts, DMs, discovery, or directly from a channel message. Blocked DMs and adverts are suppressed with a visible marker, a name-only block is promoted to the sender's public key the next time they advert or DM you, and stale blocks age out after 30 days. Everything is managed from Settings → Blocked, and where the radio's firmware supports it, your block list syncs to the device on connect.
### Notifications, your way
Each channel gets a three-way notification selector — all messages, mentions only, or muted — applied right at the notification gate and remembered per channel.
### Path tracing and topology
Path traces are now width-matched to the mesh, with a topology readout and a route-builder that groups hops the way the network actually addresses them rather than as raw bytes. Channel QR codes let you share a channel or add one by scanning, using the reference-app format.
### Fixes worth calling out
- USB no longer wedges ESP32-C6 boards: the open-time DTR pulse is gated by device type, so it only fires where it's needed.
- Outgoing DMs to a blocked contact are dropped at the send path.
- GPS refresh falls back to stock-firmware telemetry when the fork-specific query isn't available, so more radios report position without flooding the link.
See `CHANGELOG.md` for the full itemized list.

@ -0,0 +1,105 @@
## Offband Meshcore 1.2.0
Our biggest release yet. A ground-up interface redesign, a real database under the
hood, GIFs that work across every MeshCore client, and hands-on control of your
radio's RF front end. This is also the first Offband release built, signed, and
shipped to every platform automatically.
![Offband Meshcore 1.2.0 on Windows: the redesigned interface with the navigation rail pinned open alongside a channel](https://raw.githubusercontent.com/OffbandMesh/meshcore-client/3826b7372122f812cc54d4f5bae438581ad78240/docs/releases/1.2.0/redesign.png)
### One app, every platform
From this release on, every Offband build ships everywhere at once, signed with
the same key so updates just work:
- **Android**: install from Google Play or sideload the signed APK
- **Windows**: desktop build
- **Linux**: desktop build
- **Web**: run it right in your browser, no install, at [offband.app](https://offband.app)
Pick your platform below.
### A brand-new interface
The headline feature. Offband has been rebuilt around a modern navigation shell
that finally gets out of your way.
- A **left rail** you open from the hamburger button, and can **pin open** so it
lives right alongside your conversation instead of covering it.
- Your **channels live in the rail** and switch **in place**, so jumping between
channels never loses your spot or your scroll position.
- Map layer toggles, Disconnect, and Settings all found sensible new homes in the
panel, and the back button finally behaves the way it should: it backgrounds
the app instead of killing it, and never knocks you off a connected radio.
### Your data, on solid ground
Under the surface, Offband now keeps your messages, contacts, and channels in a
real SQLite database instead of a pile of loose preference files.
- Your **existing data migrates automatically** the first time you open this
version. Nothing is thrown away, and your **full message history is preserved**.
- Storage problems that used to fail silently now surface, so nothing goes wrong
in the dark.
- On desktop, your data no longer gets swept into OneDrive/Documents sync.
### GIFs everyone can see
GIFs are no longer an Offband-only party trick.
- Send a GIF and it goes out as a **plain Giphy link**, so people on **stock
MeshCore and other clients can tap it and watch it** too, not just Offband
users.
- Paste a **Giphy or Tenor** link and it renders inline, behind a trusted-host
safety allowlist.
### Take command of your radio's front end
Power users, this one's for you. On supported hardware, Offband now gives you
direct control of your radio's **FEM / LNA** (the front-end module and low-noise
amplifier that shape receive sensitivity and transmit path).
- Toggle **FEM LNA** right from Radio Settings.
- Device Info now reads out your radio's Offband capabilities, so you can see at a
glance what your hardware supports.
Both are gated on firmware capability, so they appear only where the radio
actually supports them.
### Deeper diagnostics
More signal, less guesswork when you need to see what your mesh is doing.
- Message **arrival time is now logged and persisted** separately from the claimed
send time, so you can tell when a message really reached you versus when the
sender says it left (#285).
- Path diagnostics report **honest, width-aware units** instead of mislabeled hop
counts, and path lengths decode exactly per the firmware contract.
### Faster across the board
The new storage engine and a round of under-the-hood work add up to a noticeably
snappier app.
- Connecting no longer rewrites your entire preferences file for every channel, so
connect time is quicker and lighter on storage.
- Bulk reads and writes go through the database now, which scales far better as
your message and contact history grows.
### And a few more
- Desktop remembers your window size and position between launches.
- Reach a contact's settings straight from its long-press menu.
- You can no longer accidentally block your own node.
---
See the full, itemized changelog in the repository.
### Support Offband
Offband is free and open source, built by a very small team. If it is useful to
you, please consider chipping in at
[offband.org/donate](https://offband.org/donate). Donations go directly toward
keeping the project going and toward getting Offband onto the iOS App Store, which
carries its own ongoing Apple costs. Thank you for being part of it.

@ -0,0 +1,22 @@
# GitHub release notes
One file per release: `release-notes/<version>.md`, where `<version>` is the marketing
version from `pubspec.yaml` (the part before `+`), e.g. `release-notes/1.1.2-rc.4.md`.
CI sets the GitHub release body from this file verbatim. It is **published directly**
the notes are reviewed in the release-cut PR, so there is no draft step. What is in this
file at tag time is what the world sees.
## Style
Narrative prose, not a bulleted changelog. Written for someone deciding whether to install
or update: what's new, what got fixed, anything they should know. Full Markdown is fine.
This is distinct from:
- **`CHANGELOG.md`** — cumulative, terse, issue-referenced, for developers.
- **`play/<version>.txt`** — plain, ≤500 chars, for the store.
- **`discord/<version>.md`** — casual community announcement.
Same underlying changes, four different voices. The release gate
(`scripts/release-gate.sh`) fails the build if this file is missing or empty for the
version being tagged.

@ -16,6 +16,59 @@ void main() {
final pubKey = Uint8List.fromList(List<int>.generate(32, (i) => i));
final path = Uint8List.fromList([0xAA, 0xBB]);
// Byte offset of path_len in the frame: 1 cmd + 32 pubKey + 1 type + 1 flags.
const int pathLenOffset = 35;
group('buildUpdateContactPathFrame path_len encoding (#309)', () {
test('packs the hash width into the high 2 bits', () {
// One 2-byte hop must go out as 0x41, not a bare 1. Writing the count
// raw leaves mode bits 00, which tells the radio "1-byte hashes" while
// handing it 2-byte hash data, so it routes to nodes that were never on
// the route. That is the send-side half of #240's misrouting.
final frame = buildUpdateContactPathFrame(
pubKey,
Uint8List.fromList([0xC6, 0x5C]),
1,
hashWidth: 2,
);
expect(frame[pathLenOffset], 0x41);
expect(pathHopCount(frame[pathLenOffset]), 1);
expect(pathHashSizeBytes(frame[pathLenOffset]), 2);
});
test('two 2-byte hops encode as 0x42', () {
final frame = buildUpdateContactPathFrame(
pubKey,
Uint8List.fromList([0xC6, 0x5C, 0xA1, 0xB2]),
2,
hashWidth: 2,
);
expect(frame[pathLenOffset], 0x42);
expect(pathByteLength(frame[pathLenOffset]), 4);
});
test('1-byte width is byte-identical to the pre-fix encoding', () {
// Legacy 1-byte nets must see no change on the wire.
final frame = buildUpdateContactPathFrame(
pubKey,
Uint8List.fromList([0xAA, 0xBB, 0xCC]),
3,
hashWidth: 1,
);
expect(frame[pathLenOffset], 3);
});
test('negative hop count emits the 0xFF flood sentinel', () {
final frame = buildUpdateContactPathFrame(
pubKey,
Uint8List(0),
-1,
hashWidth: 2,
);
expect(frame[pathLenOffset], 0xFF);
});
});
group('buildUpdateContactPathFrame', () {
test('omits lat/lon and timestamp tail when neither is provided', () {
final frame = buildUpdateContactPathFrame(

@ -0,0 +1,134 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/connector/meshcore_connector.dart';
import 'package:meshcore_open/connector/meshcore_protocol.dart';
void main() {
group('FEM LNA capability gate (#304)', () {
test('requires the explicit bit, never model or version', () {
expect(firmwareSupportsOffbandFemLna(offbandCapFemLna), isTrue);
expect(
firmwareSupportsOffbandFemLna(offbandCapFemLna | offbandCapBlock),
isTrue,
);
});
test('false when the bit is clear, even on an Offband radio', () {
// Offband firmware that supports block but not FEM LNA control.
expect(firmwareSupportsOffbandFemLna(offbandCapBlock), isFalse);
expect(firmwareSupportsOffbandFemLna(0x00), isFalse);
});
test('false on stock firmware (no caps byte at all)', () {
expect(firmwareSupportsOffbandFemLna(null), isFalse);
});
test('does not collide with the block capability bit', () {
expect(offbandCapFemLna & offbandCapBlock, equals(0));
});
});
group('FEM LNA device-info state byte, offset 83 (#304)', () {
Uint8List deviceInfo({required int length, int femByte = 0}) {
final frame = Uint8List(length);
frame[0] = respCodeDeviceInfo;
if (length >= 84) frame[83] = femByte;
return frame;
}
test('reads the byte immediately after caps on v16+', () {
expect(
MeshCoreConnector.parseFemLnaState(deviceInfo(length: 84, femByte: 1)),
isTrue,
);
expect(
MeshCoreConnector.parseFemLnaState(deviceInfo(length: 84, femByte: 0)),
isFalse,
);
});
test('null on pre-v16 firmware that stops at the caps byte', () {
expect(
MeshCoreConnector.parseFemLnaState(deviceInfo(length: 83)),
isNull,
);
expect(MeshCoreConnector.parseFemLnaState(Uint8List(0)), isNull);
});
test('does not disturb the caps byte at offset 82', () {
final frame = deviceInfo(length: 84, femByte: 1);
frame[82] = offbandCapBlock | offbandCapFemLna;
expect(MeshCoreConnector.parseOffbandCaps(frame), equals(0x06));
expect(MeshCoreConnector.parseFemLnaState(frame), isTrue);
});
test('byte is present on non-capable boards and reads as bypassed', () {
// Firmware appends it unconditionally, so presence indicates version,
// not capability the cap bit is what gates the UI.
final frame = deviceInfo(length: 84, femByte: 0);
frame[82] = 0x00;
expect(MeshCoreConnector.parseFemLnaState(frame), isFalse);
expect(
firmwareSupportsOffbandFemLna(
MeshCoreConnector.parseOffbandCaps(frame),
),
isFalse,
);
});
});
group('FEM LNA frames (#304)', () {
test('SET carries the enable value', () {
expect(
buildOffbandFemLnaSetFrame(true),
equals(Uint8List.fromList([0xC3, 0x01, 0x01])),
);
expect(
buildOffbandFemLnaSetFrame(false),
equals(Uint8List.fromList([0xC3, 0x01, 0x00])),
);
});
test('GET is a bare 2-byte request', () {
expect(
buildOffbandFemLnaGetFrame(),
equals(Uint8List.fromList([0xC3, 0x02])),
);
});
test('reply parses sub-type and value', () {
final reply = parseOffbandFemLnaReply(
Uint8List.fromList([0xC3, 0x02, 0x01]),
);
expect(reply, isNotNull);
expect(reply!.subType, equals(offbandFemLnaGet));
expect(reply.enabled, isTrue);
final bypassed = parseOffbandFemLnaReply(
Uint8List.fromList([0xC3, 0x02, 0x00]),
);
expect(bypassed!.enabled, isFalse);
});
test('rejects a short frame instead of throwing', () {
expect(parseOffbandFemLnaReply(Uint8List.fromList([0xC3, 0x02])), isNull);
expect(parseOffbandFemLnaReply(Uint8List.fromList([])), isNull);
});
test('ignores frames belonging to another command', () {
// The generic error reply is [0x01][0x06] and is NOT 0xC3-prefixed, so it
// must never be mistaken for a FEM LNA reply.
expect(
parseOffbandFemLnaReply(
Uint8List.fromList([respCodeErr, errCodeIllegalArg]),
),
isNull,
);
expect(
parseOffbandFemLnaReply(Uint8List.fromList([0xC2, 0x01, 0x01])),
isNull,
);
});
});
}

@ -1,32 +1,91 @@
// Path-hash width / hop-count decode (#112). The firmware path-length byte is a
// BYTE count of the hop-hash array, not a hop count, so at a 2-byte hash width a
// single 2-byte hop reports 2. realHopCount divides by the device width to get
// true hops; the Packet Path screen slices and matches at the device width so a
// 2-byte hash like 6A3D resolves to one repeater instead of two 1-byte hops.
// Path-len byte encode/decode (#309, supersedes #112/#222).
//
// The firmware path-len byte is PACKED and self-describing:
// high 2 bits = hash size - 1 (1..3 bytes per hop hash)
// low 6 bits = hash COUNT, i.e. the number of HOPS
// byte length = count * size
//
// Verified against firmware src/Packet.h:79-84:
// getPathHashSize() == (path_len >> 6) + 1
// getPathHashCount() == path_len & 63
// getPathByteLen() == getPathHashCount() * getPathHashSize()
// setPathHashSizeAndCount(sz, n) { path_len = ((sz - 1) << 6) | (n & 63); }
//
// This file previously asserted the opposite (low 6 bits = a BYTE count, and a
// realHopCount() that divided by the device width). Those assertions encoded the
// bug: the app read `count` bytes where firmware meant `count` hops, keeping
// half of every path at 2-byte width, and halved every displayed hop count.
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/connector/meshcore_protocol.dart';
void main() {
group('realHopCount', () {
test('divides path byte-length by hash width', () {
// The bug: one 2-byte hop (6A3D) reported byteLen 2 -> shown as "2 hops".
expect(realHopCount(2, 2), 1);
expect(realHopCount(4, 2), 2);
expect(realHopCount(6, 3), 2);
group('path-len decode', () {
test('low 6 bits are a HOP count, not a byte count', () {
// 0x42 = 2-byte hashes, 2 hops. The old model called this "2 bytes".
expect(pathHopCount(0x42), 2);
expect(pathHashSizeBytes(0x42), 2);
expect(pathByteLength(0x42), 4); // 2 hops * 2 bytes
});
test('is a no-op at 1-byte width (no regression for legacy nets)', () {
expect(realHopCount(3, 1), 3);
test('one 2-byte hop is 1 hop occupying 2 bytes', () {
// Bandit's C65C case: ONE hop through a 2-byte-prefixed repeater.
expect(pathHopCount(0x41), 1);
expect(pathHashSizeBytes(0x41), 2);
expect(pathByteLength(0x41), 2);
});
test('treats width < 1 as 1', () {
expect(realHopCount(3, 0), 3);
test('1-byte width is unchanged (legacy nets)', () {
expect(pathHopCount(0x03), 3);
expect(pathHashSizeBytes(0x03), 1);
expect(pathByteLength(0x03), 3);
});
test('passes null (unknown) and negative (flood) sentinels through', () {
expect(realHopCount(null, 2), isNull);
expect(realHopCount(-1, 2), -1);
test('3-byte width', () {
// 0x82 = size 3, 2 hops -> 6 bytes.
expect(pathHashSizeBytes(0x82), 3);
expect(pathHopCount(0x82), 2);
expect(pathByteLength(0x82), 6);
});
test('direct (zero-hop) at 2-byte mode carries no path bytes', () {
// 0x40 = size 2, 0 hops. Reading the raw byte as a length would have
// pulled 64 junk bytes into the path.
expect(pathHopCount(0x40), 0);
expect(pathByteLength(0x40), 0);
});
});
group('encodePathLen', () {
test('packs width and hop count the way firmware does', () {
expect(encodePathLen(2, 2), 0x42);
expect(encodePathLen(1, 2), 0x41);
expect(encodePathLen(3, 1), 0x03);
expect(encodePathLen(2, 3), 0x82);
expect(encodePathLen(0, 2), 0x40);
});
test('round-trips through the decoders', () {
for (final width in [1, 2, 3]) {
for (final hops in [0, 1, 5, 63]) {
final encoded = encodePathLen(hops, width);
expect(pathHopCount(encoded), hops, reason: 'hops w=$width');
expect(pathHashSizeBytes(encoded), width, reason: 'width w=$width');
}
}
});
test('clamps width to the 1..3 firmware range (mode 3 is reserved)', () {
expect(pathHashSizeBytes(encodePathLen(1, 0)), 1);
expect(pathHashSizeBytes(encodePathLen(1, 9)), 3);
});
test('a bare hop count would mislabel the width as 1 byte', () {
// The send-side bug: writing the count raw leaves mode bits 00, telling
// the radio "1-byte hashes" while handing it 2-byte hash data.
const rawCount = 2;
expect(pathHashSizeBytes(rawCount), 1); // what the radio would have read
expect(pathHashSizeBytes(encodePathLen(2, 2)), 2); // what it must read
});
});
}

@ -0,0 +1,105 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/helpers/gif_helper.dart';
// #283: render pasted GIF/media URLs inline, but ONLY from an allowlist of
// curated hosts (Giphy + Tenor). Anything off-list must stay a plain link and
// must never be auto-fetched: auto-loading a stranger's URL leaks the viewer's
// IP and enables tracking-pixel / read-receipt abuse, and inline-rendering
// arbitrary hosts is a malware and inappropriate-content vector.
void main() {
const gifId = 'zaMiq1BvCdAVIiu3Mb';
const giphyRender = 'https://media.giphy.com/media/$gifId/giphy.gif';
group('Giphy forms all resolve to a renderable URL', () {
test('the app payload (giphy page URL)', () {
expect(
GifHelper.resolveGifUrl('https://giphy.com/gifs/$gifId'),
giphyRender,
);
});
test('legacy g:<id>', () {
expect(GifHelper.resolveGifUrl('g:$gifId'), giphyRender);
});
test('media.giphy.com direct asset', () {
expect(GifHelper.resolveGifUrl(giphyRender), giphyRender);
});
test('i.giphy.com webp, the form users actually paste', () {
expect(
GifHelper.resolveGifUrl('https://i.giphy.com/$gifId.webp'),
giphyRender,
);
});
test('i.giphy.com gif', () {
expect(
GifHelper.resolveGifUrl('https://i.giphy.com/$gifId.gif'),
giphyRender,
);
});
test('a reply carrying a pasted Giphy URL', () {
expect(
GifHelper.resolveGifUrl('@[Some Node] https://i.giphy.com/$gifId.webp'),
giphyRender,
);
});
});
group('Tenor direct media resolves to itself', () {
test('media.tenor.com gif', () {
const url = 'https://media.tenor.com/abc123XYZ/happy-dance.gif';
expect(GifHelper.resolveGifUrl(url), url);
});
test('c.tenor.com gif', () {
const url = 'https://c.tenor.com/abc123XYZ/tenor.gif';
expect(GifHelper.resolveGifUrl(url), url);
});
test('media.tenor.com webp', () {
const url = 'https://media.tenor.com/abc123XYZ/thing.webp';
expect(GifHelper.resolveGifUrl(url), url);
});
});
group('off-allowlist URLs must NOT render (never auto-fetch)', () {
test('an arbitrary https image', () {
expect(
GifHelper.resolveGifUrl('https://evil.example.com/tracker.gif'),
isNull,
);
});
test('a lookalike host is not trusted', () {
expect(
GifHelper.resolveGifUrl('https://giphy.com.evil.example/$gifId.gif'),
isNull,
);
});
test('a general image host (imgur) is off-list', () {
expect(GifHelper.resolveGifUrl('https://i.imgur.com/abc123.gif'), isNull);
});
test('plain text is not a GIF', () {
expect(GifHelper.resolveGifUrl('hey are you there'), isNull);
});
test('a non-media URL is not a GIF', () {
expect(GifHelper.resolveGifUrl('https://example.com/page'), isNull);
});
test('a tenor PAGE url does not render (needs API resolution)', () {
// Modern Tenor CDN paths use an opaque hash not derivable from the page
// id, so a page URL cannot be resolved without the Tenor API. Stays a
// plain link rather than silently rendering the wrong thing.
expect(
GifHelper.resolveGifUrl('https://tenor.com/view/happy-dance-12345'),
isNull,
);
});
});
}

@ -46,18 +46,37 @@ void main() {
expect(c.path, [0xAA, 0xBB, 0xCC]);
});
test('2-byte-mode multi-hop (0x44) decodes to 4 bytes, not flood', () {
// High bits = mode-1 hint, low bits = 4 bytes = a 2-hop path at 2-byte
// width. Pre-#222 the raw 68 (> maxPathSize) was mis-flagged as flood.
test('2-byte-mode 2-hop (0x42) keeps all FOUR bytes (#309)', () {
// 0x42 = size 2, count 2 -> 2 hops occupying 2*2 = 4 bytes.
//
// Pre-#309 the low 6 bits were read as a byte count, so this kept only
// the first 2 bytes and silently discarded the second hop. That is the
// truncation behind #240's failed repeater logins.
final c = Contact.fromFrame(
_frame(pathLen: 0x44, pathBytes: [1, 2, 3, 4]),
_frame(pathLen: 0x42, pathBytes: [0xC6, 0x5C, 0xA1, 0xB2]),
)!;
expect(c.pathLength, 4);
expect(c.path, [1, 2, 3, 4]);
expect(
realHopCount(c.pathLength, 2),
2,
); // 4 bytes / 2-byte width = 2 hops
expect(c.pathLength, 2); // HOPS, not bytes
expect(c.pathHashWidth, 2);
expect(c.path, [0xC6, 0x5C, 0xA1, 0xB2]); // all 4 bytes retained
});
test('2-byte-mode single hop (0x41) is 1 hop of 2 bytes (#309)', () {
// Bandit's C65C case: ONE hop, previously surfaced as "2 hops".
final c = Contact.fromFrame(
_frame(pathLen: 0x41, pathBytes: [0xC6, 0x5C]),
)!;
expect(c.pathLength, 1);
expect(c.pathHashWidth, 2);
expect(c.path, [0xC6, 0x5C]);
});
test('3-byte-mode 2-hop (0x82) keeps six bytes (#309)', () {
final c = Contact.fromFrame(
_frame(pathLen: 0x82, pathBytes: [1, 2, 3, 4, 5, 6]),
)!;
expect(c.pathLength, 2);
expect(c.pathHashWidth, 3);
expect(c.path, [1, 2, 3, 4, 5, 6]);
});
test('flood sentinel (0xFF) stays -1, empty path', () {

@ -0,0 +1,96 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/helpers/gif_helper.dart';
import 'package:meshcore_open/services/notification_service.dart';
// #284: notifications summarised a GIF as "Sent a GIF" using their own
// hand-rolled `^g:<id>$` regex. #282 changed the payload to a Giphy URL, which
// that regex does not match, so notifications regressed to dumping the raw URL.
// Detection must go through GifHelper.parseGif so every payload form the app
// can actually send is summarised, and so the regex cannot drift again.
void main() {
const gifId = 'zaMiq1BvCdAVIiu3Mb';
group('GIF payloads are summarised, not dumped raw', () {
test('the current URL payload summarises', () {
expect(
NotificationService.formatNotificationText(GifHelper.encodeGif(gifId)),
'Sent a GIF',
);
});
test('a reply carrying a GIF summarises', () {
expect(
NotificationService.formatNotificationText(
'@[Some Node] ${GifHelper.encodeGif(gifId)}',
),
'Sent a GIF',
);
});
test('a legacy g:<id> payload still summarises', () {
expect(
NotificationService.formatNotificationText('g:$gifId'),
'Sent a GIF',
);
});
test('a legacy g:<id> reply still summarises', () {
expect(
NotificationService.formatNotificationText('@[Some Node] g:$gifId'),
'Sent a GIF',
);
});
});
group('everything else is untouched', () {
test('a reaction still summarises', () {
expect(
NotificationService.formatNotificationText('r:abcd:00'),
startsWith('Reacted '),
);
});
test('ordinary text passes through unchanged', () {
expect(
NotificationService.formatNotificationText('hey are you there'),
'hey are you there',
);
});
test('a non-GIF URL is not mistaken for a GIF', () {
expect(
NotificationService.formatNotificationText('https://example.com/page'),
'https://example.com/page',
);
});
});
group('allowlisted media URLs summarise too (#283 consistency)', () {
test('a pasted i.giphy.com link summarises', () {
expect(
NotificationService.formatNotificationText(
'https://i.giphy.com/$gifId.webp',
),
'Sent a GIF',
);
});
test('a pasted Tenor CDN link summarises', () {
expect(
NotificationService.formatNotificationText(
'https://media.tenor.com/abc123XYZ/happy-dance.gif',
),
'Sent a GIF',
);
});
test('an off-allowlist image URL does NOT summarise', () {
expect(
NotificationService.formatNotificationText(
'https://evil.example.com/tracker.gif',
),
'https://evil.example.com/tracker.gif',
);
});
});
}

@ -0,0 +1,178 @@
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/storage/drift/blob_store.dart';
import 'package:meshcore_open/storage/drift/offband_database.dart';
import 'package:meshcore_open/storage/prefs_manager.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Migration safety for #335.
///
/// The bar here is set by #333: a storage path chose a key silently and 566
/// real messages read as empty. A migration that gets this wrong loses data
/// permanently rather than cosmetically, so the failure paths are tested, not
/// just the happy one.
void main() {
late OffbandDatabase db;
late BlobStore store;
setUp(() async {
db = OffbandDatabase(NativeDatabase.memory());
store = BlobStore(db);
PrefsManager.reset();
});
tearDown(() => db.close());
Future<void> seedPrefs(Map<String, Object> values) async {
SharedPreferences.setMockInitialValues(values);
await PrefsManager.initialize();
}
test('moves bulk keys and removes them from prefs', () async {
await seedPrefs({
'channel_messages_devpsk_abc': '[{"m":1}]',
'messages_devcontact': '[{"m":2}]',
'contacts_dev': '[{"c":1}]',
'discovered_contacts': '[{"d":1}]',
});
final report = await store.migrateFromPrefs();
expect(report.migrated, 4);
expect(report.failed, 0);
expect(await store.read('channel_messages_devpsk_abc'), '[{"m":1}]');
expect(await store.read('discovered_contacts'), '[{"d":1}]');
final prefs = PrefsManager.instance;
expect(prefs.getString('channel_messages_devpsk_abc'), isNull);
expect(prefs.getString('discovered_contacts'), isNull);
});
test('leaves settings alone', () async {
await seedPrefs({
'ui_channels_sort_option': 'manual',
'app_settings': '{"theme":"dark"}',
'contacts_dev': '[{"c":1}]',
});
final report = await store.migrateFromPrefs();
expect(report.migrated, 1, reason: 'only the bulk key should move');
final prefs = PrefsManager.instance;
expect(prefs.getString('ui_channels_sort_option'), 'manual');
expect(prefs.getString('app_settings'), '{"theme":"dark"}');
});
test('is idempotent: a second run moves nothing and loses nothing', () async {
await seedPrefs({'contacts_dev': '[{"c":1}]'});
final first = await store.migrateFromPrefs();
expect(first.migrated, 1);
final second = await store.migrateFromPrefs();
expect(second.migrated, 0);
expect(second.failed, 0);
expect(await store.read('contacts_dev'), '[{"c":1}]');
});
test(
'#355: prefs messages absent from drift are merged, not discarded',
() async {
// The bug: an in-between build (non-drift, or any build that writes prefs)
// accumulates NEW messages in prefs. On the next drift run the key is
// "already present", so the old code discarded the prefs copy and the new
// messages were gapped out of history. They must be UNIONED in instead.
await store.write(
'channel_messages_devpsk_abc',
'[{"messageId":"a"},{"messageId":"b"}]',
);
await seedPrefs({
'channel_messages_devpsk_abc':
'[{"messageId":"a"},{"messageId":"c"},{"messageId":"d"}]',
});
final report = await store.migrateFromPrefs();
expect(report.merged, 1);
expect(report.failed, 0);
final ids = (await store.read('channel_messages_devpsk_abc'))!;
// Drift's a,b kept; prefs-only c,d appended; shared a not duplicated.
expect(
ids,
'[{"messageId":"a"},{"messageId":"b"},'
'{"messageId":"c"},{"messageId":"d"}]',
);
expect(
PrefsManager.instance.getString('channel_messages_devpsk_abc'),
isNull,
);
},
);
test(
'merge keeps the drift copy of a shared entity, adds prefs-only ones',
() async {
// Contacts collide by publicKey: the live drift copy wins for a shared key,
// and a contact seen only on the in-between build is still added.
await store.write('contacts_dev', '[{"publicKey":"A","name":"drift"}]');
await seedPrefs({
'contacts_dev':
'[{"publicKey":"A","name":"stale"},{"publicKey":"B","name":"new"}]',
});
final report = await store.migrateFromPrefs();
expect(report.merged, 1);
expect(
await store.read('contacts_dev'),
'[{"publicKey":"A","name":"drift"},{"publicKey":"B","name":"new"}]',
);
expect(PrefsManager.instance.getString('contacts_dev'), isNull);
},
);
test(
'already-present with nothing new to add reports alreadyPresent',
() async {
// Prefs is a subset of drift: union changes nothing, and the stale prefs
// copy is dropped without a needless rewrite.
await store.write('contacts_dev', '[{"publicKey":"A"}]');
await seedPrefs({'contacts_dev': '[{"publicKey":"A"}]'});
final report = await store.migrateFromPrefs();
expect(report.alreadyPresent, 1);
expect(report.merged, 0);
expect(await store.read('contacts_dev'), '[{"publicKey":"A"}]');
expect(PrefsManager.instance.getString('contacts_dev'), isNull);
},
);
test('preserves a payload larger than the 5 MiB localStorage cap', () async {
// 4 chars per element, so >1.4M elements clears 5 MiB.
final big = '[${'"x",' * 1400000}"end"]';
expect(big.length, greaterThan(5 * 1024 * 1024));
await seedPrefs({'channel_messages_devpsk_big': big});
final report = await store.migrateFromPrefs();
expect(report.failed, 0);
expect(
(await store.read('channel_messages_devpsk_big'))!.length,
big.length,
);
});
test('non-string values are skipped, not failed', () async {
// getString THROWS on a non-string, so this must be type-checked, not
// caught as a failure. Verified against the real store: 'contacts' only
// ever matches bulk blobs there, but the store must not mis-report if a
// non-string ever lands under a bulk prefix.
await seedPrefs({'contacts_probe': 42});
final report = await store.migrateFromPrefs();
expect(report.failed, 0);
expect(report.skipped, 1);
});
}

@ -0,0 +1,122 @@
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/models/channel_message.dart';
import 'package:meshcore_open/storage/channel_message_store.dart';
import 'package:meshcore_open/storage/drift/blob_store.dart';
import 'package:meshcore_open/storage/drift/offband_database.dart';
import 'package:meshcore_open/storage/prefs_manager.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// #343: saving a windowed in-memory list must not truncate the persisted
/// history. The store keeps full history; deletion is explicit.
void main() {
late OffbandDatabase db;
late ChannelMessageStore store;
setUp(() async {
SharedPreferences.setMockInitialValues({});
PrefsManager.reset();
await PrefsManager.initialize();
db = OffbandDatabase(NativeDatabase.memory());
BlobStore.overrideForTest(BlobStore(db));
store = ChannelMessageStore();
store.setPublicKeyHex = 'a' * 20;
// No PSK resolver -> uses the slot-index key, which is fine for the test.
});
tearDown(() async {
await db.close();
BlobStore.clearTestOverride();
});
ChannelMessage msg(int i) => ChannelMessage(
senderName: 's',
text: 'm$i',
timestamp: DateTime.fromMillisecondsSinceEpoch(1000 + i),
isOutgoing: false,
channelIndex: 0,
messageId: 'id$i',
);
test('saving a 200-window does not truncate a 250-message history', () async {
// Seed 250 messages, as if a full history were persisted.
await store.saveChannelMessages(0, [for (var i = 0; i < 250; i++) msg(i)]);
expect((await store.loadChannelMessages(0)).length, 250);
// The app now saves only the most-recent 200 (its in-memory window).
final window = [for (var i = 50; i < 250; i++) msg(i)];
await store.saveChannelMessages(0, window);
// Full history must survive: the older 50 are still there.
final all = await store.loadChannelMessages(0);
expect(all.length, 250, reason: 'the older 50 must not be dropped');
expect(all.first.text, 'm0');
expect(all.last.text, 'm249');
});
test('a new message appends without dropping old history', () async {
await store.saveChannelMessages(0, [for (var i = 0; i < 10; i++) msg(i)]);
await store.saveChannelMessages(0, [msg(10)]);
final all = await store.loadChannelMessages(0);
expect(all.length, 11);
expect(all.last.text, 'm10');
});
test('an edit to a message is captured, not duplicated', () async {
await store.saveChannelMessages(0, [msg(1)]);
final edited = ChannelMessage(
senderName: 's',
text: 'm1',
timestamp: DateTime.fromMillisecondsSinceEpoch(1001),
isOutgoing: false,
channelIndex: 0,
messageId: 'id1',
reactions: {'thumbsup': 2},
);
await store.saveChannelMessages(0, [edited]);
final all = await store.loadChannelMessages(0);
expect(all, hasLength(1));
expect(all.single.reactions['thumbsup'], 2);
});
test(
'concurrent saves to one channel do not lose messages (Gemini)',
() async {
await store.saveChannelMessages(0, [msg(0)]);
// Fire two saves without awaiting between them: they race on the same key.
// Serialisation must make the result the union, not last-writer-wins.
final a = store.saveChannelMessages(0, [msg(1)]);
final b = store.saveChannelMessages(0, [msg(2)]);
await Future.wait([a, b]);
final all = await store.loadChannelMessages(0);
expect(
all.map((m) => m.text),
containsAll(['m0', 'm1', 'm2']),
reason: 'neither concurrent save may clobber the other',
);
expect(all, hasLength(3));
},
);
test('deleting a message does NOT resurrect it on the next save', () async {
await store.saveChannelMessages(0, [for (var i = 0; i < 5; i++) msg(i)]);
// Delete via the explicit path.
await store.removeChannelMessage(0, msg(2));
expect(
(await store.loadChannelMessages(0)).map((m) => m.text),
isNot(contains('m2')),
);
// A later save of the remaining in-memory list must not bring it back.
final remaining = [
for (var i = 0; i < 5; i++)
if (i != 2) msg(i),
];
await store.saveChannelMessages(0, remaining);
final all = await store.loadChannelMessages(0);
expect(all.map((m) => m.text), isNot(contains('m2')));
expect(all, hasLength(4));
});
}

@ -0,0 +1,78 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
/// Guards the one way the web build can break silently (#335).
///
/// drift on the web needs two binaries shipped in `web/`, and they must match
/// the resolved `drift` package. If someone bumps drift without re-downloading
/// them, nothing fails at build time: the app compiles, deploys, and then
/// fails in the browser. SAFELANE 6 says that must not be silent, so this
/// turns it into a red test with instructions.
///
/// Update `web/drift_assets.version` whenever the assets are re-downloaded.
void main() {
test('shipped drift web assets match the pinned drift version', () {
final root = Directory.current.path;
final lock = File('$root/pubspec.lock');
expect(
lock.existsSync(),
isTrue,
reason:
'pubspec.lock must be committed so dependency resolution is '
'reproducible; the drift web assets are matched against it.',
);
// Normalise line endings first: pubspec.lock is CRLF on Windows and LF in
// CI, and a newline-sensitive pattern would pass on one and fail on the
// other. A guard that is itself platform-fragile is worse than none.
final lockText = lock.readAsStringSync().replaceAll('\r\n', '\n');
final resolved = RegExp(
r'^ drift:\n(?:.*\n)*? version: "([^"]+)"',
multiLine: true,
).firstMatch(lockText)?.group(1);
expect(resolved, isNotNull, reason: 'drift not found in pubspec.lock');
final stamp = File('$root/web/drift_assets.version');
expect(
stamp.existsSync(),
isTrue,
reason:
'web/drift_assets.version is missing. It records which drift release '
'web/sqlite3.wasm and web/drift_worker.js came from.',
);
expect(
stamp.readAsStringSync().trim(),
resolved,
reason:
'\nDrift web assets are STALE.\n'
'pubspec.lock resolves drift $resolved, but the shipped assets are '
'from ${stamp.readAsStringSync().trim()}.\n'
'The web build would compile and then fail in the browser.\n\n'
'Fix:\n'
' gh release download drift-$resolved --repo simolus3/drift \\\n'
' --pattern drift_worker.js --pattern sqlite3.wasm --clobber\n'
' (run inside web/, then write $resolved into web/drift_assets.version)\n',
);
for (final name in ['sqlite3.wasm', 'drift_worker.js']) {
expect(
File('$root/web/$name').existsSync(),
isTrue,
reason: 'web/$name is missing; the web build would fail at runtime.',
);
}
// Cheap integrity check: the wasm must actually be WebAssembly.
final magic = File('$root/web/sqlite3.wasm').openSync().readSync(4);
expect(
magic,
orderedEquals([0x00, 0x61, 0x73, 0x6d]),
reason:
'web/sqlite3.wasm does not start with the WebAssembly magic '
'bytes (\\0asm) — the download is corrupt or is not a wasm file.',
);
});
}

@ -0,0 +1,56 @@
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/storage/drift/offband_database.dart';
/// Step-1 gate for #335: the database must open, write and read back before
/// any user data is migrated into it.
///
/// This runs against an in-memory SQLite instance so it exercises the drift
/// layer and generated code on any host. It does NOT prove the per-platform
/// backends (native libs on desktop/mobile, WASM on web) those are proven by
/// building each target and by `verifyReadWrite()` at runtime.
void main() {
late OffbandDatabase db;
setUp(() => db = OffbandDatabase(NativeDatabase.memory()));
tearDown(() => db.close());
test('opens, writes and reads back (the #335 gate)', () async {
expect(await db.verifyReadWrite(), isTrue);
});
test('round-trips a payload larger than the 5 MiB localStorage cap', () async {
// The web build currently stores through localStorage, capped at 5 MiB per
// origin, while this install already holds ~7 MB. Proving a >5 MiB value
// survives is the whole point of the migration.
final big = 'x' * (6 * 1024 * 1024);
await db
.into(db.storedBlobs)
.insertOnConflictUpdate(
StoredBlobsCompanion.insert(key: 'big', value: big),
);
final row = await (db.select(
db.storedBlobs,
)..where((t) => t.key.equals('big'))).getSingle();
expect(row.value.length, big.length);
});
test('upsert replaces rather than duplicating a key', () async {
for (final v in ['first', 'second']) {
await db
.into(db.storedBlobs)
.insertOnConflictUpdate(
StoredBlobsCompanion.insert(key: 'k', value: v),
);
}
final rows = await (db.select(
db.storedBlobs,
)..where((t) => t.key.equals('k'))).get();
expect(rows, hasLength(1));
expect(rows.single.value, 'second');
});
}

@ -0,0 +1,91 @@
@Tags(['rehearsal'])
library;
import 'dart:convert';
import 'dart:io';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/storage/drift/blob_store.dart';
import 'package:meshcore_open/storage/drift/offband_database.dart';
import 'package:meshcore_open/storage/prefs_manager.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Rehearses the #335 migration against a COPY of a real 7 MB store.
///
/// Required by the plan before the migration may touch live data. Reads a
/// snapshot from disk; it never opens the user's actual store. Skipped when
/// the snapshot is absent, so CI does not depend on one machine's data.
void main() {
final path = Platform.environment['OFFBAND_REAL_STORE'];
test(
'migrates a real 7 MB store with zero loss',
() async {
if (path == null || !File(path).existsSync()) {
markTestSkipped(
'set OFFBAND_REAL_STORE to a shared_preferences.json copy',
);
return;
}
final raw =
jsonDecode(File(path).readAsStringSync()) as Map<String, dynamic>;
// Strip the `flutter.` prefix the plugin adds on disk.
final seed = <String, Object>{
for (final e in raw.entries)
if (e.value is String || e.value is int || e.value is bool)
e.key.replaceFirst('flutter.', ''): e.value as Object,
};
final expected = <String, int>{
for (final e in seed.entries)
if (e.value is String && BlobStore.isBulkKey(e.key))
e.key: (e.value as String).length,
};
SharedPreferences.setMockInitialValues(seed);
PrefsManager.reset();
await PrefsManager.initialize();
final db = OffbandDatabase(NativeDatabase.memory());
final store = BlobStore(db);
final report = await store.migrateFromPrefs();
// ignore: avoid_print
print(
'REHEARSAL: ${report.migrated} migrated, ${report.failed} failed, '
'${(report.bytes / 1024 / 1024).toStringAsFixed(2)} MB, '
'${expected.length} bulk keys expected',
);
expect(report.failed, 0, reason: 'no key may fail on real data');
expect(report.migrated, expected.length);
// Every byte accounted for, per key.
for (final e in expected.entries) {
final got = await store.read(e.key);
expect(got, isNotNull, reason: '${e.key} missing after migration');
expect(got!.length, e.value, reason: '${e.key} changed length');
expect(
PrefsManager.instance.get(e.key),
isNull,
reason: '${e.key} should be gone from prefs',
);
}
// Settings must survive untouched.
final settings = seed.keys.where((k) => !BlobStore.isBulkKey(k));
for (final k in settings) {
expect(
PrefsManager.instance.get(k),
isNotNull,
reason: 'setting $k was wrongly removed',
);
}
await db.close();
},
timeout: const Timeout(Duration(minutes: 5)),
);
}

@ -14,11 +14,15 @@
"settings_gpsStatusNoResponse",
"settings_infoFirmware",
"settings_infoModel",
"settings_infoOffbandCaps",
"settings_publicKeyCopied",
"settings_femLna",
"settings_femLnaSubtitle",
"appSettings_clock",
"appSettings_clockSystem",
"appSettings_clock12h",
"appSettings_clock24h",
"contacts_searchSensors",
"channels_notifications",
"channels_notifyAll",
"channels_notifyMentionsOnly",
@ -38,6 +42,7 @@
"channels_qrAddConfirm",
"channels_qrAdded",
"channels_qrExists",
"listFilter_sensors",
"contacts_repeaterPathTraceVia",
"snrIndicator_noNeighbors",
"block_block",
@ -72,11 +77,15 @@
"settings_gpsStatusNoResponse",
"settings_infoFirmware",
"settings_infoModel",
"settings_infoOffbandCaps",
"settings_publicKeyCopied",
"settings_femLna",
"settings_femLnaSubtitle",
"appSettings_clock",
"appSettings_clockSystem",
"appSettings_clock12h",
"appSettings_clock24h",
"contacts_searchSensors",
"channels_notifications",
"channels_notifyAll",
"channels_notifyMentionsOnly",
@ -96,6 +105,7 @@
"channels_qrAddConfirm",
"channels_qrAdded",
"channels_qrExists",
"listFilter_sensors",
"contacts_repeaterPathTraceVia",
"snrIndicator_noNeighbors",
"block_block",
@ -130,11 +140,15 @@
"settings_gpsStatusNoResponse",
"settings_infoFirmware",
"settings_infoModel",
"settings_infoOffbandCaps",
"settings_publicKeyCopied",
"settings_femLna",
"settings_femLnaSubtitle",
"appSettings_clock",
"appSettings_clockSystem",
"appSettings_clock12h",
"appSettings_clock24h",
"contacts_searchSensors",
"channels_notifications",
"channels_notifyAll",
"channels_notifyMentionsOnly",
@ -154,6 +168,7 @@
"channels_qrAddConfirm",
"channels_qrAdded",
"channels_qrExists",
"listFilter_sensors",
"contacts_repeaterPathTraceVia",
"snrIndicator_noNeighbors",
"block_block",
@ -188,11 +203,15 @@
"settings_gpsStatusNoResponse",
"settings_infoFirmware",
"settings_infoModel",
"settings_infoOffbandCaps",
"settings_publicKeyCopied",
"settings_femLna",
"settings_femLnaSubtitle",
"appSettings_clock",
"appSettings_clockSystem",
"appSettings_clock12h",
"appSettings_clock24h",
"contacts_searchSensors",
"channels_notifications",
"channels_notifyAll",
"channels_notifyMentionsOnly",
@ -212,6 +231,7 @@
"channels_qrAddConfirm",
"channels_qrAdded",
"channels_qrExists",
"listFilter_sensors",
"contacts_repeaterPathTraceVia",
"snrIndicator_noNeighbors",
"block_block",
@ -246,11 +266,15 @@
"settings_gpsStatusNoResponse",
"settings_infoFirmware",
"settings_infoModel",
"settings_infoOffbandCaps",
"settings_publicKeyCopied",
"settings_femLna",
"settings_femLnaSubtitle",
"appSettings_clock",
"appSettings_clockSystem",
"appSettings_clock12h",
"appSettings_clock24h",
"contacts_searchSensors",
"channels_notifications",
"channels_notifyAll",
"channels_notifyMentionsOnly",
@ -270,6 +294,7 @@
"channels_qrAddConfirm",
"channels_qrAdded",
"channels_qrExists",
"listFilter_sensors",
"contacts_repeaterPathTraceVia",
"snrIndicator_noNeighbors",
"block_block",
@ -304,11 +329,15 @@
"settings_gpsStatusNoResponse",
"settings_infoFirmware",
"settings_infoModel",
"settings_infoOffbandCaps",
"settings_publicKeyCopied",
"settings_femLna",
"settings_femLnaSubtitle",
"appSettings_clock",
"appSettings_clockSystem",
"appSettings_clock12h",
"appSettings_clock24h",
"contacts_searchSensors",
"channels_notifications",
"channels_notifyAll",
"channels_notifyMentionsOnly",
@ -328,6 +357,7 @@
"channels_qrAddConfirm",
"channels_qrAdded",
"channels_qrExists",
"listFilter_sensors",
"contacts_repeaterPathTraceVia",
"snrIndicator_noNeighbors",
"block_block",
@ -362,11 +392,15 @@
"settings_gpsStatusNoResponse",
"settings_infoFirmware",
"settings_infoModel",
"settings_infoOffbandCaps",
"settings_publicKeyCopied",
"settings_femLna",
"settings_femLnaSubtitle",
"appSettings_clock",
"appSettings_clockSystem",
"appSettings_clock12h",
"appSettings_clock24h",
"contacts_searchSensors",
"channels_notifications",
"channels_notifyAll",
"channels_notifyMentionsOnly",
@ -386,6 +420,7 @@
"channels_qrAddConfirm",
"channels_qrAdded",
"channels_qrExists",
"listFilter_sensors",
"contacts_repeaterPathTraceVia",
"snrIndicator_noNeighbors",
"block_block",
@ -420,11 +455,15 @@
"settings_gpsStatusNoResponse",
"settings_infoFirmware",
"settings_infoModel",
"settings_infoOffbandCaps",
"settings_publicKeyCopied",
"settings_femLna",
"settings_femLnaSubtitle",
"appSettings_clock",
"appSettings_clockSystem",
"appSettings_clock12h",
"appSettings_clock24h",
"contacts_searchSensors",
"channels_notifications",
"channels_notifyAll",
"channels_notifyMentionsOnly",
@ -444,6 +483,7 @@
"channels_qrAddConfirm",
"channels_qrAdded",
"channels_qrExists",
"listFilter_sensors",
"contacts_repeaterPathTraceVia",
"snrIndicator_noNeighbors",
"block_block",
@ -478,11 +518,15 @@
"settings_gpsStatusNoResponse",
"settings_infoFirmware",
"settings_infoModel",
"settings_infoOffbandCaps",
"settings_publicKeyCopied",
"settings_femLna",
"settings_femLnaSubtitle",
"appSettings_clock",
"appSettings_clockSystem",
"appSettings_clock12h",
"appSettings_clock24h",
"contacts_searchSensors",
"channels_notifications",
"channels_notifyAll",
"channels_notifyMentionsOnly",
@ -502,6 +546,7 @@
"channels_qrAddConfirm",
"channels_qrAdded",
"channels_qrExists",
"listFilter_sensors",
"contacts_repeaterPathTraceVia",
"snrIndicator_noNeighbors",
"block_block",
@ -536,11 +581,15 @@
"settings_gpsStatusNoResponse",
"settings_infoFirmware",
"settings_infoModel",
"settings_infoOffbandCaps",
"settings_publicKeyCopied",
"settings_femLna",
"settings_femLnaSubtitle",
"appSettings_clock",
"appSettings_clockSystem",
"appSettings_clock12h",
"appSettings_clock24h",
"contacts_searchSensors",
"channels_notifications",
"channels_notifyAll",
"channels_notifyMentionsOnly",
@ -560,6 +609,7 @@
"channels_qrAddConfirm",
"channels_qrAdded",
"channels_qrExists",
"listFilter_sensors",
"contacts_repeaterPathTraceVia",
"snrIndicator_noNeighbors",
"block_block",
@ -594,11 +644,15 @@
"settings_gpsStatusNoResponse",
"settings_infoFirmware",
"settings_infoModel",
"settings_infoOffbandCaps",
"settings_publicKeyCopied",
"settings_femLna",
"settings_femLnaSubtitle",
"appSettings_clock",
"appSettings_clockSystem",
"appSettings_clock12h",
"appSettings_clock24h",
"contacts_searchSensors",
"channels_notifications",
"channels_notifyAll",
"channels_notifyMentionsOnly",
@ -618,6 +672,7 @@
"channels_qrAddConfirm",
"channels_qrAdded",
"channels_qrExists",
"listFilter_sensors",
"contacts_repeaterPathTraceVia",
"snrIndicator_noNeighbors",
"block_block",
@ -652,11 +707,15 @@
"settings_gpsStatusNoResponse",
"settings_infoFirmware",
"settings_infoModel",
"settings_infoOffbandCaps",
"settings_publicKeyCopied",
"settings_femLna",
"settings_femLnaSubtitle",
"appSettings_clock",
"appSettings_clockSystem",
"appSettings_clock12h",
"appSettings_clock24h",
"contacts_searchSensors",
"channels_notifications",
"channels_notifyAll",
"channels_notifyMentionsOnly",
@ -676,6 +735,7 @@
"channels_qrAddConfirm",
"channels_qrAdded",
"channels_qrExists",
"listFilter_sensors",
"contacts_repeaterPathTraceVia",
"snrIndicator_noNeighbors",
"block_block",
@ -710,11 +770,15 @@
"settings_gpsStatusNoResponse",
"settings_infoFirmware",
"settings_infoModel",
"settings_infoOffbandCaps",
"settings_publicKeyCopied",
"settings_femLna",
"settings_femLnaSubtitle",
"appSettings_clock",
"appSettings_clockSystem",
"appSettings_clock12h",
"appSettings_clock24h",
"contacts_searchSensors",
"channels_notifications",
"channels_notifyAll",
"channels_notifyMentionsOnly",
@ -734,6 +798,7 @@
"channels_qrAddConfirm",
"channels_qrAdded",
"channels_qrExists",
"listFilter_sensors",
"contacts_repeaterPathTraceVia",
"snrIndicator_noNeighbors",
"block_block",
@ -768,11 +833,15 @@
"settings_gpsStatusNoResponse",
"settings_infoFirmware",
"settings_infoModel",
"settings_infoOffbandCaps",
"settings_publicKeyCopied",
"settings_femLna",
"settings_femLnaSubtitle",
"appSettings_clock",
"appSettings_clockSystem",
"appSettings_clock12h",
"appSettings_clock24h",
"contacts_searchSensors",
"channels_notifications",
"channels_notifyAll",
"channels_notifyMentionsOnly",
@ -792,6 +861,7 @@
"channels_qrAddConfirm",
"channels_qrAdded",
"channels_qrExists",
"listFilter_sensors",
"contacts_repeaterPathTraceVia",
"snrIndicator_noNeighbors",
"block_block",
@ -826,11 +896,15 @@
"settings_gpsStatusNoResponse",
"settings_infoFirmware",
"settings_infoModel",
"settings_infoOffbandCaps",
"settings_publicKeyCopied",
"settings_femLna",
"settings_femLnaSubtitle",
"appSettings_clock",
"appSettings_clockSystem",
"appSettings_clock12h",
"appSettings_clock24h",
"contacts_searchSensors",
"channels_notifications",
"channels_notifyAll",
"channels_notifyMentionsOnly",
@ -850,6 +924,7 @@
"channels_qrAddConfirm",
"channels_qrAdded",
"channels_qrExists",
"listFilter_sensors",
"contacts_repeaterPathTraceVia",
"snrIndicator_noNeighbors",
"block_block",
@ -884,11 +959,15 @@
"settings_gpsStatusNoResponse",
"settings_infoFirmware",
"settings_infoModel",
"settings_infoOffbandCaps",
"settings_publicKeyCopied",
"settings_femLna",
"settings_femLnaSubtitle",
"appSettings_clock",
"appSettings_clockSystem",
"appSettings_clock12h",
"appSettings_clock24h",
"contacts_searchSensors",
"channels_notifications",
"channels_notifyAll",
"channels_notifyMentionsOnly",
@ -908,6 +987,7 @@
"channels_qrAddConfirm",
"channels_qrAdded",
"channels_qrExists",
"listFilter_sensors",
"contacts_repeaterPathTraceVia",
"snrIndicator_noNeighbors",
"block_block",
@ -942,11 +1022,15 @@
"settings_gpsStatusNoResponse",
"settings_infoFirmware",
"settings_infoModel",
"settings_infoOffbandCaps",
"settings_publicKeyCopied",
"settings_femLna",
"settings_femLnaSubtitle",
"appSettings_clock",
"appSettings_clockSystem",
"appSettings_clock12h",
"appSettings_clock24h",
"contacts_searchSensors",
"channels_notifications",
"channels_notifyAll",
"channels_notifyMentionsOnly",
@ -966,6 +1050,7 @@
"channels_qrAddConfirm",
"channels_qrAdded",
"channels_qrExists",
"listFilter_sensors",
"contacts_repeaterPathTraceVia",
"snrIndicator_noNeighbors",
"block_block",

@ -0,0 +1,28 @@
# Cloudflare Pages response headers (#335, web storage via drift/SQLite-WASM).
#
# Syntax: a URL pattern, then indented headers. Cloudflare Pages reads this
# file from the build output and can add, override or remove response headers.
#
# Two things drift needs on the web:
#
# 1. sqlite3.wasm MUST be served as Content-Type: application/wasm, or
# WebAssembly streaming compilation refuses it.
#
# 2. COOP + COEP (cross-origin isolation) unlock drift's best storage backend,
# OPFS (`opfsLocks`). Verified locally: with these headers drift logs
# "Using WasmStorageImplementation.opfsLocks". WITHOUT them it still works,
# falling back to IndexedDB, which is slower but still unbounded compared to
# localStorage's 5 MiB cap. So these are a performance tier, not a
# correctness requirement.
#
# Verify after the first deploy:
# curl -sI https://<host>/sqlite3.wasm | grep -i 'content-type'
# -> expect: content-type: application/wasm
# and check the browser console for the opfsLocks line above.
/sqlite3.wasm
Content-Type: application/wasm
/*
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

File diff suppressed because one or more lines are too long

Binary file not shown.

@ -0,0 +1,87 @@
// Standalone web probe for #335.
//
// Loads ONLY the drift stack, so a pass or fail is unambiguous: it exercises
// sqlite3.wasm + drift_worker.js in a real browser without the rest of the app
// (BLE, permissions, radio) confusing the result.
//
// Renders the outcome into the DOM so the result is readable without a
// debugger attached.
import 'package:flutter/material.dart';
import 'package:meshcore_open/storage/drift/offband_database.dart';
void main() => runApp(const _ProbeApp());
class _ProbeApp extends StatelessWidget {
const _ProbeApp();
@override
Widget build(BuildContext context) => const MaterialApp(
home: Scaffold(body: Center(child: _Probe())),
);
}
class _Probe extends StatefulWidget {
const _Probe();
@override
State<_Probe> createState() => _ProbeState();
}
class _ProbeState extends State<_Probe> {
String _status = 'running…';
@override
void initState() {
super.initState();
_run();
}
Future<void> _run() async {
final lines = <String>[];
OffbandDatabase? db;
try {
db = OffbandDatabase();
final ok = await db.verifyReadWrite();
final r1 = ok
? 'PROBE-OK open+write+read'
: 'PROBE-FAIL readback mismatch';
lines.add(r1);
// ignore: avoid_print
print(r1);
// The point of the migration: exceed the 5 MiB localStorage ceiling.
final big = 'x' * (6 * 1024 * 1024);
await db
.into(db.storedBlobs)
.insertOnConflictUpdate(
StoredBlobsCompanion.insert(key: 'big', value: big),
);
final row = await (db.select(
db.storedBlobs,
)..where((t) => t.key.equals('big'))).getSingle();
final r2 = row.value.length == big.length
? 'PROBE-OK 6MiB round-trip (localStorage cap is 5MiB)'
: 'PROBE-FAIL 6MiB truncated to ${row.value.length}';
lines.add(r2);
// ignore: avoid_print
print(r2);
await (db.delete(db.storedBlobs)..where((t) => t.key.equals('big'))).go();
} catch (e) {
lines.add('PROBE-FAIL $e');
// ignore: avoid_print
print('PROBE-FAIL $e');
} finally {
await db?.close();
}
if (mounted) setState(() => _status = lines.join('\n'));
}
@override
Widget build(BuildContext context) => Text(
_status,
key: const Key('probe-result'),
textAlign: TextAlign.center,
);
}

@ -1,7 +0,0 @@
#:schema node_modules/wrangler/config-schema.json
name = "meshcore"
compatibility_date = "2025-10-08"
[assets]
directory = "build/web"
Loading…
Cancel
Save

Powered by TurnKey Linux.