Compare commits
65 Commits
2ff190cee1
...
4ab744ddfe
| Author | SHA1 | Date |
|---|---|---|
|
|
4ab744ddfe | 16 hours ago |
|
|
5fc41e9808 | 16 hours ago |
|
|
8c61e8afda | 16 hours ago |
|
|
1b0d9732cd | 16 hours ago |
|
|
eb8eddadb2 | 16 hours ago |
|
|
fdf31a05e0 | 16 hours ago |
|
|
755c13ab68 | 16 hours ago |
|
|
b7606de536 | 16 hours ago |
|
|
6c8d9ad2db | 18 hours ago |
|
|
615c0ed3b5 | 18 hours ago |
|
|
07ccd28afd | 18 hours ago |
|
|
8af0e0d5dc | 18 hours ago |
|
|
4108f9fecc | 20 hours ago |
|
|
1267b91770 | 20 hours ago |
|
|
7482f4166a | 20 hours ago |
|
|
c347966825 | 20 hours ago |
|
|
b7584bd0c2 | 20 hours ago |
|
|
53f87f51e3 | 21 hours ago |
|
|
3ff272ad8c | 21 hours ago |
|
|
4f8b4c3736 | 22 hours ago |
|
|
5f01f91dbd | 22 hours ago |
|
|
602776c601 | 22 hours ago |
|
|
2fefb6aa91 | 23 hours ago |
|
|
fa470c1442 | 23 hours ago |
|
|
06d3f027dd | 23 hours ago |
|
|
4cf2c632df | 24 hours ago |
|
|
349cbab48e | 24 hours ago |
|
|
47904eb4c3 | 24 hours ago |
|
|
1f128174b7 | 1 day ago |
|
|
29ca744667 | 1 day ago |
|
|
8d60b00b1b | 1 day ago |
|
|
83c965ef44 | 1 day ago |
|
|
dd67391a0e | 1 day ago |
|
|
a2c7302aad | 1 day ago |
|
|
c25b7ccbdf | 1 day ago |
|
|
847a28df3e | 1 day ago |
|
|
19858698ee | 1 day ago |
|
|
2004459556 | 1 day ago |
|
|
77625db575 | 1 day ago |
|
|
30ab360d66 | 2 days ago |
|
|
a7ef7b3325 | 2 days ago |
|
|
5eded9e33c | 2 days ago |
|
|
c7fc4dca7b | 2 days ago |
|
|
c8b36b056d | 2 days ago |
|
|
4dbceb3366 | 2 days ago |
|
|
9aaebce77c | 2 days ago |
|
|
4ece02ceff | 2 days ago |
|
|
6ccbc71c20 | 2 days ago |
|
|
4ee2f50703 | 2 days ago |
|
|
bd8779697a | 2 days ago |
|
|
7f9f6ba63b | 2 days ago |
|
|
2c80be3244 | 2 days ago |
|
|
e17f539d7e | 2 days ago |
|
|
1d6558ef8a | 2 days ago |
|
|
b4fe9c145b | 2 days ago |
|
|
5b23549054 | 2 days ago |
|
|
af6485f2e3 | 2 days ago |
|
|
d6d5dbf846 | 2 days ago |
|
|
856214e4e9 | 2 days ago |
|
|
45bfa33eed | 2 days ago |
|
|
879523d082 | 2 days ago |
|
|
b28f3b36cb | 2 days ago |
|
|
ac877ba904 | 2 days ago |
|
|
af897dc674 | 2 days ago |
|
|
1412290768 | 2 days ago |
@ -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}."
|
||||
@ -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
|
||||
@ -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."
|
||||
@ -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.
|
||||
|
After Width: | Height: | Size: 201 KiB |
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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};
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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
@ -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.
|
||||
|
||||

|
||||
|
||||
### 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.
|
||||
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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)),
|
||||
);
|
||||
}
|
||||
@ -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
|
||||
@ -0,0 +1 @@
|
||||
2.34.2
|
||||
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…
Reference in new issue