Merge pull request #1139 from OffbandMesh/dev

release: promote 1.8.0+69 to main
main v1.8.0
Strycher 3 days ago committed by GitHub
commit cbd4550ca4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Decides whether an altool run worked, from its exit code AND its output
# (#1040). altool has exited 0 with an error in its output, so a clean exit alone
# proves nothing, and nothing short of its success line counts as success.
#
# usage: ios-altool-check.sh <validate|upload> <exit code> <output file>
set -eu
mode="${1:?mode}"
code="${2:?exit code}"
output="${3:?output file}"
case "${mode}" in
validate) success='VALIDATION SUCCEEDED|No errors validating' ;;
upload) success='UPLOAD SUCCEEDED|No errors uploading' ;;
*)
echo "::error::Unknown altool mode '${mode}'."
exit 1
;;
esac
if [ "${code}" != "0" ]; then
echo "::error::altool ${mode} exited ${code}."
exit 1
fi
if grep -Eq '\*\*\* Error|ERROR:|FAILED' "${output}"; then
echo "::error::altool ${mode} exited 0, but its output contains an error."
exit 1
fi
if ! grep -Eq "${success}" "${output}"; then
echo "::error::altool ${mode} exited 0, but its output has no success line."
exit 1
fi
echo "altool ${mode} succeeded."

@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Fails unless a built Runner.app holds the native-asset libraries (#1037).
# An iOS build can compile with them missing, and the app then fails only at
# runtime, when it opens storage or loads translation.
set -eu
app="${1:?usage: ios-assert-frameworks.sh <path to Runner.app>}"
frameworks="${app}/Frameworks"
if [ ! -d "${frameworks}" ]; then
echo "::error::No Frameworks folder at ${frameworks}."
exit 1
fi
echo "Frameworks in ${app}:"
ls -1 "${frameworks}"
missing=0
for lib in sqlite3 llama; do
if ! ls -1 "${frameworks}" | grep -qi "${lib}"; then
echo "::error::No ${lib} framework in ${frameworks}."
missing=1
fi
done
exit "${missing}"

@ -0,0 +1,69 @@
"""Polls Apple's notary service for one submission until it settles or a deadline passes.
Usage: notary-wait.py <submission-id> <deadline-minutes>
Env: P8 (path to the App Store Connect .p8), NOTARY_KEY_ID, NOTARY_ISSUER_ID
Exit 0 only on Accepted. Invalid/Rejected print Apple's log and exit 1. Still
In Progress at the deadline exits 1 with how to resume: the submission keeps
going at Apple, and "Re-run failed jobs" polls the same id again (#1109).
"""
import json
import os
import sys
import time
import urllib.error
import urllib.request
import jwt
API = "https://appstoreconnect.apple.com/notary/v2/submissions/"
POLL_SECONDS = 60
submission_id = sys.argv[1]
deadline = time.time() + int(sys.argv[2]) * 60
with open(os.environ["P8"], encoding="utf-8") as fh:
private_key = fh.read()
def get(url, auth=True):
headers = {}
if auth:
now = int(time.time())
token = jwt.encode(
{"iss": os.environ["NOTARY_ISSUER_ID"], "iat": now, "exp": now + 600, "aud": "appstoreconnect-v1"},
private_key, algorithm="ES256", headers={"kid": os.environ["NOTARY_KEY_ID"], "typ": "JWT"},
)
headers["Authorization"] = f"Bearer {token}"
with urllib.request.urlopen(urllib.request.Request(url, headers=headers), timeout=30) as response:
return json.load(response)
while True:
try:
body = get(API + submission_id)
status = body["data"]["attributes"]["status"]
except (KeyError, TypeError):
print(f"::error::Unexpected notary API response for {submission_id}: {json.dumps(body)[:500]}")
sys.exit(1)
except urllib.error.HTTPError as error:
if error.code < 500:
print(f"::error::Notary API returned HTTP {error.code} for {submission_id}.")
sys.exit(1)
status = f"HTTP {error.code}, retrying"
except urllib.error.URLError as error:
status = f"network error ({error.reason}), retrying"
print(time.strftime("%H:%M:%S"), status, flush=True)
if status == "Accepted":
sys.exit(0)
if status in ("Invalid", "Rejected"):
print(f"::error::Apple returned {status} for notary submission {submission_id}.")
log_url = get(API + submission_id + "/logs")["data"]["attributes"]["developerLogUrl"]
print(json.dumps(get(log_url, auth=False), indent=2))
sys.exit(1)
if time.time() > deadline:
print(f"::error::Notary submission {submission_id} is still In Progress at Apple. "
"Nothing is lost: the signed app is kept as an artifact of this run. "
"Use 'Re-run failed jobs' later to check the same submission again.")
sys.exit(1)
time.sleep(POLL_SECONDS)

@ -131,8 +131,54 @@ jobs:
if: needs.classify.outputs.docs_only != 'true'
- run: dart run build_runner build --delete-conflicting-outputs
if: needs.classify.outputs.docs_only != 'true'
- run: flutter build ios --release --no-codesign --no-pub
- name: Write dart_defines.json
if: needs.classify.outputs.docs_only != 'true'
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 ios --release --no-codesign --no-pub --dart-define-from-file=dart_defines.json
if: needs.classify.outputs.docs_only != 'true'
# The assertion must be able to fail, so it runs against an empty app first.
- name: "Negative: the framework assertion refuses an empty app"
if: needs.classify.outputs.docs_only != 'true'
run: |
set -u
mkdir -p "${RUNNER_TEMP}/empty.app/Frameworks"
# Output discarded: its ::error lines would annotate a passing job.
if bash .github/scripts/ios-assert-frameworks.sh "${RUNNER_TEMP}/empty.app" > /dev/null; then
echo "::error::The framework assertion passed on an empty app."
exit 1
fi
echo "Refused, as required."
- name: Assert the app holds its native-asset frameworks (#1037)
if: needs.classify.outputs.docs_only != 'true'
run: bash .github/scripts/ios-assert-frameworks.sh build/ios/iphoneos/Runner.app
# ios-signed.yml cannot run on a PR, so its output check is proven here,
# with no secrets: every case that must not count as success (#1040).
- name: "Negative: the altool check refuses anything but success"
if: needs.classify.outputs.docs_only != 'true'
run: |
set -u
T="${RUNNER_TEMP}/altool"
mkdir -p "${T}"
printf 'UPLOAD SUCCEEDED with no errors\n' > "${T}/ok.txt"
printf '*** Error: Unable to upload\n' > "${T}/error.txt"
printf 'UPLOAD SUCCEEDED\nERROR: partial\n' > "${T}/mixed.txt"
printf 'something else\n' > "${T}/silent.txt"
: > "${T}/empty.txt"
CHECK=.github/scripts/ios-altool-check.sh
bash "${CHECK}" upload 0 "${T}/ok.txt" || { echo "::error::A clean upload was refused."; exit 1; }
for f in error mixed silent empty; do
if bash "${CHECK}" upload 0 "${T}/${f}.txt" > /dev/null; then
echo "::error::Exit 0 with '${f}' output was accepted."
exit 1
fi
done
if bash "${CHECK}" upload 1 "${T}/ok.txt" > /dev/null; then
echo "::error::A non-zero exit was accepted."
exit 1
fi
echo "Refused every bad case, as required."
linux:
# PINNED, not ubuntu-latest (#929). GitHub moved ubuntu-latest from 24.04
@ -201,11 +247,35 @@ jobs:
if: needs.classify.outputs.docs_only != 'true'
- run: dart run build_runner build --delete-conflicting-outputs
if: needs.classify.outputs.docs_only != 'true'
- name: Write dart_defines.json
if: needs.classify.outputs.docs_only != 'true'
env:
GIPHY_API_KEY: ${{ secrets.GIPHY_API_KEY }}
run: jq -n --arg k "$GIPHY_API_KEY" '{GIPHY_API_KEY:$k}' > dart_defines.json
- name: Enable native assets (bundles sqlite3 + llamadart libs, #445)
if: needs.classify.outputs.docs_only != 'true'
run: flutter config --enable-native-assets
- run: flutter build macos --release --no-pub
- run: flutter build macos --release --no-pub --dart-define-from-file=dart_defines.json
if: needs.classify.outputs.docs_only != 'true'
# ditto, not zip: it preserves the .app bundle's symlinks, which plain
# zip flattens into copies that break code-signing validation (#1108).
# lipo output is informational: whether the build plus native assets
# comes out universal or arm64-only is recorded here, not assumed.
- name: Zip macOS app
if: needs.classify.outputs.docs_only != 'true'
run: |
set -eu
APP=build/macos/Build/Products/Release/offband_meshcore.app
lipo -archs "${APP}/Contents/MacOS/offband_meshcore" || true
ditto -c -k --keepParent "${APP}" offband-macos.zip
- name: Upload macOS build
if: needs.classify.outputs.docs_only != 'true'
uses: actions/upload-artifact@v4
with:
name: offband-macos-r${{ github.run_number }}-${{ github.event.pull_request.head.sha || github.sha }}
path: offband-macos.zip
if-no-files-found: error
retention-days: 14
web:
runs-on: ubuntu-latest

@ -0,0 +1,300 @@
name: iOS signed build and upload
# Builds a signed IPA and uploads it to App Store Connect (#1038, epic #1013).
# Called by testflight.yml and release-signed.yml.
#
# SECURITY: this repo is PUBLIC, and this job holds the Apple Distribution key
# and the App Store Connect API key.
#
# - NEVER call this from a `pull_request` workflow. A PR (incl. from a fork)
# must never run a job that can read these secrets.
# - Only this job references the `apple-signing` Environment, so only it
# receives them, and only after the required reviewer approves.
# - Secret files live in RUNNER_TEMP or the key folder altool reads, and are
# removed with `if: always()`.
# - Values are passed via env:, never on a command line, never echoed.
#
# There is deliberately no dry-run or skip-upload input. A switch that lets this
# go green without doing its job is the shape of the #904 override. A TestFlight
# upload is private, so the lane proves itself by really uploading.
#
# Every step stops on the first thing it cannot confirm.
on:
workflow_call:
permissions:
contents: read
env:
BUNDLE_ID: app.offband.meshcore.ios
jobs:
ios-signed:
runs-on: macos-latest
environment: apple-signing # required-reviewer gate; do not remove
steps:
- uses: actions/checkout@v4
- name: Check the signing secrets are present
env:
APPLE_DIST_P12_BASE64: ${{ secrets.APPLE_DIST_P12_BASE64 }}
APPLE_DIST_P12_PASSWORD: ${{ secrets.APPLE_DIST_P12_PASSWORD }}
APPLE_PROFILE_BASE64: ${{ secrets.APPLE_PROFILE_BASE64 }}
ASC_API_KEY_P8_BASE64: ${{ secrets.ASC_API_KEY_P8_BASE64 }}
ASC_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }}
ASC_API_ISSUER_ID: ${{ secrets.ASC_API_ISSUER_ID }}
run: |
set -eu
missing=0
for name in APPLE_DIST_P12_BASE64 APPLE_DIST_P12_PASSWORD APPLE_PROFILE_BASE64 \
ASC_API_KEY_P8_BASE64 ASC_API_KEY_ID ASC_API_ISSUER_ID; do
if [ -z "${!name:-}" ]; then
echo "::error::Secret ${name} is empty or absent in this run."
missing=1
fi
done
exit "${missing}"
- name: Temporary keychain with the Distribution identity
env:
APPLE_DIST_P12_BASE64: ${{ secrets.APPLE_DIST_P12_BASE64 }}
APPLE_DIST_P12_PASSWORD: ${{ secrets.APPLE_DIST_P12_PASSWORD }}
run: |
set -eu
umask 077
KC="${RUNNER_TEMP}/signing.keychain-db"
KP="$(openssl rand -hex 16)"
echo "::add-mask::${KP}"
echo "KC=${KC}" >> "$GITHUB_ENV"
security create-keychain -p "${KP}" "${KC}"
security set-keychain-settings -lut 21600 "${KC}"
security unlock-keychain -p "${KP}" "${KC}"
P12="${RUNNER_TEMP}/dist.p12"
printf '%s' "${APPLE_DIST_P12_BASE64}" | base64 --decode > "${P12}"
security import "${P12}" -k "${KC}" -P "${APPLE_DIST_P12_PASSWORD}" \
-T /usr/bin/codesign -T /usr/bin/security
rm -f "${P12}"
security set-key-partition-list -S apple-tool:,apple: -s -k "${KP}" "${KC}" > /dev/null
security list-keychains -d user -s "${KC}" $(security list-keychains -d user | tr -d '"')
IDENTITIES="$(security find-identity -v -p codesigning "${KC}")"
COUNT="$(printf '%s\n' "${IDENTITIES}" | grep -c '"Apple Distribution: ' || true)"
if [ "${COUNT}" != "1" ]; then
echo "::error::Expected one Apple Distribution identity in the keychain, found ${COUNT}."
exit 1
fi
IDENTITY="$(printf '%s\n' "${IDENTITIES}" | sed -n 's/.*"\(Apple Distribution: .*\)".*/\1/p')"
# Holds the owner's legal name, and env values appear in public step logs.
echo "::add-mask::${IDENTITY}"
echo "IDENTITY=${IDENTITY}" >> "$GITHUB_ENV"
echo "Keychain ready with one Apple Distribution identity."
- name: Install the provisioning profile
env:
APPLE_PROFILE_BASE64: ${{ secrets.APPLE_PROFILE_BASE64 }}
run: |
set -eu
umask 077
PROFILE="${RUNNER_TEMP}/dist.mobileprovision"
printf '%s' "${APPLE_PROFILE_BASE64}" | base64 --decode > "${PROFILE}"
PLIST="${RUNNER_TEMP}/profile.plist"
security cms -D -i "${PROFILE}" > "${PLIST}"
PB=/usr/libexec/PlistBuddy
UUID="$("${PB}" -c 'Print :UUID' "${PLIST}")"
NAME="$("${PB}" -c 'Print :Name' "${PLIST}")"
TEAM="$("${PB}" -c 'Print :TeamIdentifier:0' "${PLIST}")"
APP_ID="$("${PB}" -c 'Print :Entitlements:application-identifier' "${PLIST}")"
if [ -z "${UUID}" ] || [ -z "${NAME}" ] || [ -z "${TEAM}" ]; then
echo "::error::Could not read the profile's UUID, name and team."
exit 1
fi
if [ "${APP_ID}" != "${TEAM}.${BUNDLE_ID}" ]; then
echo "::error::The profile is for ${APP_ID}, not ${TEAM}.${BUNDLE_ID}."
exit 1
fi
# Xcode 16 and later read the second folder; older versions the first.
for dir in "${HOME}/Library/MobileDevice/Provisioning Profiles" \
"${HOME}/Library/Developer/Xcode/UserData/Provisioning Profiles"; do
mkdir -p "${dir}"
cp "${PROFILE}" "${dir}/${UUID}.mobileprovision"
done
rm -f "${PROFILE}" "${PLIST}"
{
echo "PROFILE_UUID=${UUID}"
echo "PROFILE_NAME=${NAME}"
echo "TEAM=${TEAM}"
} >> "$GITHUB_ENV"
echo "Profile '${NAME}' installed for ${BUNDLE_ID}."
# Only Runner's Release config includes this xcconfig, so CocoaPods targets
# keep their own signing. Written here, not committed: the project stays
# free of a team and profile, and the compile job in build.yml is untouched.
- name: Apply manual signing and write ExportOptions.plist
run: |
set -eu
{
echo ""
echo "CODE_SIGN_STYLE = Manual"
echo "DEVELOPMENT_TEAM = ${TEAM}"
echo "PROVISIONING_PROFILE_SPECIFIER = ${PROFILE_NAME}"
echo "CODE_SIGN_IDENTITY = Apple Distribution"
echo "CODE_SIGN_IDENTITY[sdk=iphoneos*] = Apple Distribution"
} >> ios/Flutter/Release.xcconfig
OPTIONS="${RUNNER_TEMP}/ExportOptions.plist"
PB=/usr/libexec/PlistBuddy
"${PB}" -c 'Add :method string app-store-connect' \
-c 'Add :destination string export' \
-c 'Add :signingStyle string manual' \
-c 'Add :signingCertificate string Apple Distribution' \
-c "Add :teamID string ${TEAM}" \
-c 'Add :provisioningProfiles dict' \
-c "Add :provisioningProfiles:${BUNDLE_ID} string ${PROFILE_NAME}" \
"${OPTIONS}"
echo "OPTIONS=${OPTIONS}" >> "$GITHUB_ENV"
- 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: jq -n --arg k "$GIPHY_API_KEY" '{GIPHY_API_KEY:$k}' > dart_defines.json
# The build number is the run ID, not the run number: testflight.yml and
# release-signed.yml each count their own runs, so run numbers collide
# between them, and Apple refuses a duplicate. A re-run reuses its ID, so
# Apple refuses that too, which stops the job rather than hiding anything.
- name: Build the signed IPA
run: |
set -eu
BUILD_NAME="$(grep -E '^version:' pubspec.yaml | head -1 | sed 's/version:[[:space:]]*//' | sed 's/+.*//' | tr -d '[:space:]')"
BUILD_NUMBER="${GITHUB_RUN_ID}"
if [ -z "${BUILD_NAME}" ]; then
echo "::error::Could not read the version from pubspec.yaml."
exit 1
fi
echo "BUILD_NAME=${BUILD_NAME}" >> "$GITHUB_ENV"
echo "BUILD_NUMBER=${BUILD_NUMBER}" >> "$GITHUB_ENV"
echo "Building ${BUILD_NAME} (${BUILD_NUMBER})."
flutter build ipa --release --no-pub \
--build-name="${BUILD_NAME}" \
--build-number="${BUILD_NUMBER}" \
--dart-define-from-file=dart_defines.json \
--export-options-plist="${OPTIONS}"
- name: Unpack the IPA
run: |
set -eu
COUNT="$(ls build/ios/ipa/*.ipa 2>/dev/null | wc -l | tr -d ' ')"
if [ "${COUNT}" != "1" ]; then
echo "::error::Expected one IPA in build/ios/ipa, found ${COUNT}."
exit 1
fi
IPA="$(ls build/ios/ipa/*.ipa)"
echo "IPA=${IPA}" >> "$GITHUB_ENV"
UNPACKED="${RUNNER_TEMP}/ipa"
mkdir -p "${UNPACKED}"
unzip -q "${IPA}" -d "${UNPACKED}"
echo "APP=${UNPACKED}/Payload/Runner.app" >> "$GITHUB_ENV"
- name: Assert the IPA holds its native-asset frameworks (#1037)
run: bash .github/scripts/ios-assert-frameworks.sh "${APP}"
- name: Assert the IPA's identity and version
run: |
set -eu
PB=/usr/libexec/PlistBuddy
for pair in "CFBundleIdentifier=${BUNDLE_ID}" \
"CFBundleShortVersionString=${BUILD_NAME}" \
"CFBundleVersion=${BUILD_NUMBER}"; do
KEY="${pair%%=*}"
WANT="${pair#*=}"
GOT="$("${PB}" -c "Print :${KEY}" "${APP}/Info.plist")"
if [ "${GOT}" != "${WANT}" ]; then
echo "::error::${KEY} is '${GOT}', expected '${WANT}'."
exit 1
fi
done
echo "Identity and version are as expected."
# A green build does not prove what signed it, the same reason the Android
# job pins its certificate. The check runs against a wrong name first, so
# it is shown able to fail on every run.
- name: Verify the IPA is signed by the Distribution certificate
run: |
set -eu
codesign --verify --deep --strict "${APP}"
INFO="$(codesign -dvv "${APP}" 2>&1)"
if printf '%s\n' "${INFO}" | grep -qxF "Authority=Apple Distribution: Not This Team (XXXXXXXXXX)"; then
echo "::error::The signing check accepted a wrong certificate name."
exit 1
fi
if ! printf '%s\n' "${INFO}" | grep -qxF "Authority=${IDENTITY}"; then
echo "::error::The IPA is not signed by the Apple Distribution identity in the keychain."
exit 1
fi
if ! printf '%s\n' "${INFO}" | grep -qxF "TeamIdentifier=${TEAM}"; then
echo "::error::The IPA's team is not the profile's team."
exit 1
fi
echo "IPA signing verified."
- name: Install the App Store Connect API key
env:
ASC_API_KEY_P8_BASE64: ${{ secrets.ASC_API_KEY_P8_BASE64 }}
ASC_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }}
run: |
set -eu
umask 077
KEY_DIR="${HOME}/.appstoreconnect/private_keys"
mkdir -p "${KEY_DIR}"
printf '%s' "${ASC_API_KEY_P8_BASE64}" | base64 --decode > "${KEY_DIR}/AuthKey_${ASC_API_KEY_ID}.p8"
# On the runner's own tool, so the key folder and subcommands are read, not
# recalled (#1040). Prints only documentation.
- name: Record what altool documents here
run: |
xcrun altool --version || true
man altool 2>/dev/null | col -b | grep -n -i -E 'private_keys|--validate-app|--upload-app|--upload-package' || true
- name: Validate with App Store Connect
env:
ASC_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }}
ASC_API_ISSUER_ID: ${{ secrets.ASC_API_ISSUER_ID }}
run: |
set +e
OUT="${RUNNER_TEMP}/altool-validate.txt"
xcrun altool --validate-app -f "${IPA}" -t ios \
--apiKey "${ASC_API_KEY_ID}" --apiIssuer "${ASC_API_ISSUER_ID}" > "${OUT}" 2>&1
CODE=$?
set -e
cat "${OUT}"
bash .github/scripts/ios-altool-check.sh validate "${CODE}" "${OUT}"
- name: Upload to App Store Connect
env:
ASC_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }}
ASC_API_ISSUER_ID: ${{ secrets.ASC_API_ISSUER_ID }}
run: |
set +e
OUT="${RUNNER_TEMP}/altool-upload.txt"
xcrun altool --upload-app -f "${IPA}" -t ios \
--apiKey "${ASC_API_KEY_ID}" --apiIssuer "${ASC_API_ISSUER_ID}" > "${OUT}" 2>&1
CODE=$?
set -e
cat "${OUT}"
bash .github/scripts/ios-altool-check.sh upload "${CODE}" "${OUT}"
echo "Uploaded ${BUILD_NAME} (${BUILD_NUMBER})."
- name: Remove signing material
if: always()
run: |
[ -n "${KC:-}" ] && security delete-keychain "${KC}" || true
rm -rf "${HOME}/.appstoreconnect/private_keys"
rm -f "${HOME}/Library/MobileDevice/Provisioning Profiles/"*.mobileprovision
rm -f "${HOME}/Library/Developer/Xcode/UserData/Provisioning Profiles/"*.mobileprovision
rm -f dart_defines.json "${RUNNER_TEMP}/dist.p12" "${RUNNER_TEMP}/dist.mobileprovision" "${RUNNER_TEMP}/profile.plist"
echo "Signing material removed."

@ -0,0 +1,147 @@
name: iOS signing proof
# Proves, on the same macOS runner image the signed iOS lane will use, that a
# .p12 packed the way tools/ios-signing/ios-signing.ps1 packs it imports into a
# keychain with its private key paired to its certificate (#1016, epic #1012).
#
# Uses a THROWAWAY self-signed certificate generated here, every run. No real
# identity, no secret, no private key in the repository. Nothing in this job can
# reach the apple-signing Environment.
#
# Why it exists: OpenSSL 3 (which the owner's Windows script uses) writes .p12
# files with PBES2/AES-256 and a SHA-256 MAC by default; older macOS keychain
# tooling has refused that format. The script packs the legacy format
# (3DES, SHA-1 MAC) instead, and this job is what shows the choice works.
on:
pull_request:
paths:
- "tools/ios-signing/**"
- ".github/workflows/ios-signing-proof.yml"
workflow_dispatch:
permissions:
contents: read
jobs:
p12-import:
runs-on: macos-latest
env:
CN: "Offband CI throwaway"
steps:
- uses: actions/checkout@v4
- name: Locate OpenSSL 3
run: |
set -eu
PREFIX="$(brew --prefix openssl@3 2>/dev/null || true)"
OPENSSL="${PREFIX}/bin/openssl"
if [ -z "${PREFIX}" ] || [ ! -x "${OPENSSL}" ]; then
echo "::error::Homebrew openssl@3 not found on this runner; cannot reproduce the Windows script's OpenSSL 3 output."
exit 1
fi
"${OPENSSL}" version
echo "OPENSSL=${OPENSSL}" >> "$GITHUB_ENV"
- name: Make a throwaway certificate and pack it both ways
run: |
set -eu
umask 077
W="${RUNNER_TEMP}/p12"
mkdir -p "$W"
PW="$("${OPENSSL}" rand -hex 16)"
echo "::add-mask::${PW}"
echo "P12_PW=${PW}" >> "$GITHUB_ENV"
echo "W=${W}" >> "$GITHUB_ENV"
"${OPENSSL}" req -x509 -newkey rsa:2048 -nodes -days 1 \
-keyout "$W/k.pem" -out "$W/c.pem" -subj "/CN=${CN}"
# The OpenSSL 3 default, for the record only.
"${OPENSSL}" pkcs12 -export -inkey "$W/k.pem" -in "$W/c.pem" -name "${CN}" \
-out "$W/default.p12" -passout "pass:${PW}"
# The format the owner's script produces. Keep these flags identical
# to tools/ios-signing/ios-signing.ps1.
"${OPENSSL}" pkcs12 -export -inkey "$W/k.pem" -in "$W/c.pem" -name "${CN}" \
-keypbe PBE-SHA1-3DES -certpbe PBE-SHA1-3DES -macalg sha1 \
-out "$W/legacy.p12" -passout "pass:${PW}"
for f in default legacy; do
test -s "$W/$f.p12" || { echo "::error::$f.p12 was not written"; exit 1; }
echo "== $f.p12 =="
"${OPENSSL}" pkcs12 -info -noout -in "$W/$f.p12" -passin "pass:${PW}" 2>&1 | grep -E "MAC:|Encrypted data|Shrouded"
done
- name: Temporary keychain
run: |
set -eu
KC="${RUNNER_TEMP}/proof.keychain-db"
KP="$("${OPENSSL}" rand -hex 16)"
echo "::add-mask::${KP}"
security create-keychain -p "${KP}" "${KC}"
security set-keychain-settings -lut 600 "${KC}"
security unlock-keychain -p "${KP}" "${KC}"
echo "KC=${KC}" >> "$GITHUB_ENV"
# The check that must be able to fail. If a wrong password were accepted,
# every result below would be meaningless.
- name: "Negative: wrong password is refused"
run: |
set -u
if security import "${W}/legacy.p12" -k "${KC}" -P "not-the-password" -T /usr/bin/codesign; then
echo "::error::A wrong password was accepted. This proof cannot be trusted."
exit 1
fi
echo "Refused, as required."
- name: "Positive: legacy .p12 imports with its key paired"
run: |
set -eu
security import "${W}/legacy.p12" -k "${KC}" -P "${P12_PW}" -T /usr/bin/codesign
security find-certificate -c "${CN}" "${KC}" > /dev/null
IDS="$(security find-identity "${KC}")"
echo "${IDS}"
if ! printf '%s\n' "${IDS}" | grep -q "${CN}"; then
echo "::error::Certificate imported but no identity: the private key did not pair with it."
exit 1
fi
echo "Legacy .p12 imported with key and certificate paired."
# The owner's script itself, not a copy of its flags: run its selftest
# under PowerShell on this runner and import what it produced (#1017).
- name: "Script: ios-signing.ps1 selftest output imports"
run: |
set -eu
command -v pwsh > /dev/null || { echo "::error::pwsh not found on this runner"; exit 1; }
OUT="${RUNNER_TEMP}/selftest"
mkdir -p "${OUT}"
OPENSSL="${OPENSSL}" SELFTEST_OUT="${OUT}" pwsh -NoProfile -File tools/ios-signing/ios-signing.ps1 selftest
test -s "${OUT}/selftest.p12" || { echo "::error::selftest produced no .p12"; exit 1; }
SPW="$(cat "${OUT}/selftest-password.txt")"
echo "::add-mask::${SPW}"
security import "${OUT}/selftest.p12" -k "${KC}" -P "${SPW}" -T /usr/bin/codesign
IDS="$(security find-identity "${KC}")"
if ! printf '%s\n' "${IDS}" | grep -q "Offband Meshcore Distribution"; then
echo "${IDS}"
echo "::error::The script's .p12 imported but its key did not pair with its certificate."
exit 1
fi
rm -rf "${OUT}"
echo "The script's own .p12 imports with key and certificate paired."
- name: "Record: does the OpenSSL 3 default import here too?"
run: |
set -u
KC2="${RUNNER_TEMP}/default.keychain-db"
security create-keychain -p tmp "${KC2}"
security unlock-keychain -p tmp "${KC2}"
if security import "${W}/default.p12" -k "${KC2}" -P "${P12_PW}" -T /usr/bin/codesign; then
echo "::notice::The OpenSSL 3 default format also imports on this runner image."
else
echo "::notice::The OpenSSL 3 default format is REFUSED on this runner image; the legacy format is required."
fi
security delete-keychain "${KC2}" || true
- name: Clean up
if: always()
run: |
[ -n "${KC:-}" ] && security delete-keychain "${KC}" || true
[ -n "${W:-}" ] && rm -rf "${W}" || true
echo "Throwaway keychain and files removed."

@ -0,0 +1,232 @@
name: macOS signed build
# Builds the macOS app, signs it with the Developer ID Application identity,
# notarizes it with Apple and staples the ticket, so it opens on any Mac with
# no Gatekeeper warning (#1109, epic #1107). Called by release-signed.yml.
#
# Three jobs, so no Mac runner ever sits waiting on Apple's queue:
# 1. build-sign-submit (Mac): build, sign, submit, keep the signed app.
# 2. notary-wait (Linux): poll Apple for that submission, up to 340 min.
# 3. staple (Mac): staple the ticket, verify, publish the zip.
# If Apple holds a submission past the poll deadline, job 2 fails, the signed
# app stays an artifact of the run, and "Re-run failed jobs" checks the SAME
# submission again later. Nothing is rebuilt or resubmitted.
#
# SEPARATION: this lane uses its own `apple-macos` Environment and its own
# App Store Connect key. It shares no Environment, secret or key with the
# iOS lane (`apple-signing`), so a hold, revocation or failure in one Apple
# lane cannot break the other.
#
# SECURITY: this repo is PUBLIC, and jobs 1 and 2 hold the Developer ID key
# and the notary API key.
#
# - NEVER call this from a `pull_request` workflow. A PR (incl. from a fork)
# must never run a job that can read these secrets.
# - Only jobs 1 and 2 reference the `apple-macos` Environment, so only they
# receive secrets, and only after the required reviewer approves. Job 3
# needs none: stapling fetches the public ticket by the app's hash.
# - Secret files live in RUNNER_TEMP and are removed with `if: always()`.
# - Values are passed via env:, never echoed. The identity name holds the
# owner's legal name and is masked.
#
# There is deliberately no skip-notarization input: an unsigned or
# unnotarized app must fail this lane, never pass it (#904).
on:
workflow_call:
permissions:
contents: read
jobs:
build-sign-submit:
runs-on: macos-latest
environment: apple-macos # required-reviewer gate; do not remove
timeout-minutes: 60
outputs:
submission-id: ${{ steps.submit.outputs.id }}
steps:
- uses: actions/checkout@v4
- name: Check the signing secrets are present
env:
MACOS_DEVID_P12_BASE64: ${{ secrets.MACOS_DEVID_P12_BASE64 }}
MACOS_DEVID_P12_PASSWORD: ${{ secrets.MACOS_DEVID_P12_PASSWORD }}
MACOS_NOTARY_KEY_P8_BASE64: ${{ secrets.MACOS_NOTARY_KEY_P8_BASE64 }}
MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}
MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}
run: |
set -eu
missing=0
for name in MACOS_DEVID_P12_BASE64 MACOS_DEVID_P12_PASSWORD \
MACOS_NOTARY_KEY_P8_BASE64 MACOS_NOTARY_KEY_ID MACOS_NOTARY_ISSUER_ID; do
if [ -z "${!name:-}" ]; then
echo "::error::Secret ${name} is empty or absent in this run."
missing=1
fi
done
exit "${missing}"
- name: Temporary keychain with the Developer ID identity
env:
MACOS_DEVID_P12_BASE64: ${{ secrets.MACOS_DEVID_P12_BASE64 }}
MACOS_DEVID_P12_PASSWORD: ${{ secrets.MACOS_DEVID_P12_PASSWORD }}
run: |
set -eu
umask 077
KC="${RUNNER_TEMP}/signing.keychain-db"
KP="$(openssl rand -hex 16)"
echo "::add-mask::${KP}"
echo "KC=${KC}" >> "$GITHUB_ENV"
security create-keychain -p "${KP}" "${KC}"
security set-keychain-settings -lut 21600 "${KC}"
security unlock-keychain -p "${KP}" "${KC}"
P12="${RUNNER_TEMP}/devid.p12"
printf '%s' "${MACOS_DEVID_P12_BASE64}" | base64 --decode > "${P12}"
security import "${P12}" -k "${KC}" -P "${MACOS_DEVID_P12_PASSWORD}" \
-T /usr/bin/codesign -T /usr/bin/security
rm -f "${P12}"
security set-key-partition-list -S apple-tool:,apple: -s -k "${KP}" "${KC}" > /dev/null
security list-keychains -d user -s "${KC}" $(security list-keychains -d user | tr -d '"')
IDENTITIES="$(security find-identity -v -p codesigning "${KC}")"
COUNT="$(printf '%s\n' "${IDENTITIES}" | grep -c '"Developer ID Application: ' || true)"
if [ "${COUNT}" != "1" ]; then
echo "::error::Expected one Developer ID Application identity in the keychain, found ${COUNT}."
exit 1
fi
IDENTITY="$(printf '%s\n' "${IDENTITIES}" | sed -n 's/.*"\(Developer ID Application: .*\)".*/\1/p')"
echo "::add-mask::${IDENTITY}"
echo "IDENTITY=${IDENTITY}" >> "$GITHUB_ENV"
echo "Keychain ready with one Developer ID Application identity."
- 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: jq -n --arg k "$GIPHY_API_KEY" '{GIPHY_API_KEY:$k}' > dart_defines.json
- name: Enable native assets (bundles sqlite3 + llamadart libs, #445)
run: flutter config --enable-native-assets
- run: flutter build macos --release --no-pub --dart-define-from-file=dart_defines.json
# Inside-out: every nested Mach-O first, then each framework bundle, then
# the app with its entitlements. Hardened Runtime and a secure timestamp
# are what notarization requires.
- name: Sign with Developer ID
run: |
set -eu
APP=build/macos/Build/Products/Release/offband_meshcore.app
echo "APP=${APP}" >> "$GITHUB_ENV"
SIGN=(codesign --force --options runtime --timestamp --sign "${IDENTITY}" --keychain "${KC}")
while IFS= read -r -d '' f; do
if file -b "${f}" | grep -q 'Mach-O'; then
"${SIGN[@]}" "${f}"
fi
done < <(find "${APP}/Contents/Frameworks" -type f -print0)
while IFS= read -r -d '' fw; do
"${SIGN[@]}" "${fw}"
done < <(find "${APP}/Contents/Frameworks" -depth -name '*.framework' -type d -print0)
"${SIGN[@]}" --entitlements macos/Runner/Release.entitlements "${APP}"
codesign --verify --deep --strict --verbose=2 "${APP}"
lipo -archs "${APP}/Contents/MacOS/offband_meshcore"
# ditto, not zip: it preserves the .app bundle's symlinks (#1108). The
# same zip is what Apple notarizes and what job 3 staples.
- name: Zip the signed app
run: ditto -c -k --keepParent "${APP}" offband-macos-signed.zip
# Upload only, no --wait: the verdict is job 2's business.
- name: Submit for notarization
id: submit
env:
MACOS_NOTARY_KEY_P8_BASE64: ${{ secrets.MACOS_NOTARY_KEY_P8_BASE64 }}
MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}
MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}
run: |
set -eu
umask 077
P8="${RUNNER_TEMP}/AuthKey.p8"
printf '%s' "${MACOS_NOTARY_KEY_P8_BASE64}" | base64 --decode > "${P8}"
OUT="${RUNNER_TEMP}/notarize.json"
xcrun notarytool submit offband-macos-signed.zip --key "${P8}" \
--key-id "${MACOS_NOTARY_KEY_ID}" --issuer "${MACOS_NOTARY_ISSUER_ID}" \
--output-format json > "${OUT}"
cat "${OUT}"
ID="$(jq -r '.id // empty' "${OUT}")"
if [ -z "${ID}" ]; then
echo "::error::notarytool returned no submission id."
exit 1
fi
echo "id=${ID}" >> "$GITHUB_OUTPUT"
echo "Submitted for notarization: ${ID}"
# Kept 30 days so a held submission can be finished by a re-run.
- uses: actions/upload-artifact@v4
with:
name: macos-signed-unstapled
path: offband-macos-signed.zip
if-no-files-found: error
retention-days: 30
- name: Remove the signing material
if: always()
run: |
set +e
if [ -n "${KC:-}" ]; then security delete-keychain "${KC}"; fi
rm -f dart_defines.json "${RUNNER_TEMP}/devid.p12" "${RUNNER_TEMP}/AuthKey.p8"
notary-wait:
needs: build-sign-submit
runs-on: ubuntu-latest
environment: apple-macos # holds the notary key; do not remove
# GitHub's job limit is 360 minutes. A Linux runner on a public repo costs
# nothing to keep polling, and a hold of a few hours then finishes with no
# manual re-run. The poll deadline leaves room for setup inside the limit.
timeout-minutes: 355
steps:
- uses: actions/checkout@v4
- run: pip install --quiet "PyJWT[crypto]==2.10.1"
- name: Wait for Apple's verdict (340 min max)
env:
SUBMISSION_ID: ${{ needs.build-sign-submit.outputs.submission-id }}
MACOS_NOTARY_KEY_P8_BASE64: ${{ secrets.MACOS_NOTARY_KEY_P8_BASE64 }}
NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}
NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}
run: |
set -eu
umask 077
export P8="${RUNNER_TEMP}/AuthKey.p8"
printf '%s' "${MACOS_NOTARY_KEY_P8_BASE64}" | base64 --decode > "${P8}"
python3 .github/scripts/notary-wait.py "${SUBMISSION_ID}" 340
- name: Remove the notary key
if: always()
run: rm -f "${RUNNER_TEMP}/AuthKey.p8"
staple:
needs: notary-wait
runs-on: macos-latest
timeout-minutes: 20
steps:
- uses: actions/download-artifact@v4
with:
name: macos-signed-unstapled
- name: Staple and verify
run: |
set -eu
ditto -x -k offband-macos-signed.zip .
APP=offband_meshcore.app
xcrun stapler staple "${APP}"
xcrun stapler validate "${APP}"
codesign --verify --deep --strict --verbose=2 "${APP}"
spctl --assess --type execute --verbose=4 "${APP}"
ditto -c -k --keepParent "${APP}" offband-macos.zip
- uses: actions/upload-artifact@v4
with:
name: release-macos
path: offband-macos.zip
if-no-files-found: error
retention-days: 7

@ -1,8 +1,9 @@
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
# AAB, Windows and Linux, attached to a GitHub release whose body is the
# human-authored release notes. The notarized macOS zip (#1109) is added after
# Apple accepts it, by `macos-attach`. 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,
@ -13,7 +14,9 @@ name: Release (signed, all platforms)
# 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.
# reviewer approves. The windows/linux/release jobs never see them. The
# `ios` job uses the `apple-signing` Environment (ios-signed.yml); the
# `macos` jobs use the separate `apple-macos` one (macos-signed.yml).
# - Keystore written to disk only for the build, deleted with `if: always()`.
# - Signing values passed via env:, never on a command line, never echoed.
#
@ -249,6 +252,23 @@ jobs:
if-no-files-found: error
retention-days: 7
# Developer ID signed and notarized macOS build (#1109). Deliberately not in
# `release`'s needs: Apple's notary queue is outside our control, so it must
# not hold up Android, Windows and Linux. `macos-attach` adds the asset to
# the published release once Apple accepts it. An unnotarized macOS app is
# never published: if notarization fails, the release ships without it.
macos:
needs: gate
uses: ./.github/workflows/macos-signed.yml
secrets: inherit
# Uploads to App Store Connect (#1039). Deliberately not in `release`'s needs:
# an Apple-side failure must not block the Android and desktop release.
ios:
needs: gate
uses: ./.github/workflows/ios-signed.yml
secrets: inherit
# 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.
@ -326,7 +346,7 @@ jobs:
else
gh release create "${TAG}" \
--draft \
--title "Offband Meshcore ${VERSION}" \
--title "Offband MeshCore ${VERSION}" \
--notes-file "${NOTES}" \
"${PRERELEASE_FLAG}"
gh release upload "${TAG}" out/*
@ -338,3 +358,20 @@ jobs:
gh release edit "${TAG}" "${PRERELEASE_FLAG}" --draft=false
fi
echo "Release ${TAG} published with $(ls out | wc -l) assets."
# Adds the notarized macOS zip to the release after both are done (#1109).
# If macOS was held at Apple, "Re-run failed jobs" finishes the macOS lane
# and then this job, with no rebuild.
macos-attach:
needs: [release, macos]
runs-on: ubuntu-latest
permissions:
contents: write # upload one asset to the existing release
steps:
- uses: actions/download-artifact@v4
with:
name: release-macos
- name: Attach macOS asset
env:
GH_TOKEN: ${{ github.token }}
run: gh release upload "${GITHUB_REF_NAME}" offband-macos.zip --clobber --repo "${GITHUB_REPOSITORY}"

@ -0,0 +1,20 @@
name: TestFlight
# A signed build uploaded to App Store Connect, on demand (#1039, epic #1013).
# Dispatch it from a `v*` tag or `main`: the apple-signing Environment refuses
# any other ref. NEVER add a `pull_request` trigger; see ios-signed.yml.
on:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: testflight
cancel-in-progress: false
jobs:
ios:
uses: ./.github/workflows/ios-signed.yml
secrets: inherit

3
.gitignore vendored

@ -68,6 +68,9 @@ macos/Flutter/GeneratedPluginRegistrant.swift
**/ios/Flutter/Flutter.framework
**/ios/Flutter/Flutter.podspec
# Apple account paperwork, kept local
docs/Apple/
# Android
.gradle/
**/android/.gradle/

@ -2,6 +2,139 @@
All notable changes to Offband Meshcore. Pre-releases are tagged `-beta.N` / `-rc.N`.
## [1.8.0] - 2026-11-09
Production successor to 1.7.0. A channels release: message state keyed by channel identity
instead of slot, no hidden history, every channel write confirmed by the radio, the firmware
1.7.0 slot rules, plus radio-side settings over the `0xC5 0x1C` capability advert. Radio
sound, Display and LED, and rotation need Offband firmware 1.7.0 or newer. Database schema
unchanged (2).
### Upgrade notes
- First full sync purges history under channel keys the radio does not hold, plus pre-#194
slot-index keys for empty slots. Runs only when the sync saw every slot device info
reported, every slot confirmed, none erased, no write in flight, and at least one channel;
every sync logs the result or the skip reason (#1128).
- Slot 0 reserved for Public on every firmware; on stock or older firmware one slot of 40 goes
unused without Public. An existing non-Public occupant of slot 0 is left alone (#1117).
- 65 new UI strings, English-only in all 17 other locales; `untranslated.json` regenerated.
One string retired (`channels_noFreeSlot`).
### Channels and message history
- Key change (hashtag rename or new private key) asks keep or delete. Applied once the radio
confirms the clear. Keep runs `moveChannelHistory` (copy, verify, delete only copied rows,
up to 3 passes; a blob-only old key moved onto rows first) plus `moveChannelNotifyMode`.
Delete runs `deleteChannelHistory`. Failed move returns `historyNotMoved`, persistent. A key
whose change could not land is spared from the purge until the connection ends. A different
channel written over an occupied slot is settled by the next full sync, since firmware 1.7.0
may move the occupant (#1128).
- Fail-closed channel sync: an unanswered slot is UNKNOWN, surfaced by a persistent Retry
banner, never backfilled from the previous list; lookups and notify evaluation for it fail
closed (suppress). Ingress barrier holds live channel traffic until the list is confirmed,
keyed per radio, and re-holds frames for still-unconfirmed slots; pull-based frames are
never dropped; raw-RX no longer matches the offline cache. Unread survives a sync only for
the same PSK; identity changes listed in a dismissible notice. A sync with unconfirmed slots
does not overwrite the last fully confirmed cache; a failed sync on a live link retries
(#1062, #1064).
- In-memory conversation dropped and reloaded on a slot identity change; reassignment clears
only legacy slot-index keys; `sendChannelMessage` refuses an unconfirmed slot with a
persistent error; `_loadChannelMessages` merges messages that arrived during the read
(#1063).
- Channel writes (`setChannel`, `deleteChannel`, `editChannel`) await OK/ERR via the generic
ack queue with a 5 s timeout and return `ok`, `occupied`, `rejected`, `noReply` or
`clearedNotAdded`; history cleared only after OK; key change is clear-then-add; success
toasts only after OK, refusals persistent (#1026, #1025).
- Slot chosen at the write by `decideChannelSlot` / `allocateChannelSlotOrExplain`; refuses
while syncing and when full (no more `return 0`); covers channels screen, QR share, community
join (green toast only when written) and config import (`planChannelImport` requires
`isSyncing`, skips with `channelsSyncing`). Syncs defer while a write is in flight; batches
hold the guard; the last write runs one re-read (#1021, #1022, #1023, #1024, #1025).
- Firmware 1.7.0 slot rules: Public to slot 0, nothing else ever to slot 0; second Public
refused on every path; Delete Public refused at the attempt; Public's key locked in Edit and
refused on another channel; hashtag "public" (namesPublic rule) writes the real Public
(#1117, #1029).
- Restore to radio: `planChannelRestore` places by identity (live keys skipped, Public to slot
0, others to their remembered slot if free or erased and not 0, else lowest free, live slots
never a destination, erased slots cleared first); every write awaits OK/ERR; reports the
confirmed count and names what did not land; busy table refuses (#1060, #1117).
- Erased (`0xFF`) records and undecodable names refused from the wire and dropped at
`loadCachedChannels`; a sync seeing an erased slot keeps the cache (#1060).
- Edit Channel: hashtag PSK read-only, copyable, re-derived on rename; non-hashtag or `##`
names refused; community hashtag name and key read-only; Save refused when the slot's live
occupant changed since the dialog opened (#1103).
### Radio settings
- `0xC5 0x1C` capability advert (TLV, `TAG_CONTINUE` bounded at 8 rounds, unknown tags kept
and skipped) replaces device-info offset 87, which nothing reads now (#1094, #1095, #1096,
#1097).
- Capability gates on caps bits, not `FIRMWARE_VER_CODE`: block list, caplog, packet hash and
observer config (`OFFBAND_CONFIG_MIN_VER` deleted). Companion radio stats `>= 8` and region
scope `>= 13` stay version-gated by owner ruling; removal tracked in #1066 (#1065).
- Radio per-channel notify over `0xC5 0x18/0x19` (unset, all, mentions, off), gated on caps2
`0x80` and `TAG_CHAN_NOTIFY`, read on every completed sync, keyed by PSK, slot resolved at
send time; independent of the phone setting, which is relabeled Phone notifications (#1068,
#1060).
- Display mode and status LED (device-info bytes 86, 85) and rotation over `0xC5 0x1A/0x1B`
gated on `TAG_DISPLAY_ROTATION` (#523, #1098). Display and LED gated independently on advert
tags `0x04` TAG_DISPLAY_CONTROL and `0x05` TAG_LED_CONTROL, falling back to caps2 `0x04`
when neither tag is advertised, the advert is refused, or it is not answered yet; each SET
refused locally without its own capability; rotation SET gate unchanged. Client half of
firmware #2069; the tags are on firmware-base, not in `offband-v1.7.0` (#1132).
- BLE pairing PIN: `CMD_SET_DEVICE_PIN` (37), read from device-info bytes 4..7, shown in plain
text, 100000-999999 or 0 for the firmware default (#1100).
- Factory reset: `CMD_FACTORY_RESET` (51), no version floor. "Also delete this radio's data
stored in the app" (default on) deletes every perRadio surface from `scoping_inventory.dart`
when the reset is sent; no confirmation from the radio is possible. Failed delete shows a
persistent banner (#1057, #1118).
### App
- Sponsor window: 3rd launch and 7 days, Not now 30 days, Sponsor 90 days, Never Show Again;
hidden on iOS; test builds keyed by `GIT_SHA` show once per commit; five global
`sponsor_prompt_*` prefs keys (#1129).
- App-only settings from the connect screen: App Settings, Debug (Share logs, logging switch,
log viewers) and About without a radio; export failures persistent (#693, #1030).
- Bluetooth scan start failure shown persistently, with a Location-off message (#1033).
- Tile User-Agent `OffbandMeshCore (app.offband.meshcore; +https://offband.org)` (not on web);
OSM credit on all six maps (#1111).
- Notify-mode saves, notify decisions and the slot lookup's source logged (#1122).
- Long menus scroll; links beside @mentions tappable (#1074, #1075, #1079).
- "Offband MeshCore" spelling in launcher, titles, strings, notifications, web; Windows
`ProductName` unchanged so the data folder does not move (#1050).
- iOS: Google Play link, Donate and Experimental hidden; back arrow, edge swipe, keyboard
dismiss; `ITSAppUsesNonExemptEncryption` false (#1043, #1048, #1051, #1052).
### Build and release
- First App Store release (iOS 1.7.0) went live 2026-11-09. The release `ios` job uploads
1.8.0 to App Store Connect; submitting it for review is a manual step there (#200, #961).
- macOS build as a release asset (#1108), now Developer ID signed and notarized (#1109):
`macos-signed.yml` (env `apple-macos`) builds, signs and submits on a Mac without waiting,
`notary-wait` polls Apple on Linux for up to 340 minutes, `staple` runs on a Mac, and
`macos-attach` adds `offband-macos.zip` to the published release. Not in `release`'s needs;
an unnotarized app is never published.
- iOS signing toolchain, signed IPA, TestFlight upload, App Store review package (#958, #1012,
#1013, #1014, #1016, #1017, #1019, #1037, #1038, #1039, #1040, #1041, #1044, #1045, #1046,
#1047).
- Plan of record for Play publishing automation (#1077, #1091).
- Release texts gain `announcements/<version>.md`, the GitHub Discussions Announcements post
(#1137).
### Known issues
- #1121 Phone notifications Off can still notify.
- #1123 Factory reset leaves stale data for that radio on other devices.
- #1132 On firmware 1.7.0, boards with a display and no controllable LED still show the LED
switch (needs the firmware tags).
- #1124 `_sendIndicatorSet` never clears its pending-write flag, so a stale read can overwrite
a newer change.
- #1028 A refused contact add shows as added; DMs fail silently after 8 s.
- #1134 Position Reporting Interval reports success when the radio refuses it (T1000-E).
- #1066 Two capability reads still use `FIRMWARE_VER_CODE`.
## [1.7.0] - 2026-10-26
Production successor to 1.6.0. A foundations release: per-message storage with a

@ -1,8 +1,8 @@
# Offband Meshcore - Flutter Client
# Offband MeshCore - Flutter Client
**Offband Meshcore** is an open-source Flutter client for MeshCore LoRa mesh networking devices. Connects to MeshCore-compatible radios over **BLE, TCP, or USB serial** and provides direct/channel chat, contact and channel management, on-map node tracking, repeater administration, and on-device message translation.
**Offband MeshCore** is an open-source Flutter client for MeshCore LoRa mesh networking devices. Connects to MeshCore-compatible radios over **BLE, TCP, or USB serial** and provides direct/channel chat, contact and channel management, on-map node tracking, repeater administration, and on-device message translation.
> Offband Meshcore is an independent fork of **[MeshCore Open](https://github.com/zjs81/meshcore-open)** by zjs81, used under the MIT License. See [LICENSE](LICENSE) and the README Credits section.
> Offband MeshCore is an independent fork of **[MeshCore Open](https://github.com/zjs81/meshcore-open)** by zjs81, used under the MIT License. See [LICENSE](LICENSE) and the README Credits section.
## Build Commands

@ -1,4 +1,4 @@
# How to contribute to Offband Meshcore
# How to contribute to Offband MeshCore
Before submitting any pull requests (PR), please review the following information.

@ -1,12 +1,12 @@
# Offband Meshcore
# Offband MeshCore
Open-source Flutter client for MeshCore LoRa mesh networking devices.
> **Offband Meshcore** is an independent fork of **[MeshCore Open](https://github.com/zjs81/meshcore-open)** by [zjs81](https://github.com/zjs81), used under the MIT License. Full credit to the original project — see [Credits](#credits).
> **Offband MeshCore** is an independent fork of **[MeshCore Open](https://github.com/zjs81/meshcore-open)** by [zjs81](https://github.com/zjs81), used under the MIT License. Full credit to the original project — see [Credits](#credits).
## Overview
Offband Meshcore is a cross-platform mobile application for communicating with MeshCore LoRa mesh network devices via Bluetooth Low Energy (BLE). The app enables long-range, off-grid communication through peer-to-peer messaging, public channels, and mesh networking capabilities.
Offband MeshCore is a cross-platform mobile application for communicating with MeshCore LoRa mesh network devices via Bluetooth Low Energy (BLE). The app enables long-range, off-grid communication through peer-to-peer messaging, public channels, and mesh networking capabilities.
## Screenshots
@ -250,7 +250,7 @@ For issues, questions, or feature requests, please open an issue on GitHub:
## Credits
Offband Meshcore is an independent fork of **[MeshCore Open](https://github.com/zjs81/meshcore-open)** by **zjs81**, used under the MIT License. The original copyright and license are retained in [LICENSE](LICENSE). Deep thanks to zjs81 and the upstream contributors.
Offband MeshCore is an independent fork of **[MeshCore Open](https://github.com/zjs81/meshcore-open)** by **zjs81**, used under the MIT License. The original copyright and license are retained in [LICENSE](LICENSE). Deep thanks to zjs81 and the upstream contributors.
- Built with [Flutter](https://flutter.dev/)
- Map tiles from [OpenStreetMap](https://www.openstreetmap.org/)

@ -6,7 +6,7 @@
- [x] Xcode installed
- [x] Apple Transporter app installed
- [x] App icons ready (1024x1024px)
- [x] Bundle ID configured: `app.offband.meshcore`
- [x] Bundle ID configured: `app.offband.meshcore.ios`
## Step 1: Register Bundle Identifier
@ -15,8 +15,8 @@
3. Select **"App IDs"** → Continue
4. Select **"App"** → Continue
5. Fill in:
- **Description**: Offband Meshcore
- **Bundle ID**: Explicit - `app.offband.meshcore`
- **Description**: Offband MeshCore
- **Bundle ID**: Explicit - `app.offband.meshcore.ios`
- **Capabilities**: Leave defaults (or add as needed)
6. Click **Continue** → **Register**
@ -28,9 +28,9 @@
4. Click the **"+"** button → **"New App"**
5. Fill in the form:
- **Platforms**: iOS
- **Name**: Offband Meshcore
- **Name**: Offband MeshCore
- **Primary Language**: English (U.S.)
- **Bundle ID**: Select `app.offband.meshcore` from dropdown
- **Bundle ID**: Select `app.offband.meshcore.ios` from dropdown
- **SKU**: `meshcore-open-001` (or any unique identifier)
- **User Access**: Full Access
6. Click **"Create"**
@ -86,7 +86,7 @@ The IPA will be created at: `build/ios/ipa/meshcore_open.ipa`
3. Fill in **Description**:
```
Offband Meshcore is a Flutter client for MeshCore LoRa mesh networking devices.
Offband MeshCore is a Flutter client for MeshCore LoRa mesh networking devices.
Features:
- BLE connectivity to MeshCore devices
@ -105,7 +105,7 @@ The IPA will be created at: `build/ios/ipa/meshcore_open.ipa`
### Version Information
1. **What's New in This Version**:
```
Initial release of Offband Meshcore
Initial release of Offband MeshCore
- BLE device connectivity
- Mesh network messaging
@ -241,4 +241,4 @@ Distribution:
For issues with:
- **App Store Process**: [Apple Developer Support](https://developer.apple.com/contact/)
- **Flutter Build Issues**: [Flutter GitHub](https://github.com/flutter/flutter/issues)
- **Offband Meshcore App**: [GitHub Issues](https://github.com/OffbandMesh/meshcore-client/issues)
- **Offband MeshCore App**: [GitHub Issues](https://github.com/OffbandMesh/meshcore-client/issues)

@ -22,7 +22,7 @@
<uses-feature android:name="android.hardware.usb.host" android:required="false"/>
<application
android:label="Offband Meshcore"
android:label="Offband MeshCore"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<service

@ -0,0 +1,70 @@
**Title:** Offband MeshCore 1.8.0, and Offband MeshCore is on the App Store
---
Offband MeshCore 1.8.0 is out. It's a channels release: message history now belongs to the
channel itself rather than the slot it occupies on your radio, every channel write waits for
the radio to confirm it, and the app no longer keeps history out of sight where it could
reappear later.
Full release notes, downloads and known issues:
https://github.com/OffbandMesh/meshcore-client/releases/tag/v1.8.0
### Now on iPhone
Offband MeshCore is on the App Store, for iOS 16.4 or newer:
https://apps.apple.com/us/app/offband-meshcore/id6829124741
The App Store has 1.7.0 today. 1.8.0 goes up once Apple has reviewed it.
### Before you update
- **Hidden history is cleared.** On its first full sync with a radio, 1.8.0 deletes the
stored history of any channel that radio no longer holds. Channels on your radio are not
touched. If any slot went unanswered or erased, nothing is deleted. If a reflash erased the
radio's channel storage, the app keeps the history and offers **Restore to radio**.
- **Slot 0 is kept for Public on every radio.** On stock or older firmware without Public,
that slot goes unused.
- **New text is English-only for now** in the other 17 languages.
### What's new
- **Channels:** history, unread counts and notification settings follow the channel's key.
Changing a key asks whether to keep or delete the history. Adds can't overwrite another
channel. Public stays in slot 0 and can't be deleted. An incomplete sync keeps your list
and offers Retry sync. Restore to radio puts back channels a radio lost and reports what
it confirmed.
- **Your radio's settings** (Offband firmware 1.7.0 or newer): per-channel Radio sound,
separate from phone notifications, plus display mode, status LED and display rotation.
- **Any radio:** BLE pairing PIN and Factory Reset, with an option to delete that radio's data
from the app.
- **Fixed:** blocks stored on the radio, Serial capture's radio log, CoreScope observer counts
and observer settings could be hidden by a firmware version check.
- **macOS:** a desktop build signed and notarized by Apple is part of each release. It appears
on the release page once Apple accepts it. It's new and hasn't had a full test pass.
- Share logs and About work without a radio. Bluetooth scan errors stay on screen. Every map
shows the OpenStreetMap credit. Long menus scroll, and links beside @mentions can be tapped.
### Known issues
- A channel's phone notifications set to Off can still notify (#1121).
- A factory reset deletes the app data only on the phone that sent it (#1123).
- On a T1000-E, QCC badge or Heltec T096 running firmware 1.7.0, the Status LED switch
appears but the radio refuses it (#1132).
- A Display or LED change can be undone by an older reading arriving just after it (#1124).
- A contact add the radio refuses because its table is full shows as added (#1028).
- On a T1000-E, Position Reporting Interval says it saved when the radio refused it (#1134).
### Get it
- **Android:** [Google Play](https://play.google.com/store/apps/details?id=app.offband.meshcore),
rolling out in stages, or the APK on the release page
- **iPhone:** [App Store](https://apps.apple.com/us/app/offband-meshcore/id6829124741)
- **Windows, macOS, Linux:** [v1.8.0 release](https://github.com/OffbandMesh/meshcore-client/releases/tag/v1.8.0)
- **Browser:** https://offband.app
- **Offband firmware 1.7.0:** [offband-v1.7.0](https://github.com/OffbandMesh/meshcore-firmware/releases/tag/offband-v1.7.0)
Questions go in [Q&A](https://github.com/OffbandMesh/meshcore-client/discussions/categories/q-a)
and bugs in [Issues](https://github.com/OffbandMesh/meshcore-client/issues). Thanks to the
[OKIMesh community](https://okimesh.org/). If you'd like to help keep Offband going:
https://offband.org/donate

@ -0,0 +1,17 @@
# GitHub Discussions announcement
One file per release: `announcements/<version>.md`, e.g. `announcements/1.8.0.md`.
## Style
A post for the **Announcements** category of this repo's GitHub Discussions. It's for people
who follow the project on GitHub: what landed, anything to read before updating, every known
issue that ships, and where to get each platform. Plainer than the Discord text, shorter than
the release notes, and it links to the release page for the full notes.
The first line gives the discussion title (`**Title:** ...`), followed by a `---` line. The
post body is everything after that.
**The post goes up after the release is published**, because it links to the release page.
Only the owner posts it, or an agent on the owner's word. CI never posts it, and the release
gate (`.github/scripts/release-gate.sh`) does not check this file.

@ -0,0 +1,25 @@
🚀 **Offband MeshCore 1.8.0 is out.** A channels release: your message history now belongs to the channel, not to whichever slot it sits in on your radio.
📱 **And it's on iPhone.** Offband MeshCore is now on the App Store (iOS 16.4 or newer): https://apps.apple.com/us/app/offband-meshcore/id6829124741. The App Store has 1.7.0 right now; 1.8.0 goes up once Apple has reviewed it.
⚠️ **Heads up before updating.** Older versions could keep a channel's messages hidden after the channel left your radio, and bring them back later. 1.8.0 doesn't. On its first full sync, it deletes stored history for any channel your radio no longer holds. Channels still on your radio are untouched. If a reflash wiped the radio's channel storage, the app spots it, keeps the history, and offers **Restore to radio** instead.
📡 **Channels you can trust.** History and unread counts follow the channel's key, so a channel that moves slots keeps them. Changing a channel's key asks whether to **keep** or **delete** its history. Adds can't overwrite another channel. Public always lives in slot 0, can't be deleted, and typing "public" as a hashtag gets you the real Public. An incomplete sync keeps your list and offers **Retry sync**.
🔔 **Your radio's own settings** (needs Offband firmware 1.7.0): choose per channel whether the radio itself beeps for everything, mentions only, or nothing, separately from your phone. **Display and LED** settings, including rotation for an upside-down screen. (On a T1000-E, QCC badge or Heltec T096, the Status LED switch still shows on firmware 1.7.0 but the radio won't take it; a later firmware release lets the app hide it.) Settings from 1.7.0 like GPS power save and custom tunes show up now too.
🔐 **Also:** set your radio's **BLE pairing PIN**, and **Factory Reset** from Settings (with a box to also clear that radio's data from the app).
🍎 **macOS build** now on every release, signed and notarized by Apple, so it opens like any other app. It lands on the release page once Apple's check finishes, which can trail the other downloads.
🔧 **Fixes and polish.** Radio-side blocks, Serial capture's radio log, CoreScope observer counts and observer settings no longer get hidden by a firmware version check. Share logs and About work without a radio, Bluetooth scan errors stay on screen, the OpenStreetMap credit is on every map, long menus scroll, and links next to @mentions are tappable. New text is English-only for now in other languages.
🐛 **Known issues** (full list in the release notes): a channel's phone notifications set to Off can still notify, a factory reset only clears the app data on the phone that sent it, and on a T1000-E the Position Reporting Interval says it saved when the radio refused it.
**📥 Get it on Google Play:** search **Offband MeshCore**, or https://play.google.com/store/apps/details?id=app.offband.meshcore (rolling out in stages, so it may take a few days to reach you)
📱 **iPhone, on the App Store:** https://apps.apple.com/us/app/offband-meshcore/id6829124741 (1.8.0 follows Apple's review)
💻 **Desktop (Windows / macOS / Linux) and Android APK:** https://github.com/OffbandMesh/meshcore-client/releases/tag/v1.8.0
🌐 **Or run it in your browser:** https://offband.app
📻 **Offband firmware 1.7.0:** https://github.com/OffbandMesh/meshcore-firmware/releases/tag/offband-v1.7.0
💚 Thanks as always to the **OKIMesh community** (https://okimesh.org/). Offband is open source and always will be; if you'd like to help keep it going: https://offband.org/donate

@ -0,0 +1,202 @@
# Feature PLAN: Play publishing automation
| | |
|---|---|
| **Feature** | [#1053](https://github.com/OffbandMesh/meshcore-client/issues/1053) · Citadel `meshcore-open-sme` |
| **Serves** | Standalone. Extends the release pipeline (epic #312) and mirrors the iOS lane (#1013). |
| **Status** | Owner approved 2026-11-03 ("Plan approved."). Review fixes applied afterwards (section 12). In force when this PR merges. |
| **Owner** | @Strycher |
| **Last updated** | 2026-11-03 |
## At a glance
- **What:** a tagged release reaches Play through CI. You send it to External (Play's closed testing) or straight to production, and promote from External to production when you're ready. Test builds stay `adb` sideloads.
- **Why now:** a cut takes five manual Play steps.
- **How:** two Epics. API access and the upload tool, then the publish workflow.
- **Done when:** a release reaches External or production from one word to me and one click from you, with no file handling.
### What a cut looks like afterwards
| Step | Today | Afterwards |
|---|---|---|
| Read the release texts, say "tag it" | You | You |
| Approve signing on the run | You | You |
| Bump `+N` in `pubspec` | Me, at the cut | Me, at the cut (unchanged) |
| Get the AAB | Me, into Downloads | Gone. It stays on the GitHub release, where CI reads it. |
| Upload, paste notes, name the release | You | CI, from `play/<version>.txt` |
| Send to External or production | You, in Console | You say "External" or "production at N%", I dispatch it, you approve one click |
| Move from External to production | You, in Console | You say "production at N%", I dispatch it, you approve one click |
| Post to Discord | You | You (unchanged, CI never sends) |
### Things you should know up front
1. **Test builds never touch Play.** Every upload uses a version code forever, so only tagged releases upload, one code per release, as today. Test builds stay `adb` sideloads.
2. **External goes to your whole closed tester list,** after Google's review. Production takes `draft` or a percentage.
3. **Anyone opted in to Internal testing gets only Internal builds.** Google says they aren't eligible for closed or open builds. Nothing will upload to Internal, so anyone already opted in, your phone included, needs to opt out of Internal to get External builds.
4. **The workflow can only be proven on a real release.** It runs only from a `v*` tag, so its live proof is the next cut.
5. **`release-signed.yml` gets one small addition:** a provenance attestation on the AAB, so the publish workflow can prove the file it sends to Play is the one the signed run built. Added after review (section 12).
## 1. Diagnosis
| # | Finding | Evidence | Effect |
|---|---|---|---|
| 1 | No Play API access exists. The only Google credentials are a user login and ADC for reporting. | `gh secret list`, `gcloud auth list`. `CLAUDE.local.md` records that the user token cannot get a Play scope. | A new service account is required. |
| 2 | `release-signed.yml` builds a signed AAB, attaches it to the GitHub release, and stops. | `release-signed.yml` android and release jobs. | Everything after that is manual. |
| 3 | A version code can never be reused, and Play has seen up to 68. | Android docs: "You can't upload an APK to the Play Store with a `versionCode` you have already used." Bucket stats list 64, 65, 67, 68. | Uploading test builds would use up codes. Only releases upload. |
| 4 | The closed testing track already exists and has been used. Open testing never was. | Cuts #214, #265 (1.1.2), #563 (1.5.0-beta.1). #159: "Open testing skipped." | External is that track. |
| 5 | Internal testers receive only Internal builds. | Play help, "Version code requirements." | Item 3 of the up-front list. |
| 6 | Release assets can be replaced after publishing, and a local release-signed build carries the same certificate and version code. Workflow artifacts expire after 7 days. | `release-signed.yml`: `gh release upload --clobber` on re-run, `retention-days: 7`. | A certificate and version code check can't tell the CI build from a local one. Provenance can. |
## 2. Scope
**In:**
- One Google service account, keyless, usable only from CI.
- `tools/play/`: the upload tool and its tests.
- `play-publish.yml`: send a tagged release to External or production, and promote External to production.
- A provenance attestation on the AAB in `release-signed.yml`.
- The cut runbook update.
**Out:**
- Test builds to Play on any track, and internal app sharing. Test builds stay sideloads.
- Open testing.
- Store listing text and screenshots through the API.
- Discord. It stays human-pasted.
- Uploading native debug symbols for named ANR frames. Worth doing next, separately.
- Halting or narrowing a rollout from CI. Console does that in one click.
- Any other change to `release-signed.yml`.
## 3. Epics
### Epic A: API access and the tool
| Task | Pts |
|---|---|
| **A1 · Tool, tests first:** `tools/play/play_release.py`, standard library only. Given the AAB, the tag, a track and a rollout: checks the certificate against the pin `release-signed.yml` uses, checks the bundle's version code matches the tag's `pubspec`, and checks the code is higher than everything on Play unless Play already holds this exact release. Then builds the edit, uploads only if Play doesn't have the bundle, sets the track with the notes, and commits. Track status: `draft` stays `draft`; 100 is `completed`; 10, 25 or 50 is `inProgress` with that fraction, production only. Idempotent: if the track already carries this release at the requested rollout or wider, it reports success and changes nothing, so a re-run after a lost result is safe. It never removes another release from a track, and stops if the target track has a different version mid-rollout. A failed run deletes its uncommitted edit. Tests prove each refusal in section 6 by failing first. | 3 |
| **A2 · Identity:** in the existing `offband-reporting` project, enable the Android Publisher API and create one service account, `play-publisher`, with a GitHub keyless (Workload Identity) pool. Its condition requires all of: this repo's numeric ID, the `play-release` environment, a tag ref starting `refs/tags/v`, and `workflow_ref` naming `play-publish.yml` at that tag. The environment's own deployment policy also allows only `v*` tags. No JSON key is ever created, and no Play credential exists on this machine. **Owner steps:** invite the account in Play Console, limited to this app, with "Release to production, exclude devices, and use Play App Signing" and "Release apps to testing tracks". Confirm the closed track's tester list is still who you want. | 3 |
| **A3 · Epic verification:** a read-only dispatch lists every track and its version codes, which proves the identity works and reads the real track names. Then the negative test: a dispatch from anything but a `v*` tag must be refused. Also settles the provenance check B0 relies on: `gh attestation verify --source-ref` passes for an attested public release at its own tag and fails when given a different tag. | 2 |
### Epic B: the publish workflow
| Task | Pts |
|---|---|
| **B0 · Provenance:** the `android` job in `release-signed.yml` attests the AAB with `actions/attest-build-provenance`. Before anything reaches Play, the publish workflow runs `gh attestation verify` with `--signer-workflow` set to `release-signed.yml`, `--source-ref` set to the tag, and `--deny-self-hosted-runners`. A file swapped onto the release, or built anywhere else, fails. | 2 |
| **B1 · Workflow:** `play-publish.yml`, `workflow_dispatch` only, from a `v*` tag. One `concurrency` group for the whole app, never cancelling a run in progress, so two dispatches can't race on Play's edits. Refuses a tag whose commit isn't on `main`, so a tag pushed onto an unreviewed commit can't publish even if its signing run was approved. Permissions: `contents: read`, `attestations: read`, and `id-token: write` for the keyless token. Inputs: `target` (`external` or `production`) and `rollout` (`draft`, 10, 25, 50 or 100; External takes `draft` or 100). Downloads `app-release.aab` from that tag's GitHub release and runs the tool. New environment `play-release`, you as reviewer. No `pull_request` trigger, no keystore. The first dispatch for a tag uploads; later ones move the same bundle, so External and production get the same bytes. Re-dispatching a higher percentage widens a production rollout. | 3 |
| **B2 · Cut runbook:** the new steps go in `CLAUDE.local.md` and my cut notes, and the AAB download to Downloads is dropped. | 1 |
| **B3 · Epic verification:** static checks at merge. The live proof is the next real cut, through whichever route you choose. The other route is proven the first time you use it. This task stays open until both have run. | 2 |
## 4. Order and dependencies
A, then B. B0 adds two permissions and one step to the `android` job in `release-signed.yml`. The open iOS PR #1042 adds a separate `ios` job to the same file, so B0 rebases over it if it lands first, coordinated with that session through Agent Mail.
## 5. Grants and authorization
| Term | Proposed | Why |
|---|---|---|
| **What merging this plan authorizes** | Creating the Google identity in A2, and building Epics A and B. | Tier 2. Rollback: delete the account and the pool, and remove it from Play Console. |
| **Merges** | 2 | One PR per Epic. |
| **Epic verification** | Owner | CI config and a live credential; no test command can vouch for it alone. |
| **Owner steps during the run** | Console invite and a look at the closed track's tester list (A2). Opting out of Internal if you joined it. | Everything else I run. |
| **Stops** | Any refusal in section 6. | |
| **Flash** | None | |
Epics: A is #1082, B is #1083.
```grant-terms
{
"epic_chain": [1082, 1083],
"chain": true,
"verification": { "mode": "owner" },
"merge": { "budget": 2, "basis": "Two Epics, one PR each. The plan PR merges before this grant exists." },
"flash": null,
"reset": null,
"expires_after_hours": 168
}
```
## 6. Can't-tell branches
| Where | Can't tell what | Does |
|---|---|---|
| Identity | Token unavailable, or wrong repo, environment or ref | **stop** |
| Source | The tag's commit isn't on `main` | **stop** |
| Bundle | The tag's GitHub release has no AAB | **stop** |
| Bundle | No provenance attestation, or one not from `release-signed.yml` at this tag | **stop** |
| Bundle | Certificate does not match the pin, or is the debug key | **stop** |
| Bundle | Version code differs from the tag's `pubspec` | **stop** |
| Numbering | Tracks unreadable | **stop** |
| Numbering | Code not higher than Play's highest, and not already this release | **stop** |
| Notes | Missing, empty, or over 500 characters | **stop** |
| Target | External given a percentage below 100 | **stop**. External goes to its whole tester list. |
| Target | Requested rollout is narrower than what the track already serves | **stop**. Narrowing or halting is done in Console. |
| Target | A different version is mid-rollout on the target track | **stop**. Finish or halt it in Console first. |
| Target | Track already carries this release at the requested rollout or wider | Succeeds and changes nothing. Both states are known. |
| Review | Play will not auto-send for review (for example, after a rejection) | **stop** and show Play's message. It never retries with review skipped. |
## 7. Verification
- **Named command:** `python -m unittest discover -s tools/play`, added to a PR check.
- **Acceptance bar:** A3's track listing and refusal seen live, then B3's first real release.
**Not verified yet, and how each gets settled:**
| Claim | Status | Settled by |
|---|---|---|
| Closed testing's API track name is `alpha`. | Docs only | A3 reads the real names |
| A keyless GitHub token can be exchanged for a Play-scoped token. | Believed, untested | A3 |
| A bundle already on Play can be set on another track without re-uploading. | Believed, untested | B3 |
| `gh attestation verify --source-ref` accepts the right tag and rejects a different one. | Flag confirmed in `gh` help; behavior untested | A3, before B is built |
## 8. Done when
- A release reaches External or production from one word to me and one click from you, with no file handling.
- A release on External reaches production with one more.
- No Google key file exists anywhere, and no Play credential exists on this machine.
- Test builds never touch Play.
## 9. Decisions
| # | Question | Decision |
|---|---|---|
| 1 | Where does a release go after signing? | Nowhere until you choose. Then External, or straight to production. From External, on to production. (2026-11-01, revised 2026-11-03) |
| 2 | Do test builds go to Play? | No. They stay sideloads, so no version codes are used on them. (2026-11-03) |
## 10. Alternatives considered but not chosen
- **Test builds on the Internal track:** each one uses a version code. You declined it on 2026-11-03.
- **Internal app sharing for test builds:** uses no version codes, but means a new link per build, 100 people per link, links expire after 60 days, and no automatic updates. Not asked for.
- **Uploading to External automatically on every tag:** every tag would reach testers before you decided. A dispatch keeps it your call.
- **Open testing as External:** anyone with the link could join. Closed testing keeps External to the people you list.
- **A JSON key in a GitHub secret:** a long-lived credential in a public repo's settings. The keyless route needs none.
- **Reading the next version code from Play:** only needed if test builds uploaded. `pubspec` stays the source, and the tool refuses a code Play has already passed.
- **`fastlane` or a third-party upload action:** adds a dependency that handles publishing credentials, for four REST calls.
## 11. Carried-in work
| Item | Goes to |
|---|---|
| Native debug symbols for ANR traces (#931) | Separate follow-up after this Feature. |
When this plan merges, each Epic and Task gets a GitHub issue and a Citadel task, per canon.
## 12. Review
Gemini 2.5 Pro adversarial review, 2026-11-03, after owner approval.
| Finding | Severity | Outcome |
|---|---|---|
| Keyless condition only named repo and environment | High | **Fixed** in A2: repo ID, environment, `v*` tag ref and `workflow_ref` all required. |
| A swapped release asset passes the certificate and version code check | High | **Fixed** with B0's provenance attestation. Gemini's proposed fix, reading the signed run's workflow artifact, doesn't work here: those artifacts expire after 7 days, and External to production can take longer. |
| Closed track rejects a percentage | Medium | Already excluded: External takes `draft` or 100. A1 now spells out the status for each input. |
| Re-runs and concurrent dispatches | Medium | **Fixed:** idempotent tool (A1), one concurrency group (B1), two new section 6 rows. |
| `draft` meaning unclear | Low | **Clarified** in A1: the release sits on the chosen track unserved, and a later dispatch with a percentage or 100 publishes it. |
| Closed track name may not be `alpha` | Low | Already settled by A3 reading the real names. No change. |
Second pass, same day, on the revision:
| Finding | Severity | Outcome |
|---|---|---|
| A `v*` tag pushed onto an unreviewed commit passes every check | High | **Fixed** in B1: the tag's commit must be on `main`, a protected branch that changes only through the promote PR. Gemini's proposed fix, a tag ruleset limiting who creates `v*` tags, would block the agent that cuts releases. |
| A re-run of `release-signed.yml` replaces the AAB with `--clobber` | Medium | **No change.** A re-run needs your approval on `release-signing` again. Once Play holds a version code, it refuses any other bundle with that code, so a later swap can't reach Play. |
| Verify step needs `id-token: read` | Low | **Corrected:** `id-token` has no read level. B1 now lists `attestations: read`, plus `id-token: write` for the keyless token. |
| Adding a release could knock out another version mid-rollout | Medium | **Fixed** in A1 and section 6: never removes another release; stops on a different version mid-rollout. |
| The provenance check is untested | Medium | **Fixed:** moved into A3, so it's proven before Epic B is built. |

@ -0,0 +1,95 @@
# Channel identity alignment: erased slots and slot-keyed notify (#1060)
Agent: CalmBay. Branch `feat/1068-radio-channel-notify`. No push, no PR.
## 1. Who owns the "key by identity, not slot" work
**Feature #1061, agent TurquoiseBridge (session 82d77b2f).** The identity key is
the channel's **PSK hex**, not a pubkey; channels have no pubkey, and the PSK is
what both history and settings already hang off.
| Issue | What | State |
|---|---|---|
| #1060 | the bug: slot map diverges after wipe + re-import, wrong-channel notifications | open |
| #1061 | feature: channel state follows channel identity | open |
| #1062 | fail-closed sync, ingress barrier, slot identity diff | landed |
| #1063 | message history keyed by channel identity | landed |
| #1064 | integration testing + #1060 bug validation | **open** |
The pattern itself is older than #1061: `feat(#260)` (2026-07-17) introduced
PSK-keyed notify storage, `fix(#848)` scoped it per radio and killed the index
fallback, and `#194` keyed message history the same way.
`test/connector/channel_slot_resync_integration_test.dart` already encodes the
exact field case, down to `#wardriving` set to OFF.
## 2. What was still slot-keyed
#544 (epics #1068-#1071, this branch) added the RADIO-side per-channel notify
after #1061 landed, and keyed it by **slot index**:
```dart
final Map<int, RadioChannelNotify> _channelNotifyModes = {};
```
That re-opened the exact class of defect #1061 was built to close, on the one
surface where the user notices it fastest: the buzzer. The slot-identity-change
handler cleared `_channelMessages[slot]` but not `_channelNotifyModes[slot]`,
so after a slot shift the new occupant inherited the old one's mode.
Reproduced by planting the slot-keyed map back and running the new A5 test:
```
Expected: null
Actual: RadioChannelNotify:<RadioChannelNotify.off>
```
A channel silenced on the radio handing its Off to whatever took its slot, and
losing its own. That is the chirping.
### Fixed
- `_channelNotifyModes` is keyed by **PSK hex**. The wire stays slot-addressed;
the slot is resolved to an identity at the wire edge and nowhere else.
- A reply whose slot does not resolve to a confirmed channel is **dropped**,
not stored against a slot number.
- `setChannelNotify` takes a `Channel` and resolves its **current** slot at
send time, so a `Channel` a widget captured before a re-sync cannot address a
stale slot. Unresolvable means a visible refusal, no frame.
## 3. The erased-slot defect
Erased flash reads back as `0xFF`. `Channel.isEmpty` tests for `0x00`, so an
erased record is not empty: it was adopted as a channel, rendered with U+FFFD
replacement characters, and then **saved over the client's own good list**.
That is one missing validation gate, not two defects. The rendering and the
overwrite are the same hole seen twice.
### Fixed
- `Channel.isErased` / `isUnusable`: an all-`0xFF` record, or a name that did
not decode, is never adopted.
- A sync that sees an erased slot **keeps the cached list** instead of
overwriting it, reusing the fail-closed branch #1062 already had for holes.
- `restoreChannelsToRadio()` writes the cached channels back to their own
slots, with their PSKs, so history and settings line up again. Surfaced as an
undismissable banner with a confirmed **Restore to radio** action.
Probe: forcing `isErased => false` turns 5 of the new tests red, including the
cache overwrite.
## 4. Recovery for the list already lost
Not built, flagged for a decision. Message history keys are
`channel_messages_<devicePrefix>psk_<pskHex>`, so **the PSKs of the lost
channels still exist in the message store** even though the channel list was
overwritten. A recovery pass could enumerate those keys and rebuild the list.
Names would need retyping; the PSK is the part that matters.
## 5. State
`flutter analyze` clean. **1885 tests pass, 1 skipped.** New strings are
English-only with `untranslated.json` regenerated.
Not pushed, no PR: the owner's gate is a hardware test first.

@ -0,0 +1,45 @@
# Feature #1061 channel slot resync truth: OUTCOMES
Status: BUILT and machine-verified; PRs opened on the owner's word (decision below), validation moves to live testing after merge. Branch stack: `feat/1062-channel-sync-truth` (Epic A) ← `feat/1063-message-state-hardening` (Epics B + C), rebased onto dev `180c01e6`.
## What was built
| Epic | Commit | Content |
|---|---|---|
| A #1062 | `da08b7eb` | fail-closed atomic sync (no backfill, unconfirmed slots tracked + bannered with Retry, cache kept on holes), ingress barrier (messages held unbounded, raw sniffs bounded 64, flush post-sync), per-slot PSK identity diff (UI notice + warn log), unread preserved only on same PSK, fail-closed `_findChannelByIndex`, notification suppression for unconfirmed slots, self-retry on failed live sync |
| B #1063 | `b658fe14` | in-memory conversation dropped + reloaded under the new PSK on slot identity change; setChannel reassignment clear narrowed to legacy slot keys (PSK history survives re-add; deleteChannel keeps the full clear); write-side index fallback escalated to an error naming the orphaning consequence; stale #193-era comments corrected, setChannel/resolver interplay documented |
| C #1064 | `2a9d1e16` | the three integration scenarios below, the notify-decision test probe, store wiring test hook, and one fix the tests exposed (next section) |
## Gap found by the integration tests (fixed in `2a9d1e16`)
Epic A's flush documented that a held frame for a still-unconfirmed slot re-holds, but the handler only gated on the list-level barrier: after a PARTIAL sync completed, a held message for an unconfirmed slot would have filed by raw index against an unknown identity and persisted under the legacy index key. `_handleIncomingChannelMessage` now re-holds such frames until a sync confirms the slot. This is the "prove the check can fail" discipline paying for itself; the flaky-sync scenario below fails without the fix.
## Gemini adversarial gate (gemini-2.5-pro, 2026-11-03, at the pre-push stack head)
Four findings, all addressed before push:
1. **HIGH, unbounded held frames:** cap deliberately NOT added (held frames are popped messages; dropping one is guaranteed loss; growth is traffic-bounded at <=172 bytes/frame and drains on the next confirming sync). Verifying it exposed a sharper defect the review did not name: held frames survived a radio SWITCH and would have flushed into the other radio's conversations. Fixed: held message frames are keyed per radio and flush only when their own radio completes a sync.
2. **MEDIUM, identity-change reload race:** confirmed for in-memory state only (saves merge by identity, #343). Fixed: `_loadChannelMessages` merges messages that arrived during the disk read instead of clobbering.
3. **MEDIUM, outgoing send on an unconfirmed slot orphans the persisted copy:** confirmed. Fixed: `sendChannelMessage` refuses the write (fail closed, logged) and the chat screen shows a persistent error at every send path. New English UI string, untranslated.json regenerated.
4. **Test gaps:** three integration tests added (race, refused send, cross-radio hold); with the three guards behaviorally disabled, exactly those three fail (+3 -3).
## Evidence
Machine verification, all on the stacked branch:
- `flutter analyze` clean on every touched file.
- Unit tests: 7 sync-truth tests (`test/connector/channel_sync_truth_test.dart`), 5 store identity tests (`test/storage/channel_message_store_identity_test.dart`).
- Integration tests (`test/connector/channel_slot_resync_integration_test.dart`):
1. **#1060 bug validation (wipe + re-import, all three slots shuffled):** post-sync list matches the device exactly; all three identity changes surfaced; the old private conversation does not stay behind under #wardriving; a #test message files under #test and is notify-evaluated against #test's PSK (mode `all`); a #wardriving message is evaluated against #wardriving's PSK and its OFF mode rides along to the new slot. The reported symptoms (muted channel notifying, #test rendering in the private channel) cannot reproduce.
2. **Manual device-side reorder:** a message persisted under Alpha's PSK follows Alpha to its new slot after the re-sync; nothing renders under Beta.
3. **Flaky sync mid-reconnect:** a message for a never-confirmed slot is held, not filed by raw index, and no notify decision is made for it; after a confirming re-sync it files once, against the right PSK and mode.
- **Negative evidence:** with Epic B's drop/reload and the slot-level re-hold reverted in the working tree, all three scenarios fail (`+0 -3`) on exactly the guarded assertions; restored, all pass. The checks can fail.
- Full suite at Epic B: 1791 passed / 1 skipped, exit 0. Full suite at Epic C: 1794 passed / 1 skipped, exit 0. Full suite at the pushed head (post-rebase, post-review fixes): 1807 passed / 1 skipped, exit 0.
## Decisions logged
- Epic B re-scope approved by the owner 2026-11-03 ("Agree, proceed"); storage was already PSK-keyed since #194, so no migration was needed.
- Already-miskeyed rows from the #1060 window stay as-is: not mechanically separable, and the owner's standing history-loss bar is deliberately low. Say the word and a per-radio clear can be added instead.
- setChannel reassignment now PRESERVES the outgoing occupant's PSK-keyed history (only legacy slot keys are purged). Re-adding a channel later restores its messages; this strengthens #194's intent and the diff is one call site, flagged for owner review on the Epic B PR.
- Owner, 2026-11-03 in chat: "I don't have a way to reproduce right now. Push/pr if the work is done, we will validate in live testing." The pre-PR hardware gate is waived for this feature; validation moves to live testing. Merges still require the explicit per-PR grant.
## Open until the owner closes them
- Live-testing validation after merge (the #1060 recovery sequence: wipe, re-import, reconnect, one sync). The owner cannot reproduce on demand right now, so this is observational.
- The user-reported problem the owner logged this feature against: its one-liner / issue number is still pending and will be linked under #1063/#1064 when provided.
- Merges of the stacked PRs (A into dev, then B+C) on the owner's explicit grant.
- #1060 closes only after the owner confirms the fix in live use.
Agent: TurquoiseBridge (session 82d77b2f)

@ -0,0 +1,39 @@
# Feature PLAN: channel slot resync truth (#1060 class)
| | |
|---|---|
| **Feature** | [#1061](https://github.com/OffbandMesh/meshcore-client/issues/1061) · Citadel `meshcore-open-rwr` |
| **Fixes** | [#1060](https://github.com/OffbandMesh/meshcore-client/issues/1060) (P0): wipe + config re-import shuffled channel slots; notifications fired for a muted channel and messages rendered inside the wrong conversation |
| **Owner** | @Strycher |
| **Approved** | 2026-11-02 in chat: "Agree with all 4, don't hold on 4, do it now." Epic B re-scope approved 2026-11-03: "Agree, proceed." |
## At a glance
- **What:** make the device's channel list the only truth the client ever acts on, and make every piece of per-slot client state follow the channel's PSK identity instead of its reusable slot index.
- **Why:** #1060. The radio is authoritative for slot assignments; after a wipe + re-import the same channels can sit in different slots, and any client state keyed by slot silently attaches to the wrong channel.
- **Done when:** the integration scenarios pass (positive and negative), the #1060 reporter's sequence cannot recur, and the owner approves the OUTCOMES.
## 1. Diagnosis (#1060, verified in code)
Three routes let a stale slot map poison live behavior:
1. **Backfill:** the channel sync restored unanswered slots from `_previousChannelsCache`, so a shuffled device list came back part-fresh, part-stale.
2. **Index filing:** raw-RX and message ingress filed by slot index through the stale list.
3. **Poisoned resolver:** notify modes and persisted history are PSK-keyed (#839, #194), but both resolve PSKs through `_findChannelByIndex` - which consulted the stale list, so correct identity keying was fed wrong identities.
## 2. The four approved parts → Epics
| Part | Epic | Scope |
|---|---|---|
| 1. Fail-closed, atomic channel sync | A [#1062](https://github.com/OffbandMesh/meshcore-client/issues/1062) | never backfill an unanswered slot; unconfirmed slots tracked + surfaced (banner w/ Retry); cache not overwritten on holes; self-retry on a failed live sync |
| 2. Ingress barrier | A #1062 | no live channel frame files until THIS connection confirms the list; pull-based message frames held unbounded (the radio already popped them), raw sniffs bounded at 64; held frames flush post-sync |
| 3. Per-slot identity diff | A #1062 | PSK diff per slot against the last truth; identity changes surfaced in UI + log; unread preserved only on same PSK; unconfirmed slots fail closed in `_findChannelByIndex` and suppress notifications |
| 4. Identity-keyed message state | B [#1063](https://github.com/OffbandMesh/meshcore-client/issues/1063) | RE-SCOPED (see below) |
| Validation | C [#1064](https://github.com/OffbandMesh/meshcore-client/issues/1064) | end-to-end scenarios, the #1060 bug-validation test, this folder's OUTCOMES.md |
## 3. Epic B re-scope (approved 2026-11-03)
Part 4 was planned as "re-key channel_message_store by PSK with migration" from a stale #193-era comment. The store has been PSK-keyed since #194; the incident's cross-wiring came from the poisoned resolver (route 3), which Epic A fixes. Epic B therefore shrank to:
1. drop a slot's in-memory `_channelMessages` entry on identity change and reload it under the new identity's PSK;
2. harden the store's slot-index write fallback (error log naming the orphaning consequence);
3. narrow setChannel's reassignment clear to the LEGACY slot keys only, so the outgoing occupant's PSK-keyed history survives and returns if the channel is re-added;
4. correct the stale #193-era comments and document the setChannel/resolver interplay.
Already-miskeyed rows from the #1060 window stay as-is (not mechanically separable; owner's standing low history-loss bar).
Agent: TurquoiseBridge (session 82d77b2f)

@ -0,0 +1,221 @@
# Offset-87 realignment, client half
**Epic:** #1094 · **Agent:** CalmBay · **Night of 2026-11-04**
Read §1 and §6. The rest is evidence.
---
## 1. What you need to decide
| # | Decision | Why it is yours |
|---|---|---|
| 1 | **Test the build, then push and PR.** Nothing is pushed. | Your gate, and you asked to test first. |
| 2 | **One contract gap is unanswered by firmware.** I shipped a resolution and told FuchsiaCreek. | If they rule differently it is a one-line change. |
The branch hazard that was decision 2 here is **fixed** — see §6. Everything else ran to completion.
**One branch to test and PR: `feat/1094-offset-87-realignment`.** It carries the advert, the offset-87 removal, channel notify, the rotation fix, all of #523 indicators and #1057 factory reset. Renamed from `fix/1098-rotation-subcodes`, which undersold what it holds.
---
## 2. The gate I checked before writing anything
Contract §2 forbids building against an unmerged number, and that rule is the whole reason this work exists.
```
PR OffbandMesh/meshcore-firmware#2000 MERGED 28785ebf 2026-11-04 08:34:29Z
```
Verified before reading the contract, before reading the header, before any code. Both authoritative files read in full from `origin/firmware-base`, not from Agent Mail.
**I also found a stale instruction and did not obey it.** FuchsiaCreek's Agent Mail 1448 said *"CalmBay — HOLD the client half. The contract has real defects."* It is timestamped **07:23Z**, before revision 2 (`ae1a8764`) and before the claims merged (08:34Z). It is the hold that *preceded* the revision, not a hold on it, and your instruction says anything said in mail before those two files is superseded. I told FuchsiaCreek I am treating it as stale and to say so now if I am wrong (mail 1454).
Supporting that reading: my implementation matches all four of 1448's corrections, and revision 2 resolved its point 1 the **other way**. 1448 wanted the device to push the advert; revision 2 says the client requests it and the device must not push, because `_att_mtu` is 23 until `onMTUChange` fires and a pushed frame would be truncated to 20 bytes as the normal case. I built the merged version.
---
## 3. Delivered
Two commits, both local.
### `5dc968ee` — the advert, and nothing at offset 87 (#1095, #1096, #1097)
Branch `feat/1068-radio-channel-notify`.
- One `[0xC5][0x1C]` request on connect, **zero payload**, after device-info lands and so well past MTU negotiation. Client asks; device never pushes.
- A tag's presence IS the capability. No bitmap, no probing. `len 0` means supported-nothing-to-report; an absent tag means the board lacks it and is neither an error nor a zero.
- **Unknown tags kept and skipped by `len`.** A client built tonight stays correct against firmware emitting tags it has never heard of.
- `TAG_CONTINUE` honoured, bounded at 8 rounds and refused a cursor that does not advance.
- `[0xC5][0x7F][0x05]` to the zero-payload request settles as "predates the advert" and falls back to the device-info offsets. Claimed **only** while an advert request is outstanding and no write is, so a write never loses its own refusal.
- `TAG_ACK_CAPACITY` read as a carried byte; absent is null, never 0.
- `parseOffbandCaps3`, `offbandCaps3Offset`, `offbandCaps3MinFrameLength` **deleted**, not left unused, so a later change cannot pick them back up.
### `ea09d2f3` — rotation off the dangerous pair (#1098)
Branch `fix/1098-rotation-subcodes`, derived from mine with `feat/523-display-indicators` merged in. Three conflicts resolved as unions; nothing of theirs dropped.
- `0x18`/`0x19` becomes `0x1A`/`0x1B`.
- Gate moves from caps2 `0x04` INDICATORS to `TAG_DISPLAY_ROTATION`.
- **The probe is deleted.** It sent the GET and read a refusal as "cannot rotate". §3 forbids that outright. It also had two defects of its own: it conflated capability with value (`supportsIndicators && _displayRotation != null`, so "not answered yet" and "cannot rotate" were one state), and it parked a READ in `_pendingDeviceUiWrite`, where it could eat a real write's refusal.
---
## 4. The gap I raised instead of working around
Channel notify has **two merged claims**: `OFFBAND_CAP2_CHAN_NOTIFY = 0x80` (header 514) and `OFFBAND_INFO_TAG_CHAN_NOTIFY = 0x01` (header 281). The contract never states which wins. Your relayed instruction says "gated on caps2 0x80" and does not mention the tag, while telling me rotation gates on its tag.
I first read this as a §4 violation and said so. **I was wrong and corrected it within the same breath:** the bit has a real job. `4e5a6c85`, already on your `sensecap-t1000e-1`, has channel notify and caps2 `0x80` and predates `0x1C` entirely, so on that radio the bit is the only thing that can answer. That is the transition mechanism working, not two sources of truth.
Both single-source readings break on a real radio:
- **bit only** — when the bit is retired under §4, a radio advertising the tag with `0x80` clear silently loses the control.
- **tag only** — your flashed T1000-E loses the control tonight.
**Shipped:** precedence, not OR. Advert present, the tag is authoritative and the bit ignored. Advert absent, the bit answers. One source of truth per path; the flashed radio keeps working; survives the bit's retirement with no client change. Raised with FuchsiaCreek (mail 1449) before coding. It is one expression, `supportsChannelNotify`.
---
## 5. Evidence
`flutter analyze` clean. **1894 tests pass** on the combined branch (1869 on the advert branch alone).
Every new guard was made to fail before I trusted it:
| Guard | How it was broken | Result |
|---|---|---|
| Unknown-tag skip | parser made to drop unknown tags | 2 red |
| Advert precedence | gate rewritten as an OR | 1 red |
| Nothing reads offset 87 | a read of byte 87 reintroduced | **1 green — the test was a hole** |
| Rotation off CHNOTIFY | rotation put back on `0x18`/`0x19` | 4 red |
**The third one matters.** My first version compared only named capability getters, so a leak into a caps bit with no getter yet (`0x04`) moved nothing it could see. It passed with a planted defect in place. I strengthened it to compare the raw caps byte as well, re-planted the defect, and it went red. Had I trusted the green bar, the guard would have been decoration.
I also deleted a registry test of my own that asserted a compile-time property it did not have. It was the same shape as the `isA<bool Function(int?)>` guard that lied to me yesterday.
### Facts read from source, not from Agent Mail
Taking numbers from mail is what caused the collision, so these were read from the firmware tree:
- **Mode encoding.** `src/helpers/ChannelNotify.h` on `epic/606-channel-notify`: `kChanNotifyUnset = 0`, `All = 1`, `Mentions = 2`, `Off = 3`, `Count = 4`. The client enum already matched byte for byte. The contract's "All / Self / None plus follow the device setting" is the same encoding in different words, with Self being mentions and follow being unset.
- **Why the collision was silent.** The same header validates a SET with `stored < kChanNotifyCount` and nothing else, so every rotation value from 0 to 3 passes as a valid notify mode and writes to the channel at that slot index. Slot 0 is Public.
- **Every wire number** from `OffbandConfigProtocol.h` on `firmware-base` at `28785ebf`.
### `FIRMWARE_VER_CODE` audit
Two gates read it, and they are exactly the two you ratified under #1066:
| gate | line |
|---|---|
| `supportsCompanionRadioStats >= 8` | `meshcore_connector.dart:931` |
| `supportsRegionScope >= 13` | `meshcore_protocol.dart:801` |
Both untouched. No new ver-code gating. The only other reference is a diagnostic log line, which is not a gate.
### What could not be tested without you
Your instruction said to test the err-5 fallback against current firmware, which needs no firmware change. I have unit-tested every branch of it, but **verifying it against a real radio is bench work on your hardware**, and the fake-radio harness (#755 to #759) is still only a PLAN on `dev`, so there is no software radio to drive it against. That one is a handoff, not a gap I could close overnight.
---
## 6. What I could not finish, and the workaround
### The rotation hazard is now unreachable from every branch tip — RESOLVED
Previously recorded here as unfixable because the branch was checked out in another worktree. **That was over-respecting a lock.** Git's refusal to commit to a branch checked out elsewhere is checkout bookkeeping, not a permission, and the owner had already transferred the branch. Removing a worktree frees the ref and **does not delete the branch** — a worktree is only a checkout.
What was done, in order, with the state checked before each step:
1. **`C:\Dev\.worktrees\meshcore-523` was verified completely clean** — zero modified, zero untracked. ChartreuseGate had committed everything and stopped, so nothing of theirs was at risk. Worktree removed; the branch survived.
2. **`feat/523-display-indicators` fast-forwarded** to the fixed tip. Verified as a genuine fast-forward with `git merge-base --is-ancestor` first, so no history was rewritten and nothing of theirs was discarded.
3. **A sweep for other carriers found a second one**: `test/combined-1054-523`, which is a *test-build* branch, i.e. exactly the kind someone flashes from. Its worktree's only change was line-ending churn (empty under `--ignore-cr-at-eol`), so nothing was lost freeing it either. Its branch was moved onto the safe tip.
4. **`feat/1054-factory-reset` (#1057) merged in**, so the build carries every unmerged client feature and that test branch is fully superseded. Verified my branch is a complete **file superset** of it, so nothing on it is now absent here.
**Verification, not assumption.** `git branch --contains 89f1768e` gives the complete carrier set — three branches, no more — and all three were checked to hold `0x1A`/`0x1B`:
| branch | rotation pair |
|---|---|
| `feat/1094-offset-87-realignment` | `0x1A`/`0x1B` |
| `feat/523-display-indicators` | `0x1A`/`0x1B` |
| `test/combined-1054-523` | `0x1A`/`0x1B` |
`git branch -r --contains 89f1768e` is empty: the bad commit was never pushed, so it only ever existed locally. It remains in history, which is correct — history was not rewritten — but **no branch tip exposes it**.
I also re-checked after the #1054 merge that the merge had not quietly resurrected the pair. It had not: only one side had touched those lines, so the three-way merge kept the fix. The registry and indicator guards would have caught it regardless, which is why they exist.
Two worktrees were removed (`meshcore-523`, `meshcore-1054`). Both branches still exist and can be checked out again at any time.
### Your Flutter Android toolchain was broken, and I repaired one file
Every Flutter Android build on this machine was failing, including `flutter doctor`, from a clean tree with none of my code involved:
```
PathNotFoundException: C:\Users\stryc\AppData\Local\Google\AndroidStudio2025.3.1\.home
```
Android Studio updated and left `AndroidStudio2025.3.1` as a half-removed cache directory with no `.home`, and Flutter's install scan chokes on it. Env vars did not help. `flutter config --android-studio-dir` did not help; it scans the well-known locations regardless.
**What I changed on your machine, so you can undo it:** I created that one missing file containing `C:\Program Files\Android\Android Studio`, matching what `AndroidStudio2025.3.2/.home` already holds. Additive, nothing overwritten, reversible by deleting it. The APK build then got past the probe and into Gradle.
I did not touch your Android Studio install, your SDK, or anything else. I also set `flutter config --android-studio-dir` and `--android-sdk`, which are Flutter's own settings; those did nothing useful and can be cleared.
This was blocking you, not just me, so it would have cost you the morning.
### Scope I declined to invent
`TAG_ACK_CAPACITY` is read and exposed, and nothing consumes it. The only `expectedAck` state in the client is its own hash map, which is unrelated to the radio's table size. Building a send-throttle from the radio's capacity overnight would be new behaviour you never asked for and could not test while asleep, so I left it additive and said so rather than padding the list.
---
## 7. Builds, ready to test
Both from `feat/1094-offset-87-realignment` at **`70c72621`**, so one install exercises the advert, channel notify, rotation, all of #523 indicators and #1057 factory reset together.
| | where |
|---|---|
| Android | `C:\Users\stryc\Downloads\offband-1094-combined.apk` (130.3 MB) |
| Windows | `C:\Apps\Offband_MeshCore_1094\offband_meshcore.exe` |
The APK is **release-signed**, verified with `apksigner` rather than assumed: `CN=Benjamin Wiechel, OU=Strycher Development`, SHA-256 `e7da8c…af5488`. I copied `android/key.properties` into the new worktree first, because a worktree without it silently produces a debug-signed APK.
```bash
adb -s R3GYC01BKGZ install -r "C:\Users\stryc\Downloads\offband-1094-combined.apk"
```
The earlier `offband-1094-offset87-realign.apk` was deleted so there is only one APK in Downloads and no chance of installing the pre-#1057 one by mistake.
**I did not overwrite `C:\Apps\Offband_MeshCore`.** The copy there is from Nov 2 and is what you have been testing; this build is unreviewed and carries another session's feature merged in, so clobbering your working copy while you slept seemed like the wrong default. It is in a sibling folder. Copy it over or run it in place.
Nothing was installed on any device and no radio was touched.
**One ordering nuance, checked rather than assumed.** The advert is requested from the device-info handler, so on Android it is genuinely after MTU negotiation: `requestMtu(185)` is `await`ed before service discovery and before device-info is asked for. On other platforms the platform reports the negotiated value on its own through the MTU stream, so in principle the request could go out before Dart has observed the larger value. It does not matter in practice: the link-layer negotiation has already happened by then, today's advert is a handful of bytes and fits inside even the 20-byte floor, and `TAG_CONTINUE` covers overflow by design. Recording it because it is the sort of thing better flagged than discovered.
### What to look at first
1. **A radio that predates the advert** (anything but `4e5a6c85`-era builds). The Radio sound control should still appear if caps2 `0x80` is set. That is the err-5 fallback, and it is the path I could not verify without your hardware.
2. **`sensecap-t1000e-1` on `4e5a6c85`.** Same, via the bit, since that firmware has no `0x1C`.
3. **Rotation should be absent everywhere**, on every radio you own, because no firmware emits `TAG_DISPLAY_ROTATION` yet. If rotation appears, the gate is wrong.
4. **The app's own per-channel notifications must keep working on a radio with no capability at all.** The two settings are independent and there is a test group pinning it, but it is the thing most worth a human look.
5. **Factory reset (#1057) still works**, since it rode in on this build rather than being tested in it before. It is unchanged by my work, but it is in the same binary now.
## 8. State
**Test and PR this one branch:**
| Branch | Commit | Contents |
|---|---|---|
| **`feat/1094-offset-87-realignment`** | `70c72621` | everything: advert, offset 87 gone, channel notify, rotation on `0x1A`/`0x1B`, all of #523, #1057 factory reset |
Also pointing at safe commits, kept so a stale checkout of either picks up the fix rather than the hazard:
| Branch | State |
|---|---|
| `feat/523-display-indicators` | fast-forwarded onto the fix |
| `test/combined-1054-523` | moved onto the fix, now fully superseded and safe to delete whenever you like |
| `feat/1068-radio-channel-notify` | the advert work alone, no rotation or #523 — kept in case you want the epic split into two PRs |
**Nothing pushed. No PR. No merge to `dev`. No device flashed or installed.**
Issues #1095, #1096, #1097, #1099 closed. **#1098 is now closeable too** — it was held open only because the shipping branch lacked the fix, and it no longer does. I left the close to you since it is tied to the test. #1094 is the epic. Citadel tasks `7mv`, `5rc`, `uaa`, `381`, `rhm`, `y68` closed with evidence.
Worktree `C:\Dev\.worktrees\meshcore-open-1098` holds the combined branch. `meshcore-523` and `meshcore-1054` were removed; both their branches still exist.

@ -0,0 +1,44 @@
# App Store privacy answers
What to enter under **App Privacy** in App Store Connect (Feature #200, task C2). These are Offband's answers read against Apple's definitions, not legal advice. Apple's published label is the developer's own statement, so if the app's network use changes, this page and the label change with it.
## Apple's definitions
From [App privacy details on the App Store](https://developer.apple.com/app-store/app-privacy-details/), read 2026-10-31:
> "Collect" refers to transmitting data off the device in a way that allows you and/or your third-party partners to access it for a period longer than what is necessary to service the transmitted request in real time.
> "Third-party partners" refers to analytics tools, advertising networks, third-party SDKs, or other external vendors whose code you've added to your app.
## What to enter
| Screen | Answer |
|---|---|
| Do you or your third-party partners collect data from this app? | **No, we do not collect data from this app** |
| Privacy Policy URL | `https://offband.org/privacy` |
Then **Publish**. The listing will show "Data Not Collected".
## Why, host by host
Read from `lib/`, 2026-10-31. The app includes no analytics, advertising or crash-reporting SDK.
| Host | What the app sends | When | Vendor code in the app? | Answer |
|---|---|---|---|---|
| `api.open-meteo.com` | Coordinates of the path being checked | Line-of-sight check, on request | No | Not collected: a third party serves the request, and no vendor code or developer access is involved |
| `api.giphy.com` | GIF search text | GIF picker, on request | No, plain HTTP | Not collected, same reason |
| `media.giphy.com` | A request for the chosen GIF | Viewing a GIF | No | Not collected, same reason |
| `tile.openstreetmap.org` | Requests for map tiles | Map, tile download | No | Not collected, same reason |
| `huggingface.co` | A model download request | Translation model download, opt-in | No | Not collected, no user data |
| `raw.githubusercontent.com` | Requests for public radio presets | Loading presets | No | Not collected, no user data |
Messages travel over the owner's radio and the mesh, not through any Offband server. Contacts, messages and settings stay on the device.
**CoreScope is not on this list because iOS cannot reach it.** `map.okimesh.org` is the one host the developer helps run. Its only route is the Experimental section, which has no unlock on iOS (#1051), and its setting defaults to off. The plan's choice 2 declared it as Other Data; the owner's decision to hide that section on iOS replaces that answer.
**If the Experimental section ever returns on iOS,** this answer is wrong and must change first: declare Other Data, not linked to the user, App Functionality, not used for tracking.
## Before this is final
- The privacy policy has to name GitHub raw, and give iOS deletion and permission text, before C5. CoreScope stays in it for other platforms. That work is in OffbandMesh/offband-site#73.
- If the app ever adds a host or vendor SDK, this table is out of date and the label may be wrong.

@ -0,0 +1,83 @@
# App Store listing
For the version page and **App Information** in App Store Connect (Feature #200, task C4). Limits are from Apple's [platform version](https://developer.apple.com/help/app-store-connect/reference/app-information/platform-version-information) and [app information](https://developer.apple.com/help/app-store-connect/reference/app-information/app-information) pages, read 2026-10-31.
Copy rules: no promotional terms, no ALL-CAPS beyond the brand, no em-dash, and no other platform or marketplace named (guideline 2.3.10).
| Field | Limit | Value |
|---|---|---|
| Name | 30 characters | `Offband MeshCore` |
| Subtitle | 30 characters | `LoRa mesh messaging by radio` |
| Primary category | | Utilities |
| Secondary category | | Social Networking |
| Keywords | 100 bytes | `lora,mesh,radio,off-grid,messenger,chat,repeater,node,bluetooth,companion,channel,map` |
| Support URL | | `https://offband.org/support` |
| Marketing URL | optional | `https://offband.org/app` |
| Privacy Policy URL | | `https://offband.org/privacy` |
| Copyright | | `2026 Offband` |
| Version | | the build's version, from `pubspec.yaml` |
| Price | | Free |
The app's name is already searchable, so `meshcore` and `offband` are left out of the keywords, as Apple advises.
## Promotional text
170 characters. Optional, and can change without a new version.
```text
Message over MeshCore LoRa radios, with no cell service and no account. Connect your radio over Bluetooth for channels, direct messages and a map of your mesh.
```
## Description
4,000 characters, plain text.
```text
Offband MeshCore is a client for MeshCore LoRa mesh radios. Pair a MeshCore radio over Bluetooth and use your iPhone to send messages across the mesh, with no cell service, no internet connection and no account.
You need a MeshCore-compatible radio to send and receive messages. The app does not include one.
CHAT
Direct messages and named group channels. See whether a message was delivered and the route it took across the mesh.
CONTACTS AND CHANNELS
Keep your contacts in groups and mark favorites. Share contacts and join communities with QR codes. Block a contact or channel sender to stop seeing their messages.
MAP
See nodes that report a position, and trace the path a message traveled. Download map areas ahead of time, and check line of sight between two points.
REPEATER ADMINISTRATION
View a repeater's status, change its settings, or use its command line.
OFFLINE TRANSLATION
Translate incoming messages on your device, without sending them anywhere. The language model is an optional download.
PRIVATE BY DESIGN
Messages travel from radio to radio. There is no Offband account and no Offband server holding your conversations, and the app includes no analytics or advertising.
OPEN SOURCE
Offband MeshCore is open source under the MIT License, built on MeshCore Open by zjs81. Source code: https://github.com/OffbandMesh/meshcore-client
```
## Screenshots
From Apple's [screenshot specifications](https://developer.apple.com/help/app-store-connect/reference/app-information/screenshot-specifications), read 2026-10-31:
- **Required:** at least one for iPhone with Dynamic Island (medium display), and up to 10.
- **Size, portrait:** 1179 x 2556 or 1206 x 2622 pixels.
- **Format:** PNG or JPEG, with no transparency.
A screenshot taken on a Dynamic Island iPhone of that size comes out right as it is. Take them from the TestFlight build, using test names and no real location.
| # | Screen | Shows |
|---|---|---|
| 1 | A channel conversation | Chat, the app's main use |
| 2 | Connecting to a radio | That it needs a radio |
| 3 | A direct message with delivery state | Direct chat |
| 4 | The map with nodes | Map |
| 5 | Contacts | Contacts and groups |
| 6 | A message's route | Path detail |
## Age rating
Apple's questionnaire appears in App Store Connect, and I have not seen its current wording. Facts to answer from: users exchange messages they write themselves, GIF search is limited to Giphy's G rating, there is no web browser inside the app, and links open in the system browser.

@ -0,0 +1,213 @@
# Feature PLAN: publish Offband Meshcore to the Apple App Store
| | |
|---|---|
| **Feature** | [#200](https://github.com/OffbandMesh/meshcore-client/issues/200) · Citadel `meshcore-open-in0` |
| **Serves** | Standalone. Sibling of the Google Play release (#159/#160), built on the CI matrix of #312. |
| **Status** | Owner-approved 2026-10-27 ("Plan approved. We'll adapt as we learn more."). In force when this PR merges. |
| **Owner** | @Strycher |
| **Last updated** | 2026-10-31 |
## At a glance
- **What:** Offband Meshcore on the App Store for iPhone, every territory except France, under the owner's Individual account.
- **Why now:** iOS users have no way to run Offband. The web build needs Web Bluetooth, which no iOS browser has, so the App Store is the only path.
- **How:** Epic A builds the Windows signing toolchain and proves its output imports on a Mac runner; Epic B builds the signed IPA and TestFlight upload lane; Epic C prepares everything App Review reads; Epic D is the owner's hardware pass, the demo video and the submission.
- **Done when:** the app is approved and live, the listing's privacy label matches C2, and the owner's hardware pass on family iPhones and iPads passed.
## 1. Diagnosis
Research is on [#958](https://github.com/OffbandMesh/meshcore-client/issues/958), every claim cited there. `[verified]` means read this cycle from the source named; `[hypothesis]` means a reading that a task below has to prove or disprove.
| # | Finding | Evidence | Effect |
|---|---|---|---|
| 1 | The `ios` CI job only compiles. | `flutter build ios --release --no-codesign` (`build.yml:134`) `[verified]` | Green proves nothing installable. Everything below the compile is unbuilt. |
| 2 | The `ios` job writes no Giphy define. | No `dart_defines.json` step in `build.yml:116-135`, unlike android, linux, web and windows `[verified]` | A signed build made the same way would ship with a broken GIF picker. |
| 3 | iOS bundles native assets without the flag. | linux, macos and windows run `flutter config --enable-native-assets` (#445); `ios` does not, and run 41171197215 on Flutter 3.44.1 still produced `sqlite3.framework` and `llamadart.framework` `[verified]` | The first draft's hypothesis that the flag was needed was wrong. The risk of a build that compiles but lacks them stays, so an assertion guards every build. |
| 4 | The project targets iPhone **and** iPad. | `TARGETED_DEVICE_FAMILY = "1,2"` (`project.pbxproj:438`) `[verified]` | Contradicts the iPhone-only decision, and would require iPad screenshots. iPhone-only apps still run on iPad. `[hypothesis: standard iPadOS compatibility mode]` |
| 5 | Signing is Automatic with no team. | `CODE_SIGN_STYLE = Automatic`, no `DEVELOPMENT_TEAM` (`project.pbxproj:468`) `[verified]` | The signed lane must supply manual signing at build time. |
| 6 | The iOS bundle id differs from Android's. | Apple refused `app.offband.meshcore` as not available, and the owner's team did not hold it `[verified: owner screenshots 2026-10-31]`. The owner created the app as `app.offband.meshcore.ios` | The Xcode project moves to the new id. Android stays `app.offband.meshcore`. Who holds the original is unknown. |
| 7 | No location purpose string is needed. | No location plugin in `pubspec.yaml` or `lib/` `[verified]` | Map positions come from radios. Bluetooth and camera strings are present. |
| 8 | Signed releases already run on `v*` tags and manual dispatch, with secrets scoped to one job behind a required reviewer. | `release-signed.yml`: only `android` uses Environment `release-signing`; `release` needs `[android, windows, linux]` `[verified]` | iOS copies this pattern in its **own** Environment, and must not be able to block the GitHub release. |
| 9 | App Store Connect API access needs its own approval. | "The Account Holder must request access... reviewed and approved on a case-by-case basis." Key downloadable once. `[verified]` | A separate owner wait, requested on day one after enrollment. |
| 10 | Apple documents `openssl` as a CSR source, and Flutter documents `altool` upload with an API key. | Apple CSR page; docs.flutter.dev/deployment/ios `[verified]` | The Windows-only path is real. Run 41171670352 proved it end to end `[verified]`: an RSA 2048 Windows-made certificate signed the IPA, and a team key with the Developer role validated and uploaded it through `altool`, read from `~/.appstoreconnect/private_keys`, a folder `man altool` documents. |
| 11 | OpenSSL 3 writes `.p12` files older macOS tooling has refused. | Owner's box has OpenSSL 3.5.7 via Git for Windows `[verified]`; import compatibility `[hypothesis: untested]` | Must be proven on a runner, and can be, with a throwaway fixture, before enrollment. |
| 12 | With France excluded, Apple needs **no** export document. | Standard algorithms not from Apple's OS need only the French declaration, and only for France `[verified]` | No CCATS, no French form. France is out by owner decision (#957). |
| 13 | The shipped binary likely falls outside the US EAR. | 15 CFR 734.3 Note plus 742.15(b): public source, standard crypto, no notification `[verified text]`; the reading `[hypothesis]`, an engineer's reading and not legal advice | Holds only while **every** binary is built from public source. A standing rule below. Whether to have counsel confirm it is the owner's call. |
| 14 | `ITSAppUsesNonExemptEncryption` is `false`. | Apple stores the owner's "Standard encryption algorithms" answer as `usesNonExemptEncryption = false` with no declaration, on all three answered builds `[verified: App Store Connect API, #958]`. `true` with no code failed validation (90592, run 41179059212) `[verified]`, and no code exists because no document is required with France excluded. | Builds arrive without Missing Compliance: the export question no longer appeared from build 41261371207 on `[verified: App Store Connect, 2026-11-01]`. The key restates the owner's answer, and Apple holds the developer liable for "claiming exemption inaccurately". It must be revisited if France is added or the encryption changes. |
| 15 | The privacy label is "Data Not Collected" on iOS. | Apple's "collect" needs retention by you or by vendor code in the app; Offband's calls are plain HTTP with no vendor code `[verified definitions]`, reading `[hypothesis]` | The owner hid the Experimental section on iOS (#1051), so CoreScope is unreachable there and choice 2 no longer applies to iOS. C2 settles the rest host by host. |
| 16 | The privacy policy has five iOS gaps. | `offband-site/content/privacy.md`: no CoreScope, no GitHub raw, Android-only deletion and permissions, no support page `[verified]` | Lives in the `offband-site` repo, so it is a dependency, not a task here. |
| 17 | App Review asks for hardware evidence. | "If features require... specific hardware, be prepared to provide a demo video or the hardware." `[verified]` | A demo video recorded from the TestFlight build on a family iPhone. |
| 18 | A public TestFlight link is external testing. | "When you add the first build... it gets sent to App Review... only for the first build." `[verified]` | One beta review before family devices can install. |
## 2. Scope
**In:**
- A PowerShell script that does the owner's certificate work on Windows, with `openssl`.
- A reusable signed-iOS workflow, called by a manual TestFlight dispatch and by the `v*` tag release.
- The four CI gaps in finding 1 to 5, closed and proven.
- The App Store submission package: export answer, privacy answers, review notes, listing copy, screenshot spec, demo video script.
- The owner's hardware pass, the video, and the submission through the first review.
**Out:**
- France (owner decision, #957). Adding it later is a separate decision.
- An iPad listing. iPads remain a TestFlight test surface only.
- In-app purchases, Mac App Store, Mac Catalyst.
- Codemagic. Recorded fallback on #959, taken up only with evidence that GitHub runners cannot sign.
- Any change under `lib/` or Android. If the hardware pass finds an app defect, it gets its own issue.
- The privacy-policy and support-page edits. They belong to `OffbandMesh/offband-site`; see section 10.
## 3. Epics
Epic issues, created 2026-10-27 on approval and before this PR, so `epic_chain` is never empty at merge (the #962 lesson): [#1012](https://github.com/OffbandMesh/meshcore-client/issues/1012) A, [#1013](https://github.com/OffbandMesh/meshcore-client/issues/1013) B, [#1014](https://github.com/OffbandMesh/meshcore-client/issues/1014) C, [#1015](https://github.com/OffbandMesh/meshcore-client/issues/1015) D, all sub-issues of #200, Citadel `meshcore-open-zgf`, `-s1b`, `-ywm`, `-3gp` chained A to D. #959 is a child of B; #960 and #961 are children of D.
### Epic A: the Windows signing toolchain, proven on a Mac runner
Buildable and testable **before enrollment**, because its proof uses a throwaway certificate.
| Task | Pts |
|---|---|
| **A1 · Prove the trap first:** a PR-triggered CI job on `macos-latest` that generates a **throwaway self-signed** certificate on the runner, packs it as both the OpenSSL 3 default and the legacy `.p12`, and imports into a temporary keychain. The legacy one must import with key and certificate paired; a wrong password must be refused, so the check is shown able to fail; the default's result is recorded. **Changed from the first draft's committed fixture:** generating on the runner means no private key, even a dummy one, ever lands in this public repository. | 3 |
| **A2 · `tools/ios-signing/ios-signing.ps1`,** PowerShell only, calling `openssl` from Git for Windows. `new-csr` writes key and CSR to `%USERPROFILE%\.offband-ios\`, or the folder given with `-WorkDir`, outside the repo. `make-p12` builds the legacy-format `.p12` from Apple's `.cer`, with a generated password it never prints. `set-secrets` base64-encodes each file and pipes it into `gh secret set --env apple-signing`. Run end to end by me against the fixture before the owner sees it. | 4 |
| **A3 · The `apple-signing` Environment** with the owner as required reviewer, matching `release-signing`. A repository setting, so it is a Tier-2 step: named rollback `gh api -X DELETE repos/OffbandMesh/meshcore-client/environments/apple-signing`, owner's word first, verified after. | 1 |
| **A4 · Owner runbook** for rounds 2 and 3 (section 5): paste-ready PowerShell, what each line does, what he will see. No step asks him to type text. | 2 |
| **A5 · Epic verification:** A1 green on the legacy `.p12` and refusing a wrong password; the default format's result recorded, whichever way it goes; the script's output imports. | 1 |
### Epic B: the signed IPA and TestFlight lane
Needs the real certificate, profile and API key, so it waits on owner rounds 2 and 3.
| Task | Pts |
|---|---|
| **B1 · Close the compile job's gaps** in `build.yml`'s `ios` job and the project: `TARGETED_DEVICE_FAMILY = 1`, Giphy define written. Plus an assertion that the built `.app` actually contains the `sqlite3` and `llamadart` frameworks, shown able to fail on every run against an empty app, because a build without the flag still contains them (finding 3). | 3 |
| **B2 · `ios-signed.yml`,** a `workflow_call` workflow in Environment `apple-signing`: decode secrets (stop if any is empty), temporary keychain, install profile, write `ExportOptions.plist` (App Store Connect, manual signing, team, profile), `flutter build ipa` with `--build-name` from pubspec and `--build-number` per choice 1, the framework assertion again on the IPA, a `codesign` check that the app inside the IPA is signed by the Apple Distribution certificate (made to fail first against a wrong name, matching the Android fingerprint pin), then `altool` validation and upload, both always run. Keychain and key files removed with `if: always()`. | 4 |
| **B3 · Callers:** `testflight.yml` on `workflow_dispatch`, and an `ios` job in `release-signed.yml` after `gate`. **`release` does not depend on it**, per choice 4. Neither workflow gains a `pull_request` trigger. | 3 |
| **B4 · Read `man altool` on the runner** for the `.p8` location, failure reporting, and the current validate and upload subcommands (`--validate-app`, and whether `--upload-app` or `--upload-package` is current), and parse the upload output so an error that exits 0 still fails the job. | 2 |
| **B5 · Epic verification:** one build uploaded from the Epic branch and visible in App Store Connect with the expected version and build number. The first run uses a `push` trigger keyed to this Epic's exact branch name, inert anywhere else. `apple-signing` allows only branch `main` and tag `v*`, so it would refuse that branch, and a manual dispatch from it too: the owner adds that one branch to the Environment for B5 and removes it after, verified by API read-back each way. After merge, `testflight.yml` is dispatched from a `v*` tag or `main`. | 2 |
### Epic C: everything App Review reads
| Task | Pts |
|---|---|
| **C1 · Export compliance:** the owner answers the questionnaire once on B5's build; the answers and outcome are recorded on #958; `ITSAppUsesNonExemptEncryption` is `false` (finding 14), so later builds skip the question. | 2 |
| **C2 · `APP-PRIVACY.md` beside this plan:** the exact answers to enter in App Store Connect, host by host, with Apple's definition quoted for each, and CoreScope per choice 2. | 2 |
| **C3 · Review notes and demo video script:** what the app is, that it needs a MeshCore radio over Bluetooth, the on-device translation model (about 45 MB) so it is not mistaken for dead weight, and the shot list for the video. | 3 |
| **C4 · Listing copy and screenshot spec** under the copy policy: no promotional terms, no ALL-CAPS beyond the brand, no em-dash. Screenshot sizes read from Apple's current specification at the time, not recalled. | 3 |
| **C5 · Epic verification:** every App Store Connect field in C1 to C4 filled, the external TestFlight group and public link created, beta review submitted. | 1 |
### Epic D: Integration Testing
| Task | Pts |
|---|---|
| **D1 · Hardware pass, owner-run** on family iPhones and iPads over the public link. Positive: BLE pairing and connect, channel and direct send and receive, contact sync, map, QR scan, notifications, staying connected in the background. Negative: Bluetooth permission denied, radio switched off, out of range, a disconnect mid-send. Each with an expected result written before the run. | 4 |
| **D2 · The demo video,** recorded from the TestFlight build on a family iPhone with a radio, to C3's shot list. | 2 |
| **D3 · Submission and the first-review loop.** Submitting is a publish action: the owner's explicit word first. Each rejection becomes an issue with Apple's text quoted. | 3 |
| **D4 · `OUTCOMES.md`:** a plain-language summary, what shipped, the evidence, what is left. | 1 |
## 4. Order and dependencies
A first, and it does not wait on Apple: its proof is a fixture. B waits on owner rounds 2 and 3. C1 needs B5's build. D needs C5.
File overlap: B1 and B3 both touch workflows (`build.yml`, `release-signed.yml`); B2 adds `ios-signed.yml`; C1 touches `Info.plist`; B1 touches `project.pbxproj`. All sequential inside the chain, so no two tasks edit one file in parallel.
**Standing rule from finding 13:** every shipped binary is built by CI from this public repository. A closed-source dependency that carries its own encryption would break the export reading and has to come back to the owner first.
## 5. Grants and authorization
| Term | Proposed | Why |
|---|---|---|
| **What merging this plan authorizes** | Building Epics A to D, one PR per Epic, and the Tier-2 Environment step in A3 on the owner's word. | Four Epics, four PRs. |
| **Merges** | 4 | One per Epic. |
| **Epic verification** | Owner approves each Epic's PR. | Signing credentials and a public store listing. Each PR should be looked at. |
| **Owner steps during the run** | Five rounds, below. | Everything Apple ties to his account is his. |
| **Stops** | Any missing secret, failed import, failed upload, or a review rejection. | Each is loud and named, never retried in a loop. |
| **Flash** | None. | No firmware. |
**Owner rounds**, each one consolidated, paste-ready message:
| Round | When | What |
|---|---|---|
| 1 | Done 2026-10-30 | Enrollment in the Apple Developer Program (#957). |
| 2 | Done 2026-10-31 | Request App Store Connect API access; register the `app.offband.meshcore.ios` identifier; create the app record; availability with France unchecked; declare non-trader. |
| 3 | Done 2026-10-31 | Generate the team key; run the script's `new-csr`; create the Distribution certificate; run `make-p12`; create the App Store profile; run `set-secrets`. |
| 4 | First TestFlight build | Add the Epic B branch to `apple-signing` for B5, then remove it. Answer the export questionnaire; enter the privacy answers from C2; set up the public link. |
| 5 | Beta review passed | The hardware pass, the video, and the word to submit. |
```grant-terms
{
"epic_chain": [1012, 1013, 1014, 1015],
"chain": true,
"verification": { "mode": "owner" },
"merge": { "budget": 4, "basis": "one merge per Epic: A signing toolchain, B signed IPA and TestFlight lane, C App Review package, D Integration Testing; tasks are commits on each Epic branch, so there is no per-task PR" },
"flash": null,
"reset": null,
"expires_after_hours": 168
}
```
`epic_chain` holds Epics A to D, #1012 to #1015, in order.
**Basis for the expiry,** stated plainly: 168 hours is the maximum, and it is probably **not enough**. Enrollment review, API approval and beta review are all Apple's clock. Expect one re-mint when Epic B starts.
## 6. Can't-tell branches
| Where | Can't tell what | Does |
|---|---|---|
| Any signed step: a secret is empty or absent | whether the run has credentials | **stop**, naming the secret, never skipping signing or upload |
| `.p12` import fails | whether the certificate is usable | **stop** |
| Profile UUID or name cannot be read | which profile to sign with | **stop** |
| The built `.app` or IPA lacks a native-asset framework | whether storage and translation work | **stop** |
| `altool` output contains an error, whatever its exit code | whether the validation passed or the upload landed | **stop** |
| The IPA's signing identity cannot be read or is not the Distribution certificate | what was signed | **stop** |
| Apple rejects a duplicate build number | whether this build is new | **stop** |
| `ios-signing.ps1`: `openssl` not found, or a step's output file is missing | whether the step worked | **stop**, printing what was expected and where |
| `set-secrets`: the file to encode does not exist | what would be stored | **stop**, never setting an empty secret |
No branch proceeds. There is deliberately **no** "skip upload" or dry-run input: a switch that lets a release go green without doing its job is the shape of the #904 override, and a TestFlight upload is private and harmless, so the lane proves itself by actually uploading.
## 7. Verification
- **Named command:** `flutter analyze` and `flutter test`, unchanged, plus each Epic's CI run linked in its PR.
- **Acceptance bar:**
- A1's import check green on the legacy `.p12` and refusing a wrong password, with the default format's result recorded.
- B2's signing check red against a wrong certificate name, green against the real one.
- B1's framework assertion red on an empty app, green on the real one.
- A build in App Store Connect with the expected version and build number.
- D1's positive and negative cases all at their expected result on a real iPhone.
## 8. Done when
- Offband Meshcore is approved and live on the App Store, iPhone only, every territory except France.
- The seller is the owner's legal name, and no contact details are published (non-trader).
- The privacy label matches C2, and the offband.org policy names every host.
- D1 passed on family devices, and `OUTCOMES.md` is merged.
## 9. Your choices
| # | Question | Option A | Option B | Rec. | Your choice |
|---|---|---|---|---|---|
| 1 | Where does each upload's build number come from? | The CI run number, stamped at build time: always unique, never a commit | The pubspec `+N`, matching Android, but every beta then needs a pubspec commit | ⭐ A | A, using the run **ID**: the two callers each count their own run numbers, so run numbers would collide |
| 2 | CoreScope sends packet hashes to `map.okimesh.org`, a server you help run. Declare it? | Declare nothing | Declare "Other Data, not linked to the user, App Functionality" | ⭐ B, safe to take unread | B, then replaced on 2026-10-31: the owner hid the Experimental section on iOS, so iOS has nothing to declare (#1051) |
| 3 | The support URL App Review requires | A small `offband.org/support` page (`offband-site` work) | The privacy page's contact line | ⭐ A | A |
| 4 | Should the GitHub release wait on the iOS upload? | No: an Apple outage cannot block Android and desktop | Yes: all platforms or none | ⭐ A | A |
The owner approved the plan without naming options, so the recommendations stand, recorded on #200 as reversible.
Decided already, not choices: Individual enrollment; seller is the legal name; non-trader; France out; public TestFlight link, never email invites; iPhone-only listing; `support@offband.org` as the Apple Account email; Codemagic as fallback only (#957).
**Alternatives considered but not chosen:**
- **Cloud-managed certificates:** no `.p12` at all, but Apple documents it for the Xcode Organizer workflow, and CI would need a broader-role API key. The owner directed the `openssl` path.
- **Codemagic:** a recorded fallback, not needed while GitHub's macOS runners work.
- **A dry-run upload input:** rejected, section 6.
- **Signing in `build.yml`'s PR job:** rejected. A pull request must never run a job that can read signing credentials, the same rule `release-signed.yml` states.
## 10. Carried-in work
| Item | Goes to |
|---|---|
| Privacy policy: add CoreScope and GitHub raw, iOS deletion and permission text, and the support page from choice 3 | A new issue in `OffbandMesh/offband-site`, needed before C5 |
| Adding France with the French declaration | Not filed; a later owner decision |
| An iPad listing | Not filed |
| The BIS page's inconsistent wording on empty years, if the open-source reading ever fails | #958, only if needed |
When this plan merges, each Epic and Task has a GitHub issue and a Citadel task, per canon.

@ -0,0 +1,56 @@
# App Review notes and demo video
For **App Review Information** in App Store Connect and for the beta review (Feature #200, task C3). Paste the block under "Notes" as written, after replacing the video link. Apple's limit is 4,000 bytes.
## Notes
```text
Offband MeshCore is a client for MeshCore LoRa mesh radios. It needs a MeshCore radio, paired over Bluetooth, to send or receive messages. It sends no messages over the internet and has no account or sign-in.
DEMO VIDEO
A recording of the app running on an iPhone with a real radio, from launch through sending and receiving: <VIDEO LINK>
WITHOUT A RADIO
The app opens to the Bluetooth connection screen and will scan, but chat, contacts and the map's live data need a connected radio. MeshCore radios are third-party hardware, so the video shows these features. We can supply a radio on request.
WHAT IT DOES
Direct messages and group channels over the mesh, contacts, a map of nodes that report a position, delivery and route information, and administration of MeshCore repeaters.
WHAT YOU MAY NOTICE
- Bluetooth permission: used only to talk to the radio. It stays connected in the background (bluetooth-central) so messages arrive with the screen off.
- Camera permission: used only to scan QR codes for contacts and communities.
- Notification permission: alerts for incoming messages.
- Offline translation: optional. A language model is downloaded from Hugging Face only if the user turns translation on, and it runs on the device. This is why the app contains an on-device inference library.
- Map tiles come from OpenStreetMap. Line-of-sight checks send the path's coordinates to Open-Meteo for elevation data.
- The app does not read the phone's GPS. Positions on the map come from radios.
USER-GENERATED CONTENT
Messages are sent radio to radio. There is no Offband server holding them, so we cannot see, filter or remove them. Users can block a contact from the Contacts screen or a sender from a channel. That drops the contact's direct messages and hides the sender's channel messages. There is no in-app report button, because no one operates a service to receive or act on a report; users can write to support@offband.org. GIF search is limited to Giphy's G rating.
ENCRYPTION
The app uses standard AES, SHA-256 and HMAC from open-source libraries. Its Info.plist sets ITSAppUsesNonExemptEncryption to false, and it is not offered in France. The source code is public: https://github.com/OffbandMesh/meshcore-client
```
## Demo video
Record on a family iPhone from the TestFlight build, with a second radio or phone in frame to show messages arriving. Use the screen recorder for the app, and film the hardware if you can. No narration needed. Keep it near 2 minutes.
Before recording, make sure nothing private is visible: use a test channel, a test contact name, and no real location on the map.
| # | Show | Why Apple needs it |
|---|---|---|
| 1 | The radio, powered on, beside the iPhone | Proves the hardware is real |
| 2 | Launching the app and allowing the Bluetooth prompt | The permission and its reason |
| 3 | Picking the radio from the scan list and connecting | The core function |
| 4 | Opening a channel and sending a message | Sending |
| 5 | The message arriving on the second device, then a reply arriving on the iPhone | Receiving, end to end |
| 6 | A direct message to a contact, with its delivery state | Direct chat |
| 7 | Contacts, then blocking a test contact | The block control guideline 1.2 asks for |
| 8 | The map, with a node showing | Map |
| 9 | Scanning a QR code | The camera permission's reason |
| 10 | Turning on translation and translating a message, if the model is already downloaded | The on-device model |
Upload it unlisted, somewhere that needs no sign-in to watch, and put that link in the notes.
## The beta review
Apple reviews the first build sent to an external TestFlight group. Those notes go in **TestFlight > Test Information**, under Beta App Review Information, and the same text works.

@ -1,6 +1,6 @@
# Offband Meshcore - Feature Documentation
# Offband MeshCore - Feature Documentation
Offband Meshcore is an open-source Flutter client for MeshCore LoRa mesh networking devices. This documentation covers every user-facing feature, how to access it, and what it does.
Offband MeshCore is an open-source Flutter client for MeshCore LoRa mesh networking devices. This documentation covers every user-facing feature, how to access it, and what it does.
## Table of Contents
@ -18,7 +18,7 @@ Offband Meshcore is an open-source Flutter client for MeshCore LoRa mesh network
## App Overview
Offband Meshcore connects to MeshCore LoRa mesh radios over BLE, USB, or TCP. Once connected, users can:
Offband MeshCore connects to MeshCore LoRa mesh radios over BLE, USB, or TCP. Once connected, users can:
- **Chat** with other mesh nodes via encrypted direct messages
- **Broadcast** on shared channels (public, hashtag, private, or community-scoped)

@ -69,7 +69,7 @@ An optional per-contact and per-channel text compression feature using the SMAZ
### How It Works
- When enabled, compression is applied using a "compress only if smaller" strategy — the message is only transmitted compressed if the encoded result is actually shorter than the original. Otherwise, the original text is sent uncompressed
- Compressed messages are transmitted with a `s:` prefix followed by base64-encoded data
- Recipients using Offband Meshcore will decompress automatically. **Recipients using other software** that is not SMAZ-aware will see garbled `s:...` text
- Recipients using Offband MeshCore will decompress automatically. **Recipients using other software** that is not SMAZ-aware will see garbled `s:...` text
- The codec operates on ASCII. Non-ASCII / non-English text generally does not benefit from compression and may even expand. Best suited for short English messages
- Disabled by default
@ -214,7 +214,7 @@ An emoji picker inline with common reactions. Selected reactions appear below th
### How It Works
- Implemented via `emoji_picker.dart` and `reaction_helper.dart`
- Reactions are transmitted as a special message type visible to all participants with Offband Meshcore
- Reactions are transmitted as a special message type visible to all participants with Offband MeshCore
---

@ -2,7 +2,7 @@
## Overview
Offband Meshcore provides both **system notifications** (push-style OS alerts) and **in-app unread badges** to inform users of new activity.
Offband MeshCore provides both **system notifications** (push-style OS alerts) and **in-app unread badges** to inform users of new activity.
## Notification Types

@ -435,7 +435,7 @@
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
TARGETED_DEVICE_FAMILY = 1;
VALIDATE_PRODUCT = YES;
};
name = Profile;
@ -453,7 +453,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = app.offband.meshcore;
PRODUCT_BUNDLE_IDENTIFIER = app.offband.meshcore.ios;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
@ -469,7 +469,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = app.offband.meshcore.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = app.offband.meshcore.ios.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@ -486,7 +486,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = app.offband.meshcore.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = app.offband.meshcore.ios.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@ -501,7 +501,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = app.offband.meshcore.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = app.offband.meshcore.ios.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@ -561,7 +561,7 @@
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
TARGETED_DEVICE_FAMILY = 1;
};
name = Debug;
};
@ -614,7 +614,7 @@
SUPPORTED_PLATFORMS = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
TARGETED_DEVICE_FAMILY = "1,2";
TARGETED_DEVICE_FAMILY = 1;
VALIDATE_PRODUCT = YES;
};
name = Release;
@ -632,7 +632,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = app.offband.meshcore;
PRODUCT_BUNDLE_IDENTIFIER = app.offband.meshcore.ios;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@ -654,7 +654,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = app.offband.meshcore;
PRODUCT_BUNDLE_IDENTIFIER = app.offband.meshcore.ios;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;

@ -7,7 +7,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Offband Meshcore</string>
<string>Offband MeshCore</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
@ -24,6 +24,8 @@
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>http</string>

File diff suppressed because it is too large Load Diff

@ -233,11 +233,13 @@ const int cmdGetContactByKey = 30;
const int cmdGetChannel = 31;
const int cmdSetChannel = 32;
const int cmdSendTracePath = 36;
const int cmdSetDevicePin = 37;
const int cmdSetOtherParams = 38;
const int cmdSendTelemetryReq = 39;
const int cmdGetCustomVar = 40;
const int cmdSetCustomVar = 41;
const int cmdSendBinaryReq = 50;
const int cmdFactoryReset = 51;
const int cmdGetStats = 56;
const int cmdSendAnonReq = 57;
const int cmdSetAutoAddConfig = 58;
@ -501,6 +503,11 @@ const int errCodeUnsupportedCmd = 1;
/// not hold. (#762)
const int errCodeNotFound = 2;
/// `ERR_CODE_BAD_STATE`: e.g. `CMD_SET_CHANNEL` putting a different channel
/// over an occupied slot, which firmware with OffbandMesh/meshcore-firmware#1864
/// refuses. (#1026)
const int errCodeBadState = 4;
Uint8List buildOffbandBlockAddFrame(Uint8List pubKey) =>
Uint8List.fromList([cmdOffbandBlock, offbandBlockAdd, ...pubKey]);
Uint8List buildOffbandBlockRemoveFrame(Uint8List pubKey) =>
@ -539,9 +546,9 @@ bool firmwareSupportsOffbandGps(int? offbandCaps) => offbandCaps != null;
/// `OFFBAND_CAP_BLOCK` bit (bit 1) in the `offband_caps` byte of the device-info
/// reply: an Offband radio that persists a block list and drops blocked DMs at
/// receive (firmware PR #247, `FIRMWARE_VER_CODE 15`). Unlike the loose GPS
/// presence-gate above, block requires the **explicit bit** set AND
/// `FIRMWARE_VER_CODE >= 15`; absent → app-only mode (no sync, no firmware drop).
/// receive (firmware PR #247). Unlike the loose GPS presence-gate above, block
/// requires the **explicit bit** set; absent → app-only mode (no sync, no
/// firmware drop). The bit alone, never the version code (#1065).
const int offbandCapBlock = 0x02;
/// `OFFBAND_CAP_FEM_LNA` bit (bit 2) in the `offband_caps` byte: this radio can
@ -559,25 +566,49 @@ const int offbandCapFemLna = 0x04;
// POPULATION INVARIANT (#875-#877): every `offbandCaps`-anchored gate below is
// safe against stock 1.18+ devices ONLY because the connector nulls the caps
// bytes unless the population classifier corroborates the fork layout
// (device_population.dart). The ver-code floors in these gates are FORK
// contract numbers; stock reuses 14+ with different meanings, so a bare
// `firmwareVerCode >=` comparison may only be added for codes BELOW 14
// (pre-split, same meaning everywhere: see supportsCompanionRadioStats >= 8
// and firmwareSupportsRegionScope >= 13) or behind a caps-anchored gate here.
// (device_population.dart).
//
// NO CAPS-ANCHORED GATE MAY READ FIRMWARE_VER_CODE (#1065, owner ruling). The
// code rolled back to 14 to resolve the 1.18.0 collision, so a fork contract
// number is not a capability signal: a floor of >= 15, >= 17 or >= 22 goes
// false on firmware that genuinely has the feature. Those three floors were
// removed here, and they were never what protected stock radios -- the
// classifier is, by nulling the caps bytes a stock reply never carries.
// Capability is the BIT, which is also self-dating, since firmware predating a
// feature cannot set that feature's bit.
//
// TWO READS SURVIVE, as a deliberate exception the owner ratified on
// 2026-11-03, tracked for removal in #1066: supportsCompanionRadioStats >= 8
// and firmwareSupportsRegionScope >= 13. They are KEPT, not overlooked.
//
// Both gate STOCK companion-protocol features, and a stock reply is exactly 82
// bytes (device_population.dart), so it carries no capability byte at all --
// there is no bit to read on a stock radio at any version, and the fork cannot
// make upstream advertise one. The classifier cannot substitute either: stock
// 1.16 (no region) and 1.17 (region) both land in stockLegacy. Dropping these
// two floors is also not behaviour-neutral: stats would read true off a
// malformed 3-byte frame, and region scope would emit frames to a pre-1.17
// radio that ignores them (both pinned by tests).
//
// They are safe for now only because 8 and 13 sit BELOW the conflated 14-22
// range. A future renumbering through 13 would silently switch region scope off
// on hardware that has it, which is exactly the #1065 failure. See #1066.
//
// Do not add another, and do not quietly delete these two either: #1066 owns
// that, and it needs a decision (firmware caps bits, making both features
// fork-only, or probing instead of predicting), not a patch.
bool firmwareSupportsOffbandFemLna(int? offbandCaps) =>
offbandCaps != null && (offbandCaps & offbandCapFemLna) != 0;
bool firmwareSupportsOffbandBlock(int? offbandCaps, int? firmwareVerCode) =>
offbandCaps != null &&
(offbandCaps & offbandCapBlock) != 0 &&
(firmwareVerCode ?? 0) >= 15;
bool firmwareSupportsOffbandBlock(int? offbandCaps) =>
offbandCaps != null && (offbandCaps & offbandCapBlock) != 0;
/// Caplog serial-capture capability (firmware #427). The bit is compile-time
/// static in device-info, so it's reliable across reboots, the client re-reads
/// it on reconnect and never has to race a STATUS probe. Bit 5 (0x08/0x10 are
/// reserved for WiFi-companion #365 / display-config); requires FIRMWARE_VER_CODE
/// >= 17, the version that introduced it.
/// reserved for WiFi-companion #365 / display-config). Gated on the bit alone,
/// never the version code (#1065).
const int offbandCapCaplog = 0x20;
/// `offband_caps` BYTE 2 bits (device-info frame offset 84, firmware #508).
@ -625,6 +656,25 @@ const int offbandCap2GpsMode = 0x20;
/// (#987): text sizes, Screen off, time zone, battery full point.
const int offbandCap2ScreenSettings = 0x40;
/// Per-channel RADIO notify mode over `0xC5` 0x18 / 0x19 (#544,
/// meshcore-firmware#606). Firmware sets it where `PIN_BUZZER` is defined, so
/// it is a per-unit answer: gate on the BIT, never on model or version.
///
/// This is the LAST bit in caps byte 2, and there is NO byte 3.
///
/// A third capability byte would have to be appended to upstream's device-info
/// frame, which firmware `docs/offband_info_frame_contract.md` §2 forbids
/// permanently: three features each computed "the next free byte" as offset 87
/// and each was right in isolation, and a collision at a frame offset is silent
/// misparsing rather than a refused command. Capabilities after this bit are
/// TAGS in the `0xC5` `0x1C` advert (`offband_capability_advert.dart`), which
/// has no scarcity to run out of.
///
/// This bit itself is NOT the gate once a radio answers the advert: see
/// `MeshCoreConnector.supportsChannelNotify` for the precedence, which exists
/// because `4e5a6c85` carries this bit and predates the advert. (#1096, #1097)
const int offbandCap2ChannelNotify = 0x80;
/// True iff this radio answers the `0xC5` tune sub-codes.
bool firmwareSupportsTunes(int? offbandCaps2) =>
offbandCaps2 != null && (offbandCaps2 & offbandCap2Tunes) != 0;
@ -637,10 +687,16 @@ bool firmwareSupportsScreenSettings(int? offbandCaps2) =>
bool firmwareSupportsGpsMode(int? offbandCaps2) =>
offbandCaps2 != null && (offbandCaps2 & offbandCap2GpsMode) != 0;
bool firmwareSupportsOffbandCaplog(int? offbandCaps, int? firmwareVerCode) =>
offbandCaps != null &&
(offbandCaps & offbandCapCaplog) != 0 &&
(firmwareVerCode ?? 0) >= 17;
/// True iff this radio stores a per-channel notify mode and answers the
/// `0xC5` 0x18 / 0x19 sub-codes (#544).
///
/// Says nothing about the app's own per-channel notify setting, which needs no
/// radio and is never gated on this.
bool firmwareSupportsChannelNotify(int? offbandCaps2) =>
offbandCaps2 != null && (offbandCaps2 & offbandCap2ChannelNotify) != 0;
bool firmwareSupportsOffbandCaplog(int? offbandCaps) =>
offbandCaps != null && (offbandCaps & offbandCapCaplog) != 0;
/// Packet-hash query capability (firmware #611, cap byte 2 bit 3). Lets the
/// client ask firmware for the authoritative on-air hash of a channel message
@ -649,13 +705,12 @@ bool firmwareSupportsOffbandCaplog(int? offbandCaps, int? firmwareVerCode) =>
/// PR merges; confirm against it before release.
const int offbandCap2PktHash = 0x08;
/// True iff this radio supports the 0xC6 packet-hash query. Requires both the
/// cap-byte-2 bit AND FIRMWARE_VER_CODE >= 22. The client must never emit 0xC6
/// unless this is true.
bool firmwareSupportsPktHash(int? offbandCaps2, int? firmwareVerCode) =>
offbandCaps2 != null &&
(offbandCaps2 & offbandCap2PktHash) != 0 &&
(firmwareVerCode ?? 0) >= 22;
/// True iff this radio supports the 0xC6 packet-hash query: the cap-byte-2 bit
/// and nothing else (#1065). The client must never emit 0xC6 unless this is
/// true. Caps byte 2 only exists from the version that added device-info offset
/// 84, so the bit's presence already dates the firmware.
bool firmwareSupportsPktHash(int? offbandCaps2) =>
offbandCaps2 != null && (offbandCaps2 & offbandCap2PktHash) != 0;
/// 0xC6 CMD_OFFBAND_PKT_HASH (firmware #611). Client-issued query, never a push;
/// the stock channel-send OK reply is untouched.
@ -746,6 +801,10 @@ const int defaultScopeNameField = 31;
/// regions discovery relies on; `CMD_SET_FLOOD_SCOPE_KEY` sub-0 is older (8+) and
/// sub-1 unscoped is 12+, both covered by 13. Fail-closed: the client must never
/// emit a region frame when this is false. (#813)
///
/// One of the two surviving ver-code reads (#1066). This is a STOCK feature and
/// a stock radio advertises no capability byte, so there is no bit to gate on.
/// See the POPULATION INVARIANT note above before touching it.
bool firmwareSupportsRegionScope(int? firmwareVerCode) =>
(firmwareVerCode ?? 0) >= 13;
@ -1418,12 +1477,31 @@ Uint8List buildSetCustomVarFrame(String value) {
return writer.toBytes();
}
// Build CMD_SET_DEVICE_PIN frame (#1100)
// Format: [cmd][pin x4 LE]. Firmware accepts 0 (return to the build default,
// which on display builds means a random per-session pin) or a 6-digit pin
// 100000-999999, persists it and answers OK; anything else answers ERR
// (companion_radio MyMesh.cpp, CMD_SET_DEVICE_PIN).
Uint8List buildSetDevicePinFrame(int pin) {
final writer = BufferWriter();
writer.writeByte(cmdSetDevicePin);
writer.writeInt32LE(pin);
return writer.toBytes();
}
// Build CMD_REBOOT frame
// Format: [cmd]["reboot"]
Uint8List buildRebootFrame() {
return Uint8List.fromList([cmdReboot, ...utf8.encode('reboot')]);
}
// Build CMD_FACTORY_RESET frame (stock since firmware ver code 7)
// Format: [cmd]["reset"]. The radio disables its link before formatting, so
// success is seen as a drop, never an OK.
Uint8List buildFactoryResetFrame() {
return Uint8List.fromList([cmdFactoryReset, ...utf8.encode('reset')]);
}
// Build CMD_SYNC_NEXT_MESSAGE frame
Uint8List buildSyncNextMessageFrame() {
return Uint8List.fromList([cmdSyncNextMessage]);

@ -91,9 +91,6 @@ class ObserverConfigClient {
static const int cmdConfig = 0xC0;
static const int respConfig = 0xC0;
/// Command exists iff FIRMWARE_VER_CODE >= this AND the capability bit is set.
static const int minVerCode = 14;
/// Bit 0 of the `offband_caps` byte appended to the device-info reply.
static const int capWifiObserver = 0x01;
@ -117,16 +114,19 @@ class ObserverConfigClient {
/// Number of MQTT broker slots (firmware OFFBAND_MAX_BROKERS).
static const int brokerSlotCount = 10;
/// True iff the device advertises config support: version gate AND the
/// True iff the device advertises config support: the
/// `OFFBAND_CAP_WIFI_OBSERVER` bit in the device-info `offband_caps` byte.
/// The WHOLE command (display included) is observer-gated, so both must hold.
static bool supportsConfig({
required int firmwareVerCode,
required int offbandCaps,
}) {
return firmwareVerCode >= minVerCode &&
(offbandCaps & capWifiObserver) != 0;
}
/// The WHOLE command (display included) is observer-gated.
///
/// The bit alone, never `FIRMWARE_VER_CODE` (#1065). This used to also
/// require `>= 14`, mirroring firmware's `OFFBAND_CONFIG_MIN_VER`, which left
/// it passing at zero margin once the code rolled back onto 14. The bit is a
/// sounder signal than the floor ever was: caps byte 1 exists only on fork
/// replies, which are the only ones that carry the `0xC0` command, and a
/// build without the observer backend compiled in leaves the bit clear
/// whatever it reports as its version.
static bool supportsConfig({required int offbandCaps}) =>
(offbandCaps & capWifiObserver) != 0;
// --- Request builders -----------------------------------------------------

@ -0,0 +1,210 @@
import 'dart:typed_data';
import 'offband_device_ui.dart';
/// The `0xC5` `0x1C` capability advert (#1095, firmware #1994).
///
/// Replaces the practice of appending Offband capability bytes to upstream's
/// `RESP_CODE_DEVICE_INFO` frame. Three features each computed "the next free
/// byte" as **87** and each was right in isolation, and a collision at a frame
/// offset is silent misparsing rather than a refused command. Two permanent
/// rules came out of that (firmware `docs/offband_info_frame_contract.md` §2):
/// no Offband field is ever added to upstream's frame again, and a number is
/// claimed by merge before any work begins.
///
/// THE AUTHORITY for every number here is firmware
/// `examples/companion_radio/OffbandConfigProtocol.h`, merged to
/// `firmware-base` in PR OffbandMesh/meshcore-firmware#2000 (`28785ebf`,
/// 2026-11-04). The contract's §5 table is a convenience copy and the header
/// wins on any disagreement. Do not take a number from an Agent Mail message:
/// that habit is what caused the collision.
const int offbandUiInfoGet = 0x1C;
/// A tag's PRESENCE is the capability. There is no capability bitmap in this
/// frame: tags already answer "is this supported", so a bitmap beside them
/// would be a second source of truth for one question (the defect removed from
/// contract revision 1).
///
/// - `len = 0` means "I have this feature, nothing to report". The normal case.
/// - `len > 0` only where the value is one or two bytes, fixed for the build,
/// and has no sub-code of its own. Everything else is read with its own GET.
/// - **Absent** means the board does not have the feature. Not an error, and
/// not a zero value.
const int offbandInfoTagChanNotify = 0x01;
const int offbandInfoTagDisplayRotation = 0x02;
const int offbandInfoTagAckCapacity = 0x03;
/// Display control and LED control are separate capabilities (firmware #2067,
/// PR OffbandMesh/meshcore-firmware#2080, merged `c835aeca`): a board can have
/// one without the other, and caps2 `0x04` INDICATORS answers both with one
/// bit. Values are read over `0x05`/`0x06` and `0x07`/`0x08`.
const int offbandInfoTagDisplayControl = 0x04;
const int offbandInfoTagLedControl = 0x05;
const int offbandInfoTagContinue = 0xFF;
/// `0x00` is not a valid tag.
const int _invalidTag = 0x00;
/// Firmware `OFFBAND_UI_ERR_MALFORMED = 5`.
///
/// The `0xC5` handler returns this reason for BOTH a malformed payload on a
/// known sub-code and a sub-code it does not implement at all. That overload is
/// the entire reason the advert request must carry zero payload: with no
/// payload there is nothing to be malformed, so the answer can only mean the
/// radio does not implement `0x1C`.
const int offbandUiErrReasonMalformed = 0x05;
/// Ceiling on continuation rounds. The advert is a handful of bytes today and
/// no shipping firmware can overflow it, so any long chain is a firmware fault
/// rather than a large reply. Bounded because an unbounded request loop driven
/// by device data is a worse failure than a truncated capability list.
const int offbandInfoMaxContinuations = 8;
/// Request the whole advert. **Zero payload, deliberately.**
///
/// A `[0xC5][0x7F][0x05]` answer to a zero-payload request is unambiguous:
/// reason 5 is also what a KNOWN sub-code returns for a malformed payload, so
/// a request carrying bytes could not be told apart from a device that simply
/// predates the advert.
Uint8List buildCapabilityAdvertRequestFrame() =>
Uint8List.fromList([cmdOffbandDeviceUi, offbandUiInfoGet]);
/// Resume from [cursor], which is the value `TAG_CONTINUE` carried.
Uint8List buildCapabilityAdvertResumeFrame(int cursor) {
if (cursor < 0 || cursor > 0xFF) {
throw ArgumentError.value(cursor, 'cursor', 'must be 0 to 255');
}
return Uint8List.fromList([cmdOffbandDeviceUi, offbandUiInfoGet, cursor]);
}
/// One `0xC5` `0x1C` reply, parsed.
class OffbandCapabilityAdvert {
const OffbandCapabilityAdvert({
required this.tags,
this.continueCursor,
this.malformed = false,
});
/// Tag to value. An empty value is the `len = 0` case and still means
/// supported, so membership is the question to ask, never emptiness.
final Map<int, Uint8List> tags;
/// Non-null when the reply ended in `TAG_CONTINUE`: re-request with this as
/// the cursor. A reply without it is complete.
final int? continueCursor;
/// A record was unparseable and parsing stopped there. The tags BEFORE it are
/// still returned, because capability absence is safe (the feature hides)
/// while discarding a whole good reply over a bad trailing byte is not.
///
/// Firmware must not split a record across frames, so this is a firmware
/// fault and worth logging rather than swallowing.
final bool malformed;
bool has(int tag) => tags.containsKey(tag);
/// The single-byte value of [tag], or null when the tag is absent or does not
/// carry exactly one byte. Null is never coerced to 0: "the device did not
/// say" and "the device said zero" are different answers.
int? byteValue(int tag) {
final value = tags[tag];
if (value == null || value.length != 1) return null;
return value[0];
}
@override
String toString() {
final names = tags.keys.toList()..sort();
return 'advert(tags=[${names.map((t) => '0x${t.toRadixString(16).padLeft(2, '0')}').join(',')}]'
'${continueCursor == null ? '' : ', continue=0x${continueCursor!.toRadixString(16).padLeft(2, '0')}'}'
'${malformed ? ', MALFORMED' : ''})';
}
}
/// Parse a `0xC5` `0x1C` reply. Null when the frame is not one of those at all.
///
/// A reply with no records is valid and means "nothing further", which is what
/// a cursor above every available tag returns. It is not an error.
///
/// Unknown tags are KEPT and skipped by `len`, never rejected: a client built
/// today has to stay correct against firmware emitting tags it has never heard
/// of, and failing on one is non-conforming.
OffbandCapabilityAdvert? parseCapabilityAdvertReply(List<int> frame) {
if (frame.length < 2) return null;
if (frame[0] != respCodeOffbandDeviceUi) return null;
if (frame[1] != offbandUiInfoGet) return null;
final tags = <int, Uint8List>{};
int? continueCursor;
var malformed = false;
var i = 2;
while (i < frame.length) {
final tag = frame[i];
if (tag == _invalidTag) {
malformed = true;
break;
}
if (i + 1 >= frame.length) {
// A tag with no length byte.
malformed = true;
break;
}
final len = frame[i + 1];
final valueStart = i + 2;
final valueEnd = valueStart + len;
if (valueEnd > frame.length) {
// A record running past the frame. Firmware must not split one.
malformed = true;
break;
}
final value = Uint8List.fromList(frame.sublist(valueStart, valueEnd));
if (tag == offbandInfoTagContinue) {
if (len != 1) {
malformed = true;
break;
}
continueCursor = value[0];
// TAG_CONTINUE is the last record by construction. Anything after it is
// not something to guess at.
malformed = valueEnd != frame.length;
break;
}
// Records arrive in ascending tag order, but position is never relied on,
// so an out-of-order frame still parses. A repeated tag is a firmware
// fault: keep the FIRST value and say so, rather than letting a later
// record silently redefine a capability.
if (tags.containsKey(tag)) {
malformed = true;
} else {
tags[tag] = value;
}
i = valueEnd;
}
return OffbandCapabilityAdvert(
tags: tags,
continueCursor: continueCursor,
malformed: malformed,
);
}
/// How far the client has got with asking this radio for its advert.
///
/// Distinguishes "has not answered yet" from "answered, and says it does not
/// have this mechanism". Those lead to different gating, and a single nullable
/// field would conflate them exactly where the collision taught us not to.
enum CapabilityAdvertState {
/// No request sent, or sent and still outstanding.
unknown,
/// A `0x1C` reply arrived and was parsed.
advertised,
/// `[0xC5][0x7F][0x05]` to our zero-payload request: this radio predates the
/// advert, so capability reading falls back to the absolute device-info
/// offsets that shipped before it.
unsupported,
}

@ -0,0 +1,111 @@
import 'dart:typed_data';
import 'offband_device_ui.dart';
/// Per-channel notification mode stored on the RADIO, over `0xC5` 0x18 / 0x19
/// (#544, meshcore-firmware#606).
///
/// This governs the RADIO's buzzer and nothing else. It is deliberately
/// independent of the app's own per-channel `ChannelNotifyMode`, which governs
/// whether the PHONE notifies: two settings, two outputs, no bridging between
/// them (owner, 2026-11-03: "they are separate controls and separate
/// notifications... they are independent settings"). Never resolve one from
/// the other, and never offer an "inherit the app setting" value here.
///
/// Firmware is the authority and there is no firmware inherit concept: the
/// owner's ruling was "Inherit is on the client side, not on the firmware
/// side. How would firmware inherit from firmware?". [unset] is simply the
/// absence of a per-channel choice, and the radio's own device-wide scope
/// answers for that channel instead.
const int offbandUiChannelNotifyGet = 0x18;
const int offbandUiChannelNotifySet = 0x19;
/// A channel's radio notify mode, as the radio stores it.
///
/// The byte is **1-based** and 0 means "no choice made", because the value
/// lives in a field that reads zero on every existing record and every fresh
/// channel. If 0 meant a real mode, every untouched channel in the field would
/// silently adopt it. The shipped `ChannelTune` byte uses the same convention
/// for the same reason, so the two are consistent on the wire.
///
/// `[verified 2026-11-04 against firmware src/helpers/ChannelNotify.h on
/// epic/606-channel-notify: kChanNotifyUnset = 0, kChanNotifyAll = 1,
/// kChanNotifyMentions = 2, kChanNotifyOff = 3, kChanNotifyCount = 4.]`
/// The contract calls these All / Self / None plus "follow the device
/// setting"; that is the same encoding in different words, with "Self" being
/// [mentions] and "follow" being [unset].
///
/// Firmware validates a SET with `stored < kChanNotifyCount`, i.e. `mode < 4`
/// and nothing else. That is why a rotation SET on `0x18`/`0x19` was silent
/// corruption rather than a refused command: every rotation value in 0 to 3
/// passes as a valid notify mode and writes to the channel at that slot. See
/// `offband_indicators.dart`, which now uses `0x1A`/`0x1B`.
enum RadioChannelNotify {
/// No per-channel choice: the radio's device-wide scope answers instead.
unset(0x00),
/// Beep for any message on this channel.
all(0x01),
/// Beep only when the message names us, by the `@[name]` rule.
mentions(0x02),
/// Never beep for this channel.
off(0x03);
const RadioChannelNotify(this.stored);
/// The wire byte. Never built by hand.
final int stored;
static RadioChannelNotify? fromStored(int stored) {
for (final m in RadioChannelNotify.values) {
if (m.stored == stored) return m;
}
return null;
}
}
/// One channel's radio notify mode, as a `0xC5` 0x18 or 0x19 reply reports it.
class RadioChannelNotifyReply {
const RadioChannelNotifyReply({required this.channel, required this.mode});
final int channel;
final RadioChannelNotify mode;
}
int _channelByte(int channel) {
if (channel < 0 || channel > 0xFF) {
throw ArgumentError.value(channel, 'channel', 'must be 0 to 255');
}
return channel;
}
Uint8List buildChannelNotifyGetFrame(int channel) => Uint8List.fromList([
cmdOffbandDeviceUi,
offbandUiChannelNotifyGet,
_channelByte(channel),
]);
Uint8List buildChannelNotifySetFrame(int channel, RadioChannelNotify mode) =>
Uint8List.fromList([
cmdOffbandDeviceUi,
offbandUiChannelNotifySet,
_channelByte(channel),
mode.stored,
]);
/// Parse a `0xC5` channel-notify reply (0x18 or 0x19). Null when this is not
/// one, when it is short, or when the mode byte is not a value we know: an
/// unrecognized mode is dropped rather than guessed at, so a firmware that
/// grows a fourth mode cannot have it read as one of ours.
RadioChannelNotifyReply? parseChannelNotifyReply(List<int> frame) {
if (frame.length < 4) return null;
if (frame[0] != respCodeOffbandDeviceUi) return null;
if (frame[1] != offbandUiChannelNotifyGet &&
frame[1] != offbandUiChannelNotifySet) {
return null;
}
final mode = RadioChannelNotify.fromStored(frame[3]);
if (mode == null) return null;
return RadioChannelNotifyReply(channel: frame[2], mode: mode);
}

@ -101,7 +101,8 @@ enum ButtonConfigError {
0x09,
'Not charged yet. Charge the radio fully, then set 100% again. Nothing '
'was changed.',
);
),
noRotation(0x0A, "This radio's display cannot be rotated");
const ButtonConfigError(this.code, this.message);
final int code;
@ -130,6 +131,15 @@ enum DeviceUiSurface {
tunePlay,
tuneSlot,
screenSettings,
/// The radio's own per-channel notify mode (#544). Distinct from the app's
/// per-channel notify setting, which is not a device-UI surface at all.
channelNotify,
display,
led,
/// Display rotation over `0xC5` `0x1A`/`0x1B` (#1098, firmware #1990).
rotation,
}
/// The refusal reason for a GPS write. Reason 1 means Power save on a build

@ -0,0 +1,199 @@
import 'dart:typed_data';
import 'offband_capability_advert.dart';
import 'offband_device_ui.dart';
/// The radio's display and LED over `0xC5` (#523, #944): display mode, display
/// rotation and the status LED. Gated on caps byte 2 `0x04` INDICATORS
/// (meshcore-firmware #542, rotation #1990).
///
/// display GET `[0xC5][0x05]` -> `[0xC5][0x05][mode]`
/// SET `[0xC5][0x06][mode]` -> the same
/// led GET `[0xC5][0x07]` -> `[0xC5][0x07][on]`
/// SET `[0xC5][0x08][on]` -> the same
/// rotation GET `[0xC5][0x1A]` -> `[0xC5][0x1A][deg]`
/// SET `[0xC5][0x1B][deg]` -> the same
///
/// ROTATION IS `0x1A`/`0x1B`, NOT `0x18`/`0x19`. Those are CHNOTIFY, and this
/// file held them until 2026-11-04. The collision was not theoretical:
/// firmware `4e5a6c85`, already flashed on the owner's T1000-E, answers
/// `0x18`/`0x19` as per-channel channel-notify and its SET validates only
/// `mode < 4`, so a rotation SET of `0` landed as a valid notify-mode write on
/// channel slot 0, which is Public and undeletable, and BOTH sides reported
/// success. No error, no banner, nothing in any log.
///
/// The numbers here are transcribed from firmware
/// `examples/companion_radio/OffbandConfigProtocol.h` on `firmware-base` at
/// `28785ebf` (PR OffbandMesh/meshcore-firmware#2000). The header is the
/// registry. Do not take a sub-code from an Agent Mail message or from this
/// comment: read the header, because taking numbers from mail is what caused
/// the collision.
///
/// Rotation is advertised by `TAG_DISPLAY_ROTATION` in the `0xC5` `0x1C`
/// advert, NOT by caps2 `0x04` INDICATORS and NOT by probing `0x1A` and
/// watching for a refusal. Contract §3 forbids that probing outright:
/// "what is forbidden is discovering support by trying each feature's sub-code
/// and watching for errors." Display mode and the LED each have their own
/// advert tag, with the INDICATORS bit as the fallback: see
/// [resolveIndicatorSupport].
///
/// A board without a display, or without a controllable LED, refuses that
/// half with reason 6; a display that cannot turn refuses rotation with 10.
const int offbandCap2Indicators = 0x04;
const int offbandUiDisplayGet = 0x05;
const int offbandUiDisplaySet = 0x06;
const int offbandUiLedGet = 0x07;
const int offbandUiLedSet = 0x08;
const int offbandUiRotationGet = 0x1A;
const int offbandUiRotationSet = 0x1B;
/// The rotations the radio accepts, in degrees.
const List<int> displayRotations = [0, 180];
bool firmwareSupportsIndicators(int? offbandCaps2) =>
offbandCaps2 != null && (offbandCaps2 & offbandCap2Indicators) != 0;
/// Which indicator controls this radio has, each on its own (#1132, firmware
/// #2069).
///
/// caps2 `0x04` answers display AND LED with one bit, so a board with a real
/// display and no controllable LED showed an LED switch the radio then refused
/// with `[0xC5][0x7F][0x06]` (firmware #2065: a T096, a T1000-E, a QCC badge).
/// Precedence, from the firmware header on `firmware-base`, the same shape as
/// channel notify:
///
/// - the advert carries `TAG_DISPLAY_CONTROL` and/or `TAG_LED_CONTROL`: the
/// tags decide, each control on its own, and the bit is ignored;
/// - the advert answered with neither tag: firmware from before the tags, so
/// the bit answers both, as it always has;
/// - no advert (the radio predates `0x1C`, or has not answered yet): the bit
/// answers both.
///
/// The bit is not retired: radios on firmware 1.7.0 and earlier answer the
/// advert without these tags, and dropping the bit would hide working display
/// control on them. Support is never found by sending a GET and reading the
/// refusal (contract §3).
({bool display, bool led}) resolveIndicatorSupport({
required int? offbandCaps2,
required OffbandCapabilityAdvert? advert,
}) {
if (advert != null &&
(advert.has(offbandInfoTagDisplayControl) ||
advert.has(offbandInfoTagLedControl))) {
return (
display: advert.has(offbandInfoTagDisplayControl),
led: advert.has(offbandInfoTagLedControl),
);
}
final bit = firmwareSupportsIndicators(offbandCaps2);
return (display: bit, led: bit);
}
/// When the radio's screen is lit. Values are the wire encoding.
enum DisplayMode {
auto(0, 'Auto', 'On while in use, then off after the screen timeout'),
alwaysOn(1, 'Always on', 'Never turns off by itself'),
alwaysOff(2, 'Always off', 'Stays dark');
const DisplayMode(this.code, this.label, this.description);
final int code;
final String label;
final String description;
static DisplayMode? fromCode(int code) {
for (final m in DisplayMode.values) {
if (m.code == code) return m;
}
return null;
}
}
Uint8List buildDisplayGetFrame() =>
Uint8List.fromList([cmdOffbandDeviceUi, offbandUiDisplayGet]);
Uint8List buildDisplaySetFrame(DisplayMode mode) =>
Uint8List.fromList([cmdOffbandDeviceUi, offbandUiDisplaySet, mode.code]);
Uint8List buildLedGetFrame() =>
Uint8List.fromList([cmdOffbandDeviceUi, offbandUiLedGet]);
Uint8List buildLedSetFrame({required bool on}) =>
Uint8List.fromList([cmdOffbandDeviceUi, offbandUiLedSet, on ? 1 : 0]);
Uint8List buildRotationGetFrame() =>
Uint8List.fromList([cmdOffbandDeviceUi, offbandUiRotationGet]);
Uint8List buildRotationSetFrame(int degrees) {
if (!displayRotations.contains(degrees)) {
throw ArgumentError.value(degrees, 'degrees', 'must be 0 or 180');
}
return Uint8List.fromList([
cmdOffbandDeviceUi,
offbandUiRotationSet,
degrees,
]);
}
/// Parse a display, LED or rotation reply. Null if this is not one, or if it
/// is truncated. The shared 0x7F error is owned by [parseButtonMatrixReply].
IndicatorReply? parseIndicatorReply(Uint8List frame) {
if (frame.length < 3 || frame[0] != respCodeOffbandDeviceUi) return null;
final sub = frame[1];
final value = frame[2];
switch (sub) {
case offbandUiLedGet || offbandUiLedSet:
return IndicatorReply(sub: sub, ledOn: value != 0);
case offbandUiDisplayGet || offbandUiDisplaySet:
final mode = DisplayMode.fromCode(value);
if (mode == null) {
return IndicatorReply(
sub: sub,
errorMessage:
'The radio reported an unknown display mode (${_hex(value)})',
);
}
return IndicatorReply(sub: sub, displayMode: mode);
case offbandUiRotationGet || offbandUiRotationSet:
if (!displayRotations.contains(value)) {
return IndicatorReply(
sub: sub,
errorMessage: 'The radio reported an unknown rotation ($value)',
);
}
return IndicatorReply(sub: sub, rotation: value);
}
return null;
}
String _hex(int b) => '0x${b.toRadixString(16).padLeft(2, '0')}';
class IndicatorReply {
const IndicatorReply({
required this.sub,
this.displayMode,
this.ledOn,
this.rotation,
this.errorMessage,
});
final int sub;
final DisplayMode? displayMode;
final bool? ledOn;
/// Degrees, always one of [displayRotations].
final int? rotation;
final String? errorMessage;
DeviceUiSurface get surface => switch (sub) {
offbandUiLedGet || offbandUiLedSet => DeviceUiSurface.led,
offbandUiRotationGet || offbandUiRotationSet => DeviceUiSurface.rotation,
_ => DeviceUiSurface.display,
};
bool get isWrite =>
sub == offbandUiDisplaySet ||
sub == offbandUiLedSet ||
sub == offbandUiRotationSet;
}

@ -0,0 +1,118 @@
import '../models/channel.dart';
/// One channel to write back to the radio, and where.
class ChannelRestoreStep {
const ChannelRestoreStep({
required this.channel,
required this.slot,
required this.clearFirst,
});
final Channel channel;
final int slot;
/// The slot holds an erased record. Firmware treats that as occupied
/// (#1865), so it is cleared before the channel is written.
final bool clearFirst;
}
/// What a restore achieved, as the radio reported it.
class ChannelRestoreResult {
const ChannelRestoreResult({
required this.confirmed,
required this.total,
required this.notRestored,
});
/// Channels the radio confirmed taking.
final int confirmed;
/// Channels the restore set out to put back.
final int total;
/// Channels the radio refused or never answered for, or had no room for.
final List<Channel> notRestored;
}
class ChannelRestorePlan {
const ChannelRestorePlan(this.steps, this.noRoom);
final List<ChannelRestoreStep> steps;
/// Cached channels with no slot left to go to.
final List<Channel> noRoom;
}
/// Decides how to put a radio's lost channels back (#1060, #1117).
///
/// The client's cached list says which channels the radio had. Replaying each
/// one into its remembered slot no longer works on current firmware, which
/// refuses a channel over an occupied slot (#1865), treats an erased record
/// as occupied, refuses any channel but Public in slot 0, and refuses Public
/// anywhere else (#1896). So this places by identity:
///
/// * A cached channel whose key is already live on the radio, in any slot, is
/// left out: the radio has it.
/// * Public goes to slot 0. If slot 0 holds a live channel (older firmware
/// only), Public takes a free slot instead, so nothing is overwritten.
/// * Any other channel goes back to its remembered slot when that slot is
/// free or erased and is not slot 0; otherwise to the lowest free slot.
/// * A live channel is never a destination, so nothing on the radio is
/// overwritten.
/// * An erased slot is cleared first.
///
/// Order does not matter: nothing but Public is ever placed in slot 0, so
/// Public always finds it free unless a live channel holds it.
ChannelRestorePlan planChannelRestore({
required List<Channel> cached,
required List<Channel> live,
required Set<int> erasedSlots,
required int maxChannels,
}) {
final liveKeys = live.map((c) => c.pskHex).toSet();
final taken = live.map((c) => c.index).toSet();
final candidates = cached
.where((c) => !c.isUnusable && !liveKeys.contains(c.pskHex))
.toList();
final steps = <ChannelRestoreStep>[];
final noRoom = <Channel>[];
final placedKeys = <String>{};
bool free(int slot) =>
slot >= 0 && slot < maxChannels && !taken.contains(slot);
int? firstFree() {
for (var i = 0; i < maxChannels; i++) {
if (i == Channel.publicSlot) continue;
if (free(i)) return i;
}
return null;
}
for (final channel in candidates) {
// A cache can hold the same key twice; write it once.
if (!placedKeys.add(channel.pskHex)) continue;
final int? slot;
if (channel.isPublicChannel) {
slot = free(Channel.publicSlot) ? Channel.publicSlot : firstFree();
} else if (channel.index != Channel.publicSlot && free(channel.index)) {
slot = channel.index;
} else {
slot = firstFree();
}
if (slot == null) {
noRoom.add(channel);
continue;
}
taken.add(slot);
steps.add(
ChannelRestoreStep(
channel: channel,
slot: slot,
clearFirst: erasedSlots.contains(slot),
),
);
}
return ChannelRestorePlan(steps, noRoom);
}

@ -0,0 +1,73 @@
import '../models/channel.dart';
/// Why a channel slot could not be allocated.
enum ChannelSlotRefusal {
/// The channel list is mid-rewrite, so no slot can be trusted to be free.
syncing,
/// Every slot on the radio is in use.
full,
}
/// Either a slot that is safe to write, or the reason it was refused.
class ChannelSlotDecision {
const ChannelSlotDecision.slot(int this.slot) : refusal = null;
const ChannelSlotDecision.refused(ChannelSlotRefusal this.refusal)
: slot = null;
final int? slot;
final ChannelSlotRefusal? refusal;
bool get isAllowed => slot != null;
}
/// Decide which channel slot a new channel may occupy (#1021).
///
/// Pulled out of the channels screen so it can be tested without a widget, and
/// so the QR and config-import paths can share one answer instead of each
/// carrying their own (there were three divergent implementations, and only one
/// of them had the bug).
///
/// Two refusals, both deliberate:
///
/// * [isSyncing] true means the connector has emptied its channel list and is
/// refilling it slot by slot. Any index would look free. Choosing one here is
/// what overwrote Public: a slot picked during a sync pointed at an occupied
/// slot by the time it was written.
/// * No free index means the radio is full. The caller must be told, never
/// handed slot 0 as a consolation, which is the old `return 0` bug and is
/// where Public normally lives.
///
/// Callers pass the connector's `isChannelTableBusy` (a sync, or a write the
/// radio has not answered yet), not `hasLoadedChannels`: the latter stays false
/// after a *failed* sync while the cached list is restored into use, so gating
/// on it would refuse forever on a flaky link.
///
/// An absent index genuinely means a free slot, because the connector never
/// adds an empty channel to its list.
///
/// Slot 0 is Public's (firmware #1896, owner 2026-10-28: slot 0 "should always
/// remain public"). Firmware refuses any other channel there, even while it is
/// empty, so [isPublic] false never gets slot 0. Public goes to slot 0 when it
/// is free; firmware refuses Public anywhere else, so the fallback to another
/// free slot exists only for older firmware, where slot 0 can be held by some
/// other channel. [isPublic] is required so no caller can forget the rule.
ChannelSlotDecision decideChannelSlot({
required List<Channel> channels,
required int maxChannels,
required bool isSyncing,
required bool isPublic,
}) {
if (isSyncing) {
return const ChannelSlotDecision.refused(ChannelSlotRefusal.syncing);
}
final used = channels.map((c) => c.index).toSet();
if (isPublic && !used.contains(Channel.publicSlot)) {
return const ChannelSlotDecision.slot(Channel.publicSlot);
}
for (var i = 0; i < maxChannels; i++) {
if (i == Channel.publicSlot) continue;
if (!used.contains(i)) return ChannelSlotDecision.slot(i);
}
return const ChannelSlotDecision.refused(ChannelSlotRefusal.full);
}

@ -0,0 +1,171 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart';
import '../models/channel.dart';
import 'channel_slot_decision.dart';
import 'snack_bar_builder.dart';
/// A channel slot that is safe to write right now, or null after telling the
/// user why not (#1021).
///
/// Every surface that adds a channel goes through this one function: the
/// channels screen, the community QR scanner, and anything else that writes a
/// slot. That is deliberate. The bug this fixes existed because the same
/// question ("which slot is free?") was answered three different ways in three
/// files, and only one of them was wrong. One answer cannot drift.
///
/// Call it at the moment of the write and never earlier. The connector empties
/// its channel list for the duration of a channel sync, and `setChannel` itself
/// starts one, so a slot chosen before a dialog or a confirmation can point at
/// an occupied slot by the time it is written.
///
/// The owner's ruling is that the unsafe write is blocked but the control stays
/// live: a greyed-out button prevents the damage and tells the user nothing. So
/// a refusal returns null *and* says what is wrong.
///
/// [psk] is the key about to be written. Public's key gets slot 0 and nothing
/// else does (#1896), and a second copy of Public is refused, whichever path
/// the key came in by: Join Public, a typed private key, a QR share, or a
/// hashtag join of "public".
int? allocateChannelSlotOrExplain(
BuildContext context,
MeshCoreConnector connector, {
required Uint8List psk,
}) {
final isPublic = Channel.isPublicKey(psk);
if (isPublic && refusePublicAlreadyOnRadio(context, connector)) return null;
final decision = decideChannelSlot(
channels: connector.channels,
maxChannels: connector.maxChannels,
// Not hasLoadedChannels: that stays false after a FAILED sync while the
// cached list is restored, so gating on it would make adding a channel
// impossible on a flaky link. Busy also covers a write the radio has not
// answered yet (#1026): until it does, the list does not show its slot.
isSyncing: connector.isChannelTableBusy,
isPublic: isPublic,
);
switch (decision.refusal) {
case ChannelSlotRefusal.syncing:
showChannelBlockedMessage(context, context.l10n.channels_slotBusySyncing);
return null;
case ChannelSlotRefusal.full:
showChannelBlockedMessage(
context,
context.l10n.channels_slotsAllFull(connector.maxChannels),
);
return null;
case null:
return decision.slot;
}
}
/// A refusal the user has to be able to read and act on, so it persists until
/// dismissed.
///
/// `showDismissibleSnackBar` defaults to `persist: false` and a 4 second toast,
/// which SAFELANE 6 rules out for an error.
void showChannelBlockedMessage(BuildContext context, String message) {
showDismissibleSnackBar(context, content: Text(message), persist: true);
}
/// Explains a channel write the radio did not confirm, and returns true if it
/// did. A caller shows its own success message only when this returns false,
/// so nothing claims success before the radio has said OK (#1026).
///
/// A success toast shown after this would replace the explanation (a newer
/// toast replaces the visible one), so callers must not show one.
bool explainUnconfirmedChannelWrite(BuildContext context, ChannelWrite result) {
final l10n = context.l10n;
final message = switch (result) {
ChannelWrite.ok => null,
ChannelWrite.occupied => l10n.channels_writeOccupied,
ChannelWrite.rejected => l10n.channels_writeRejected,
ChannelWrite.noReply => l10n.channels_writeNoReply,
ChannelWrite.clearedNotAdded => l10n.channels_writeClearedNotAdded,
ChannelWrite.historyNotMoved => l10n.channels_writeHistoryNotMoved,
};
if (message == null) return false;
showChannelBlockedMessage(context, message);
return true;
}
/// Asks what a key change does with [channelName]'s message history: kept
/// under the new key, or deleted. Null means the user backed out, and nothing
/// may be written (#1128).
Future<KeyChangeHistory?> askKeyChangeHistory(
BuildContext context,
String channelName,
) {
return showDialog<KeyChangeHistory>(
context: context,
builder: (dialogContext) {
final l10n = dialogContext.l10n;
return AlertDialog(
title: Text(l10n.channels_keyChangeHistoryTitle),
content: Text(l10n.channels_keyChangeHistoryBody(channelName)),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: Text(l10n.common_cancel),
),
TextButton(
style: TextButton.styleFrom(
foregroundColor: Theme.of(dialogContext).colorScheme.error,
),
onPressed: () =>
Navigator.pop(dialogContext, KeyChangeHistory.delete),
child: Text(l10n.channels_keyChangeDeleteHistory),
),
FilledButton(
onPressed: () =>
Navigator.pop(dialogContext, KeyChangeHistory.keep),
child: Text(l10n.channels_keyChangeKeepHistory),
),
],
);
},
);
}
/// Refuses a second Public channel, and says so. Returns true if it refused.
///
/// Public is identified by its PSK (`Channel.isPublicChannel`), not by name or
/// slot, so this finds it wherever older firmware left it.
bool refusePublicAlreadyOnRadio(
BuildContext context,
MeshCoreConnector connector,
) {
if (!connector.channels.any((c) => c.isPublicChannel)) return false;
showChannelBlockedMessage(context, context.l10n.channels_publicAlreadyAdded);
return true;
}
/// For a delete: refuses to remove Public, and says so. Returns true if it
/// refused. Firmware #1896 refuses the clear anyway, with the same error code
/// as an occupied slot, so without this the user would read the wrong reason
/// (owner 2026-10-28: "make sure Public can't be deleted").
bool refuseDeletingPublic(BuildContext context, Channel channel) {
if (!channel.isPublicChannel) return false;
showChannelBlockedMessage(
context,
context.l10n.channels_publicCannotBeRemoved,
);
return true;
}
/// For an edit or delete: refuses while the channel list is being re-read or
/// a write is unanswered, and says so. Returns true if it refused.
///
/// Replies are matched to commands by order, so a write sent during a sync
/// could take one of the sync's replies as its own.
bool refuseWhileChannelTableBusy(
BuildContext context,
MeshCoreConnector connector,
) {
if (!connector.isChannelTableBusy) return false;
showChannelBlockedMessage(context, context.l10n.channels_slotBusySyncing);
return true;
}

@ -14,6 +14,12 @@ class LinkHandler {
return base.copyWith(color: orange, decoration: TextDecoration.underline);
}
static const _options = LinkifyOptions(
humanize: false,
defaultToHttps: false,
);
static const _linkifiers = [UrlLinkifier(), EmailLinkifier()];
/// Returns a [SelectableLinkify] on desktop or a [Linkify] on mobile.
static Widget buildLinkifyText({
required BuildContext context,
@ -22,8 +28,6 @@ class LinkHandler {
TextStyle? linkStyle,
}) {
final effectiveLinkStyle = linkStyle ?? defaultLinkStyle(context, style);
const options = LinkifyOptions(humanize: false, defaultToHttps: false);
const linkifiers = [UrlLinkifier(), EmailLinkifier()];
void onOpen(LinkableElement link) => handleLinkTap(context, link.url);
if (PlatformInfo.isDesktop) {
@ -31,8 +35,8 @@ class LinkHandler {
text: text,
style: style,
linkStyle: effectiveLinkStyle,
options: options,
linkifiers: linkifiers,
options: _options,
linkifiers: _linkifiers,
onOpen: onOpen,
);
}
@ -40,12 +44,30 @@ class LinkHandler {
text: text,
style: style,
linkStyle: effectiveLinkStyle,
options: options,
linkifiers: linkifiers,
options: _options,
linkifiers: _linkifiers,
onOpen: onOpen,
);
}
/// A span with the same links, style and confirm-on-tap as
/// [buildLinkifyText], for text that sits between widget spans such as
/// mention chips. (#1074)
static InlineSpan buildLinkifySpan({
required BuildContext context,
required String text,
required TextStyle style,
}) {
return LinkifySpan(
text: text,
style: style,
linkStyle: defaultLinkStyle(context, style),
options: _options,
linkifiers: _linkifiers,
onOpen: (link) => handleLinkTap(context, link.url),
);
}
static Future<void> handleLinkTap(BuildContext context, String url) async {
// Show confirmation dialog
final shouldOpen = await showDialog<bool>(

@ -8,7 +8,7 @@
}
},
"@@locale": "bg",
"appTitle": "Offband Meshcore",
"appTitle": "Offband MeshCore",
"nav_contacts": "Контакти",
"nav_channels": "Канали",
"nav_map": "Карта",
@ -125,7 +125,7 @@
"settings_appDebugLog": "Лог на отстраняване на грешки на приложението",
"settings_appDebugLogSubtitle": "Съобщения за отстраняване на грешки на приложението",
"settings_about": "За нас",
"settings_aboutVersion": "Offband Meshcore, версия {version}",
"settings_aboutVersion": "Offband MeshCore, версия {version}",
"@settings_aboutVersion": {
"placeholders": {
"version": {

@ -8,7 +8,7 @@
}
},
"@@locale": "de",
"appTitle": "Offband Meshcore",
"appTitle": "Offband MeshCore",
"nav_contacts": "Kontakte",
"nav_channels": "Kanäle",
"nav_map": "Karte",
@ -125,7 +125,7 @@
"settings_appDebugLog": "App-Debug-Protokoll",
"settings_appDebugLogSubtitle": "Anwendung Debug-Nachrichten",
"settings_about": "Über",
"settings_aboutVersion": "Offband Meshcore, Version {version}",
"settings_aboutVersion": "Offband MeshCore, Version {version}",
"@settings_aboutVersion": {
"placeholders": {
"version": {

@ -1,6 +1,6 @@
{
"@@locale": "en",
"appTitle": "Offband Meshcore",
"appTitle": "Offband MeshCore",
"nav_contacts": "Contacts",
"nav_channels": "Channels",
"nav_map": "Map",
@ -50,7 +50,7 @@
},
"common_autoRefresh": "Autorefresh",
"common_interval": "Interval",
"scanner_title": "Offband Meshcore",
"scanner_title": "Offband MeshCore",
"connectionChoiceUsbLabel": "USB",
"connectionChoiceBluetoothLabel": "Bluetooth",
"connectionChoiceTcpLabel": "TCP",
@ -158,6 +158,14 @@
"scanner_scan": "Scan",
"scanner_bluetoothOff": "Bluetooth is off",
"scanner_bluetoothOffMessage": "Please turn on Bluetooth to scan for devices",
"scanner_scanFailedLocationOff": "Location is off, so Android won't let the app scan for Bluetooth radios. Turn on Location in your phone's quick settings, then tap Scan again.",
"@scanner_scanFailedLocationOff": {
"description": "Shown when a Bluetooth scan is refused because system Location is off (Android 11 and below) (#1033)."
},
"scanner_scanFailedGeneric": "Could not start the Bluetooth scan. Tap Scan to try again. If it keeps failing, turn Bluetooth off and on.",
"@scanner_scanFailedGeneric": {
"description": "Shown for any other failure to start a Bluetooth scan; the raw error goes to the log only (#1033)."
},
"scanner_connectFailedTransient": "The radio refused the connection (a common transient Bluetooth error). It was already retried; wait a few seconds and try again.",
"@scanner_connectFailedTransient": {
"description": "Human-readable text for an Android GATT 133 connect failure after the bounded retry also failed (#698)."
@ -377,6 +385,8 @@
"settings_buttonActionsSubtitle": "Assign what the button does",
"settings_badgeScreen": "Screen",
"settings_badgeScreenSubtitle": "Text size, screen off, time zone",
"settings_indicators": "Display and LED",
"settings_indicatorsSubtitle": "Display mode, rotation, status LED",
"settings_badgeBattery": "Battery",
"settings_badgeBatterySubtitle": "100% at {mv} mV",
"@settings_badgeBatterySubtitle": {
@ -423,6 +433,38 @@
"settings_rebootDevice": "Reboot Device",
"settings_rebootDeviceSubtitle": "Restart the MeshCore device",
"settings_rebootDeviceConfirm": "Are you sure you want to reboot the device? You will be disconnected.",
"settings_factoryReset":"Factory Reset",
"settings_factoryResetSubtitle": "Erase the radio and give it a new identity",
"settings_factoryResetConfirm": "This erases everything on the radio: its identity, contacts, channels and settings. It can't be undone.",
"settings_factoryResetDeleteAppData": "Also delete this radio's data stored in the app",
"settings_factoryResetNotConnected": "Factory reset not sent: no radio is connected.",
"factoryResetSentTitle": "Factory reset sent",
"factoryResetSentBody": "The radio is erasing itself and will restart with a new identity.",
"factoryResetSentBleBody": "The radio is erasing itself and will restart with a new identity. Its Bluetooth pairing is erased too: remove the old pairing for this radio in your phone or computer's Bluetooth settings, then reconnect.",
"factoryResetDeleteFailedTitle": "Factory reset sent, app cleanup failed",
"factoryResetDeleteFailedBody": "The reset was sent, but deleting this radio's data from the app failed.",
"settings_blePin": "BLE pairing PIN",
"settings_blePinDefault": "Firmware default",
"settings_blePinHelp": "6 digits (100000-999999). New phones and PCs must enter it when pairing. Reset to default to return to the firmware's built-in behavior.",
"settings_blePinInvalid": "Enter a 6-digit PIN between 100000 and 999999",
"settings_blePinSaved": "PIN set to {pin}. Already-paired devices may need to forget the radio and pair again.",
"@settings_blePinSaved": {
"placeholders": {
"pin": {
"type": "String"
}
}
},
"settings_blePinResetDone": "PIN reset to the firmware default. Already-paired devices may need to forget the radio and pair again.",
"settings_blePinSendFailed": "The PIN was not changed: sending to the radio failed ({error})",
"@settings_blePinSendFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"settings_blePinResetAction": "Reset to default",
"settings_debug": "Debug",
"settings_experimental": "Experimental",
"settings_experimentalSubtitle": "Advanced and in-development features",
@ -447,7 +489,7 @@
"settings_appDebugLog": "App Debug Log",
"settings_appDebugLogSubtitle": "Application debug messages",
"settings_about": "About",
"settings_aboutVersion": "Offband Meshcore v{version}",
"settings_aboutVersion": "Offband MeshCore v{version}",
"@settings_aboutVersion": {
"placeholders": {
"version": {
@ -456,11 +498,17 @@
}
},
"settings_aboutLegalese": "© 2026 Offband. Based on MeshCore Open by zjs81, MIT licensed.",
"settings_aboutDescription": "Offband Meshcore is an open-source client for MeshCore LoRa mesh networking devices.",
"settings_aboutDescription": "Offband MeshCore is an open-source client for MeshCore LoRa mesh networking devices.",
"settings_aboutLicenses": "View licenses",
"settings_aboutWebsite": "Offband website",
"settings_aboutPlayStore": "Google Play",
"settings_aboutDonate": "Donate or sponsor",
"sponsorPrompt_title": "Support Offband",
"sponsorPrompt_body": "Offband MeshCore is free, open source, and built by an independent developer. If it's useful to you, sponsoring helps keep it maintained and improving. Thank you for using it.",
"sponsorPrompt_sponsor": "Sponsor",
"sponsorPrompt_anytime": "You can support Offband anytime from About in Settings, or at offband.org/donate.",
"sponsorPrompt_notNow": "Not now",
"sponsorPrompt_never": "Never Show Again",
"settings_aboutLinkFailed": "Could not open the link",
"settings_aboutOpenMeteoAttribution": "LOS elevation data: Open-Meteo (CC BY 4.0)",
"settings_aboutPresetsAttribution": "Regional radio presets: MeshCore's suggested settings, maintained by Liam Cottle (api.meshcore.nz), with Offband additions.",
@ -828,6 +876,68 @@
"channels_noChannelsConfigured": "No channels configured",
"channels_addPublicChannel": "Add Public Channel",
"channels_searchChannels": "Search channels...",
"channels_unconfirmedSlots": "The radio did not confirm channel slot(s) {slots}. Those channels are unknown until a sync succeeds.",
"@channels_unconfirmedSlots": {
"placeholders": {
"slots": {
"type": "String"
}
}
},
"channels_retrySync": "Retry sync",
"channels_erasedSlots": "The radio has lost the channels in slot(s) {slots}; it reported them as erased. Your channel list here has been kept and was not overwritten.",
"@channels_erasedSlots": {
"placeholders": {
"slots": {
"type": "String"
}
}
},
"channels_restoreToRadio": "Restore to radio",
"channels_restoreToRadioConfirm": "Write {count} channel(s) back to the radio: {names}. Channels the radio still has are left as they are.",
"@channels_restoreToRadioConfirm": {
"placeholders": {
"count": {
"type": "int"
},
"names": {
"type": "String"
}
}
},
"channels_restoreToRadioDone": "The radio confirmed all {count} channel(s).",
"@channels_restoreToRadioDone": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"channels_restoreToRadioPartial": "The radio confirmed {confirmed} of {total} channel(s). Not restored: {names}.",
"@channels_restoreToRadioPartial": {
"description": "Restore to radio finished with some channels refused, unanswered, or without a free slot. Persistent (#1117).",
"placeholders": {
"confirmed": {
"type": "int"
},
"total": {
"type": "int"
},
"names": {
"type": "String"
}
}
},
"channels_restoreToRadioFailed": "Could not restore the channel list: nothing cached to restore, or the radio is not connected.",
"channels_slotIdentityChanged": "Channel layout changed on the radio: {changes}",
"@channels_slotIdentityChanged": {
"placeholders": {
"changes": {
"type": "String"
}
}
},
"common_dismiss": "Dismiss",
"channels_noChannelsFound": "No channels found",
"channels_channelIndex": "Channel {index}",
"@channels_channelIndex": {
@ -850,10 +960,22 @@
"channels_editChannel": "Edit channel",
"channels_muteChannel": "Mute channel",
"channels_unmuteChannel": "Unmute channel",
"channels_notifications": "Notifications",
"channels_notifications": "Phone notifications",
"channels_notifyAll": "All messages",
"channels_notifyMentionsOnly": "Mentions only",
"channels_notifyOff": "Off",
"channels_radioSound": "Radio sound",
"channels_radioSoundUnset": "Not set",
"channels_radioSoundFollows": "Follows the radio: {scope}",
"@channels_radioSoundFollows": {
"placeholders": {
"scope": {
"type": "String"
}
}
},
"channels_radioSoundMutedByRadio": "Silent: the radio is set to None",
"channels_radioSoundReading": "Reading from the radio",
"channels_deleteChannel": "Delete channel",
"channels_deleteChannelConfirm": "Delete \"{name}\"? This cannot be undone.",
"@channels_deleteChannelConfirm": {
@ -941,6 +1063,61 @@
}
},
"channels_publicChannelAdded": "Public channel added",
"channels_slotBusySyncing": "The radio is still updating its channel list. Try again in a moment.",
"channels_slotsAllFull": "All {count} channel slots are in use. Delete a channel first.",
"@channels_slotsAllFull": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"channels_publicAlreadyAdded": "The Public channel is already on this radio.",
"channels_publicCannotBeRemoved": "Public can't be removed from the radio. It always stays in slot 0.",
"@channels_publicCannotBeRemoved": {
"description": "Refusal when the user tries to delete the Public channel. Firmware #1896 keeps Public permanently in slot 0. Persistent (#1117)."
},
"channels_writeOccupied": "The radio refused: another channel already uses that slot, and it was left unchanged. Try again now that the list has been re-read.",
"@channels_writeOccupied": {
"description": "The radio refused a channel write because a different channel holds the slot (ERR_CODE_BAD_STATE, firmware #1864). Persistent (#1026)."
},
"channels_writeRejected": "The radio refused the change, so the channel is as it was.",
"@channels_writeRejected": {
"description": "The radio answered a channel write with an error other than slot-occupied. Persistent (#1026)."
},
"channels_writeNoReply": "The radio did not confirm the change. Check the channel list before trying again.",
"@channels_writeNoReply": {
"description": "A channel write got no reply before the timeout, or the radio is not connected; it may or may not have landed. Persistent (#1026)."
},
"channels_writeClearedNotAdded": "The old channel was removed, but the radio did not confirm the new one, so that slot is now empty. Add the channel again.",
"@channels_writeClearedNotAdded": {
"description": "A channel key change is clear-then-add; the clear landed and the add did not, so the edited channel is gone from the radio. Persistent (#1026)."
},
"channels_writeHistoryNotMoved": "The channel was changed, but its message history could not be moved to it. The app log has the details.",
"@channels_writeHistoryNotMoved": {
"description": "The user chose to keep message history across a key change; the radio took the new key but the app could not move the stored messages. Persistent (#1128)."
},
"channels_keyChangeHistoryTitle": "Keep message history?",
"@channels_keyChangeHistoryTitle": {
"description": "Title of the question asked before a channel's key changes (a hashtag rename, or a new private key) (#1128)."
},
"channels_keyChangeHistoryBody": "This gives \"{name}\" a new key. Keep its message history under the new key, or delete it? Deleted history cannot be recovered.",
"@channels_keyChangeHistoryBody": {
"description": "Body of the keep-or-delete question for a channel key change (#1128).",
"placeholders": {
"name": {
"type": "String"
}
}
},
"channels_keyChangeKeepHistory": "Keep message history",
"@channels_keyChangeKeepHistory": {
"description": "Moves the channel's stored messages to its new key (#1128)."
},
"channels_keyChangeDeleteHistory": "Delete message history",
"@channels_keyChangeDeleteHistory": {
"description": "Deletes the channel's stored messages, as Delete Channel does (#1128)."
},
"channels_sortBy": "Sort by",
"channels_sortManual": "Manual",
"channels_sortAZ": "A-Z",
@ -1046,9 +1223,10 @@
"debugLog_shareLog": "Share logs",
"debugLog_saveLog": "Save logs",
"debugLog_downloadLog": "Download logs",
"debugLog_shareSubject": "Offband Meshcore logs",
"debugLog_shareSubject": "Offband MeshCore logs",
"debugLog_logSaved": "Log saved",
"debugLog_logUnavailable": "No log available to export",
"debugLog_exportFailed": "Couldn't share the log. Please try again.",
"debugLog_copied": "Debug log copied",
"debugLog_bleCopied": "BLE log copied",
"debugLog_noEntries": "No debug logs yet",
@ -1178,6 +1356,13 @@
}
},
"channel_senderAddNotConnected": "Not connected to a radio, so this sender can't be added yet",
"channels_pskDerivedFromName": "Key derives from the channel name",
"channels_communityDerived": "Derived from the community secret; manage it through the community",
"channels_copyPsk": "Copy key",
"channels_pskCopied": "Key copied",
"channels_hashtagMustKeepHash": "A hashtag channel's name must start with a single #. Add a new channel to use a different type.",
"channels_channelChangedReopen": "This channel changed on the radio while the dialog was open. Nothing was saved; reopen it and try again.",
"channel_slotUnconfirmedSendBlocked": "Message not sent: the radio hasn't confirmed this channel slot yet. Retry the channel sync, then send again.",
"channel_senderAddFailed": "Couldn't add this sender: {error}",
"@channel_senderAddFailed": {
"placeholders": {
@ -2415,7 +2600,6 @@
"channels_scanQrDesc": "Add a community or channel from a QR code",
"channels_scanQrInstructions": "Point at a MeshCore QR code",
"channels_invalidQr": "Invalid QR code",
"channels_noFreeSlot": "No free channel slot",
"channels_qrAddConfirm": "Add channel \"{name}\"?",
"@channels_qrAddConfirm": {
"placeholders": {
@ -3306,6 +3490,10 @@
"@importConfig_reasonNoFreeSlot": {
"description": "Skip reason: every channel slot is occupied (#576)."
},
"importConfig_reasonChannelsSyncing": "channels were still being read from the radio; import again in a moment",
"@importConfig_reasonChannelsSyncing": {
"description": "Skip reason: the channel list was mid-sync, so no slot could be trusted to be free (#1021)."
},
"importConfig_reasonAbsentFromFile": "not present in this file",
"@importConfig_reasonAbsentFromFile": {
"description": "Failure reason: the file did not contain the section (#576)."

@ -8,7 +8,7 @@
}
},
"@@locale": "es",
"appTitle": "Offband Meshcore",
"appTitle": "Offband MeshCore",
"nav_contacts": "Contactos",
"nav_channels": "Canales",
"nav_map": "Mapa",
@ -125,7 +125,7 @@
"settings_appDebugLog": "Registro de Depuración de la App",
"settings_appDebugLogSubtitle": "Mensajes de depuración de la aplicación",
"settings_about": "Acerca de",
"settings_aboutVersion": "Offband Meshcore versión {version}",
"settings_aboutVersion": "Offband MeshCore versión {version}",
"@settings_aboutVersion": {
"placeholders": {
"version": {

@ -8,7 +8,7 @@
}
},
"@@locale": "fr",
"appTitle": "Offband Meshcore",
"appTitle": "Offband MeshCore",
"nav_contacts": "Coordonnées",
"nav_channels": "Canaux",
"nav_map": "Carte",
@ -54,7 +54,7 @@
}
}
},
"scanner_title": "Offband Meshcore",
"scanner_title": "Offband MeshCore",
"scanner_scanning": "Recherche de périphériques...",
"scanner_connecting": "Connexion en cours...",
"scanner_disconnecting": "Déconnexion...",
@ -125,7 +125,7 @@
"settings_appDebugLog": "Journal de débogage de l'application",
"settings_appDebugLogSubtitle": "Messages de débogage de l'application",
"settings_about": "À propos",
"settings_aboutVersion": "Offband Meshcore {version}",
"settings_aboutVersion": "Offband MeshCore {version}",
"@settings_aboutVersion": {
"placeholders": {
"version": {

@ -1,6 +1,6 @@
{
"@@locale": "hu",
"appTitle": "Offband Meshcore",
"appTitle": "Offband MeshCore",
"nav_contacts": "Kapcsolatok",
"nav_channels": "Csatornák",
"nav_map": "Térkép",
@ -188,7 +188,7 @@
"settings_appDebugLog": "App-debug log",
"settings_appDebugLogSubtitle": "Programozási hibajelzések",
"settings_about": "Ról",
"settings_aboutVersion": "Offband Meshcore {version} verzió",
"settings_aboutVersion": "Offband MeshCore {version} verzió",
"@settings_aboutVersion": {
"placeholders": {
"version": {

@ -8,7 +8,7 @@
}
},
"@@locale": "it",
"appTitle": "Offband Meshcore",
"appTitle": "Offband MeshCore",
"nav_contacts": "Contatti",
"nav_channels": "Canali",
"nav_map": "Mappa",
@ -54,7 +54,7 @@
}
}
},
"scanner_title": "Offband Meshcore",
"scanner_title": "Offband MeshCore",
"scanner_scanning": "Scansione in corso per i dispositivi...",
"scanner_connecting": "Connessione...",
"scanner_disconnecting": "Disconnessione...",
@ -125,7 +125,7 @@
"settings_appDebugLog": "Log di Debug dell'App",
"settings_appDebugLogSubtitle": "Messaggi di debug dell'applicazione",
"settings_about": "Informazioni",
"settings_aboutVersion": "Offband Meshcore versione {version}",
"settings_aboutVersion": "Offband MeshCore versione {version}",
"@settings_aboutVersion": {
"placeholders": {
"version": {

@ -1,6 +1,6 @@
{
"@@locale": "ja",
"appTitle": "Offband Meshcore",
"appTitle": "Offband MeshCore",
"nav_contacts": "連絡先",
"nav_channels": "チャンネル",
"nav_map": "地図",
@ -188,7 +188,7 @@
"settings_appDebugLog": "アプリケーションのデバッグログ",
"settings_appDebugLogSubtitle": "アプリケーションのデバッグメッセージ",
"settings_about": "概要",
"settings_aboutVersion": "Offband Meshcore {version}版",
"settings_aboutVersion": "Offband MeshCore {version}版",
"@settings_aboutVersion": {
"placeholders": {
"version": {

@ -1,6 +1,6 @@
{
"@@locale": "ko",
"appTitle": "Offband Meshcore",
"appTitle": "Offband MeshCore",
"nav_contacts": "연락처",
"nav_channels": "채널",
"nav_map": "지도",
@ -188,7 +188,7 @@
"settings_appDebugLog": "앱 디버깅 로그",
"settings_appDebugLogSubtitle": "애플리케이션 디버깅 메시지",
"settings_about": "소개",
"settings_aboutVersion": "Offband Meshcore {version} 버전",
"settings_aboutVersion": "Offband MeshCore {version} 버전",
"@settings_aboutVersion": {
"placeholders": {
"version": {

@ -133,7 +133,7 @@ abstract class AppLocalizations {
/// No description provided for @appTitle.
///
/// In en, this message translates to:
/// **'Offband Meshcore'**
/// **'Offband MeshCore'**
String get appTitle;
/// No description provided for @nav_contacts.
@ -349,7 +349,7 @@ abstract class AppLocalizations {
/// No description provided for @scanner_title.
///
/// In en, this message translates to:
/// **'Offband Meshcore'**
/// **'Offband MeshCore'**
String get scanner_title;
/// No description provided for @connectionChoiceUsbLabel.
@ -664,6 +664,18 @@ abstract class AppLocalizations {
/// **'Please turn on Bluetooth to scan for devices'**
String get scanner_bluetoothOffMessage;
/// Shown when a Bluetooth scan is refused because system Location is off (Android 11 and below) (#1033).
///
/// In en, this message translates to:
/// **'Location is off, so Android won\'t let the app scan for Bluetooth radios. Turn on Location in your phone\'s quick settings, then tap Scan again.'**
String get scanner_scanFailedLocationOff;
/// Shown for any other failure to start a Bluetooth scan; the raw error goes to the log only (#1033).
///
/// In en, this message translates to:
/// **'Could not start the Bluetooth scan. Tap Scan to try again. If it keeps failing, turn Bluetooth off and on.'**
String get scanner_scanFailedGeneric;
/// Human-readable text for an Android GATT 133 connect failure after the bounded retry also failed (#698).
///
/// In en, this message translates to:
@ -1228,6 +1240,18 @@ abstract class AppLocalizations {
/// **'Text size, screen off, time zone'**
String get settings_badgeScreenSubtitle;
/// No description provided for @settings_indicators.
///
/// In en, this message translates to:
/// **'Display and LED'**
String get settings_indicators;
/// No description provided for @settings_indicatorsSubtitle.
///
/// In en, this message translates to:
/// **'Display mode, rotation, status LED'**
String get settings_indicatorsSubtitle;
/// No description provided for @settings_badgeBattery.
///
/// In en, this message translates to:
@ -1462,6 +1486,114 @@ abstract class AppLocalizations {
/// **'Are you sure you want to reboot the device? You will be disconnected.'**
String get settings_rebootDeviceConfirm;
/// No description provided for @settings_factoryReset.
///
/// In en, this message translates to:
/// **'Factory Reset'**
String get settings_factoryReset;
/// No description provided for @settings_factoryResetSubtitle.
///
/// In en, this message translates to:
/// **'Erase the radio and give it a new identity'**
String get settings_factoryResetSubtitle;
/// No description provided for @settings_factoryResetConfirm.
///
/// In en, this message translates to:
/// **'This erases everything on the radio: its identity, contacts, channels and settings. It can\'t be undone.'**
String get settings_factoryResetConfirm;
/// No description provided for @settings_factoryResetDeleteAppData.
///
/// In en, this message translates to:
/// **'Also delete this radio\'s data stored in the app'**
String get settings_factoryResetDeleteAppData;
/// No description provided for @settings_factoryResetNotConnected.
///
/// In en, this message translates to:
/// **'Factory reset not sent: no radio is connected.'**
String get settings_factoryResetNotConnected;
/// No description provided for @factoryResetSentTitle.
///
/// In en, this message translates to:
/// **'Factory reset sent'**
String get factoryResetSentTitle;
/// No description provided for @factoryResetSentBody.
///
/// In en, this message translates to:
/// **'The radio is erasing itself and will restart with a new identity.'**
String get factoryResetSentBody;
/// No description provided for @factoryResetSentBleBody.
///
/// In en, this message translates to:
/// **'The radio is erasing itself and will restart with a new identity. Its Bluetooth pairing is erased too: remove the old pairing for this radio in your phone or computer\'s Bluetooth settings, then reconnect.'**
String get factoryResetSentBleBody;
/// No description provided for @factoryResetDeleteFailedTitle.
///
/// In en, this message translates to:
/// **'Factory reset sent, app cleanup failed'**
String get factoryResetDeleteFailedTitle;
/// No description provided for @factoryResetDeleteFailedBody.
///
/// In en, this message translates to:
/// **'The reset was sent, but deleting this radio\'s data from the app failed.'**
String get factoryResetDeleteFailedBody;
/// No description provided for @settings_blePin.
///
/// In en, this message translates to:
/// **'BLE pairing PIN'**
String get settings_blePin;
/// No description provided for @settings_blePinDefault.
///
/// In en, this message translates to:
/// **'Firmware default'**
String get settings_blePinDefault;
/// No description provided for @settings_blePinHelp.
///
/// In en, this message translates to:
/// **'6 digits (100000-999999). New phones and PCs must enter it when pairing. Reset to default to return to the firmware\'s built-in behavior.'**
String get settings_blePinHelp;
/// No description provided for @settings_blePinInvalid.
///
/// In en, this message translates to:
/// **'Enter a 6-digit PIN between 100000 and 999999'**
String get settings_blePinInvalid;
/// No description provided for @settings_blePinSaved.
///
/// In en, this message translates to:
/// **'PIN set to {pin}. Already-paired devices may need to forget the radio and pair again.'**
String settings_blePinSaved(String pin);
/// No description provided for @settings_blePinResetDone.
///
/// In en, this message translates to:
/// **'PIN reset to the firmware default. Already-paired devices may need to forget the radio and pair again.'**
String get settings_blePinResetDone;
/// No description provided for @settings_blePinSendFailed.
///
/// In en, this message translates to:
/// **'The PIN was not changed: sending to the radio failed ({error})'**
String settings_blePinSendFailed(String error);
/// No description provided for @settings_blePinResetAction.
///
/// In en, this message translates to:
/// **'Reset to default'**
String get settings_blePinResetAction;
/// No description provided for @settings_debug.
///
/// In en, this message translates to:
@ -1567,7 +1699,7 @@ abstract class AppLocalizations {
/// No description provided for @settings_aboutVersion.
///
/// In en, this message translates to:
/// **'Offband Meshcore v{version}'**
/// **'Offband MeshCore v{version}'**
String settings_aboutVersion(String version);
/// No description provided for @settings_aboutLegalese.
@ -1579,7 +1711,7 @@ abstract class AppLocalizations {
/// No description provided for @settings_aboutDescription.
///
/// In en, this message translates to:
/// **'Offband Meshcore is an open-source client for MeshCore LoRa mesh networking devices.'**
/// **'Offband MeshCore is an open-source client for MeshCore LoRa mesh networking devices.'**
String get settings_aboutDescription;
/// No description provided for @settings_aboutLicenses.
@ -1606,6 +1738,42 @@ abstract class AppLocalizations {
/// **'Donate or sponsor'**
String get settings_aboutDonate;
/// No description provided for @sponsorPrompt_title.
///
/// In en, this message translates to:
/// **'Support Offband'**
String get sponsorPrompt_title;
/// No description provided for @sponsorPrompt_body.
///
/// In en, this message translates to:
/// **'Offband MeshCore is free, open source, and built by an independent developer. If it\'s useful to you, sponsoring helps keep it maintained and improving. Thank you for using it.'**
String get sponsorPrompt_body;
/// No description provided for @sponsorPrompt_sponsor.
///
/// In en, this message translates to:
/// **'Sponsor'**
String get sponsorPrompt_sponsor;
/// No description provided for @sponsorPrompt_anytime.
///
/// In en, this message translates to:
/// **'You can support Offband anytime from About in Settings, or at offband.org/donate.'**
String get sponsorPrompt_anytime;
/// No description provided for @sponsorPrompt_notNow.
///
/// In en, this message translates to:
/// **'Not now'**
String get sponsorPrompt_notNow;
/// No description provided for @sponsorPrompt_never.
///
/// In en, this message translates to:
/// **'Never Show Again'**
String get sponsorPrompt_never;
/// No description provided for @settings_aboutLinkFailed.
///
/// In en, this message translates to:
@ -2806,6 +2974,66 @@ abstract class AppLocalizations {
/// **'Search channels...'**
String get channels_searchChannels;
/// No description provided for @channels_unconfirmedSlots.
///
/// In en, this message translates to:
/// **'The radio did not confirm channel slot(s) {slots}. Those channels are unknown until a sync succeeds.'**
String channels_unconfirmedSlots(String slots);
/// No description provided for @channels_retrySync.
///
/// In en, this message translates to:
/// **'Retry sync'**
String get channels_retrySync;
/// No description provided for @channels_erasedSlots.
///
/// In en, this message translates to:
/// **'The radio has lost the channels in slot(s) {slots}; it reported them as erased. Your channel list here has been kept and was not overwritten.'**
String channels_erasedSlots(String slots);
/// No description provided for @channels_restoreToRadio.
///
/// In en, this message translates to:
/// **'Restore to radio'**
String get channels_restoreToRadio;
/// No description provided for @channels_restoreToRadioConfirm.
///
/// In en, this message translates to:
/// **'Write {count} channel(s) back to the radio: {names}. Channels the radio still has are left as they are.'**
String channels_restoreToRadioConfirm(int count, String names);
/// No description provided for @channels_restoreToRadioDone.
///
/// In en, this message translates to:
/// **'The radio confirmed all {count} channel(s).'**
String channels_restoreToRadioDone(int count);
/// Restore to radio finished with some channels refused, unanswered, or without a free slot. Persistent (#1117).
///
/// In en, this message translates to:
/// **'The radio confirmed {confirmed} of {total} channel(s). Not restored: {names}.'**
String channels_restoreToRadioPartial(int confirmed, int total, String names);
/// No description provided for @channels_restoreToRadioFailed.
///
/// In en, this message translates to:
/// **'Could not restore the channel list: nothing cached to restore, or the radio is not connected.'**
String get channels_restoreToRadioFailed;
/// No description provided for @channels_slotIdentityChanged.
///
/// In en, this message translates to:
/// **'Channel layout changed on the radio: {changes}'**
String channels_slotIdentityChanged(String changes);
/// No description provided for @common_dismiss.
///
/// In en, this message translates to:
/// **'Dismiss'**
String get common_dismiss;
/// No description provided for @channels_noChannelsFound.
///
/// In en, this message translates to:
@ -2857,7 +3085,7 @@ abstract class AppLocalizations {
/// No description provided for @channels_notifications.
///
/// In en, this message translates to:
/// **'Notifications'**
/// **'Phone notifications'**
String get channels_notifications;
/// No description provided for @channels_notifyAll.
@ -2878,6 +3106,36 @@ abstract class AppLocalizations {
/// **'Off'**
String get channels_notifyOff;
/// No description provided for @channels_radioSound.
///
/// In en, this message translates to:
/// **'Radio sound'**
String get channels_radioSound;
/// No description provided for @channels_radioSoundUnset.
///
/// In en, this message translates to:
/// **'Not set'**
String get channels_radioSoundUnset;
/// No description provided for @channels_radioSoundFollows.
///
/// In en, this message translates to:
/// **'Follows the radio: {scope}'**
String channels_radioSoundFollows(String scope);
/// No description provided for @channels_radioSoundMutedByRadio.
///
/// In en, this message translates to:
/// **'Silent: the radio is set to None'**
String get channels_radioSoundMutedByRadio;
/// No description provided for @channels_radioSoundReading.
///
/// In en, this message translates to:
/// **'Reading from the radio'**
String get channels_radioSoundReading;
/// No description provided for @channels_deleteChannel.
///
/// In en, this message translates to:
@ -3076,6 +3334,84 @@ abstract class AppLocalizations {
/// **'Public channel added'**
String get channels_publicChannelAdded;
/// No description provided for @channels_slotBusySyncing.
///
/// In en, this message translates to:
/// **'The radio is still updating its channel list. Try again in a moment.'**
String get channels_slotBusySyncing;
/// No description provided for @channels_slotsAllFull.
///
/// In en, this message translates to:
/// **'All {count} channel slots are in use. Delete a channel first.'**
String channels_slotsAllFull(int count);
/// No description provided for @channels_publicAlreadyAdded.
///
/// In en, this message translates to:
/// **'The Public channel is already on this radio.'**
String get channels_publicAlreadyAdded;
/// Refusal when the user tries to delete the Public channel. Firmware #1896 keeps Public permanently in slot 0. Persistent (#1117).
///
/// In en, this message translates to:
/// **'Public can\'t be removed from the radio. It always stays in slot 0.'**
String get channels_publicCannotBeRemoved;
/// The radio refused a channel write because a different channel holds the slot (ERR_CODE_BAD_STATE, firmware #1864). Persistent (#1026).
///
/// In en, this message translates to:
/// **'The radio refused: another channel already uses that slot, and it was left unchanged. Try again now that the list has been re-read.'**
String get channels_writeOccupied;
/// The radio answered a channel write with an error other than slot-occupied. Persistent (#1026).
///
/// In en, this message translates to:
/// **'The radio refused the change, so the channel is as it was.'**
String get channels_writeRejected;
/// A channel write got no reply before the timeout, or the radio is not connected; it may or may not have landed. Persistent (#1026).
///
/// In en, this message translates to:
/// **'The radio did not confirm the change. Check the channel list before trying again.'**
String get channels_writeNoReply;
/// A channel key change is clear-then-add; the clear landed and the add did not, so the edited channel is gone from the radio. Persistent (#1026).
///
/// In en, this message translates to:
/// **'The old channel was removed, but the radio did not confirm the new one, so that slot is now empty. Add the channel again.'**
String get channels_writeClearedNotAdded;
/// The user chose to keep message history across a key change; the radio took the new key but the app could not move the stored messages. Persistent (#1128).
///
/// In en, this message translates to:
/// **'The channel was changed, but its message history could not be moved to it. The app log has the details.'**
String get channels_writeHistoryNotMoved;
/// Title of the question asked before a channel's key changes (a hashtag rename, or a new private key) (#1128).
///
/// In en, this message translates to:
/// **'Keep message history?'**
String get channels_keyChangeHistoryTitle;
/// Body of the keep-or-delete question for a channel key change (#1128).
///
/// In en, this message translates to:
/// **'This gives \"{name}\" a new key. Keep its message history under the new key, or delete it? Deleted history cannot be recovered.'**
String channels_keyChangeHistoryBody(String name);
/// Moves the channel's stored messages to its new key (#1128).
///
/// In en, this message translates to:
/// **'Keep message history'**
String get channels_keyChangeKeepHistory;
/// Deletes the channel's stored messages, as Delete Channel does (#1128).
///
/// In en, this message translates to:
/// **'Delete message history'**
String get channels_keyChangeDeleteHistory;
/// No description provided for @channels_sortBy.
///
/// In en, this message translates to:
@ -3439,7 +3775,7 @@ abstract class AppLocalizations {
/// No description provided for @debugLog_shareSubject.
///
/// In en, this message translates to:
/// **'Offband Meshcore logs'**
/// **'Offband MeshCore logs'**
String get debugLog_shareSubject;
/// No description provided for @debugLog_logSaved.
@ -3454,6 +3790,12 @@ abstract class AppLocalizations {
/// **'No log available to export'**
String get debugLog_logUnavailable;
/// No description provided for @debugLog_exportFailed.
///
/// In en, this message translates to:
/// **'Couldn\'t share the log. Please try again.'**
String get debugLog_exportFailed;
/// No description provided for @debugLog_copied.
///
/// In en, this message translates to:
@ -3688,6 +4030,48 @@ abstract class AppLocalizations {
/// **'Not connected to a radio, so this sender can\'t be added yet'**
String get channel_senderAddNotConnected;
/// No description provided for @channels_pskDerivedFromName.
///
/// In en, this message translates to:
/// **'Key derives from the channel name'**
String get channels_pskDerivedFromName;
/// No description provided for @channels_communityDerived.
///
/// In en, this message translates to:
/// **'Derived from the community secret; manage it through the community'**
String get channels_communityDerived;
/// No description provided for @channels_copyPsk.
///
/// In en, this message translates to:
/// **'Copy key'**
String get channels_copyPsk;
/// No description provided for @channels_pskCopied.
///
/// In en, this message translates to:
/// **'Key copied'**
String get channels_pskCopied;
/// No description provided for @channels_hashtagMustKeepHash.
///
/// In en, this message translates to:
/// **'A hashtag channel\'s name must start with a single #. Add a new channel to use a different type.'**
String get channels_hashtagMustKeepHash;
/// No description provided for @channels_channelChangedReopen.
///
/// In en, this message translates to:
/// **'This channel changed on the radio while the dialog was open. Nothing was saved; reopen it and try again.'**
String get channels_channelChangedReopen;
/// No description provided for @channel_slotUnconfirmedSendBlocked.
///
/// In en, this message translates to:
/// **'Message not sent: the radio hasn\'t confirmed this channel slot yet. Retry the channel sync, then send again.'**
String get channel_slotUnconfirmedSendBlocked;
/// No description provided for @channel_senderAddFailed.
///
/// In en, this message translates to:
@ -7292,12 +7676,6 @@ abstract class AppLocalizations {
/// **'Invalid QR code'**
String get channels_invalidQr;
/// No description provided for @channels_noFreeSlot.
///
/// In en, this message translates to:
/// **'No free channel slot'**
String get channels_noFreeSlot;
/// No description provided for @channels_qrAddConfirm.
///
/// In en, this message translates to:
@ -9292,6 +9670,12 @@ abstract class AppLocalizations {
/// **'no free channel slot on the device'**
String get importConfig_reasonNoFreeSlot;
/// Skip reason: the channel list was mid-sync, so no slot could be trusted to be free (#1021).
///
/// In en, this message translates to:
/// **'channels were still being read from the radio; import again in a moment'**
String get importConfig_reasonChannelsSyncing;
/// Failure reason: the file did not contain the section (#576).
///
/// In en, this message translates to:

@ -9,7 +9,7 @@ class AppLocalizationsBg extends AppLocalizations {
AppLocalizationsBg([String locale = 'bg']) : super(locale);
@override
String get appTitle => 'Offband Meshcore';
String get appTitle => 'Offband MeshCore';
@override
String get nav_contacts => 'Контакти';
@ -307,6 +307,14 @@ class AppLocalizationsBg extends AppLocalizations {
String get scanner_bluetoothOffMessage =>
'Моля, активирайте Bluetooth, за да сканирате за устройства.';
@override
String get scanner_scanFailedLocationOff =>
'Location is off, so Android won\'t let the app scan for Bluetooth radios. Turn on Location in your phone\'s quick settings, then tap Scan again.';
@override
String get scanner_scanFailedGeneric =>
'Could not start the Bluetooth scan. Tap Scan to try again. If it keeps failing, turn Bluetooth off and on.';
@override
String get scanner_connectFailedTransient =>
'The radio refused the connection (a common transient Bluetooth error). It was already retried; wait a few seconds and try again.';
@ -660,6 +668,13 @@ class AppLocalizationsBg extends AppLocalizations {
@override
String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone';
@override
String get settings_indicators => 'Display and LED';
@override
String get settings_indicatorsSubtitle =>
'Display mode, rotation, status LED';
@override
String get settings_badgeBattery => 'Battery';
@ -794,6 +809,75 @@ class AppLocalizationsBg extends AppLocalizations {
String get settings_rebootDeviceConfirm =>
'Сигурни ли сте, че искате да рестартирате устройството? Ще бъдете откъснати.';
@override
String get settings_factoryReset => 'Factory Reset';
@override
String get settings_factoryResetSubtitle =>
'Erase the radio and give it a new identity';
@override
String get settings_factoryResetConfirm =>
'This erases everything on the radio: its identity, contacts, channels and settings. It can\'t be undone.';
@override
String get settings_factoryResetDeleteAppData =>
'Also delete this radio\'s data stored in the app';
@override
String get settings_factoryResetNotConnected =>
'Factory reset not sent: no radio is connected.';
@override
String get factoryResetSentTitle => 'Factory reset sent';
@override
String get factoryResetSentBody =>
'The radio is erasing itself and will restart with a new identity.';
@override
String get factoryResetSentBleBody =>
'The radio is erasing itself and will restart with a new identity. Its Bluetooth pairing is erased too: remove the old pairing for this radio in your phone or computer\'s Bluetooth settings, then reconnect.';
@override
String get factoryResetDeleteFailedTitle =>
'Factory reset sent, app cleanup failed';
@override
String get factoryResetDeleteFailedBody =>
'The reset was sent, but deleting this radio\'s data from the app failed.';
@override
String get settings_blePin => 'BLE pairing PIN';
@override
String get settings_blePinDefault => 'Firmware default';
@override
String get settings_blePinHelp =>
'6 digits (100000-999999). New phones and PCs must enter it when pairing. Reset to default to return to the firmware\'s built-in behavior.';
@override
String get settings_blePinInvalid =>
'Enter a 6-digit PIN between 100000 and 999999';
@override
String settings_blePinSaved(String pin) {
return 'PIN set to $pin. Already-paired devices may need to forget the radio and pair again.';
}
@override
String get settings_blePinResetDone =>
'PIN reset to the firmware default. Already-paired devices may need to forget the radio and pair again.';
@override
String settings_blePinSendFailed(String error) {
return 'The PIN was not changed: sending to the radio failed ($error)';
}
@override
String get settings_blePinResetAction => 'Reset to default';
@override
String get settings_debug => 'Отстрани';
@ -859,7 +943,7 @@ class AppLocalizationsBg extends AppLocalizations {
@override
String settings_aboutVersion(String version) {
return 'Offband Meshcore, версия $version';
return 'Offband MeshCore, версия $version';
}
@override
@ -881,6 +965,26 @@ class AppLocalizationsBg extends AppLocalizations {
@override
String get settings_aboutDonate => 'Donate or sponsor';
@override
String get sponsorPrompt_title => 'Support Offband';
@override
String get sponsorPrompt_body =>
'Offband MeshCore is free, open source, and built by an independent developer. If it\'s useful to you, sponsoring helps keep it maintained and improving. Thank you for using it.';
@override
String get sponsorPrompt_sponsor => 'Sponsor';
@override
String get sponsorPrompt_anytime =>
'You can support Offband anytime from About in Settings, or at offband.org/donate.';
@override
String get sponsorPrompt_notNow => 'Not now';
@override
String get sponsorPrompt_never => 'Never Show Again';
@override
String get settings_aboutLinkFailed => 'Could not open the link';
@ -1571,6 +1675,53 @@ class AppLocalizationsBg extends AppLocalizations {
@override
String get channels_searchChannels => 'Търсене на канали...';
@override
String channels_unconfirmedSlots(String slots) {
return 'The radio did not confirm channel slot(s) $slots. Those channels are unknown until a sync succeeds.';
}
@override
String get channels_retrySync => 'Retry sync';
@override
String channels_erasedSlots(String slots) {
return 'The radio has lost the channels in slot(s) $slots; it reported them as erased. Your channel list here has been kept and was not overwritten.';
}
@override
String get channels_restoreToRadio => 'Restore to radio';
@override
String channels_restoreToRadioConfirm(int count, String names) {
return 'Write $count channel(s) back to the radio: $names. Channels the radio still has are left as they are.';
}
@override
String channels_restoreToRadioDone(int count) {
return 'The radio confirmed all $count channel(s).';
}
@override
String channels_restoreToRadioPartial(
int confirmed,
int total,
String names,
) {
return 'The radio confirmed $confirmed of $total channel(s). Not restored: $names.';
}
@override
String get channels_restoreToRadioFailed =>
'Could not restore the channel list: nothing cached to restore, or the radio is not connected.';
@override
String channels_slotIdentityChanged(String changes) {
return 'Channel layout changed on the radio: $changes';
}
@override
String get common_dismiss => 'Dismiss';
@override
String get channels_noChannelsFound => 'Няма намерени канали';
@ -1600,7 +1751,7 @@ class AppLocalizationsBg extends AppLocalizations {
String get channels_unmuteChannel => 'Включи известията на канала';
@override
String get channels_notifications => 'Notifications';
String get channels_notifications => 'Phone notifications';
@override
String get channels_notifyAll => 'All messages';
@ -1611,6 +1762,24 @@ class AppLocalizationsBg extends AppLocalizations {
@override
String get channels_notifyOff => 'Off';
@override
String get channels_radioSound => 'Radio sound';
@override
String get channels_radioSoundUnset => 'Not set';
@override
String channels_radioSoundFollows(String scope) {
return 'Follows the radio: $scope';
}
@override
String get channels_radioSoundMutedByRadio =>
'Silent: the radio is set to None';
@override
String get channels_radioSoundReading => 'Reading from the radio';
@override
String get channels_deleteChannel => 'Изтрий канала';
@ -1731,6 +1900,57 @@ class AppLocalizationsBg extends AppLocalizations {
@override
String get channels_publicChannelAdded => 'Публичен канал добавен';
@override
String get channels_slotBusySyncing =>
'The radio is still updating its channel list. Try again in a moment.';
@override
String channels_slotsAllFull(int count) {
return 'All $count channel slots are in use. Delete a channel first.';
}
@override
String get channels_publicAlreadyAdded =>
'The Public channel is already on this radio.';
@override
String get channels_publicCannotBeRemoved =>
'Public can\'t be removed from the radio. It always stays in slot 0.';
@override
String get channels_writeOccupied =>
'The radio refused: another channel already uses that slot, and it was left unchanged. Try again now that the list has been re-read.';
@override
String get channels_writeRejected =>
'The radio refused the change, so the channel is as it was.';
@override
String get channels_writeNoReply =>
'The radio did not confirm the change. Check the channel list before trying again.';
@override
String get channels_writeClearedNotAdded =>
'The old channel was removed, but the radio did not confirm the new one, so that slot is now empty. Add the channel again.';
@override
String get channels_writeHistoryNotMoved =>
'The channel was changed, but its message history could not be moved to it. The app log has the details.';
@override
String get channels_keyChangeHistoryTitle => 'Keep message history?';
@override
String channels_keyChangeHistoryBody(String name) {
return 'This gives \"$name\" a new key. Keep its message history under the new key, or delete it? Deleted history cannot be recovered.';
}
@override
String get channels_keyChangeKeepHistory => 'Keep message history';
@override
String get channels_keyChangeDeleteHistory => 'Delete message history';
@override
String get channels_sortBy => 'Сортирай по';
@ -1931,7 +2151,7 @@ class AppLocalizationsBg extends AppLocalizations {
String get debugLog_downloadLog => 'Download logs';
@override
String get debugLog_shareSubject => 'Offband Meshcore logs';
String get debugLog_shareSubject => 'Offband MeshCore logs';
@override
String get debugLog_logSaved => 'Log saved';
@ -1939,6 +2159,10 @@ class AppLocalizationsBg extends AppLocalizations {
@override
String get debugLog_logUnavailable => 'No log available to export';
@override
String get debugLog_exportFailed =>
'Couldn\'t share the log. Please try again.';
@override
String get debugLog_copied => 'Копирано лого за отстраняване на грешки';
@ -2109,6 +2333,31 @@ class AppLocalizationsBg extends AppLocalizations {
String get channel_senderAddNotConnected =>
'Not connected to a radio, so this sender can\'t be added yet';
@override
String get channels_pskDerivedFromName => 'Key derives from the channel name';
@override
String get channels_communityDerived =>
'Derived from the community secret; manage it through the community';
@override
String get channels_copyPsk => 'Copy key';
@override
String get channels_pskCopied => 'Key copied';
@override
String get channels_hashtagMustKeepHash =>
'A hashtag channel\'s name must start with a single #. Add a new channel to use a different type.';
@override
String get channels_channelChangedReopen =>
'This channel changed on the radio while the dialog was open. Nothing was saved; reopen it and try again.';
@override
String get channel_slotUnconfirmedSendBlocked =>
'Message not sent: the radio hasn\'t confirmed this channel slot yet. Retry the channel sync, then send again.';
@override
String channel_senderAddFailed(String error) {
return 'Couldn\'t add this sender: $error';
@ -4279,9 +4528,6 @@ class AppLocalizationsBg extends AppLocalizations {
@override
String get channels_invalidQr => 'Invalid QR code';
@override
String get channels_noFreeSlot => 'No free channel slot';
@override
String channels_qrAddConfirm(String name) {
return 'Add channel \"$name\"?';
@ -5508,6 +5754,10 @@ class AppLocalizationsBg extends AppLocalizations {
String get importConfig_reasonNoFreeSlot =>
'no free channel slot on the device';
@override
String get importConfig_reasonChannelsSyncing =>
'channels were still being read from the radio; import again in a moment';
@override
String get importConfig_reasonAbsentFromFile => 'not present in this file';

@ -9,7 +9,7 @@ class AppLocalizationsDe extends AppLocalizations {
AppLocalizationsDe([String locale = 'de']) : super(locale);
@override
String get appTitle => 'Offband Meshcore';
String get appTitle => 'Offband MeshCore';
@override
String get nav_contacts => 'Kontakte';
@ -310,6 +310,14 @@ class AppLocalizationsDe extends AppLocalizations {
String get scanner_bluetoothOffMessage =>
'Bitte aktivieren Sie Bluetooth, um nach Geräten zu suchen.';
@override
String get scanner_scanFailedLocationOff =>
'Location is off, so Android won\'t let the app scan for Bluetooth radios. Turn on Location in your phone\'s quick settings, then tap Scan again.';
@override
String get scanner_scanFailedGeneric =>
'Could not start the Bluetooth scan. Tap Scan to try again. If it keeps failing, turn Bluetooth off and on.';
@override
String get scanner_connectFailedTransient =>
'The radio refused the connection (a common transient Bluetooth error). It was already retried; wait a few seconds and try again.';
@ -662,6 +670,13 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone';
@override
String get settings_indicators => 'Display and LED';
@override
String get settings_indicatorsSubtitle =>
'Display mode, rotation, status LED';
@override
String get settings_badgeBattery => 'Battery';
@ -791,6 +806,75 @@ class AppLocalizationsDe extends AppLocalizations {
String get settings_rebootDeviceConfirm =>
'Sind Sie sicher, dass Sie das Gerät neu starten möchten? Sie werden getrennt.';
@override
String get settings_factoryReset => 'Factory Reset';
@override
String get settings_factoryResetSubtitle =>
'Erase the radio and give it a new identity';
@override
String get settings_factoryResetConfirm =>
'This erases everything on the radio: its identity, contacts, channels and settings. It can\'t be undone.';
@override
String get settings_factoryResetDeleteAppData =>
'Also delete this radio\'s data stored in the app';
@override
String get settings_factoryResetNotConnected =>
'Factory reset not sent: no radio is connected.';
@override
String get factoryResetSentTitle => 'Factory reset sent';
@override
String get factoryResetSentBody =>
'The radio is erasing itself and will restart with a new identity.';
@override
String get factoryResetSentBleBody =>
'The radio is erasing itself and will restart with a new identity. Its Bluetooth pairing is erased too: remove the old pairing for this radio in your phone or computer\'s Bluetooth settings, then reconnect.';
@override
String get factoryResetDeleteFailedTitle =>
'Factory reset sent, app cleanup failed';
@override
String get factoryResetDeleteFailedBody =>
'The reset was sent, but deleting this radio\'s data from the app failed.';
@override
String get settings_blePin => 'BLE pairing PIN';
@override
String get settings_blePinDefault => 'Firmware default';
@override
String get settings_blePinHelp =>
'6 digits (100000-999999). New phones and PCs must enter it when pairing. Reset to default to return to the firmware\'s built-in behavior.';
@override
String get settings_blePinInvalid =>
'Enter a 6-digit PIN between 100000 and 999999';
@override
String settings_blePinSaved(String pin) {
return 'PIN set to $pin. Already-paired devices may need to forget the radio and pair again.';
}
@override
String get settings_blePinResetDone =>
'PIN reset to the firmware default. Already-paired devices may need to forget the radio and pair again.';
@override
String settings_blePinSendFailed(String error) {
return 'The PIN was not changed: sending to the radio failed ($error)';
}
@override
String get settings_blePinResetAction => 'Reset to default';
@override
String get settings_debug => 'Fehlerbehebung';
@ -854,7 +938,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String settings_aboutVersion(String version) {
return 'Offband Meshcore, Version $version';
return 'Offband MeshCore, Version $version';
}
@override
@ -876,6 +960,26 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get settings_aboutDonate => 'Donate or sponsor';
@override
String get sponsorPrompt_title => 'Support Offband';
@override
String get sponsorPrompt_body =>
'Offband MeshCore is free, open source, and built by an independent developer. If it\'s useful to you, sponsoring helps keep it maintained and improving. Thank you for using it.';
@override
String get sponsorPrompt_sponsor => 'Sponsor';
@override
String get sponsorPrompt_anytime =>
'You can support Offband anytime from About in Settings, or at offband.org/donate.';
@override
String get sponsorPrompt_notNow => 'Not now';
@override
String get sponsorPrompt_never => 'Never Show Again';
@override
String get settings_aboutLinkFailed => 'Could not open the link';
@ -1565,6 +1669,53 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get channels_searchChannels => 'Suche Kanäle...';
@override
String channels_unconfirmedSlots(String slots) {
return 'The radio did not confirm channel slot(s) $slots. Those channels are unknown until a sync succeeds.';
}
@override
String get channels_retrySync => 'Retry sync';
@override
String channels_erasedSlots(String slots) {
return 'The radio has lost the channels in slot(s) $slots; it reported them as erased. Your channel list here has been kept and was not overwritten.';
}
@override
String get channels_restoreToRadio => 'Restore to radio';
@override
String channels_restoreToRadioConfirm(int count, String names) {
return 'Write $count channel(s) back to the radio: $names. Channels the radio still has are left as they are.';
}
@override
String channels_restoreToRadioDone(int count) {
return 'The radio confirmed all $count channel(s).';
}
@override
String channels_restoreToRadioPartial(
int confirmed,
int total,
String names,
) {
return 'The radio confirmed $confirmed of $total channel(s). Not restored: $names.';
}
@override
String get channels_restoreToRadioFailed =>
'Could not restore the channel list: nothing cached to restore, or the radio is not connected.';
@override
String channels_slotIdentityChanged(String changes) {
return 'Channel layout changed on the radio: $changes';
}
@override
String get common_dismiss => 'Dismiss';
@override
String get channels_noChannelsFound => 'Keine Kanäle gefunden';
@ -1594,7 +1745,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get channels_unmuteChannel => 'Kanal Stummschaltung aufheben';
@override
String get channels_notifications => 'Notifications';
String get channels_notifications => 'Phone notifications';
@override
String get channels_notifyAll => 'All messages';
@ -1605,6 +1756,24 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get channels_notifyOff => 'Off';
@override
String get channels_radioSound => 'Radio sound';
@override
String get channels_radioSoundUnset => 'Not set';
@override
String channels_radioSoundFollows(String scope) {
return 'Follows the radio: $scope';
}
@override
String get channels_radioSoundMutedByRadio =>
'Silent: the radio is set to None';
@override
String get channels_radioSoundReading => 'Reading from the radio';
@override
String get channels_deleteChannel => 'Lösche den Kanal';
@ -1726,6 +1895,57 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get channels_publicChannelAdded => 'Öffentlicher Kanal hinzugefügt';
@override
String get channels_slotBusySyncing =>
'The radio is still updating its channel list. Try again in a moment.';
@override
String channels_slotsAllFull(int count) {
return 'All $count channel slots are in use. Delete a channel first.';
}
@override
String get channels_publicAlreadyAdded =>
'The Public channel is already on this radio.';
@override
String get channels_publicCannotBeRemoved =>
'Public can\'t be removed from the radio. It always stays in slot 0.';
@override
String get channels_writeOccupied =>
'The radio refused: another channel already uses that slot, and it was left unchanged. Try again now that the list has been re-read.';
@override
String get channels_writeRejected =>
'The radio refused the change, so the channel is as it was.';
@override
String get channels_writeNoReply =>
'The radio did not confirm the change. Check the channel list before trying again.';
@override
String get channels_writeClearedNotAdded =>
'The old channel was removed, but the radio did not confirm the new one, so that slot is now empty. Add the channel again.';
@override
String get channels_writeHistoryNotMoved =>
'The channel was changed, but its message history could not be moved to it. The app log has the details.';
@override
String get channels_keyChangeHistoryTitle => 'Keep message history?';
@override
String channels_keyChangeHistoryBody(String name) {
return 'This gives \"$name\" a new key. Keep its message history under the new key, or delete it? Deleted history cannot be recovered.';
}
@override
String get channels_keyChangeKeepHistory => 'Keep message history';
@override
String get channels_keyChangeDeleteHistory => 'Delete message history';
@override
String get channels_sortBy => 'Sortiere nach';
@ -1928,7 +2148,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get debugLog_downloadLog => 'Download logs';
@override
String get debugLog_shareSubject => 'Offband Meshcore logs';
String get debugLog_shareSubject => 'Offband MeshCore logs';
@override
String get debugLog_logSaved => 'Log saved';
@ -1936,6 +2156,10 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get debugLog_logUnavailable => 'No log available to export';
@override
String get debugLog_exportFailed =>
'Couldn\'t share the log. Please try again.';
@override
String get debugLog_copied => 'Debug-Protokoll kopiert';
@ -2107,6 +2331,31 @@ class AppLocalizationsDe extends AppLocalizations {
String get channel_senderAddNotConnected =>
'Not connected to a radio, so this sender can\'t be added yet';
@override
String get channels_pskDerivedFromName => 'Key derives from the channel name';
@override
String get channels_communityDerived =>
'Derived from the community secret; manage it through the community';
@override
String get channels_copyPsk => 'Copy key';
@override
String get channels_pskCopied => 'Key copied';
@override
String get channels_hashtagMustKeepHash =>
'A hashtag channel\'s name must start with a single #. Add a new channel to use a different type.';
@override
String get channels_channelChangedReopen =>
'This channel changed on the radio while the dialog was open. Nothing was saved; reopen it and try again.';
@override
String get channel_slotUnconfirmedSendBlocked =>
'Message not sent: the radio hasn\'t confirmed this channel slot yet. Retry the channel sync, then send again.';
@override
String channel_senderAddFailed(String error) {
return 'Couldn\'t add this sender: $error';
@ -4285,9 +4534,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get channels_invalidQr => 'Invalid QR code';
@override
String get channels_noFreeSlot => 'No free channel slot';
@override
String channels_qrAddConfirm(String name) {
return 'Add channel \"$name\"?';
@ -5525,6 +5771,10 @@ class AppLocalizationsDe extends AppLocalizations {
String get importConfig_reasonNoFreeSlot =>
'no free channel slot on the device';
@override
String get importConfig_reasonChannelsSyncing =>
'channels were still being read from the radio; import again in a moment';
@override
String get importConfig_reasonAbsentFromFile => 'not present in this file';

@ -9,7 +9,7 @@ class AppLocalizationsEn extends AppLocalizations {
AppLocalizationsEn([String locale = 'en']) : super(locale);
@override
String get appTitle => 'Offband Meshcore';
String get appTitle => 'Offband MeshCore';
@override
String get nav_contacts => 'Contacts';
@ -121,7 +121,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get common_interval => 'Interval';
@override
String get scanner_title => 'Offband Meshcore';
String get scanner_title => 'Offband MeshCore';
@override
String get connectionChoiceUsbLabel => 'USB';
@ -304,6 +304,14 @@ class AppLocalizationsEn extends AppLocalizations {
String get scanner_bluetoothOffMessage =>
'Please turn on Bluetooth to scan for devices';
@override
String get scanner_scanFailedLocationOff =>
'Location is off, so Android won\'t let the app scan for Bluetooth radios. Turn on Location in your phone\'s quick settings, then tap Scan again.';
@override
String get scanner_scanFailedGeneric =>
'Could not start the Bluetooth scan. Tap Scan to try again. If it keeps failing, turn Bluetooth off and on.';
@override
String get scanner_connectFailedTransient =>
'The radio refused the connection (a common transient Bluetooth error). It was already retried; wait a few seconds and try again.';
@ -656,6 +664,13 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone';
@override
String get settings_indicators => 'Display and LED';
@override
String get settings_indicatorsSubtitle =>
'Display mode, rotation, status LED';
@override
String get settings_badgeBattery => 'Battery';
@ -782,6 +797,75 @@ class AppLocalizationsEn extends AppLocalizations {
String get settings_rebootDeviceConfirm =>
'Are you sure you want to reboot the device? You will be disconnected.';
@override
String get settings_factoryReset => 'Factory Reset';
@override
String get settings_factoryResetSubtitle =>
'Erase the radio and give it a new identity';
@override
String get settings_factoryResetConfirm =>
'This erases everything on the radio: its identity, contacts, channels and settings. It can\'t be undone.';
@override
String get settings_factoryResetDeleteAppData =>
'Also delete this radio\'s data stored in the app';
@override
String get settings_factoryResetNotConnected =>
'Factory reset not sent: no radio is connected.';
@override
String get factoryResetSentTitle => 'Factory reset sent';
@override
String get factoryResetSentBody =>
'The radio is erasing itself and will restart with a new identity.';
@override
String get factoryResetSentBleBody =>
'The radio is erasing itself and will restart with a new identity. Its Bluetooth pairing is erased too: remove the old pairing for this radio in your phone or computer\'s Bluetooth settings, then reconnect.';
@override
String get factoryResetDeleteFailedTitle =>
'Factory reset sent, app cleanup failed';
@override
String get factoryResetDeleteFailedBody =>
'The reset was sent, but deleting this radio\'s data from the app failed.';
@override
String get settings_blePin => 'BLE pairing PIN';
@override
String get settings_blePinDefault => 'Firmware default';
@override
String get settings_blePinHelp =>
'6 digits (100000-999999). New phones and PCs must enter it when pairing. Reset to default to return to the firmware\'s built-in behavior.';
@override
String get settings_blePinInvalid =>
'Enter a 6-digit PIN between 100000 and 999999';
@override
String settings_blePinSaved(String pin) {
return 'PIN set to $pin. Already-paired devices may need to forget the radio and pair again.';
}
@override
String get settings_blePinResetDone =>
'PIN reset to the firmware default. Already-paired devices may need to forget the radio and pair again.';
@override
String settings_blePinSendFailed(String error) {
return 'The PIN was not changed: sending to the radio failed ($error)';
}
@override
String get settings_blePinResetAction => 'Reset to default';
@override
String get settings_debug => 'Debug';
@ -844,7 +928,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String settings_aboutVersion(String version) {
return 'Offband Meshcore v$version';
return 'Offband MeshCore v$version';
}
@override
@ -853,7 +937,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get settings_aboutDescription =>
'Offband Meshcore is an open-source client for MeshCore LoRa mesh networking devices.';
'Offband MeshCore is an open-source client for MeshCore LoRa mesh networking devices.';
@override
String get settings_aboutLicenses => 'View licenses';
@ -867,6 +951,26 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get settings_aboutDonate => 'Donate or sponsor';
@override
String get sponsorPrompt_title => 'Support Offband';
@override
String get sponsorPrompt_body =>
'Offband MeshCore is free, open source, and built by an independent developer. If it\'s useful to you, sponsoring helps keep it maintained and improving. Thank you for using it.';
@override
String get sponsorPrompt_sponsor => 'Sponsor';
@override
String get sponsorPrompt_anytime =>
'You can support Offband anytime from About in Settings, or at offband.org/donate.';
@override
String get sponsorPrompt_notNow => 'Not now';
@override
String get sponsorPrompt_never => 'Never Show Again';
@override
String get settings_aboutLinkFailed => 'Could not open the link';
@ -1547,6 +1651,53 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get channels_searchChannels => 'Search channels...';
@override
String channels_unconfirmedSlots(String slots) {
return 'The radio did not confirm channel slot(s) $slots. Those channels are unknown until a sync succeeds.';
}
@override
String get channels_retrySync => 'Retry sync';
@override
String channels_erasedSlots(String slots) {
return 'The radio has lost the channels in slot(s) $slots; it reported them as erased. Your channel list here has been kept and was not overwritten.';
}
@override
String get channels_restoreToRadio => 'Restore to radio';
@override
String channels_restoreToRadioConfirm(int count, String names) {
return 'Write $count channel(s) back to the radio: $names. Channels the radio still has are left as they are.';
}
@override
String channels_restoreToRadioDone(int count) {
return 'The radio confirmed all $count channel(s).';
}
@override
String channels_restoreToRadioPartial(
int confirmed,
int total,
String names,
) {
return 'The radio confirmed $confirmed of $total channel(s). Not restored: $names.';
}
@override
String get channels_restoreToRadioFailed =>
'Could not restore the channel list: nothing cached to restore, or the radio is not connected.';
@override
String channels_slotIdentityChanged(String changes) {
return 'Channel layout changed on the radio: $changes';
}
@override
String get common_dismiss => 'Dismiss';
@override
String get channels_noChannelsFound => 'No channels found';
@ -1576,7 +1727,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get channels_unmuteChannel => 'Unmute channel';
@override
String get channels_notifications => 'Notifications';
String get channels_notifications => 'Phone notifications';
@override
String get channels_notifyAll => 'All messages';
@ -1587,6 +1738,24 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get channels_notifyOff => 'Off';
@override
String get channels_radioSound => 'Radio sound';
@override
String get channels_radioSoundUnset => 'Not set';
@override
String channels_radioSoundFollows(String scope) {
return 'Follows the radio: $scope';
}
@override
String get channels_radioSoundMutedByRadio =>
'Silent: the radio is set to None';
@override
String get channels_radioSoundReading => 'Reading from the radio';
@override
String get channels_deleteChannel => 'Delete channel';
@ -1704,6 +1873,57 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get channels_publicChannelAdded => 'Public channel added';
@override
String get channels_slotBusySyncing =>
'The radio is still updating its channel list. Try again in a moment.';
@override
String channels_slotsAllFull(int count) {
return 'All $count channel slots are in use. Delete a channel first.';
}
@override
String get channels_publicAlreadyAdded =>
'The Public channel is already on this radio.';
@override
String get channels_publicCannotBeRemoved =>
'Public can\'t be removed from the radio. It always stays in slot 0.';
@override
String get channels_writeOccupied =>
'The radio refused: another channel already uses that slot, and it was left unchanged. Try again now that the list has been re-read.';
@override
String get channels_writeRejected =>
'The radio refused the change, so the channel is as it was.';
@override
String get channels_writeNoReply =>
'The radio did not confirm the change. Check the channel list before trying again.';
@override
String get channels_writeClearedNotAdded =>
'The old channel was removed, but the radio did not confirm the new one, so that slot is now empty. Add the channel again.';
@override
String get channels_writeHistoryNotMoved =>
'The channel was changed, but its message history could not be moved to it. The app log has the details.';
@override
String get channels_keyChangeHistoryTitle => 'Keep message history?';
@override
String channels_keyChangeHistoryBody(String name) {
return 'This gives \"$name\" a new key. Keep its message history under the new key, or delete it? Deleted history cannot be recovered.';
}
@override
String get channels_keyChangeKeepHistory => 'Keep message history';
@override
String get channels_keyChangeDeleteHistory => 'Delete message history';
@override
String get channels_sortBy => 'Sort by';
@ -1901,7 +2121,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get debugLog_downloadLog => 'Download logs';
@override
String get debugLog_shareSubject => 'Offband Meshcore logs';
String get debugLog_shareSubject => 'Offband MeshCore logs';
@override
String get debugLog_logSaved => 'Log saved';
@ -1909,6 +2129,10 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get debugLog_logUnavailable => 'No log available to export';
@override
String get debugLog_exportFailed =>
'Couldn\'t share the log. Please try again.';
@override
String get debugLog_copied => 'Debug log copied';
@ -2078,6 +2302,31 @@ class AppLocalizationsEn extends AppLocalizations {
String get channel_senderAddNotConnected =>
'Not connected to a radio, so this sender can\'t be added yet';
@override
String get channels_pskDerivedFromName => 'Key derives from the channel name';
@override
String get channels_communityDerived =>
'Derived from the community secret; manage it through the community';
@override
String get channels_copyPsk => 'Copy key';
@override
String get channels_pskCopied => 'Key copied';
@override
String get channels_hashtagMustKeepHash =>
'A hashtag channel\'s name must start with a single #. Add a new channel to use a different type.';
@override
String get channels_channelChangedReopen =>
'This channel changed on the radio while the dialog was open. Nothing was saved; reopen it and try again.';
@override
String get channel_slotUnconfirmedSendBlocked =>
'Message not sent: the radio hasn\'t confirmed this channel slot yet. Retry the channel sync, then send again.';
@override
String channel_senderAddFailed(String error) {
return 'Couldn\'t add this sender: $error';
@ -4216,9 +4465,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get channels_invalidQr => 'Invalid QR code';
@override
String get channels_noFreeSlot => 'No free channel slot';
@override
String channels_qrAddConfirm(String name) {
return 'Add channel \"$name\"?';
@ -5431,6 +5677,10 @@ class AppLocalizationsEn extends AppLocalizations {
String get importConfig_reasonNoFreeSlot =>
'no free channel slot on the device';
@override
String get importConfig_reasonChannelsSyncing =>
'channels were still being read from the radio; import again in a moment';
@override
String get importConfig_reasonAbsentFromFile => 'not present in this file';

@ -9,7 +9,7 @@ class AppLocalizationsEs extends AppLocalizations {
AppLocalizationsEs([String locale = 'es']) : super(locale);
@override
String get appTitle => 'Offband Meshcore';
String get appTitle => 'Offband MeshCore';
@override
String get nav_contacts => 'Contactos';
@ -308,6 +308,14 @@ class AppLocalizationsEs extends AppLocalizations {
String get scanner_bluetoothOffMessage =>
'Por favor, active el Bluetooth para escanear dispositivos.';
@override
String get scanner_scanFailedLocationOff =>
'Location is off, so Android won\'t let the app scan for Bluetooth radios. Turn on Location in your phone\'s quick settings, then tap Scan again.';
@override
String get scanner_scanFailedGeneric =>
'Could not start the Bluetooth scan. Tap Scan to try again. If it keeps failing, turn Bluetooth off and on.';
@override
String get scanner_connectFailedTransient =>
'The radio refused the connection (a common transient Bluetooth error). It was already retried; wait a few seconds and try again.';
@ -660,6 +668,13 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone';
@override
String get settings_indicators => 'Display and LED';
@override
String get settings_indicatorsSubtitle =>
'Display mode, rotation, status LED';
@override
String get settings_badgeBattery => 'Battery';
@ -792,6 +807,75 @@ class AppLocalizationsEs extends AppLocalizations {
String get settings_rebootDeviceConfirm =>
'¿Está seguro de que desea reiniciar el dispositivo? Se desconectará.';
@override
String get settings_factoryReset => 'Factory Reset';
@override
String get settings_factoryResetSubtitle =>
'Erase the radio and give it a new identity';
@override
String get settings_factoryResetConfirm =>
'This erases everything on the radio: its identity, contacts, channels and settings. It can\'t be undone.';
@override
String get settings_factoryResetDeleteAppData =>
'Also delete this radio\'s data stored in the app';
@override
String get settings_factoryResetNotConnected =>
'Factory reset not sent: no radio is connected.';
@override
String get factoryResetSentTitle => 'Factory reset sent';
@override
String get factoryResetSentBody =>
'The radio is erasing itself and will restart with a new identity.';
@override
String get factoryResetSentBleBody =>
'The radio is erasing itself and will restart with a new identity. Its Bluetooth pairing is erased too: remove the old pairing for this radio in your phone or computer\'s Bluetooth settings, then reconnect.';
@override
String get factoryResetDeleteFailedTitle =>
'Factory reset sent, app cleanup failed';
@override
String get factoryResetDeleteFailedBody =>
'The reset was sent, but deleting this radio\'s data from the app failed.';
@override
String get settings_blePin => 'BLE pairing PIN';
@override
String get settings_blePinDefault => 'Firmware default';
@override
String get settings_blePinHelp =>
'6 digits (100000-999999). New phones and PCs must enter it when pairing. Reset to default to return to the firmware\'s built-in behavior.';
@override
String get settings_blePinInvalid =>
'Enter a 6-digit PIN between 100000 and 999999';
@override
String settings_blePinSaved(String pin) {
return 'PIN set to $pin. Already-paired devices may need to forget the radio and pair again.';
}
@override
String get settings_blePinResetDone =>
'PIN reset to the firmware default. Already-paired devices may need to forget the radio and pair again.';
@override
String settings_blePinSendFailed(String error) {
return 'The PIN was not changed: sending to the radio failed ($error)';
}
@override
String get settings_blePinResetAction => 'Reset to default';
@override
String get settings_debug => 'Depurar';
@ -855,7 +939,7 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String settings_aboutVersion(String version) {
return 'Offband Meshcore versión $version';
return 'Offband MeshCore versión $version';
}
@override
@ -877,6 +961,26 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get settings_aboutDonate => 'Donate or sponsor';
@override
String get sponsorPrompt_title => 'Support Offband';
@override
String get sponsorPrompt_body =>
'Offband MeshCore is free, open source, and built by an independent developer. If it\'s useful to you, sponsoring helps keep it maintained and improving. Thank you for using it.';
@override
String get sponsorPrompt_sponsor => 'Sponsor';
@override
String get sponsorPrompt_anytime =>
'You can support Offband anytime from About in Settings, or at offband.org/donate.';
@override
String get sponsorPrompt_notNow => 'Not now';
@override
String get sponsorPrompt_never => 'Never Show Again';
@override
String get settings_aboutLinkFailed => 'Could not open the link';
@ -1566,6 +1670,53 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get channels_searchChannels => 'Buscar canales...';
@override
String channels_unconfirmedSlots(String slots) {
return 'The radio did not confirm channel slot(s) $slots. Those channels are unknown until a sync succeeds.';
}
@override
String get channels_retrySync => 'Retry sync';
@override
String channels_erasedSlots(String slots) {
return 'The radio has lost the channels in slot(s) $slots; it reported them as erased. Your channel list here has been kept and was not overwritten.';
}
@override
String get channels_restoreToRadio => 'Restore to radio';
@override
String channels_restoreToRadioConfirm(int count, String names) {
return 'Write $count channel(s) back to the radio: $names. Channels the radio still has are left as they are.';
}
@override
String channels_restoreToRadioDone(int count) {
return 'The radio confirmed all $count channel(s).';
}
@override
String channels_restoreToRadioPartial(
int confirmed,
int total,
String names,
) {
return 'The radio confirmed $confirmed of $total channel(s). Not restored: $names.';
}
@override
String get channels_restoreToRadioFailed =>
'Could not restore the channel list: nothing cached to restore, or the radio is not connected.';
@override
String channels_slotIdentityChanged(String changes) {
return 'Channel layout changed on the radio: $changes';
}
@override
String get common_dismiss => 'Dismiss';
@override
String get channels_noChannelsFound => 'No se encontraron canales';
@ -1595,7 +1746,7 @@ class AppLocalizationsEs extends AppLocalizations {
String get channels_unmuteChannel => 'Activar canal';
@override
String get channels_notifications => 'Notifications';
String get channels_notifications => 'Phone notifications';
@override
String get channels_notifyAll => 'All messages';
@ -1606,6 +1757,24 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get channels_notifyOff => 'Off';
@override
String get channels_radioSound => 'Radio sound';
@override
String get channels_radioSoundUnset => 'Not set';
@override
String channels_radioSoundFollows(String scope) {
return 'Follows the radio: $scope';
}
@override
String get channels_radioSoundMutedByRadio =>
'Silent: the radio is set to None';
@override
String get channels_radioSoundReading => 'Reading from the radio';
@override
String get channels_deleteChannel => 'Eliminar canal';
@ -1727,6 +1896,57 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get channels_publicChannelAdded => 'Canal público añadido';
@override
String get channels_slotBusySyncing =>
'The radio is still updating its channel list. Try again in a moment.';
@override
String channels_slotsAllFull(int count) {
return 'All $count channel slots are in use. Delete a channel first.';
}
@override
String get channels_publicAlreadyAdded =>
'The Public channel is already on this radio.';
@override
String get channels_publicCannotBeRemoved =>
'Public can\'t be removed from the radio. It always stays in slot 0.';
@override
String get channels_writeOccupied =>
'The radio refused: another channel already uses that slot, and it was left unchanged. Try again now that the list has been re-read.';
@override
String get channels_writeRejected =>
'The radio refused the change, so the channel is as it was.';
@override
String get channels_writeNoReply =>
'The radio did not confirm the change. Check the channel list before trying again.';
@override
String get channels_writeClearedNotAdded =>
'The old channel was removed, but the radio did not confirm the new one, so that slot is now empty. Add the channel again.';
@override
String get channels_writeHistoryNotMoved =>
'The channel was changed, but its message history could not be moved to it. The app log has the details.';
@override
String get channels_keyChangeHistoryTitle => 'Keep message history?';
@override
String channels_keyChangeHistoryBody(String name) {
return 'This gives \"$name\" a new key. Keep its message history under the new key, or delete it? Deleted history cannot be recovered.';
}
@override
String get channels_keyChangeKeepHistory => 'Keep message history';
@override
String get channels_keyChangeDeleteHistory => 'Delete message history';
@override
String get channels_sortBy => 'Ordenar por';
@ -1927,7 +2147,7 @@ class AppLocalizationsEs extends AppLocalizations {
String get debugLog_downloadLog => 'Download logs';
@override
String get debugLog_shareSubject => 'Offband Meshcore logs';
String get debugLog_shareSubject => 'Offband MeshCore logs';
@override
String get debugLog_logSaved => 'Log saved';
@ -1935,6 +2155,10 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get debugLog_logUnavailable => 'No log available to export';
@override
String get debugLog_exportFailed =>
'Couldn\'t share the log. Please try again.';
@override
String get debugLog_copied => 'Registro de depuración copiado';
@ -2104,6 +2328,31 @@ class AppLocalizationsEs extends AppLocalizations {
String get channel_senderAddNotConnected =>
'Not connected to a radio, so this sender can\'t be added yet';
@override
String get channels_pskDerivedFromName => 'Key derives from the channel name';
@override
String get channels_communityDerived =>
'Derived from the community secret; manage it through the community';
@override
String get channels_copyPsk => 'Copy key';
@override
String get channels_pskCopied => 'Key copied';
@override
String get channels_hashtagMustKeepHash =>
'A hashtag channel\'s name must start with a single #. Add a new channel to use a different type.';
@override
String get channels_channelChangedReopen =>
'This channel changed on the radio while the dialog was open. Nothing was saved; reopen it and try again.';
@override
String get channel_slotUnconfirmedSendBlocked =>
'Message not sent: the radio hasn\'t confirmed this channel slot yet. Retry the channel sync, then send again.';
@override
String channel_senderAddFailed(String error) {
return 'Couldn\'t add this sender: $error';
@ -4274,9 +4523,6 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get channels_invalidQr => 'Invalid QR code';
@override
String get channels_noFreeSlot => 'No free channel slot';
@override
String channels_qrAddConfirm(String name) {
return 'Add channel \"$name\"?';
@ -5513,6 +5759,10 @@ class AppLocalizationsEs extends AppLocalizations {
String get importConfig_reasonNoFreeSlot =>
'no free channel slot on the device';
@override
String get importConfig_reasonChannelsSyncing =>
'channels were still being read from the radio; import again in a moment';
@override
String get importConfig_reasonAbsentFromFile => 'not present in this file';

@ -9,7 +9,7 @@ class AppLocalizationsFr extends AppLocalizations {
AppLocalizationsFr([String locale = 'fr']) : super(locale);
@override
String get appTitle => 'Offband Meshcore';
String get appTitle => 'Offband MeshCore';
@override
String get nav_contacts => 'Coordonnées';
@ -121,7 +121,7 @@ class AppLocalizationsFr extends AppLocalizations {
String get common_interval => 'Intervalle';
@override
String get scanner_title => 'Offband Meshcore';
String get scanner_title => 'Offband MeshCore';
@override
String get connectionChoiceUsbLabel => 'USB';
@ -310,6 +310,14 @@ class AppLocalizationsFr extends AppLocalizations {
String get scanner_bluetoothOffMessage =>
'Veuillez activer le Bluetooth pour rechercher des appareils.';
@override
String get scanner_scanFailedLocationOff =>
'Location is off, so Android won\'t let the app scan for Bluetooth radios. Turn on Location in your phone\'s quick settings, then tap Scan again.';
@override
String get scanner_scanFailedGeneric =>
'Could not start the Bluetooth scan. Tap Scan to try again. If it keeps failing, turn Bluetooth off and on.';
@override
String get scanner_connectFailedTransient =>
'The radio refused the connection (a common transient Bluetooth error). It was already retried; wait a few seconds and try again.';
@ -663,6 +671,13 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone';
@override
String get settings_indicators => 'Display and LED';
@override
String get settings_indicatorsSubtitle =>
'Display mode, rotation, status LED';
@override
String get settings_badgeBattery => 'Battery';
@ -796,6 +811,75 @@ class AppLocalizationsFr extends AppLocalizations {
String get settings_rebootDeviceConfirm =>
'Êtes-vous sûr de vouloir redémarrer l\'appareil ? Vous serez déconnecté.';
@override
String get settings_factoryReset => 'Factory Reset';
@override
String get settings_factoryResetSubtitle =>
'Erase the radio and give it a new identity';
@override
String get settings_factoryResetConfirm =>
'This erases everything on the radio: its identity, contacts, channels and settings. It can\'t be undone.';
@override
String get settings_factoryResetDeleteAppData =>
'Also delete this radio\'s data stored in the app';
@override
String get settings_factoryResetNotConnected =>
'Factory reset not sent: no radio is connected.';
@override
String get factoryResetSentTitle => 'Factory reset sent';
@override
String get factoryResetSentBody =>
'The radio is erasing itself and will restart with a new identity.';
@override
String get factoryResetSentBleBody =>
'The radio is erasing itself and will restart with a new identity. Its Bluetooth pairing is erased too: remove the old pairing for this radio in your phone or computer\'s Bluetooth settings, then reconnect.';
@override
String get factoryResetDeleteFailedTitle =>
'Factory reset sent, app cleanup failed';
@override
String get factoryResetDeleteFailedBody =>
'The reset was sent, but deleting this radio\'s data from the app failed.';
@override
String get settings_blePin => 'BLE pairing PIN';
@override
String get settings_blePinDefault => 'Firmware default';
@override
String get settings_blePinHelp =>
'6 digits (100000-999999). New phones and PCs must enter it when pairing. Reset to default to return to the firmware\'s built-in behavior.';
@override
String get settings_blePinInvalid =>
'Enter a 6-digit PIN between 100000 and 999999';
@override
String settings_blePinSaved(String pin) {
return 'PIN set to $pin. Already-paired devices may need to forget the radio and pair again.';
}
@override
String get settings_blePinResetDone =>
'PIN reset to the firmware default. Already-paired devices may need to forget the radio and pair again.';
@override
String settings_blePinSendFailed(String error) {
return 'The PIN was not changed: sending to the radio failed ($error)';
}
@override
String get settings_blePinResetAction => 'Reset to default';
@override
String get settings_debug => 'Déboguer';
@ -859,7 +943,7 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String settings_aboutVersion(String version) {
return 'Offband Meshcore $version';
return 'Offband MeshCore $version';
}
@override
@ -881,6 +965,26 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get settings_aboutDonate => 'Donate or sponsor';
@override
String get sponsorPrompt_title => 'Support Offband';
@override
String get sponsorPrompt_body =>
'Offband MeshCore is free, open source, and built by an independent developer. If it\'s useful to you, sponsoring helps keep it maintained and improving. Thank you for using it.';
@override
String get sponsorPrompt_sponsor => 'Sponsor';
@override
String get sponsorPrompt_anytime =>
'You can support Offband anytime from About in Settings, or at offband.org/donate.';
@override
String get sponsorPrompt_notNow => 'Not now';
@override
String get sponsorPrompt_never => 'Never Show Again';
@override
String get settings_aboutLinkFailed => 'Could not open the link';
@ -1571,6 +1675,53 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get channels_searchChannels => 'Rechercher des canaux...';
@override
String channels_unconfirmedSlots(String slots) {
return 'The radio did not confirm channel slot(s) $slots. Those channels are unknown until a sync succeeds.';
}
@override
String get channels_retrySync => 'Retry sync';
@override
String channels_erasedSlots(String slots) {
return 'The radio has lost the channels in slot(s) $slots; it reported them as erased. Your channel list here has been kept and was not overwritten.';
}
@override
String get channels_restoreToRadio => 'Restore to radio';
@override
String channels_restoreToRadioConfirm(int count, String names) {
return 'Write $count channel(s) back to the radio: $names. Channels the radio still has are left as they are.';
}
@override
String channels_restoreToRadioDone(int count) {
return 'The radio confirmed all $count channel(s).';
}
@override
String channels_restoreToRadioPartial(
int confirmed,
int total,
String names,
) {
return 'The radio confirmed $confirmed of $total channel(s). Not restored: $names.';
}
@override
String get channels_restoreToRadioFailed =>
'Could not restore the channel list: nothing cached to restore, or the radio is not connected.';
@override
String channels_slotIdentityChanged(String changes) {
return 'Channel layout changed on the radio: $changes';
}
@override
String get common_dismiss => 'Dismiss';
@override
String get channels_noChannelsFound => 'Aucun canal trouvé';
@ -1600,7 +1751,7 @@ class AppLocalizationsFr extends AppLocalizations {
String get channels_unmuteChannel => 'Réactiver les notifications du canal';
@override
String get channels_notifications => 'Notifications';
String get channels_notifications => 'Phone notifications';
@override
String get channels_notifyAll => 'All messages';
@ -1611,6 +1762,24 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get channels_notifyOff => 'Off';
@override
String get channels_radioSound => 'Radio sound';
@override
String get channels_radioSoundUnset => 'Not set';
@override
String channels_radioSoundFollows(String scope) {
return 'Follows the radio: $scope';
}
@override
String get channels_radioSoundMutedByRadio =>
'Silent: the radio is set to None';
@override
String get channels_radioSoundReading => 'Reading from the radio';
@override
String get channels_deleteChannel => 'Supprimer le canal';
@ -1732,6 +1901,57 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get channels_publicChannelAdded => 'Le canal public a été ajouté';
@override
String get channels_slotBusySyncing =>
'The radio is still updating its channel list. Try again in a moment.';
@override
String channels_slotsAllFull(int count) {
return 'All $count channel slots are in use. Delete a channel first.';
}
@override
String get channels_publicAlreadyAdded =>
'The Public channel is already on this radio.';
@override
String get channels_publicCannotBeRemoved =>
'Public can\'t be removed from the radio. It always stays in slot 0.';
@override
String get channels_writeOccupied =>
'The radio refused: another channel already uses that slot, and it was left unchanged. Try again now that the list has been re-read.';
@override
String get channels_writeRejected =>
'The radio refused the change, so the channel is as it was.';
@override
String get channels_writeNoReply =>
'The radio did not confirm the change. Check the channel list before trying again.';
@override
String get channels_writeClearedNotAdded =>
'The old channel was removed, but the radio did not confirm the new one, so that slot is now empty. Add the channel again.';
@override
String get channels_writeHistoryNotMoved =>
'The channel was changed, but its message history could not be moved to it. The app log has the details.';
@override
String get channels_keyChangeHistoryTitle => 'Keep message history?';
@override
String channels_keyChangeHistoryBody(String name) {
return 'This gives \"$name\" a new key. Keep its message history under the new key, or delete it? Deleted history cannot be recovered.';
}
@override
String get channels_keyChangeKeepHistory => 'Keep message history';
@override
String get channels_keyChangeDeleteHistory => 'Delete message history';
@override
String get channels_sortBy => 'Trier par';
@ -1932,7 +2152,7 @@ class AppLocalizationsFr extends AppLocalizations {
String get debugLog_downloadLog => 'Download logs';
@override
String get debugLog_shareSubject => 'Offband Meshcore logs';
String get debugLog_shareSubject => 'Offband MeshCore logs';
@override
String get debugLog_logSaved => 'Log saved';
@ -1940,6 +2160,10 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get debugLog_logUnavailable => 'No log available to export';
@override
String get debugLog_exportFailed =>
'Couldn\'t share the log. Please try again.';
@override
String get debugLog_copied => 'Journal de débogage copié';
@ -2111,6 +2335,31 @@ class AppLocalizationsFr extends AppLocalizations {
String get channel_senderAddNotConnected =>
'Not connected to a radio, so this sender can\'t be added yet';
@override
String get channels_pskDerivedFromName => 'Key derives from the channel name';
@override
String get channels_communityDerived =>
'Derived from the community secret; manage it through the community';
@override
String get channels_copyPsk => 'Copy key';
@override
String get channels_pskCopied => 'Key copied';
@override
String get channels_hashtagMustKeepHash =>
'A hashtag channel\'s name must start with a single #. Add a new channel to use a different type.';
@override
String get channels_channelChangedReopen =>
'This channel changed on the radio while the dialog was open. Nothing was saved; reopen it and try again.';
@override
String get channel_slotUnconfirmedSendBlocked =>
'Message not sent: the radio hasn\'t confirmed this channel slot yet. Retry the channel sync, then send again.';
@override
String channel_senderAddFailed(String error) {
return 'Couldn\'t add this sender: $error';
@ -4296,9 +4545,6 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get channels_invalidQr => 'Invalid QR code';
@override
String get channels_noFreeSlot => 'No free channel slot';
@override
String channels_qrAddConfirm(String name) {
return 'Add channel \"$name\"?';
@ -5541,6 +5787,10 @@ class AppLocalizationsFr extends AppLocalizations {
String get importConfig_reasonNoFreeSlot =>
'no free channel slot on the device';
@override
String get importConfig_reasonChannelsSyncing =>
'channels were still being read from the radio; import again in a moment';
@override
String get importConfig_reasonAbsentFromFile => 'not present in this file';

@ -9,7 +9,7 @@ class AppLocalizationsHu extends AppLocalizations {
AppLocalizationsHu([String locale = 'hu']) : super(locale);
@override
String get appTitle => 'Offband Meshcore';
String get appTitle => 'Offband MeshCore';
@override
String get nav_contacts => 'Kapcsolatok';
@ -307,6 +307,14 @@ class AppLocalizationsHu extends AppLocalizations {
String get scanner_bluetoothOffMessage =>
'Kérjük, kapcsolja be a Bluetooth-ot, hogy eszközök keresése lehessen.';
@override
String get scanner_scanFailedLocationOff =>
'Location is off, so Android won\'t let the app scan for Bluetooth radios. Turn on Location in your phone\'s quick settings, then tap Scan again.';
@override
String get scanner_scanFailedGeneric =>
'Could not start the Bluetooth scan. Tap Scan to try again. If it keeps failing, turn Bluetooth off and on.';
@override
String get scanner_connectFailedTransient =>
'The radio refused the connection (a common transient Bluetooth error). It was already retried; wait a few seconds and try again.';
@ -662,6 +670,13 @@ class AppLocalizationsHu extends AppLocalizations {
@override
String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone';
@override
String get settings_indicators => 'Display and LED';
@override
String get settings_indicatorsSubtitle =>
'Display mode, rotation, status LED';
@override
String get settings_badgeBattery => 'Battery';
@ -794,6 +809,75 @@ class AppLocalizationsHu extends AppLocalizations {
String get settings_rebootDeviceConfirm =>
'Biztosan szeretné újraindítani a készüléket? Ebben az esetben a kapcsolat megszűnik.';
@override
String get settings_factoryReset => 'Factory Reset';
@override
String get settings_factoryResetSubtitle =>
'Erase the radio and give it a new identity';
@override
String get settings_factoryResetConfirm =>
'This erases everything on the radio: its identity, contacts, channels and settings. It can\'t be undone.';
@override
String get settings_factoryResetDeleteAppData =>
'Also delete this radio\'s data stored in the app';
@override
String get settings_factoryResetNotConnected =>
'Factory reset not sent: no radio is connected.';
@override
String get factoryResetSentTitle => 'Factory reset sent';
@override
String get factoryResetSentBody =>
'The radio is erasing itself and will restart with a new identity.';
@override
String get factoryResetSentBleBody =>
'The radio is erasing itself and will restart with a new identity. Its Bluetooth pairing is erased too: remove the old pairing for this radio in your phone or computer\'s Bluetooth settings, then reconnect.';
@override
String get factoryResetDeleteFailedTitle =>
'Factory reset sent, app cleanup failed';
@override
String get factoryResetDeleteFailedBody =>
'The reset was sent, but deleting this radio\'s data from the app failed.';
@override
String get settings_blePin => 'BLE pairing PIN';
@override
String get settings_blePinDefault => 'Firmware default';
@override
String get settings_blePinHelp =>
'6 digits (100000-999999). New phones and PCs must enter it when pairing. Reset to default to return to the firmware\'s built-in behavior.';
@override
String get settings_blePinInvalid =>
'Enter a 6-digit PIN between 100000 and 999999';
@override
String settings_blePinSaved(String pin) {
return 'PIN set to $pin. Already-paired devices may need to forget the radio and pair again.';
}
@override
String get settings_blePinResetDone =>
'PIN reset to the firmware default. Already-paired devices may need to forget the radio and pair again.';
@override
String settings_blePinSendFailed(String error) {
return 'The PIN was not changed: sending to the radio failed ($error)';
}
@override
String get settings_blePinResetAction => 'Reset to default';
@override
String get settings_debug => 'Hibakeresés';
@ -856,7 +940,7 @@ class AppLocalizationsHu extends AppLocalizations {
@override
String settings_aboutVersion(String version) {
return 'Offband Meshcore $version verzió';
return 'Offband MeshCore $version verzió';
}
@override
@ -879,6 +963,26 @@ class AppLocalizationsHu extends AppLocalizations {
@override
String get settings_aboutDonate => 'Donate or sponsor';
@override
String get sponsorPrompt_title => 'Support Offband';
@override
String get sponsorPrompt_body =>
'Offband MeshCore is free, open source, and built by an independent developer. If it\'s useful to you, sponsoring helps keep it maintained and improving. Thank you for using it.';
@override
String get sponsorPrompt_sponsor => 'Sponsor';
@override
String get sponsorPrompt_anytime =>
'You can support Offband anytime from About in Settings, or at offband.org/donate.';
@override
String get sponsorPrompt_notNow => 'Not now';
@override
String get sponsorPrompt_never => 'Never Show Again';
@override
String get settings_aboutLinkFailed => 'Could not open the link';
@ -1572,6 +1676,53 @@ class AppLocalizationsHu extends AppLocalizations {
@override
String get channels_searchChannels => 'Keresési opciók...';
@override
String channels_unconfirmedSlots(String slots) {
return 'The radio did not confirm channel slot(s) $slots. Those channels are unknown until a sync succeeds.';
}
@override
String get channels_retrySync => 'Retry sync';
@override
String channels_erasedSlots(String slots) {
return 'The radio has lost the channels in slot(s) $slots; it reported them as erased. Your channel list here has been kept and was not overwritten.';
}
@override
String get channels_restoreToRadio => 'Restore to radio';
@override
String channels_restoreToRadioConfirm(int count, String names) {
return 'Write $count channel(s) back to the radio: $names. Channels the radio still has are left as they are.';
}
@override
String channels_restoreToRadioDone(int count) {
return 'The radio confirmed all $count channel(s).';
}
@override
String channels_restoreToRadioPartial(
int confirmed,
int total,
String names,
) {
return 'The radio confirmed $confirmed of $total channel(s). Not restored: $names.';
}
@override
String get channels_restoreToRadioFailed =>
'Could not restore the channel list: nothing cached to restore, or the radio is not connected.';
@override
String channels_slotIdentityChanged(String changes) {
return 'Channel layout changed on the radio: $changes';
}
@override
String get common_dismiss => 'Dismiss';
@override
String get channels_noChannelsFound => 'Nincs megtalálható csatorna';
@ -1601,7 +1752,7 @@ class AppLocalizationsHu extends AppLocalizations {
String get channels_unmuteChannel => 'Engedje be a hangot';
@override
String get channels_notifications => 'Notifications';
String get channels_notifications => 'Phone notifications';
@override
String get channels_notifyAll => 'All messages';
@ -1612,6 +1763,24 @@ class AppLocalizationsHu extends AppLocalizations {
@override
String get channels_notifyOff => 'Off';
@override
String get channels_radioSound => 'Radio sound';
@override
String get channels_radioSoundUnset => 'Not set';
@override
String channels_radioSoundFollows(String scope) {
return 'Follows the radio: $scope';
}
@override
String get channels_radioSoundMutedByRadio =>
'Silent: the radio is set to None';
@override
String get channels_radioSoundReading => 'Reading from the radio';
@override
String get channels_deleteChannel => 'Mozdony törlése';
@ -1732,6 +1901,57 @@ class AppLocalizationsHu extends AppLocalizations {
@override
String get channels_publicChannelAdded => 'A nyilvános csatorna hozzáadva';
@override
String get channels_slotBusySyncing =>
'The radio is still updating its channel list. Try again in a moment.';
@override
String channels_slotsAllFull(int count) {
return 'All $count channel slots are in use. Delete a channel first.';
}
@override
String get channels_publicAlreadyAdded =>
'The Public channel is already on this radio.';
@override
String get channels_publicCannotBeRemoved =>
'Public can\'t be removed from the radio. It always stays in slot 0.';
@override
String get channels_writeOccupied =>
'The radio refused: another channel already uses that slot, and it was left unchanged. Try again now that the list has been re-read.';
@override
String get channels_writeRejected =>
'The radio refused the change, so the channel is as it was.';
@override
String get channels_writeNoReply =>
'The radio did not confirm the change. Check the channel list before trying again.';
@override
String get channels_writeClearedNotAdded =>
'The old channel was removed, but the radio did not confirm the new one, so that slot is now empty. Add the channel again.';
@override
String get channels_writeHistoryNotMoved =>
'The channel was changed, but its message history could not be moved to it. The app log has the details.';
@override
String get channels_keyChangeHistoryTitle => 'Keep message history?';
@override
String channels_keyChangeHistoryBody(String name) {
return 'This gives \"$name\" a new key. Keep its message history under the new key, or delete it? Deleted history cannot be recovered.';
}
@override
String get channels_keyChangeKeepHistory => 'Keep message history';
@override
String get channels_keyChangeDeleteHistory => 'Delete message history';
@override
String get channels_sortBy => 'Szűrés';
@ -1935,7 +2155,7 @@ class AppLocalizationsHu extends AppLocalizations {
String get debugLog_downloadLog => 'Download logs';
@override
String get debugLog_shareSubject => 'Offband Meshcore logs';
String get debugLog_shareSubject => 'Offband MeshCore logs';
@override
String get debugLog_logSaved => 'Log saved';
@ -1943,6 +2163,10 @@ class AppLocalizationsHu extends AppLocalizations {
@override
String get debugLog_logUnavailable => 'No log available to export';
@override
String get debugLog_exportFailed =>
'Couldn\'t share the log. Please try again.';
@override
String get debugLog_copied => 'Hibajelentő napló másolva';
@ -2116,6 +2340,31 @@ class AppLocalizationsHu extends AppLocalizations {
String get channel_senderAddNotConnected =>
'Not connected to a radio, so this sender can\'t be added yet';
@override
String get channels_pskDerivedFromName => 'Key derives from the channel name';
@override
String get channels_communityDerived =>
'Derived from the community secret; manage it through the community';
@override
String get channels_copyPsk => 'Copy key';
@override
String get channels_pskCopied => 'Key copied';
@override
String get channels_hashtagMustKeepHash =>
'A hashtag channel\'s name must start with a single #. Add a new channel to use a different type.';
@override
String get channels_channelChangedReopen =>
'This channel changed on the radio while the dialog was open. Nothing was saved; reopen it and try again.';
@override
String get channel_slotUnconfirmedSendBlocked =>
'Message not sent: the radio hasn\'t confirmed this channel slot yet. Retry the channel sync, then send again.';
@override
String channel_senderAddFailed(String error) {
return 'Couldn\'t add this sender: $error';
@ -4291,9 +4540,6 @@ class AppLocalizationsHu extends AppLocalizations {
@override
String get channels_invalidQr => 'Invalid QR code';
@override
String get channels_noFreeSlot => 'No free channel slot';
@override
String channels_qrAddConfirm(String name) {
return 'Add channel \"$name\"?';
@ -5530,6 +5776,10 @@ class AppLocalizationsHu extends AppLocalizations {
String get importConfig_reasonNoFreeSlot =>
'no free channel slot on the device';
@override
String get importConfig_reasonChannelsSyncing =>
'channels were still being read from the radio; import again in a moment';
@override
String get importConfig_reasonAbsentFromFile => 'not present in this file';

@ -9,7 +9,7 @@ class AppLocalizationsIt extends AppLocalizations {
AppLocalizationsIt([String locale = 'it']) : super(locale);
@override
String get appTitle => 'Offband Meshcore';
String get appTitle => 'Offband MeshCore';
@override
String get nav_contacts => 'Contatti';
@ -121,7 +121,7 @@ class AppLocalizationsIt extends AppLocalizations {
String get common_interval => 'Intervallo';
@override
String get scanner_title => 'Offband Meshcore';
String get scanner_title => 'Offband MeshCore';
@override
String get connectionChoiceUsbLabel => 'USB';
@ -310,6 +310,14 @@ class AppLocalizationsIt extends AppLocalizations {
String get scanner_bluetoothOffMessage =>
'Si prega di attivare il Bluetooth per effettuare la scansione dei dispositivi.';
@override
String get scanner_scanFailedLocationOff =>
'Location is off, so Android won\'t let the app scan for Bluetooth radios. Turn on Location in your phone\'s quick settings, then tap Scan again.';
@override
String get scanner_scanFailedGeneric =>
'Could not start the Bluetooth scan. Tap Scan to try again. If it keeps failing, turn Bluetooth off and on.';
@override
String get scanner_connectFailedTransient =>
'The radio refused the connection (a common transient Bluetooth error). It was already retried; wait a few seconds and try again.';
@ -662,6 +670,13 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone';
@override
String get settings_indicators => 'Display and LED';
@override
String get settings_indicatorsSubtitle =>
'Display mode, rotation, status LED';
@override
String get settings_badgeBattery => 'Battery';
@ -794,6 +809,75 @@ class AppLocalizationsIt extends AppLocalizations {
String get settings_rebootDeviceConfirm =>
'Sei sicuro di voler riavviare il dispositivo? Sarai disconnesso.';
@override
String get settings_factoryReset => 'Factory Reset';
@override
String get settings_factoryResetSubtitle =>
'Erase the radio and give it a new identity';
@override
String get settings_factoryResetConfirm =>
'This erases everything on the radio: its identity, contacts, channels and settings. It can\'t be undone.';
@override
String get settings_factoryResetDeleteAppData =>
'Also delete this radio\'s data stored in the app';
@override
String get settings_factoryResetNotConnected =>
'Factory reset not sent: no radio is connected.';
@override
String get factoryResetSentTitle => 'Factory reset sent';
@override
String get factoryResetSentBody =>
'The radio is erasing itself and will restart with a new identity.';
@override
String get factoryResetSentBleBody =>
'The radio is erasing itself and will restart with a new identity. Its Bluetooth pairing is erased too: remove the old pairing for this radio in your phone or computer\'s Bluetooth settings, then reconnect.';
@override
String get factoryResetDeleteFailedTitle =>
'Factory reset sent, app cleanup failed';
@override
String get factoryResetDeleteFailedBody =>
'The reset was sent, but deleting this radio\'s data from the app failed.';
@override
String get settings_blePin => 'BLE pairing PIN';
@override
String get settings_blePinDefault => 'Firmware default';
@override
String get settings_blePinHelp =>
'6 digits (100000-999999). New phones and PCs must enter it when pairing. Reset to default to return to the firmware\'s built-in behavior.';
@override
String get settings_blePinInvalid =>
'Enter a 6-digit PIN between 100000 and 999999';
@override
String settings_blePinSaved(String pin) {
return 'PIN set to $pin. Already-paired devices may need to forget the radio and pair again.';
}
@override
String get settings_blePinResetDone =>
'PIN reset to the firmware default. Already-paired devices may need to forget the radio and pair again.';
@override
String settings_blePinSendFailed(String error) {
return 'The PIN was not changed: sending to the radio failed ($error)';
}
@override
String get settings_blePinResetAction => 'Reset to default';
@override
String get settings_debug => 'Risoluzione dei problemi';
@ -857,7 +941,7 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String settings_aboutVersion(String version) {
return 'Offband Meshcore versione $version';
return 'Offband MeshCore versione $version';
}
@override
@ -879,6 +963,26 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get settings_aboutDonate => 'Donate or sponsor';
@override
String get sponsorPrompt_title => 'Support Offband';
@override
String get sponsorPrompt_body =>
'Offband MeshCore is free, open source, and built by an independent developer. If it\'s useful to you, sponsoring helps keep it maintained and improving. Thank you for using it.';
@override
String get sponsorPrompt_sponsor => 'Sponsor';
@override
String get sponsorPrompt_anytime =>
'You can support Offband anytime from About in Settings, or at offband.org/donate.';
@override
String get sponsorPrompt_notNow => 'Not now';
@override
String get sponsorPrompt_never => 'Never Show Again';
@override
String get settings_aboutLinkFailed => 'Could not open the link';
@ -1567,6 +1671,53 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get channels_searchChannels => 'Cerca canali...';
@override
String channels_unconfirmedSlots(String slots) {
return 'The radio did not confirm channel slot(s) $slots. Those channels are unknown until a sync succeeds.';
}
@override
String get channels_retrySync => 'Retry sync';
@override
String channels_erasedSlots(String slots) {
return 'The radio has lost the channels in slot(s) $slots; it reported them as erased. Your channel list here has been kept and was not overwritten.';
}
@override
String get channels_restoreToRadio => 'Restore to radio';
@override
String channels_restoreToRadioConfirm(int count, String names) {
return 'Write $count channel(s) back to the radio: $names. Channels the radio still has are left as they are.';
}
@override
String channels_restoreToRadioDone(int count) {
return 'The radio confirmed all $count channel(s).';
}
@override
String channels_restoreToRadioPartial(
int confirmed,
int total,
String names,
) {
return 'The radio confirmed $confirmed of $total channel(s). Not restored: $names.';
}
@override
String get channels_restoreToRadioFailed =>
'Could not restore the channel list: nothing cached to restore, or the radio is not connected.';
@override
String channels_slotIdentityChanged(String changes) {
return 'Channel layout changed on the radio: $changes';
}
@override
String get common_dismiss => 'Dismiss';
@override
String get channels_noChannelsFound => 'Nessun canale trovato';
@ -1596,7 +1747,7 @@ class AppLocalizationsIt extends AppLocalizations {
String get channels_unmuteChannel => 'Attiva notifiche canale';
@override
String get channels_notifications => 'Notifications';
String get channels_notifications => 'Phone notifications';
@override
String get channels_notifyAll => 'All messages';
@ -1607,6 +1758,24 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get channels_notifyOff => 'Off';
@override
String get channels_radioSound => 'Radio sound';
@override
String get channels_radioSoundUnset => 'Not set';
@override
String channels_radioSoundFollows(String scope) {
return 'Follows the radio: $scope';
}
@override
String get channels_radioSoundMutedByRadio =>
'Silent: the radio is set to None';
@override
String get channels_radioSoundReading => 'Reading from the radio';
@override
String get channels_deleteChannel => 'Elimina canale';
@ -1729,6 +1898,57 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get channels_publicChannelAdded => 'Canale pubblico aggiunto';
@override
String get channels_slotBusySyncing =>
'The radio is still updating its channel list. Try again in a moment.';
@override
String channels_slotsAllFull(int count) {
return 'All $count channel slots are in use. Delete a channel first.';
}
@override
String get channels_publicAlreadyAdded =>
'The Public channel is already on this radio.';
@override
String get channels_publicCannotBeRemoved =>
'Public can\'t be removed from the radio. It always stays in slot 0.';
@override
String get channels_writeOccupied =>
'The radio refused: another channel already uses that slot, and it was left unchanged. Try again now that the list has been re-read.';
@override
String get channels_writeRejected =>
'The radio refused the change, so the channel is as it was.';
@override
String get channels_writeNoReply =>
'The radio did not confirm the change. Check the channel list before trying again.';
@override
String get channels_writeClearedNotAdded =>
'The old channel was removed, but the radio did not confirm the new one, so that slot is now empty. Add the channel again.';
@override
String get channels_writeHistoryNotMoved =>
'The channel was changed, but its message history could not be moved to it. The app log has the details.';
@override
String get channels_keyChangeHistoryTitle => 'Keep message history?';
@override
String channels_keyChangeHistoryBody(String name) {
return 'This gives \"$name\" a new key. Keep its message history under the new key, or delete it? Deleted history cannot be recovered.';
}
@override
String get channels_keyChangeKeepHistory => 'Keep message history';
@override
String get channels_keyChangeDeleteHistory => 'Delete message history';
@override
String get channels_sortBy => 'Ordina per';
@ -1929,7 +2149,7 @@ class AppLocalizationsIt extends AppLocalizations {
String get debugLog_downloadLog => 'Download logs';
@override
String get debugLog_shareSubject => 'Offband Meshcore logs';
String get debugLog_shareSubject => 'Offband MeshCore logs';
@override
String get debugLog_logSaved => 'Log saved';
@ -1937,6 +2157,10 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get debugLog_logUnavailable => 'No log available to export';
@override
String get debugLog_exportFailed =>
'Couldn\'t share the log. Please try again.';
@override
String get debugLog_copied => 'Log di debug copiato';
@ -2106,6 +2330,31 @@ class AppLocalizationsIt extends AppLocalizations {
String get channel_senderAddNotConnected =>
'Not connected to a radio, so this sender can\'t be added yet';
@override
String get channels_pskDerivedFromName => 'Key derives from the channel name';
@override
String get channels_communityDerived =>
'Derived from the community secret; manage it through the community';
@override
String get channels_copyPsk => 'Copy key';
@override
String get channels_pskCopied => 'Key copied';
@override
String get channels_hashtagMustKeepHash =>
'A hashtag channel\'s name must start with a single #. Add a new channel to use a different type.';
@override
String get channels_channelChangedReopen =>
'This channel changed on the radio while the dialog was open. Nothing was saved; reopen it and try again.';
@override
String get channel_slotUnconfirmedSendBlocked =>
'Message not sent: the radio hasn\'t confirmed this channel slot yet. Retry the channel sync, then send again.';
@override
String channel_senderAddFailed(String error) {
return 'Couldn\'t add this sender: $error';
@ -4279,9 +4528,6 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get channels_invalidQr => 'Invalid QR code';
@override
String get channels_noFreeSlot => 'No free channel slot';
@override
String channels_qrAddConfirm(String name) {
return 'Add channel \"$name\"?';
@ -5518,6 +5764,10 @@ class AppLocalizationsIt extends AppLocalizations {
String get importConfig_reasonNoFreeSlot =>
'no free channel slot on the device';
@override
String get importConfig_reasonChannelsSyncing =>
'channels were still being read from the radio; import again in a moment';
@override
String get importConfig_reasonAbsentFromFile => 'not present in this file';

@ -9,7 +9,7 @@ class AppLocalizationsJa extends AppLocalizations {
AppLocalizationsJa([String locale = 'ja']) : super(locale);
@override
String get appTitle => 'Offband Meshcore';
String get appTitle => 'Offband MeshCore';
@override
String get nav_contacts => '連絡先';
@ -297,6 +297,14 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get scanner_bluetoothOffMessage => 'Bluetoothを有効にして、デバイスを検索してください。';
@override
String get scanner_scanFailedLocationOff =>
'Location is off, so Android won\'t let the app scan for Bluetooth radios. Turn on Location in your phone\'s quick settings, then tap Scan again.';
@override
String get scanner_scanFailedGeneric =>
'Could not start the Bluetooth scan. Tap Scan to try again. If it keeps failing, turn Bluetooth off and on.';
@override
String get scanner_connectFailedTransient =>
'The radio refused the connection (a common transient Bluetooth error). It was already retried; wait a few seconds and try again.';
@ -645,6 +653,13 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone';
@override
String get settings_indicators => 'Display and LED';
@override
String get settings_indicatorsSubtitle =>
'Display mode, rotation, status LED';
@override
String get settings_badgeBattery => 'Battery';
@ -768,6 +783,75 @@ class AppLocalizationsJa extends AppLocalizations {
String get settings_rebootDeviceConfirm =>
'本当にデバイスを再起動したいですか? その場合、接続が切断されます。';
@override
String get settings_factoryReset => 'Factory Reset';
@override
String get settings_factoryResetSubtitle =>
'Erase the radio and give it a new identity';
@override
String get settings_factoryResetConfirm =>
'This erases everything on the radio: its identity, contacts, channels and settings. It can\'t be undone.';
@override
String get settings_factoryResetDeleteAppData =>
'Also delete this radio\'s data stored in the app';
@override
String get settings_factoryResetNotConnected =>
'Factory reset not sent: no radio is connected.';
@override
String get factoryResetSentTitle => 'Factory reset sent';
@override
String get factoryResetSentBody =>
'The radio is erasing itself and will restart with a new identity.';
@override
String get factoryResetSentBleBody =>
'The radio is erasing itself and will restart with a new identity. Its Bluetooth pairing is erased too: remove the old pairing for this radio in your phone or computer\'s Bluetooth settings, then reconnect.';
@override
String get factoryResetDeleteFailedTitle =>
'Factory reset sent, app cleanup failed';
@override
String get factoryResetDeleteFailedBody =>
'The reset was sent, but deleting this radio\'s data from the app failed.';
@override
String get settings_blePin => 'BLE pairing PIN';
@override
String get settings_blePinDefault => 'Firmware default';
@override
String get settings_blePinHelp =>
'6 digits (100000-999999). New phones and PCs must enter it when pairing. Reset to default to return to the firmware\'s built-in behavior.';
@override
String get settings_blePinInvalid =>
'Enter a 6-digit PIN between 100000 and 999999';
@override
String settings_blePinSaved(String pin) {
return 'PIN set to $pin. Already-paired devices may need to forget the radio and pair again.';
}
@override
String get settings_blePinResetDone =>
'PIN reset to the firmware default. Already-paired devices may need to forget the radio and pair again.';
@override
String settings_blePinSendFailed(String error) {
return 'The PIN was not changed: sending to the radio failed ($error)';
}
@override
String get settings_blePinResetAction => 'Reset to default';
@override
String get settings_debug => 'デバッグ';
@ -830,7 +914,7 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String settings_aboutVersion(String version) {
return 'Offband Meshcore $version版';
return 'Offband MeshCore $version版';
}
@override
@ -852,6 +936,26 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get settings_aboutDonate => 'Donate or sponsor';
@override
String get sponsorPrompt_title => 'Support Offband';
@override
String get sponsorPrompt_body =>
'Offband MeshCore is free, open source, and built by an independent developer. If it\'s useful to you, sponsoring helps keep it maintained and improving. Thank you for using it.';
@override
String get sponsorPrompt_sponsor => 'Sponsor';
@override
String get sponsorPrompt_anytime =>
'You can support Offband anytime from About in Settings, or at offband.org/donate.';
@override
String get sponsorPrompt_notNow => 'Not now';
@override
String get sponsorPrompt_never => 'Never Show Again';
@override
String get settings_aboutLinkFailed => 'Could not open the link';
@ -1515,6 +1619,53 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get channels_searchChannels => '検索オプション...';
@override
String channels_unconfirmedSlots(String slots) {
return 'The radio did not confirm channel slot(s) $slots. Those channels are unknown until a sync succeeds.';
}
@override
String get channels_retrySync => 'Retry sync';
@override
String channels_erasedSlots(String slots) {
return 'The radio has lost the channels in slot(s) $slots; it reported them as erased. Your channel list here has been kept and was not overwritten.';
}
@override
String get channels_restoreToRadio => 'Restore to radio';
@override
String channels_restoreToRadioConfirm(int count, String names) {
return 'Write $count channel(s) back to the radio: $names. Channels the radio still has are left as they are.';
}
@override
String channels_restoreToRadioDone(int count) {
return 'The radio confirmed all $count channel(s).';
}
@override
String channels_restoreToRadioPartial(
int confirmed,
int total,
String names,
) {
return 'The radio confirmed $confirmed of $total channel(s). Not restored: $names.';
}
@override
String get channels_restoreToRadioFailed =>
'Could not restore the channel list: nothing cached to restore, or the radio is not connected.';
@override
String channels_slotIdentityChanged(String changes) {
return 'Channel layout changed on the radio: $changes';
}
@override
String get common_dismiss => 'Dismiss';
@override
String get channels_noChannelsFound => 'チャンネルが見つかりませんでした';
@ -1544,7 +1695,7 @@ class AppLocalizationsJa extends AppLocalizations {
String get channels_unmuteChannel => 'ミュートを解除する';
@override
String get channels_notifications => 'Notifications';
String get channels_notifications => 'Phone notifications';
@override
String get channels_notifyAll => 'All messages';
@ -1555,6 +1706,24 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get channels_notifyOff => 'Off';
@override
String get channels_radioSound => 'Radio sound';
@override
String get channels_radioSoundUnset => 'Not set';
@override
String channels_radioSoundFollows(String scope) {
return 'Follows the radio: $scope';
}
@override
String get channels_radioSoundMutedByRadio =>
'Silent: the radio is set to None';
@override
String get channels_radioSoundReading => 'Reading from the radio';
@override
String get channels_deleteChannel => 'チャンネルを削除する';
@ -1670,6 +1839,57 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get channels_publicChannelAdded => 'パブリックチャンネルが追加されました';
@override
String get channels_slotBusySyncing =>
'The radio is still updating its channel list. Try again in a moment.';
@override
String channels_slotsAllFull(int count) {
return 'All $count channel slots are in use. Delete a channel first.';
}
@override
String get channels_publicAlreadyAdded =>
'The Public channel is already on this radio.';
@override
String get channels_publicCannotBeRemoved =>
'Public can\'t be removed from the radio. It always stays in slot 0.';
@override
String get channels_writeOccupied =>
'The radio refused: another channel already uses that slot, and it was left unchanged. Try again now that the list has been re-read.';
@override
String get channels_writeRejected =>
'The radio refused the change, so the channel is as it was.';
@override
String get channels_writeNoReply =>
'The radio did not confirm the change. Check the channel list before trying again.';
@override
String get channels_writeClearedNotAdded =>
'The old channel was removed, but the radio did not confirm the new one, so that slot is now empty. Add the channel again.';
@override
String get channels_writeHistoryNotMoved =>
'The channel was changed, but its message history could not be moved to it. The app log has the details.';
@override
String get channels_keyChangeHistoryTitle => 'Keep message history?';
@override
String channels_keyChangeHistoryBody(String name) {
return 'This gives \"$name\" a new key. Keep its message history under the new key, or delete it? Deleted history cannot be recovered.';
}
@override
String get channels_keyChangeKeepHistory => 'Keep message history';
@override
String get channels_keyChangeDeleteHistory => 'Delete message history';
@override
String get channels_sortBy => '並び替え';
@ -1866,7 +2086,7 @@ class AppLocalizationsJa extends AppLocalizations {
String get debugLog_downloadLog => 'Download logs';
@override
String get debugLog_shareSubject => 'Offband Meshcore logs';
String get debugLog_shareSubject => 'Offband MeshCore logs';
@override
String get debugLog_logSaved => 'Log saved';
@ -1874,6 +2094,10 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get debugLog_logUnavailable => 'No log available to export';
@override
String get debugLog_exportFailed =>
'Couldn\'t share the log. Please try again.';
@override
String get debugLog_copied => 'デバッグログをコピー';
@ -2041,6 +2265,31 @@ class AppLocalizationsJa extends AppLocalizations {
String get channel_senderAddNotConnected =>
'Not connected to a radio, so this sender can\'t be added yet';
@override
String get channels_pskDerivedFromName => 'Key derives from the channel name';
@override
String get channels_communityDerived =>
'Derived from the community secret; manage it through the community';
@override
String get channels_copyPsk => 'Copy key';
@override
String get channels_pskCopied => 'Key copied';
@override
String get channels_hashtagMustKeepHash =>
'A hashtag channel\'s name must start with a single #. Add a new channel to use a different type.';
@override
String get channels_channelChangedReopen =>
'This channel changed on the radio while the dialog was open. Nothing was saved; reopen it and try again.';
@override
String get channel_slotUnconfirmedSendBlocked =>
'Message not sent: the radio hasn\'t confirmed this channel slot yet. Retry the channel sync, then send again.';
@override
String channel_senderAddFailed(String error) {
return 'Couldn\'t add this sender: $error';
@ -4093,9 +4342,6 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get channels_invalidQr => 'Invalid QR code';
@override
String get channels_noFreeSlot => 'No free channel slot';
@override
String channels_qrAddConfirm(String name) {
return 'Add channel \"$name\"?';
@ -5284,6 +5530,10 @@ class AppLocalizationsJa extends AppLocalizations {
String get importConfig_reasonNoFreeSlot =>
'no free channel slot on the device';
@override
String get importConfig_reasonChannelsSyncing =>
'channels were still being read from the radio; import again in a moment';
@override
String get importConfig_reasonAbsentFromFile => 'not present in this file';

@ -9,7 +9,7 @@ class AppLocalizationsKo extends AppLocalizations {
AppLocalizationsKo([String locale = 'ko']) : super(locale);
@override
String get appTitle => 'Offband Meshcore';
String get appTitle => 'Offband MeshCore';
@override
String get nav_contacts => '연락처';
@ -296,6 +296,14 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get scanner_bluetoothOffMessage => '블루투스를 켜서 장치를 검색해주세요.';
@override
String get scanner_scanFailedLocationOff =>
'Location is off, so Android won\'t let the app scan for Bluetooth radios. Turn on Location in your phone\'s quick settings, then tap Scan again.';
@override
String get scanner_scanFailedGeneric =>
'Could not start the Bluetooth scan. Tap Scan to try again. If it keeps failing, turn Bluetooth off and on.';
@override
String get scanner_connectFailedTransient =>
'The radio refused the connection (a common transient Bluetooth error). It was already retried; wait a few seconds and try again.';
@ -645,6 +653,13 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone';
@override
String get settings_indicators => 'Display and LED';
@override
String get settings_indicatorsSubtitle =>
'Display mode, rotation, status LED';
@override
String get settings_badgeBattery => 'Battery';
@ -768,6 +783,75 @@ class AppLocalizationsKo extends AppLocalizations {
String get settings_rebootDeviceConfirm =>
'정말로 장치를 재부팅하시겠습니까? 이 경우 연결이 끊어집니다.';
@override
String get settings_factoryReset => 'Factory Reset';
@override
String get settings_factoryResetSubtitle =>
'Erase the radio and give it a new identity';
@override
String get settings_factoryResetConfirm =>
'This erases everything on the radio: its identity, contacts, channels and settings. It can\'t be undone.';
@override
String get settings_factoryResetDeleteAppData =>
'Also delete this radio\'s data stored in the app';
@override
String get settings_factoryResetNotConnected =>
'Factory reset not sent: no radio is connected.';
@override
String get factoryResetSentTitle => 'Factory reset sent';
@override
String get factoryResetSentBody =>
'The radio is erasing itself and will restart with a new identity.';
@override
String get factoryResetSentBleBody =>
'The radio is erasing itself and will restart with a new identity. Its Bluetooth pairing is erased too: remove the old pairing for this radio in your phone or computer\'s Bluetooth settings, then reconnect.';
@override
String get factoryResetDeleteFailedTitle =>
'Factory reset sent, app cleanup failed';
@override
String get factoryResetDeleteFailedBody =>
'The reset was sent, but deleting this radio\'s data from the app failed.';
@override
String get settings_blePin => 'BLE pairing PIN';
@override
String get settings_blePinDefault => 'Firmware default';
@override
String get settings_blePinHelp =>
'6 digits (100000-999999). New phones and PCs must enter it when pairing. Reset to default to return to the firmware\'s built-in behavior.';
@override
String get settings_blePinInvalid =>
'Enter a 6-digit PIN between 100000 and 999999';
@override
String settings_blePinSaved(String pin) {
return 'PIN set to $pin. Already-paired devices may need to forget the radio and pair again.';
}
@override
String get settings_blePinResetDone =>
'PIN reset to the firmware default. Already-paired devices may need to forget the radio and pair again.';
@override
String settings_blePinSendFailed(String error) {
return 'The PIN was not changed: sending to the radio failed ($error)';
}
@override
String get settings_blePinResetAction => 'Reset to default';
@override
String get settings_debug => '디버깅';
@ -830,7 +914,7 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String settings_aboutVersion(String version) {
return 'Offband Meshcore $version 버전';
return 'Offband MeshCore $version 버전';
}
@override
@ -852,6 +936,26 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get settings_aboutDonate => 'Donate or sponsor';
@override
String get sponsorPrompt_title => 'Support Offband';
@override
String get sponsorPrompt_body =>
'Offband MeshCore is free, open source, and built by an independent developer. If it\'s useful to you, sponsoring helps keep it maintained and improving. Thank you for using it.';
@override
String get sponsorPrompt_sponsor => 'Sponsor';
@override
String get sponsorPrompt_anytime =>
'You can support Offband anytime from About in Settings, or at offband.org/donate.';
@override
String get sponsorPrompt_notNow => 'Not now';
@override
String get sponsorPrompt_never => 'Never Show Again';
@override
String get settings_aboutLinkFailed => 'Could not open the link';
@ -1510,6 +1614,53 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get channels_searchChannels => '검색 채널...';
@override
String channels_unconfirmedSlots(String slots) {
return 'The radio did not confirm channel slot(s) $slots. Those channels are unknown until a sync succeeds.';
}
@override
String get channels_retrySync => 'Retry sync';
@override
String channels_erasedSlots(String slots) {
return 'The radio has lost the channels in slot(s) $slots; it reported them as erased. Your channel list here has been kept and was not overwritten.';
}
@override
String get channels_restoreToRadio => 'Restore to radio';
@override
String channels_restoreToRadioConfirm(int count, String names) {
return 'Write $count channel(s) back to the radio: $names. Channels the radio still has are left as they are.';
}
@override
String channels_restoreToRadioDone(int count) {
return 'The radio confirmed all $count channel(s).';
}
@override
String channels_restoreToRadioPartial(
int confirmed,
int total,
String names,
) {
return 'The radio confirmed $confirmed of $total channel(s). Not restored: $names.';
}
@override
String get channels_restoreToRadioFailed =>
'Could not restore the channel list: nothing cached to restore, or the radio is not connected.';
@override
String channels_slotIdentityChanged(String changes) {
return 'Channel layout changed on the radio: $changes';
}
@override
String get common_dismiss => 'Dismiss';
@override
String get channels_noChannelsFound => '채널을 찾을 수 없습니다.';
@ -1539,7 +1690,7 @@ class AppLocalizationsKo extends AppLocalizations {
String get channels_unmuteChannel => '채널 음소거 해제';
@override
String get channels_notifications => 'Notifications';
String get channels_notifications => 'Phone notifications';
@override
String get channels_notifyAll => 'All messages';
@ -1550,6 +1701,24 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get channels_notifyOff => 'Off';
@override
String get channels_radioSound => 'Radio sound';
@override
String get channels_radioSoundUnset => 'Not set';
@override
String channels_radioSoundFollows(String scope) {
return 'Follows the radio: $scope';
}
@override
String get channels_radioSoundMutedByRadio =>
'Silent: the radio is set to None';
@override
String get channels_radioSoundReading => 'Reading from the radio';
@override
String get channels_deleteChannel => '채널 삭제';
@ -1665,6 +1834,57 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get channels_publicChannelAdded => '공개 채널 추가';
@override
String get channels_slotBusySyncing =>
'The radio is still updating its channel list. Try again in a moment.';
@override
String channels_slotsAllFull(int count) {
return 'All $count channel slots are in use. Delete a channel first.';
}
@override
String get channels_publicAlreadyAdded =>
'The Public channel is already on this radio.';
@override
String get channels_publicCannotBeRemoved =>
'Public can\'t be removed from the radio. It always stays in slot 0.';
@override
String get channels_writeOccupied =>
'The radio refused: another channel already uses that slot, and it was left unchanged. Try again now that the list has been re-read.';
@override
String get channels_writeRejected =>
'The radio refused the change, so the channel is as it was.';
@override
String get channels_writeNoReply =>
'The radio did not confirm the change. Check the channel list before trying again.';
@override
String get channels_writeClearedNotAdded =>
'The old channel was removed, but the radio did not confirm the new one, so that slot is now empty. Add the channel again.';
@override
String get channels_writeHistoryNotMoved =>
'The channel was changed, but its message history could not be moved to it. The app log has the details.';
@override
String get channels_keyChangeHistoryTitle => 'Keep message history?';
@override
String channels_keyChangeHistoryBody(String name) {
return 'This gives \"$name\" a new key. Keep its message history under the new key, or delete it? Deleted history cannot be recovered.';
}
@override
String get channels_keyChangeKeepHistory => 'Keep message history';
@override
String get channels_keyChangeDeleteHistory => 'Delete message history';
@override
String get channels_sortBy => '정렬 기준 선택';
@ -1861,7 +2081,7 @@ class AppLocalizationsKo extends AppLocalizations {
String get debugLog_downloadLog => 'Download logs';
@override
String get debugLog_shareSubject => 'Offband Meshcore logs';
String get debugLog_shareSubject => 'Offband MeshCore logs';
@override
String get debugLog_logSaved => 'Log saved';
@ -1869,6 +2089,10 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get debugLog_logUnavailable => 'No log available to export';
@override
String get debugLog_exportFailed =>
'Couldn\'t share the log. Please try again.';
@override
String get debugLog_copied => '디버깅 로그 복사';
@ -2037,6 +2261,31 @@ class AppLocalizationsKo extends AppLocalizations {
String get channel_senderAddNotConnected =>
'Not connected to a radio, so this sender can\'t be added yet';
@override
String get channels_pskDerivedFromName => 'Key derives from the channel name';
@override
String get channels_communityDerived =>
'Derived from the community secret; manage it through the community';
@override
String get channels_copyPsk => 'Copy key';
@override
String get channels_pskCopied => 'Key copied';
@override
String get channels_hashtagMustKeepHash =>
'A hashtag channel\'s name must start with a single #. Add a new channel to use a different type.';
@override
String get channels_channelChangedReopen =>
'This channel changed on the radio while the dialog was open. Nothing was saved; reopen it and try again.';
@override
String get channel_slotUnconfirmedSendBlocked =>
'Message not sent: the radio hasn\'t confirmed this channel slot yet. Retry the channel sync, then send again.';
@override
String channel_senderAddFailed(String error) {
return 'Couldn\'t add this sender: $error';
@ -4095,9 +4344,6 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get channels_invalidQr => 'Invalid QR code';
@override
String get channels_noFreeSlot => 'No free channel slot';
@override
String channels_qrAddConfirm(String name) {
return 'Add channel \"$name\"?';
@ -5285,6 +5531,10 @@ class AppLocalizationsKo extends AppLocalizations {
String get importConfig_reasonNoFreeSlot =>
'no free channel slot on the device';
@override
String get importConfig_reasonChannelsSyncing =>
'channels were still being read from the radio; import again in a moment';
@override
String get importConfig_reasonAbsentFromFile => 'not present in this file';

@ -9,7 +9,7 @@ class AppLocalizationsNl extends AppLocalizations {
AppLocalizationsNl([String locale = 'nl']) : super(locale);
@override
String get appTitle => 'Offband Meshcore';
String get appTitle => 'Offband MeshCore';
@override
String get nav_contacts => 'Contacten';
@ -121,7 +121,7 @@ class AppLocalizationsNl extends AppLocalizations {
String get common_interval => 'Tijdsinterval';
@override
String get scanner_title => 'Offband Meshcore';
String get scanner_title => 'Offband MeshCore';
@override
String get connectionChoiceUsbLabel => 'USB';
@ -306,6 +306,14 @@ class AppLocalizationsNl extends AppLocalizations {
String get scanner_bluetoothOffMessage =>
'Zorg ervoor dat Bluetooth is ingeschakeld om naar apparaten te zoeken.';
@override
String get scanner_scanFailedLocationOff =>
'Location is off, so Android won\'t let the app scan for Bluetooth radios. Turn on Location in your phone\'s quick settings, then tap Scan again.';
@override
String get scanner_scanFailedGeneric =>
'Could not start the Bluetooth scan. Tap Scan to try again. If it keeps failing, turn Bluetooth off and on.';
@override
String get scanner_connectFailedTransient =>
'The radio refused the connection (a common transient Bluetooth error). It was already retried; wait a few seconds and try again.';
@ -659,6 +667,13 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone';
@override
String get settings_indicators => 'Display and LED';
@override
String get settings_indicatorsSubtitle =>
'Display mode, rotation, status LED';
@override
String get settings_badgeBattery => 'Battery';
@ -788,6 +803,75 @@ class AppLocalizationsNl extends AppLocalizations {
String get settings_rebootDeviceConfirm =>
'Ben je er zeker van dat je het apparaat opnieuw wilt opstarten? Je wordt losgekoppeld.';
@override
String get settings_factoryReset => 'Factory Reset';
@override
String get settings_factoryResetSubtitle =>
'Erase the radio and give it a new identity';
@override
String get settings_factoryResetConfirm =>
'This erases everything on the radio: its identity, contacts, channels and settings. It can\'t be undone.';
@override
String get settings_factoryResetDeleteAppData =>
'Also delete this radio\'s data stored in the app';
@override
String get settings_factoryResetNotConnected =>
'Factory reset not sent: no radio is connected.';
@override
String get factoryResetSentTitle => 'Factory reset sent';
@override
String get factoryResetSentBody =>
'The radio is erasing itself and will restart with a new identity.';
@override
String get factoryResetSentBleBody =>
'The radio is erasing itself and will restart with a new identity. Its Bluetooth pairing is erased too: remove the old pairing for this radio in your phone or computer\'s Bluetooth settings, then reconnect.';
@override
String get factoryResetDeleteFailedTitle =>
'Factory reset sent, app cleanup failed';
@override
String get factoryResetDeleteFailedBody =>
'The reset was sent, but deleting this radio\'s data from the app failed.';
@override
String get settings_blePin => 'BLE pairing PIN';
@override
String get settings_blePinDefault => 'Firmware default';
@override
String get settings_blePinHelp =>
'6 digits (100000-999999). New phones and PCs must enter it when pairing. Reset to default to return to the firmware\'s built-in behavior.';
@override
String get settings_blePinInvalid =>
'Enter a 6-digit PIN between 100000 and 999999';
@override
String settings_blePinSaved(String pin) {
return 'PIN set to $pin. Already-paired devices may need to forget the radio and pair again.';
}
@override
String get settings_blePinResetDone =>
'PIN reset to the firmware default. Already-paired devices may need to forget the radio and pair again.';
@override
String settings_blePinSendFailed(String error) {
return 'The PIN was not changed: sending to the radio failed ($error)';
}
@override
String get settings_blePinResetAction => 'Reset to default';
@override
String get settings_debug => 'Foutopsporing';
@ -851,7 +935,7 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String settings_aboutVersion(String version) {
return 'Offband Meshcore versie $version';
return 'Offband MeshCore versie $version';
}
@override
@ -873,6 +957,26 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get settings_aboutDonate => 'Donate or sponsor';
@override
String get sponsorPrompt_title => 'Support Offband';
@override
String get sponsorPrompt_body =>
'Offband MeshCore is free, open source, and built by an independent developer. If it\'s useful to you, sponsoring helps keep it maintained and improving. Thank you for using it.';
@override
String get sponsorPrompt_sponsor => 'Sponsor';
@override
String get sponsorPrompt_anytime =>
'You can support Offband anytime from About in Settings, or at offband.org/donate.';
@override
String get sponsorPrompt_notNow => 'Not now';
@override
String get sponsorPrompt_never => 'Never Show Again';
@override
String get settings_aboutLinkFailed => 'Could not open the link';
@ -1558,6 +1662,53 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get channels_searchChannels => 'Zoek kanalen...';
@override
String channels_unconfirmedSlots(String slots) {
return 'The radio did not confirm channel slot(s) $slots. Those channels are unknown until a sync succeeds.';
}
@override
String get channels_retrySync => 'Retry sync';
@override
String channels_erasedSlots(String slots) {
return 'The radio has lost the channels in slot(s) $slots; it reported them as erased. Your channel list here has been kept and was not overwritten.';
}
@override
String get channels_restoreToRadio => 'Restore to radio';
@override
String channels_restoreToRadioConfirm(int count, String names) {
return 'Write $count channel(s) back to the radio: $names. Channels the radio still has are left as they are.';
}
@override
String channels_restoreToRadioDone(int count) {
return 'The radio confirmed all $count channel(s).';
}
@override
String channels_restoreToRadioPartial(
int confirmed,
int total,
String names,
) {
return 'The radio confirmed $confirmed of $total channel(s). Not restored: $names.';
}
@override
String get channels_restoreToRadioFailed =>
'Could not restore the channel list: nothing cached to restore, or the radio is not connected.';
@override
String channels_slotIdentityChanged(String changes) {
return 'Channel layout changed on the radio: $changes';
}
@override
String get common_dismiss => 'Dismiss';
@override
String get channels_noChannelsFound => 'Geen kanalen gevonden';
@ -1587,7 +1738,7 @@ class AppLocalizationsNl extends AppLocalizations {
String get channels_unmuteChannel => 'Kanaal dempen opheffen';
@override
String get channels_notifications => 'Notifications';
String get channels_notifications => 'Phone notifications';
@override
String get channels_notifyAll => 'All messages';
@ -1598,6 +1749,24 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get channels_notifyOff => 'Off';
@override
String get channels_radioSound => 'Radio sound';
@override
String get channels_radioSoundUnset => 'Not set';
@override
String channels_radioSoundFollows(String scope) {
return 'Follows the radio: $scope';
}
@override
String get channels_radioSoundMutedByRadio =>
'Silent: the radio is set to None';
@override
String get channels_radioSoundReading => 'Reading from the radio';
@override
String get channels_deleteChannel => 'Kanaal verwijderen';
@ -1717,6 +1886,57 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get channels_publicChannelAdded => 'Open kanaal toegevoegd';
@override
String get channels_slotBusySyncing =>
'The radio is still updating its channel list. Try again in a moment.';
@override
String channels_slotsAllFull(int count) {
return 'All $count channel slots are in use. Delete a channel first.';
}
@override
String get channels_publicAlreadyAdded =>
'The Public channel is already on this radio.';
@override
String get channels_publicCannotBeRemoved =>
'Public can\'t be removed from the radio. It always stays in slot 0.';
@override
String get channels_writeOccupied =>
'The radio refused: another channel already uses that slot, and it was left unchanged. Try again now that the list has been re-read.';
@override
String get channels_writeRejected =>
'The radio refused the change, so the channel is as it was.';
@override
String get channels_writeNoReply =>
'The radio did not confirm the change. Check the channel list before trying again.';
@override
String get channels_writeClearedNotAdded =>
'The old channel was removed, but the radio did not confirm the new one, so that slot is now empty. Add the channel again.';
@override
String get channels_writeHistoryNotMoved =>
'The channel was changed, but its message history could not be moved to it. The app log has the details.';
@override
String get channels_keyChangeHistoryTitle => 'Keep message history?';
@override
String channels_keyChangeHistoryBody(String name) {
return 'This gives \"$name\" a new key. Keep its message history under the new key, or delete it? Deleted history cannot be recovered.';
}
@override
String get channels_keyChangeKeepHistory => 'Keep message history';
@override
String get channels_keyChangeDeleteHistory => 'Delete message history';
@override
String get channels_sortBy => 'Sorteren door';
@ -1917,7 +2137,7 @@ class AppLocalizationsNl extends AppLocalizations {
String get debugLog_downloadLog => 'Download logs';
@override
String get debugLog_shareSubject => 'Offband Meshcore logs';
String get debugLog_shareSubject => 'Offband MeshCore logs';
@override
String get debugLog_logSaved => 'Log saved';
@ -1925,6 +2145,10 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get debugLog_logUnavailable => 'No log available to export';
@override
String get debugLog_exportFailed =>
'Couldn\'t share the log. Please try again.';
@override
String get debugLog_copied => 'Debuglog gekopieerd';
@ -2094,6 +2318,31 @@ class AppLocalizationsNl extends AppLocalizations {
String get channel_senderAddNotConnected =>
'Not connected to a radio, so this sender can\'t be added yet';
@override
String get channels_pskDerivedFromName => 'Key derives from the channel name';
@override
String get channels_communityDerived =>
'Derived from the community secret; manage it through the community';
@override
String get channels_copyPsk => 'Copy key';
@override
String get channels_pskCopied => 'Key copied';
@override
String get channels_hashtagMustKeepHash =>
'A hashtag channel\'s name must start with a single #. Add a new channel to use a different type.';
@override
String get channels_channelChangedReopen =>
'This channel changed on the radio while the dialog was open. Nothing was saved; reopen it and try again.';
@override
String get channel_slotUnconfirmedSendBlocked =>
'Message not sent: the radio hasn\'t confirmed this channel slot yet. Retry the channel sync, then send again.';
@override
String channel_senderAddFailed(String error) {
return 'Couldn\'t add this sender: $error';
@ -4260,9 +4509,6 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get channels_invalidQr => 'Invalid QR code';
@override
String get channels_noFreeSlot => 'No free channel slot';
@override
String channels_qrAddConfirm(String name) {
return 'Add channel \"$name\"?';
@ -5493,6 +5739,10 @@ class AppLocalizationsNl extends AppLocalizations {
String get importConfig_reasonNoFreeSlot =>
'no free channel slot on the device';
@override
String get importConfig_reasonChannelsSyncing =>
'channels were still being read from the radio; import again in a moment';
@override
String get importConfig_reasonAbsentFromFile => 'not present in this file';

@ -9,7 +9,7 @@ class AppLocalizationsPl extends AppLocalizations {
AppLocalizationsPl([String locale = 'pl']) : super(locale);
@override
String get appTitle => 'Offband Meshcore';
String get appTitle => 'Offband MeshCore';
@override
String get nav_contacts => 'Kontakty';
@ -311,6 +311,14 @@ class AppLocalizationsPl extends AppLocalizations {
String get scanner_bluetoothOffMessage =>
'Prosimy włączyć Bluetooth, aby przeskanować urządzenia.';
@override
String get scanner_scanFailedLocationOff =>
'Location is off, so Android won\'t let the app scan for Bluetooth radios. Turn on Location in your phone\'s quick settings, then tap Scan again.';
@override
String get scanner_scanFailedGeneric =>
'Could not start the Bluetooth scan. Tap Scan to try again. If it keeps failing, turn Bluetooth off and on.';
@override
String get scanner_connectFailedTransient =>
'The radio refused the connection (a common transient Bluetooth error). It was already retried; wait a few seconds and try again.';
@ -665,6 +673,13 @@ class AppLocalizationsPl extends AppLocalizations {
@override
String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone';
@override
String get settings_indicators => 'Display and LED';
@override
String get settings_indicatorsSubtitle =>
'Display mode, rotation, status LED';
@override
String get settings_badgeBattery => 'Battery';
@ -796,6 +811,75 @@ class AppLocalizationsPl extends AppLocalizations {
String get settings_rebootDeviceConfirm =>
'Czy na pewno chcesz zrestartować urządzenie? Będziesz odłączony.';
@override
String get settings_factoryReset => 'Factory Reset';
@override
String get settings_factoryResetSubtitle =>
'Erase the radio and give it a new identity';
@override
String get settings_factoryResetConfirm =>
'This erases everything on the radio: its identity, contacts, channels and settings. It can\'t be undone.';
@override
String get settings_factoryResetDeleteAppData =>
'Also delete this radio\'s data stored in the app';
@override
String get settings_factoryResetNotConnected =>
'Factory reset not sent: no radio is connected.';
@override
String get factoryResetSentTitle => 'Factory reset sent';
@override
String get factoryResetSentBody =>
'The radio is erasing itself and will restart with a new identity.';
@override
String get factoryResetSentBleBody =>
'The radio is erasing itself and will restart with a new identity. Its Bluetooth pairing is erased too: remove the old pairing for this radio in your phone or computer\'s Bluetooth settings, then reconnect.';
@override
String get factoryResetDeleteFailedTitle =>
'Factory reset sent, app cleanup failed';
@override
String get factoryResetDeleteFailedBody =>
'The reset was sent, but deleting this radio\'s data from the app failed.';
@override
String get settings_blePin => 'BLE pairing PIN';
@override
String get settings_blePinDefault => 'Firmware default';
@override
String get settings_blePinHelp =>
'6 digits (100000-999999). New phones and PCs must enter it when pairing. Reset to default to return to the firmware\'s built-in behavior.';
@override
String get settings_blePinInvalid =>
'Enter a 6-digit PIN between 100000 and 999999';
@override
String settings_blePinSaved(String pin) {
return 'PIN set to $pin. Already-paired devices may need to forget the radio and pair again.';
}
@override
String get settings_blePinResetDone =>
'PIN reset to the firmware default. Already-paired devices may need to forget the radio and pair again.';
@override
String settings_blePinSendFailed(String error) {
return 'The PIN was not changed: sending to the radio failed ($error)';
}
@override
String get settings_blePinResetAction => 'Reset to default';
@override
String get settings_debug => 'Naprawianie błędów';
@ -859,7 +943,7 @@ class AppLocalizationsPl extends AppLocalizations {
@override
String settings_aboutVersion(String version) {
return 'Offband Meshcore w wersji $version';
return 'Offband MeshCore w wersji $version';
}
@override
@ -881,6 +965,26 @@ class AppLocalizationsPl extends AppLocalizations {
@override
String get settings_aboutDonate => 'Donate or sponsor';
@override
String get sponsorPrompt_title => 'Support Offband';
@override
String get sponsorPrompt_body =>
'Offband MeshCore is free, open source, and built by an independent developer. If it\'s useful to you, sponsoring helps keep it maintained and improving. Thank you for using it.';
@override
String get sponsorPrompt_sponsor => 'Sponsor';
@override
String get sponsorPrompt_anytime =>
'You can support Offband anytime from About in Settings, or at offband.org/donate.';
@override
String get sponsorPrompt_notNow => 'Not now';
@override
String get sponsorPrompt_never => 'Never Show Again';
@override
String get settings_aboutLinkFailed => 'Could not open the link';
@ -1577,6 +1681,53 @@ class AppLocalizationsPl extends AppLocalizations {
@override
String get channels_searchChannels => 'Wyszukaj kanały...';
@override
String channels_unconfirmedSlots(String slots) {
return 'The radio did not confirm channel slot(s) $slots. Those channels are unknown until a sync succeeds.';
}
@override
String get channels_retrySync => 'Retry sync';
@override
String channels_erasedSlots(String slots) {
return 'The radio has lost the channels in slot(s) $slots; it reported them as erased. Your channel list here has been kept and was not overwritten.';
}
@override
String get channels_restoreToRadio => 'Restore to radio';
@override
String channels_restoreToRadioConfirm(int count, String names) {
return 'Write $count channel(s) back to the radio: $names. Channels the radio still has are left as they are.';
}
@override
String channels_restoreToRadioDone(int count) {
return 'The radio confirmed all $count channel(s).';
}
@override
String channels_restoreToRadioPartial(
int confirmed,
int total,
String names,
) {
return 'The radio confirmed $confirmed of $total channel(s). Not restored: $names.';
}
@override
String get channels_restoreToRadioFailed =>
'Could not restore the channel list: nothing cached to restore, or the radio is not connected.';
@override
String channels_slotIdentityChanged(String changes) {
return 'Channel layout changed on the radio: $changes';
}
@override
String get common_dismiss => 'Dismiss';
@override
String get channels_noChannelsFound => 'Brak znalezionych kanałów';
@ -1606,7 +1757,7 @@ class AppLocalizationsPl extends AppLocalizations {
String get channels_unmuteChannel => 'Wyłącz wyciszenie kanału';
@override
String get channels_notifications => 'Notifications';
String get channels_notifications => 'Phone notifications';
@override
String get channels_notifyAll => 'All messages';
@ -1617,6 +1768,24 @@ class AppLocalizationsPl extends AppLocalizations {
@override
String get channels_notifyOff => 'Off';
@override
String get channels_radioSound => 'Radio sound';
@override
String get channels_radioSoundUnset => 'Not set';
@override
String channels_radioSoundFollows(String scope) {
return 'Follows the radio: $scope';
}
@override
String get channels_radioSoundMutedByRadio =>
'Silent: the radio is set to None';
@override
String get channels_radioSoundReading => 'Reading from the radio';
@override
String get channels_deleteChannel => 'Usuń kanał';
@ -1738,6 +1907,57 @@ class AppLocalizationsPl extends AppLocalizations {
@override
String get channels_publicChannelAdded => 'Kanał publiczny dodany';
@override
String get channels_slotBusySyncing =>
'The radio is still updating its channel list. Try again in a moment.';
@override
String channels_slotsAllFull(int count) {
return 'All $count channel slots are in use. Delete a channel first.';
}
@override
String get channels_publicAlreadyAdded =>
'The Public channel is already on this radio.';
@override
String get channels_publicCannotBeRemoved =>
'Public can\'t be removed from the radio. It always stays in slot 0.';
@override
String get channels_writeOccupied =>
'The radio refused: another channel already uses that slot, and it was left unchanged. Try again now that the list has been re-read.';
@override
String get channels_writeRejected =>
'The radio refused the change, so the channel is as it was.';
@override
String get channels_writeNoReply =>
'The radio did not confirm the change. Check the channel list before trying again.';
@override
String get channels_writeClearedNotAdded =>
'The old channel was removed, but the radio did not confirm the new one, so that slot is now empty. Add the channel again.';
@override
String get channels_writeHistoryNotMoved =>
'The channel was changed, but its message history could not be moved to it. The app log has the details.';
@override
String get channels_keyChangeHistoryTitle => 'Keep message history?';
@override
String channels_keyChangeHistoryBody(String name) {
return 'This gives \"$name\" a new key. Keep its message history under the new key, or delete it? Deleted history cannot be recovered.';
}
@override
String get channels_keyChangeKeepHistory => 'Keep message history';
@override
String get channels_keyChangeDeleteHistory => 'Delete message history';
@override
String get channels_sortBy => 'Sortuj po';
@ -1939,7 +2159,7 @@ class AppLocalizationsPl extends AppLocalizations {
String get debugLog_downloadLog => 'Download logs';
@override
String get debugLog_shareSubject => 'Offband Meshcore logs';
String get debugLog_shareSubject => 'Offband MeshCore logs';
@override
String get debugLog_logSaved => 'Log saved';
@ -1947,6 +2167,10 @@ class AppLocalizationsPl extends AppLocalizations {
@override
String get debugLog_logUnavailable => 'No log available to export';
@override
String get debugLog_exportFailed =>
'Couldn\'t share the log. Please try again.';
@override
String get debugLog_copied => 'Skopiowano dziennik debugowania';
@ -2119,6 +2343,31 @@ class AppLocalizationsPl extends AppLocalizations {
String get channel_senderAddNotConnected =>
'Not connected to a radio, so this sender can\'t be added yet';
@override
String get channels_pskDerivedFromName => 'Key derives from the channel name';
@override
String get channels_communityDerived =>
'Derived from the community secret; manage it through the community';
@override
String get channels_copyPsk => 'Copy key';
@override
String get channels_pskCopied => 'Key copied';
@override
String get channels_hashtagMustKeepHash =>
'A hashtag channel\'s name must start with a single #. Add a new channel to use a different type.';
@override
String get channels_channelChangedReopen =>
'This channel changed on the radio while the dialog was open. Nothing was saved; reopen it and try again.';
@override
String get channel_slotUnconfirmedSendBlocked =>
'Message not sent: the radio hasn\'t confirmed this channel slot yet. Retry the channel sync, then send again.';
@override
String channel_senderAddFailed(String error) {
return 'Couldn\'t add this sender: $error';
@ -4292,9 +4541,6 @@ class AppLocalizationsPl extends AppLocalizations {
@override
String get channels_invalidQr => 'Invalid QR code';
@override
String get channels_noFreeSlot => 'No free channel slot';
@override
String channels_qrAddConfirm(String name) {
return 'Add channel \"$name\"?';
@ -5530,6 +5776,10 @@ class AppLocalizationsPl extends AppLocalizations {
String get importConfig_reasonNoFreeSlot =>
'no free channel slot on the device';
@override
String get importConfig_reasonChannelsSyncing =>
'channels were still being read from the radio; import again in a moment';
@override
String get importConfig_reasonAbsentFromFile => 'not present in this file';

@ -9,7 +9,7 @@ class AppLocalizationsPt extends AppLocalizations {
AppLocalizationsPt([String locale = 'pt']) : super(locale);
@override
String get appTitle => 'Offband Meshcore';
String get appTitle => 'Offband MeshCore';
@override
String get nav_contacts => 'Contactos';
@ -309,6 +309,14 @@ class AppLocalizationsPt extends AppLocalizations {
String get scanner_bluetoothOffMessage =>
'Por favor, ative o Bluetooth para escanear por dispositivos.';
@override
String get scanner_scanFailedLocationOff =>
'Location is off, so Android won\'t let the app scan for Bluetooth radios. Turn on Location in your phone\'s quick settings, then tap Scan again.';
@override
String get scanner_scanFailedGeneric =>
'Could not start the Bluetooth scan. Tap Scan to try again. If it keeps failing, turn Bluetooth off and on.';
@override
String get scanner_connectFailedTransient =>
'The radio refused the connection (a common transient Bluetooth error). It was already retried; wait a few seconds and try again.';
@ -662,6 +670,13 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone';
@override
String get settings_indicators => 'Display and LED';
@override
String get settings_indicatorsSubtitle =>
'Display mode, rotation, status LED';
@override
String get settings_badgeBattery => 'Battery';
@ -794,6 +809,75 @@ class AppLocalizationsPt extends AppLocalizations {
String get settings_rebootDeviceConfirm =>
'Tem certeza de que deseja reiniciar o dispositivo? Você será desconectado.';
@override
String get settings_factoryReset => 'Factory Reset';
@override
String get settings_factoryResetSubtitle =>
'Erase the radio and give it a new identity';
@override
String get settings_factoryResetConfirm =>
'This erases everything on the radio: its identity, contacts, channels and settings. It can\'t be undone.';
@override
String get settings_factoryResetDeleteAppData =>
'Also delete this radio\'s data stored in the app';
@override
String get settings_factoryResetNotConnected =>
'Factory reset not sent: no radio is connected.';
@override
String get factoryResetSentTitle => 'Factory reset sent';
@override
String get factoryResetSentBody =>
'The radio is erasing itself and will restart with a new identity.';
@override
String get factoryResetSentBleBody =>
'The radio is erasing itself and will restart with a new identity. Its Bluetooth pairing is erased too: remove the old pairing for this radio in your phone or computer\'s Bluetooth settings, then reconnect.';
@override
String get factoryResetDeleteFailedTitle =>
'Factory reset sent, app cleanup failed';
@override
String get factoryResetDeleteFailedBody =>
'The reset was sent, but deleting this radio\'s data from the app failed.';
@override
String get settings_blePin => 'BLE pairing PIN';
@override
String get settings_blePinDefault => 'Firmware default';
@override
String get settings_blePinHelp =>
'6 digits (100000-999999). New phones and PCs must enter it when pairing. Reset to default to return to the firmware\'s built-in behavior.';
@override
String get settings_blePinInvalid =>
'Enter a 6-digit PIN between 100000 and 999999';
@override
String settings_blePinSaved(String pin) {
return 'PIN set to $pin. Already-paired devices may need to forget the radio and pair again.';
}
@override
String get settings_blePinResetDone =>
'PIN reset to the firmware default. Already-paired devices may need to forget the radio and pair again.';
@override
String settings_blePinSendFailed(String error) {
return 'The PIN was not changed: sending to the radio failed ($error)';
}
@override
String get settings_blePinResetAction => 'Reset to default';
@override
String get settings_debug => 'Depurar';
@ -857,7 +941,7 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String settings_aboutVersion(String version) {
return 'Offband Meshcore v$version';
return 'Offband MeshCore v$version';
}
@override
@ -879,6 +963,26 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get settings_aboutDonate => 'Donate or sponsor';
@override
String get sponsorPrompt_title => 'Support Offband';
@override
String get sponsorPrompt_body =>
'Offband MeshCore is free, open source, and built by an independent developer. If it\'s useful to you, sponsoring helps keep it maintained and improving. Thank you for using it.';
@override
String get sponsorPrompt_sponsor => 'Sponsor';
@override
String get sponsorPrompt_anytime =>
'You can support Offband anytime from About in Settings, or at offband.org/donate.';
@override
String get sponsorPrompt_notNow => 'Not now';
@override
String get sponsorPrompt_never => 'Never Show Again';
@override
String get settings_aboutLinkFailed => 'Could not open the link';
@ -1567,6 +1671,53 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get channels_searchChannels => 'Pesquisar canais...';
@override
String channels_unconfirmedSlots(String slots) {
return 'The radio did not confirm channel slot(s) $slots. Those channels are unknown until a sync succeeds.';
}
@override
String get channels_retrySync => 'Retry sync';
@override
String channels_erasedSlots(String slots) {
return 'The radio has lost the channels in slot(s) $slots; it reported them as erased. Your channel list here has been kept and was not overwritten.';
}
@override
String get channels_restoreToRadio => 'Restore to radio';
@override
String channels_restoreToRadioConfirm(int count, String names) {
return 'Write $count channel(s) back to the radio: $names. Channels the radio still has are left as they are.';
}
@override
String channels_restoreToRadioDone(int count) {
return 'The radio confirmed all $count channel(s).';
}
@override
String channels_restoreToRadioPartial(
int confirmed,
int total,
String names,
) {
return 'The radio confirmed $confirmed of $total channel(s). Not restored: $names.';
}
@override
String get channels_restoreToRadioFailed =>
'Could not restore the channel list: nothing cached to restore, or the radio is not connected.';
@override
String channels_slotIdentityChanged(String changes) {
return 'Channel layout changed on the radio: $changes';
}
@override
String get common_dismiss => 'Dismiss';
@override
String get channels_noChannelsFound => 'Nenhum canal encontrado';
@ -1596,7 +1747,7 @@ class AppLocalizationsPt extends AppLocalizations {
String get channels_unmuteChannel => 'Ativar canal';
@override
String get channels_notifications => 'Notifications';
String get channels_notifications => 'Phone notifications';
@override
String get channels_notifyAll => 'All messages';
@ -1607,6 +1758,24 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get channels_notifyOff => 'Off';
@override
String get channels_radioSound => 'Radio sound';
@override
String get channels_radioSoundUnset => 'Not set';
@override
String channels_radioSoundFollows(String scope) {
return 'Follows the radio: $scope';
}
@override
String get channels_radioSoundMutedByRadio =>
'Silent: the radio is set to None';
@override
String get channels_radioSoundReading => 'Reading from the radio';
@override
String get channels_deleteChannel => 'Excluir canal';
@ -1726,6 +1895,57 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get channels_publicChannelAdded => 'Canal público adicionado';
@override
String get channels_slotBusySyncing =>
'The radio is still updating its channel list. Try again in a moment.';
@override
String channels_slotsAllFull(int count) {
return 'All $count channel slots are in use. Delete a channel first.';
}
@override
String get channels_publicAlreadyAdded =>
'The Public channel is already on this radio.';
@override
String get channels_publicCannotBeRemoved =>
'Public can\'t be removed from the radio. It always stays in slot 0.';
@override
String get channels_writeOccupied =>
'The radio refused: another channel already uses that slot, and it was left unchanged. Try again now that the list has been re-read.';
@override
String get channels_writeRejected =>
'The radio refused the change, so the channel is as it was.';
@override
String get channels_writeNoReply =>
'The radio did not confirm the change. Check the channel list before trying again.';
@override
String get channels_writeClearedNotAdded =>
'The old channel was removed, but the radio did not confirm the new one, so that slot is now empty. Add the channel again.';
@override
String get channels_writeHistoryNotMoved =>
'The channel was changed, but its message history could not be moved to it. The app log has the details.';
@override
String get channels_keyChangeHistoryTitle => 'Keep message history?';
@override
String channels_keyChangeHistoryBody(String name) {
return 'This gives \"$name\" a new key. Keep its message history under the new key, or delete it? Deleted history cannot be recovered.';
}
@override
String get channels_keyChangeKeepHistory => 'Keep message history';
@override
String get channels_keyChangeDeleteHistory => 'Delete message history';
@override
String get channels_sortBy => 'Ordenar por';
@ -1926,7 +2146,7 @@ class AppLocalizationsPt extends AppLocalizations {
String get debugLog_downloadLog => 'Download logs';
@override
String get debugLog_shareSubject => 'Offband Meshcore logs';
String get debugLog_shareSubject => 'Offband MeshCore logs';
@override
String get debugLog_logSaved => 'Log saved';
@ -1934,6 +2154,10 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get debugLog_logUnavailable => 'No log available to export';
@override
String get debugLog_exportFailed =>
'Couldn\'t share the log. Please try again.';
@override
String get debugLog_copied => 'Log de depuração copiado';
@ -2103,6 +2327,31 @@ class AppLocalizationsPt extends AppLocalizations {
String get channel_senderAddNotConnected =>
'Not connected to a radio, so this sender can\'t be added yet';
@override
String get channels_pskDerivedFromName => 'Key derives from the channel name';
@override
String get channels_communityDerived =>
'Derived from the community secret; manage it through the community';
@override
String get channels_copyPsk => 'Copy key';
@override
String get channels_pskCopied => 'Key copied';
@override
String get channels_hashtagMustKeepHash =>
'A hashtag channel\'s name must start with a single #. Add a new channel to use a different type.';
@override
String get channels_channelChangedReopen =>
'This channel changed on the radio while the dialog was open. Nothing was saved; reopen it and try again.';
@override
String get channel_slotUnconfirmedSendBlocked =>
'Message not sent: the radio hasn\'t confirmed this channel slot yet. Retry the channel sync, then send again.';
@override
String channel_senderAddFailed(String error) {
return 'Couldn\'t add this sender: $error';
@ -4273,9 +4522,6 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get channels_invalidQr => 'Invalid QR code';
@override
String get channels_noFreeSlot => 'No free channel slot';
@override
String channels_qrAddConfirm(String name) {
return 'Add channel \"$name\"?';
@ -5506,6 +5752,10 @@ class AppLocalizationsPt extends AppLocalizations {
String get importConfig_reasonNoFreeSlot =>
'no free channel slot on the device';
@override
String get importConfig_reasonChannelsSyncing =>
'channels were still being read from the radio; import again in a moment';
@override
String get importConfig_reasonAbsentFromFile => 'not present in this file';

@ -9,7 +9,7 @@ class AppLocalizationsRu extends AppLocalizations {
AppLocalizationsRu([String locale = 'ru']) : super(locale);
@override
String get appTitle => 'Offband Meshcore';
String get appTitle => 'Offband MeshCore';
@override
String get nav_contacts => 'Контакты';
@ -121,7 +121,7 @@ class AppLocalizationsRu extends AppLocalizations {
String get common_interval => 'Интервал';
@override
String get scanner_title => 'Offband Meshcore';
String get scanner_title => 'Offband MeshCore';
@override
String get connectionChoiceUsbLabel => 'USB';
@ -309,6 +309,14 @@ class AppLocalizationsRu extends AppLocalizations {
String get scanner_bluetoothOffMessage =>
'Пожалуйста, включите Bluetooth, чтобы найти устройства.';
@override
String get scanner_scanFailedLocationOff =>
'Location is off, so Android won\'t let the app scan for Bluetooth radios. Turn on Location in your phone\'s quick settings, then tap Scan again.';
@override
String get scanner_scanFailedGeneric =>
'Could not start the Bluetooth scan. Tap Scan to try again. If it keeps failing, turn Bluetooth off and on.';
@override
String get scanner_connectFailedTransient =>
'The radio refused the connection (a common transient Bluetooth error). It was already retried; wait a few seconds and try again.';
@ -661,6 +669,13 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone';
@override
String get settings_indicators => 'Display and LED';
@override
String get settings_indicatorsSubtitle =>
'Display mode, rotation, status LED';
@override
String get settings_badgeBattery => 'Battery';
@ -793,6 +808,75 @@ class AppLocalizationsRu extends AppLocalizations {
String get settings_rebootDeviceConfirm =>
'Вы уверены, что хотите перезагрузить устройство? Вы будете отключены.';
@override
String get settings_factoryReset => 'Factory Reset';
@override
String get settings_factoryResetSubtitle =>
'Erase the radio and give it a new identity';
@override
String get settings_factoryResetConfirm =>
'This erases everything on the radio: its identity, contacts, channels and settings. It can\'t be undone.';
@override
String get settings_factoryResetDeleteAppData =>
'Also delete this radio\'s data stored in the app';
@override
String get settings_factoryResetNotConnected =>
'Factory reset not sent: no radio is connected.';
@override
String get factoryResetSentTitle => 'Factory reset sent';
@override
String get factoryResetSentBody =>
'The radio is erasing itself and will restart with a new identity.';
@override
String get factoryResetSentBleBody =>
'The radio is erasing itself and will restart with a new identity. Its Bluetooth pairing is erased too: remove the old pairing for this radio in your phone or computer\'s Bluetooth settings, then reconnect.';
@override
String get factoryResetDeleteFailedTitle =>
'Factory reset sent, app cleanup failed';
@override
String get factoryResetDeleteFailedBody =>
'The reset was sent, but deleting this radio\'s data from the app failed.';
@override
String get settings_blePin => 'BLE pairing PIN';
@override
String get settings_blePinDefault => 'Firmware default';
@override
String get settings_blePinHelp =>
'6 digits (100000-999999). New phones and PCs must enter it when pairing. Reset to default to return to the firmware\'s built-in behavior.';
@override
String get settings_blePinInvalid =>
'Enter a 6-digit PIN between 100000 and 999999';
@override
String settings_blePinSaved(String pin) {
return 'PIN set to $pin. Already-paired devices may need to forget the radio and pair again.';
}
@override
String get settings_blePinResetDone =>
'PIN reset to the firmware default. Already-paired devices may need to forget the radio and pair again.';
@override
String settings_blePinSendFailed(String error) {
return 'The PIN was not changed: sending to the radio failed ($error)';
}
@override
String get settings_blePinResetAction => 'Reset to default';
@override
String get settings_debug => 'Отладка';
@ -856,7 +940,7 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String settings_aboutVersion(String version) {
return 'Offband Meshcore v$version';
return 'Offband MeshCore v$version';
}
@override
@ -878,6 +962,26 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get settings_aboutDonate => 'Donate or sponsor';
@override
String get sponsorPrompt_title => 'Support Offband';
@override
String get sponsorPrompt_body =>
'Offband MeshCore is free, open source, and built by an independent developer. If it\'s useful to you, sponsoring helps keep it maintained and improving. Thank you for using it.';
@override
String get sponsorPrompt_sponsor => 'Sponsor';
@override
String get sponsorPrompt_anytime =>
'You can support Offband anytime from About in Settings, or at offband.org/donate.';
@override
String get sponsorPrompt_notNow => 'Not now';
@override
String get sponsorPrompt_never => 'Never Show Again';
@override
String get settings_aboutLinkFailed => 'Could not open the link';
@ -1568,6 +1672,53 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get channels_searchChannels => 'Поиск каналов...';
@override
String channels_unconfirmedSlots(String slots) {
return 'The radio did not confirm channel slot(s) $slots. Those channels are unknown until a sync succeeds.';
}
@override
String get channels_retrySync => 'Retry sync';
@override
String channels_erasedSlots(String slots) {
return 'The radio has lost the channels in slot(s) $slots; it reported them as erased. Your channel list here has been kept and was not overwritten.';
}
@override
String get channels_restoreToRadio => 'Restore to radio';
@override
String channels_restoreToRadioConfirm(int count, String names) {
return 'Write $count channel(s) back to the radio: $names. Channels the radio still has are left as they are.';
}
@override
String channels_restoreToRadioDone(int count) {
return 'The radio confirmed all $count channel(s).';
}
@override
String channels_restoreToRadioPartial(
int confirmed,
int total,
String names,
) {
return 'The radio confirmed $confirmed of $total channel(s). Not restored: $names.';
}
@override
String get channels_restoreToRadioFailed =>
'Could not restore the channel list: nothing cached to restore, or the radio is not connected.';
@override
String channels_slotIdentityChanged(String changes) {
return 'Channel layout changed on the radio: $changes';
}
@override
String get common_dismiss => 'Dismiss';
@override
String get channels_noChannelsFound => 'Каналы не найдены';
@ -1597,7 +1748,7 @@ class AppLocalizationsRu extends AppLocalizations {
String get channels_unmuteChannel => 'Включить уведомления канала';
@override
String get channels_notifications => 'Notifications';
String get channels_notifications => 'Phone notifications';
@override
String get channels_notifyAll => 'All messages';
@ -1608,6 +1759,24 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get channels_notifyOff => 'Off';
@override
String get channels_radioSound => 'Radio sound';
@override
String get channels_radioSoundUnset => 'Not set';
@override
String channels_radioSoundFollows(String scope) {
return 'Follows the radio: $scope';
}
@override
String get channels_radioSoundMutedByRadio =>
'Silent: the radio is set to None';
@override
String get channels_radioSoundReading => 'Reading from the radio';
@override
String get channels_deleteChannel => 'Удалить канал';
@ -1727,6 +1896,57 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get channels_publicChannelAdded => 'Публичный канал добавлен';
@override
String get channels_slotBusySyncing =>
'The radio is still updating its channel list. Try again in a moment.';
@override
String channels_slotsAllFull(int count) {
return 'All $count channel slots are in use. Delete a channel first.';
}
@override
String get channels_publicAlreadyAdded =>
'The Public channel is already on this radio.';
@override
String get channels_publicCannotBeRemoved =>
'Public can\'t be removed from the radio. It always stays in slot 0.';
@override
String get channels_writeOccupied =>
'The radio refused: another channel already uses that slot, and it was left unchanged. Try again now that the list has been re-read.';
@override
String get channels_writeRejected =>
'The radio refused the change, so the channel is as it was.';
@override
String get channels_writeNoReply =>
'The radio did not confirm the change. Check the channel list before trying again.';
@override
String get channels_writeClearedNotAdded =>
'The old channel was removed, but the radio did not confirm the new one, so that slot is now empty. Add the channel again.';
@override
String get channels_writeHistoryNotMoved =>
'The channel was changed, but its message history could not be moved to it. The app log has the details.';
@override
String get channels_keyChangeHistoryTitle => 'Keep message history?';
@override
String channels_keyChangeHistoryBody(String name) {
return 'This gives \"$name\" a new key. Keep its message history under the new key, or delete it? Deleted history cannot be recovered.';
}
@override
String get channels_keyChangeKeepHistory => 'Keep message history';
@override
String get channels_keyChangeDeleteHistory => 'Delete message history';
@override
String get channels_sortBy => 'Сортировка';
@ -1927,7 +2147,7 @@ class AppLocalizationsRu extends AppLocalizations {
String get debugLog_downloadLog => 'Download logs';
@override
String get debugLog_shareSubject => 'Offband Meshcore logs';
String get debugLog_shareSubject => 'Offband MeshCore logs';
@override
String get debugLog_logSaved => 'Log saved';
@ -1935,6 +2155,10 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get debugLog_logUnavailable => 'No log available to export';
@override
String get debugLog_exportFailed =>
'Couldn\'t share the log. Please try again.';
@override
String get debugLog_copied => 'Журнал отладки скопирован';
@ -2107,6 +2331,31 @@ class AppLocalizationsRu extends AppLocalizations {
String get channel_senderAddNotConnected =>
'Not connected to a radio, so this sender can\'t be added yet';
@override
String get channels_pskDerivedFromName => 'Key derives from the channel name';
@override
String get channels_communityDerived =>
'Derived from the community secret; manage it through the community';
@override
String get channels_copyPsk => 'Copy key';
@override
String get channels_pskCopied => 'Key copied';
@override
String get channels_hashtagMustKeepHash =>
'A hashtag channel\'s name must start with a single #. Add a new channel to use a different type.';
@override
String get channels_channelChangedReopen =>
'This channel changed on the radio while the dialog was open. Nothing was saved; reopen it and try again.';
@override
String get channel_slotUnconfirmedSendBlocked =>
'Message not sent: the radio hasn\'t confirmed this channel slot yet. Retry the channel sync, then send again.';
@override
String channel_senderAddFailed(String error) {
return 'Couldn\'t add this sender: $error';
@ -4281,9 +4530,6 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get channels_invalidQr => 'Invalid QR code';
@override
String get channels_noFreeSlot => 'No free channel slot';
@override
String channels_qrAddConfirm(String name) {
return 'Add channel \"$name\"?';
@ -5524,6 +5770,10 @@ class AppLocalizationsRu extends AppLocalizations {
String get importConfig_reasonNoFreeSlot =>
'no free channel slot on the device';
@override
String get importConfig_reasonChannelsSyncing =>
'channels were still being read from the radio; import again in a moment';
@override
String get importConfig_reasonAbsentFromFile => 'not present in this file';

@ -9,7 +9,7 @@ class AppLocalizationsSk extends AppLocalizations {
AppLocalizationsSk([String locale = 'sk']) : super(locale);
@override
String get appTitle => 'Offband Meshcore';
String get appTitle => 'Offband MeshCore';
@override
String get nav_contacts => 'Kontakty';
@ -308,6 +308,14 @@ class AppLocalizationsSk extends AppLocalizations {
String get scanner_bluetoothOffMessage =>
'Prosím, zapnite Bluetooth, aby ste mohli skenovať pre zariadenia.';
@override
String get scanner_scanFailedLocationOff =>
'Location is off, so Android won\'t let the app scan for Bluetooth radios. Turn on Location in your phone\'s quick settings, then tap Scan again.';
@override
String get scanner_scanFailedGeneric =>
'Could not start the Bluetooth scan. Tap Scan to try again. If it keeps failing, turn Bluetooth off and on.';
@override
String get scanner_connectFailedTransient =>
'The radio refused the connection (a common transient Bluetooth error). It was already retried; wait a few seconds and try again.';
@ -660,6 +668,13 @@ class AppLocalizationsSk extends AppLocalizations {
@override
String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone';
@override
String get settings_indicators => 'Display and LED';
@override
String get settings_indicatorsSubtitle =>
'Display mode, rotation, status LED';
@override
String get settings_badgeBattery => 'Battery';
@ -788,6 +803,75 @@ class AppLocalizationsSk extends AppLocalizations {
String get settings_rebootDeviceConfirm =>
'Ste si istý, že chcete zariadenie reštartovať? Budete odpojení.';
@override
String get settings_factoryReset => 'Factory Reset';
@override
String get settings_factoryResetSubtitle =>
'Erase the radio and give it a new identity';
@override
String get settings_factoryResetConfirm =>
'This erases everything on the radio: its identity, contacts, channels and settings. It can\'t be undone.';
@override
String get settings_factoryResetDeleteAppData =>
'Also delete this radio\'s data stored in the app';
@override
String get settings_factoryResetNotConnected =>
'Factory reset not sent: no radio is connected.';
@override
String get factoryResetSentTitle => 'Factory reset sent';
@override
String get factoryResetSentBody =>
'The radio is erasing itself and will restart with a new identity.';
@override
String get factoryResetSentBleBody =>
'The radio is erasing itself and will restart with a new identity. Its Bluetooth pairing is erased too: remove the old pairing for this radio in your phone or computer\'s Bluetooth settings, then reconnect.';
@override
String get factoryResetDeleteFailedTitle =>
'Factory reset sent, app cleanup failed';
@override
String get factoryResetDeleteFailedBody =>
'The reset was sent, but deleting this radio\'s data from the app failed.';
@override
String get settings_blePin => 'BLE pairing PIN';
@override
String get settings_blePinDefault => 'Firmware default';
@override
String get settings_blePinHelp =>
'6 digits (100000-999999). New phones and PCs must enter it when pairing. Reset to default to return to the firmware\'s built-in behavior.';
@override
String get settings_blePinInvalid =>
'Enter a 6-digit PIN between 100000 and 999999';
@override
String settings_blePinSaved(String pin) {
return 'PIN set to $pin. Already-paired devices may need to forget the radio and pair again.';
}
@override
String get settings_blePinResetDone =>
'PIN reset to the firmware default. Already-paired devices may need to forget the radio and pair again.';
@override
String settings_blePinSendFailed(String error) {
return 'The PIN was not changed: sending to the radio failed ($error)';
}
@override
String get settings_blePinResetAction => 'Reset to default';
@override
String get settings_debug => 'Ladenie';
@ -873,6 +957,26 @@ class AppLocalizationsSk extends AppLocalizations {
@override
String get settings_aboutDonate => 'Donate or sponsor';
@override
String get sponsorPrompt_title => 'Support Offband';
@override
String get sponsorPrompt_body =>
'Offband MeshCore is free, open source, and built by an independent developer. If it\'s useful to you, sponsoring helps keep it maintained and improving. Thank you for using it.';
@override
String get sponsorPrompt_sponsor => 'Sponsor';
@override
String get sponsorPrompt_anytime =>
'You can support Offband anytime from About in Settings, or at offband.org/donate.';
@override
String get sponsorPrompt_notNow => 'Not now';
@override
String get sponsorPrompt_never => 'Never Show Again';
@override
String get settings_aboutLinkFailed => 'Could not open the link';
@ -1558,6 +1662,53 @@ class AppLocalizationsSk extends AppLocalizations {
@override
String get channels_searchChannels => 'Vyhľadávajte kanály...';
@override
String channels_unconfirmedSlots(String slots) {
return 'The radio did not confirm channel slot(s) $slots. Those channels are unknown until a sync succeeds.';
}
@override
String get channels_retrySync => 'Retry sync';
@override
String channels_erasedSlots(String slots) {
return 'The radio has lost the channels in slot(s) $slots; it reported them as erased. Your channel list here has been kept and was not overwritten.';
}
@override
String get channels_restoreToRadio => 'Restore to radio';
@override
String channels_restoreToRadioConfirm(int count, String names) {
return 'Write $count channel(s) back to the radio: $names. Channels the radio still has are left as they are.';
}
@override
String channels_restoreToRadioDone(int count) {
return 'The radio confirmed all $count channel(s).';
}
@override
String channels_restoreToRadioPartial(
int confirmed,
int total,
String names,
) {
return 'The radio confirmed $confirmed of $total channel(s). Not restored: $names.';
}
@override
String get channels_restoreToRadioFailed =>
'Could not restore the channel list: nothing cached to restore, or the radio is not connected.';
@override
String channels_slotIdentityChanged(String changes) {
return 'Channel layout changed on the radio: $changes';
}
@override
String get common_dismiss => 'Dismiss';
@override
String get channels_noChannelsFound => 'Neobsiahlo sa žiadnych kanálov.';
@ -1587,7 +1738,7 @@ class AppLocalizationsSk extends AppLocalizations {
String get channels_unmuteChannel => 'Zrušiť stlmenie kanála';
@override
String get channels_notifications => 'Notifications';
String get channels_notifications => 'Phone notifications';
@override
String get channels_notifyAll => 'All messages';
@ -1598,6 +1749,24 @@ class AppLocalizationsSk extends AppLocalizations {
@override
String get channels_notifyOff => 'Off';
@override
String get channels_radioSound => 'Radio sound';
@override
String get channels_radioSoundUnset => 'Not set';
@override
String channels_radioSoundFollows(String scope) {
return 'Follows the radio: $scope';
}
@override
String get channels_radioSoundMutedByRadio =>
'Silent: the radio is set to None';
@override
String get channels_radioSoundReading => 'Reading from the radio';
@override
String get channels_deleteChannel => 'Odstrániť kanál';
@ -1718,6 +1887,57 @@ class AppLocalizationsSk extends AppLocalizations {
@override
String get channels_publicChannelAdded => 'Veľký kanál pridaný';
@override
String get channels_slotBusySyncing =>
'The radio is still updating its channel list. Try again in a moment.';
@override
String channels_slotsAllFull(int count) {
return 'All $count channel slots are in use. Delete a channel first.';
}
@override
String get channels_publicAlreadyAdded =>
'The Public channel is already on this radio.';
@override
String get channels_publicCannotBeRemoved =>
'Public can\'t be removed from the radio. It always stays in slot 0.';
@override
String get channels_writeOccupied =>
'The radio refused: another channel already uses that slot, and it was left unchanged. Try again now that the list has been re-read.';
@override
String get channels_writeRejected =>
'The radio refused the change, so the channel is as it was.';
@override
String get channels_writeNoReply =>
'The radio did not confirm the change. Check the channel list before trying again.';
@override
String get channels_writeClearedNotAdded =>
'The old channel was removed, but the radio did not confirm the new one, so that slot is now empty. Add the channel again.';
@override
String get channels_writeHistoryNotMoved =>
'The channel was changed, but its message history could not be moved to it. The app log has the details.';
@override
String get channels_keyChangeHistoryTitle => 'Keep message history?';
@override
String channels_keyChangeHistoryBody(String name) {
return 'This gives \"$name\" a new key. Keep its message history under the new key, or delete it? Deleted history cannot be recovered.';
}
@override
String get channels_keyChangeKeepHistory => 'Keep message history';
@override
String get channels_keyChangeDeleteHistory => 'Delete message history';
@override
String get channels_sortBy => 'Triediť podľa';
@ -1917,7 +2137,7 @@ class AppLocalizationsSk extends AppLocalizations {
String get debugLog_downloadLog => 'Download logs';
@override
String get debugLog_shareSubject => 'Offband Meshcore logs';
String get debugLog_shareSubject => 'Offband MeshCore logs';
@override
String get debugLog_logSaved => 'Log saved';
@ -1925,6 +2145,10 @@ class AppLocalizationsSk extends AppLocalizations {
@override
String get debugLog_logUnavailable => 'No log available to export';
@override
String get debugLog_exportFailed =>
'Couldn\'t share the log. Please try again.';
@override
String get debugLog_copied => 'Záznam ladenia skopírovaný';
@ -2096,6 +2320,31 @@ class AppLocalizationsSk extends AppLocalizations {
String get channel_senderAddNotConnected =>
'Not connected to a radio, so this sender can\'t be added yet';
@override
String get channels_pskDerivedFromName => 'Key derives from the channel name';
@override
String get channels_communityDerived =>
'Derived from the community secret; manage it through the community';
@override
String get channels_copyPsk => 'Copy key';
@override
String get channels_pskCopied => 'Key copied';
@override
String get channels_hashtagMustKeepHash =>
'A hashtag channel\'s name must start with a single #. Add a new channel to use a different type.';
@override
String get channels_channelChangedReopen =>
'This channel changed on the radio while the dialog was open. Nothing was saved; reopen it and try again.';
@override
String get channel_slotUnconfirmedSendBlocked =>
'Message not sent: the radio hasn\'t confirmed this channel slot yet. Retry the channel sync, then send again.';
@override
String channel_senderAddFailed(String error) {
return 'Couldn\'t add this sender: $error';
@ -4260,9 +4509,6 @@ class AppLocalizationsSk extends AppLocalizations {
@override
String get channels_invalidQr => 'Invalid QR code';
@override
String get channels_noFreeSlot => 'No free channel slot';
@override
String channels_qrAddConfirm(String name) {
return 'Add channel \"$name\"?';
@ -5490,6 +5736,10 @@ class AppLocalizationsSk extends AppLocalizations {
String get importConfig_reasonNoFreeSlot =>
'no free channel slot on the device';
@override
String get importConfig_reasonChannelsSyncing =>
'channels were still being read from the radio; import again in a moment';
@override
String get importConfig_reasonAbsentFromFile => 'not present in this file';

@ -9,7 +9,7 @@ class AppLocalizationsSl extends AppLocalizations {
AppLocalizationsSl([String locale = 'sl']) : super(locale);
@override
String get appTitle => 'Offband Meshcore';
String get appTitle => 'Offband MeshCore';
@override
String get nav_contacts => 'Stiki';
@ -306,6 +306,14 @@ class AppLocalizationsSl extends AppLocalizations {
String get scanner_bluetoothOffMessage =>
'Prosimo, vklopite Bluetooth, da lahko poiščete naprave.';
@override
String get scanner_scanFailedLocationOff =>
'Location is off, so Android won\'t let the app scan for Bluetooth radios. Turn on Location in your phone\'s quick settings, then tap Scan again.';
@override
String get scanner_scanFailedGeneric =>
'Could not start the Bluetooth scan. Tap Scan to try again. If it keeps failing, turn Bluetooth off and on.';
@override
String get scanner_connectFailedTransient =>
'The radio refused the connection (a common transient Bluetooth error). It was already retried; wait a few seconds and try again.';
@ -658,6 +666,13 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone';
@override
String get settings_indicators => 'Display and LED';
@override
String get settings_indicatorsSubtitle =>
'Display mode, rotation, status LED';
@override
String get settings_badgeBattery => 'Battery';
@ -786,6 +801,75 @@ class AppLocalizationsSl extends AppLocalizations {
String get settings_rebootDeviceConfirm =>
'Ste prepričani, da želite ponovno zagnati napravo? Povezava bo prekinjena.';
@override
String get settings_factoryReset => 'Factory Reset';
@override
String get settings_factoryResetSubtitle =>
'Erase the radio and give it a new identity';
@override
String get settings_factoryResetConfirm =>
'This erases everything on the radio: its identity, contacts, channels and settings. It can\'t be undone.';
@override
String get settings_factoryResetDeleteAppData =>
'Also delete this radio\'s data stored in the app';
@override
String get settings_factoryResetNotConnected =>
'Factory reset not sent: no radio is connected.';
@override
String get factoryResetSentTitle => 'Factory reset sent';
@override
String get factoryResetSentBody =>
'The radio is erasing itself and will restart with a new identity.';
@override
String get factoryResetSentBleBody =>
'The radio is erasing itself and will restart with a new identity. Its Bluetooth pairing is erased too: remove the old pairing for this radio in your phone or computer\'s Bluetooth settings, then reconnect.';
@override
String get factoryResetDeleteFailedTitle =>
'Factory reset sent, app cleanup failed';
@override
String get factoryResetDeleteFailedBody =>
'The reset was sent, but deleting this radio\'s data from the app failed.';
@override
String get settings_blePin => 'BLE pairing PIN';
@override
String get settings_blePinDefault => 'Firmware default';
@override
String get settings_blePinHelp =>
'6 digits (100000-999999). New phones and PCs must enter it when pairing. Reset to default to return to the firmware\'s built-in behavior.';
@override
String get settings_blePinInvalid =>
'Enter a 6-digit PIN between 100000 and 999999';
@override
String settings_blePinSaved(String pin) {
return 'PIN set to $pin. Already-paired devices may need to forget the radio and pair again.';
}
@override
String get settings_blePinResetDone =>
'PIN reset to the firmware default. Already-paired devices may need to forget the radio and pair again.';
@override
String settings_blePinSendFailed(String error) {
return 'The PIN was not changed: sending to the radio failed ($error)';
}
@override
String get settings_blePinResetAction => 'Reset to default';
@override
String get settings_debug => 'Odpravljanje napak';
@ -870,6 +954,26 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get settings_aboutDonate => 'Donate or sponsor';
@override
String get sponsorPrompt_title => 'Support Offband';
@override
String get sponsorPrompt_body =>
'Offband MeshCore is free, open source, and built by an independent developer. If it\'s useful to you, sponsoring helps keep it maintained and improving. Thank you for using it.';
@override
String get sponsorPrompt_sponsor => 'Sponsor';
@override
String get sponsorPrompt_anytime =>
'You can support Offband anytime from About in Settings, or at offband.org/donate.';
@override
String get sponsorPrompt_notNow => 'Not now';
@override
String get sponsorPrompt_never => 'Never Show Again';
@override
String get settings_aboutLinkFailed => 'Could not open the link';
@ -1555,6 +1659,53 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get channels_searchChannels => 'Poišči kanale...';
@override
String channels_unconfirmedSlots(String slots) {
return 'The radio did not confirm channel slot(s) $slots. Those channels are unknown until a sync succeeds.';
}
@override
String get channels_retrySync => 'Retry sync';
@override
String channels_erasedSlots(String slots) {
return 'The radio has lost the channels in slot(s) $slots; it reported them as erased. Your channel list here has been kept and was not overwritten.';
}
@override
String get channels_restoreToRadio => 'Restore to radio';
@override
String channels_restoreToRadioConfirm(int count, String names) {
return 'Write $count channel(s) back to the radio: $names. Channels the radio still has are left as they are.';
}
@override
String channels_restoreToRadioDone(int count) {
return 'The radio confirmed all $count channel(s).';
}
@override
String channels_restoreToRadioPartial(
int confirmed,
int total,
String names,
) {
return 'The radio confirmed $confirmed of $total channel(s). Not restored: $names.';
}
@override
String get channels_restoreToRadioFailed =>
'Could not restore the channel list: nothing cached to restore, or the radio is not connected.';
@override
String channels_slotIdentityChanged(String changes) {
return 'Channel layout changed on the radio: $changes';
}
@override
String get common_dismiss => 'Dismiss';
@override
String get channels_noChannelsFound => 'Ne najdem kanalov.';
@ -1584,7 +1735,7 @@ class AppLocalizationsSl extends AppLocalizations {
String get channels_unmuteChannel => 'Vklopi obvestila kanala';
@override
String get channels_notifications => 'Notifications';
String get channels_notifications => 'Phone notifications';
@override
String get channels_notifyAll => 'All messages';
@ -1595,6 +1746,24 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get channels_notifyOff => 'Off';
@override
String get channels_radioSound => 'Radio sound';
@override
String get channels_radioSoundUnset => 'Not set';
@override
String channels_radioSoundFollows(String scope) {
return 'Follows the radio: $scope';
}
@override
String get channels_radioSoundMutedByRadio =>
'Silent: the radio is set to None';
@override
String get channels_radioSoundReading => 'Reading from the radio';
@override
String get channels_deleteChannel => 'Pošlji kanal';
@ -1715,6 +1884,57 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get channels_publicChannelAdded => 'javna skupnost dodana';
@override
String get channels_slotBusySyncing =>
'The radio is still updating its channel list. Try again in a moment.';
@override
String channels_slotsAllFull(int count) {
return 'All $count channel slots are in use. Delete a channel first.';
}
@override
String get channels_publicAlreadyAdded =>
'The Public channel is already on this radio.';
@override
String get channels_publicCannotBeRemoved =>
'Public can\'t be removed from the radio. It always stays in slot 0.';
@override
String get channels_writeOccupied =>
'The radio refused: another channel already uses that slot, and it was left unchanged. Try again now that the list has been re-read.';
@override
String get channels_writeRejected =>
'The radio refused the change, so the channel is as it was.';
@override
String get channels_writeNoReply =>
'The radio did not confirm the change. Check the channel list before trying again.';
@override
String get channels_writeClearedNotAdded =>
'The old channel was removed, but the radio did not confirm the new one, so that slot is now empty. Add the channel again.';
@override
String get channels_writeHistoryNotMoved =>
'The channel was changed, but its message history could not be moved to it. The app log has the details.';
@override
String get channels_keyChangeHistoryTitle => 'Keep message history?';
@override
String channels_keyChangeHistoryBody(String name) {
return 'This gives \"$name\" a new key. Keep its message history under the new key, or delete it? Deleted history cannot be recovered.';
}
@override
String get channels_keyChangeKeepHistory => 'Keep message history';
@override
String get channels_keyChangeDeleteHistory => 'Delete message history';
@override
String get channels_sortBy => 'Sortiraj po';
@ -1915,7 +2135,7 @@ class AppLocalizationsSl extends AppLocalizations {
String get debugLog_downloadLog => 'Download logs';
@override
String get debugLog_shareSubject => 'Offband Meshcore logs';
String get debugLog_shareSubject => 'Offband MeshCore logs';
@override
String get debugLog_logSaved => 'Log saved';
@ -1923,6 +2143,10 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get debugLog_logUnavailable => 'No log available to export';
@override
String get debugLog_exportFailed =>
'Couldn\'t share the log. Please try again.';
@override
String get debugLog_copied => 'Beležka kopirana.';
@ -2092,6 +2316,31 @@ class AppLocalizationsSl extends AppLocalizations {
String get channel_senderAddNotConnected =>
'Not connected to a radio, so this sender can\'t be added yet';
@override
String get channels_pskDerivedFromName => 'Key derives from the channel name';
@override
String get channels_communityDerived =>
'Derived from the community secret; manage it through the community';
@override
String get channels_copyPsk => 'Copy key';
@override
String get channels_pskCopied => 'Key copied';
@override
String get channels_hashtagMustKeepHash =>
'A hashtag channel\'s name must start with a single #. Add a new channel to use a different type.';
@override
String get channels_channelChangedReopen =>
'This channel changed on the radio while the dialog was open. Nothing was saved; reopen it and try again.';
@override
String get channel_slotUnconfirmedSendBlocked =>
'Message not sent: the radio hasn\'t confirmed this channel slot yet. Retry the channel sync, then send again.';
@override
String channel_senderAddFailed(String error) {
return 'Couldn\'t add this sender: $error';
@ -4255,9 +4504,6 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get channels_invalidQr => 'Invalid QR code';
@override
String get channels_noFreeSlot => 'No free channel slot';
@override
String channels_qrAddConfirm(String name) {
return 'Add channel \"$name\"?';
@ -5488,6 +5734,10 @@ class AppLocalizationsSl extends AppLocalizations {
String get importConfig_reasonNoFreeSlot =>
'no free channel slot on the device';
@override
String get importConfig_reasonChannelsSyncing =>
'channels were still being read from the radio; import again in a moment';
@override
String get importConfig_reasonAbsentFromFile => 'not present in this file';

@ -9,7 +9,7 @@ class AppLocalizationsSv extends AppLocalizations {
AppLocalizationsSv([String locale = 'sv']) : super(locale);
@override
String get appTitle => 'Offband Meshcore';
String get appTitle => 'Offband MeshCore';
@override
String get nav_contacts => 'Kontakter';
@ -305,6 +305,14 @@ class AppLocalizationsSv extends AppLocalizations {
String get scanner_bluetoothOffMessage =>
'Vänligen aktivera Bluetooth för att söka efter enheter.';
@override
String get scanner_scanFailedLocationOff =>
'Location is off, so Android won\'t let the app scan for Bluetooth radios. Turn on Location in your phone\'s quick settings, then tap Scan again.';
@override
String get scanner_scanFailedGeneric =>
'Could not start the Bluetooth scan. Tap Scan to try again. If it keeps failing, turn Bluetooth off and on.';
@override
String get scanner_connectFailedTransient =>
'The radio refused the connection (a common transient Bluetooth error). It was already retried; wait a few seconds and try again.';
@ -657,6 +665,13 @@ class AppLocalizationsSv extends AppLocalizations {
@override
String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone';
@override
String get settings_indicators => 'Display and LED';
@override
String get settings_indicatorsSubtitle =>
'Display mode, rotation, status LED';
@override
String get settings_badgeBattery => 'Battery';
@ -783,6 +798,75 @@ class AppLocalizationsSv extends AppLocalizations {
String get settings_rebootDeviceConfirm =>
'Är du säker på att du vill starta om enheten? Du kommer att bli avkopplad.';
@override
String get settings_factoryReset => 'Factory Reset';
@override
String get settings_factoryResetSubtitle =>
'Erase the radio and give it a new identity';
@override
String get settings_factoryResetConfirm =>
'This erases everything on the radio: its identity, contacts, channels and settings. It can\'t be undone.';
@override
String get settings_factoryResetDeleteAppData =>
'Also delete this radio\'s data stored in the app';
@override
String get settings_factoryResetNotConnected =>
'Factory reset not sent: no radio is connected.';
@override
String get factoryResetSentTitle => 'Factory reset sent';
@override
String get factoryResetSentBody =>
'The radio is erasing itself and will restart with a new identity.';
@override
String get factoryResetSentBleBody =>
'The radio is erasing itself and will restart with a new identity. Its Bluetooth pairing is erased too: remove the old pairing for this radio in your phone or computer\'s Bluetooth settings, then reconnect.';
@override
String get factoryResetDeleteFailedTitle =>
'Factory reset sent, app cleanup failed';
@override
String get factoryResetDeleteFailedBody =>
'The reset was sent, but deleting this radio\'s data from the app failed.';
@override
String get settings_blePin => 'BLE pairing PIN';
@override
String get settings_blePinDefault => 'Firmware default';
@override
String get settings_blePinHelp =>
'6 digits (100000-999999). New phones and PCs must enter it when pairing. Reset to default to return to the firmware\'s built-in behavior.';
@override
String get settings_blePinInvalid =>
'Enter a 6-digit PIN between 100000 and 999999';
@override
String settings_blePinSaved(String pin) {
return 'PIN set to $pin. Already-paired devices may need to forget the radio and pair again.';
}
@override
String get settings_blePinResetDone =>
'PIN reset to the firmware default. Already-paired devices may need to forget the radio and pair again.';
@override
String settings_blePinSendFailed(String error) {
return 'The PIN was not changed: sending to the radio failed ($error)';
}
@override
String get settings_blePinResetAction => 'Reset to default';
@override
String get settings_debug => 'Felsök';
@ -846,7 +930,7 @@ class AppLocalizationsSv extends AppLocalizations {
@override
String settings_aboutVersion(String version) {
return 'Offband Meshcore version $version';
return 'Offband MeshCore version $version';
}
@override
@ -868,6 +952,26 @@ class AppLocalizationsSv extends AppLocalizations {
@override
String get settings_aboutDonate => 'Donate or sponsor';
@override
String get sponsorPrompt_title => 'Support Offband';
@override
String get sponsorPrompt_body =>
'Offband MeshCore is free, open source, and built by an independent developer. If it\'s useful to you, sponsoring helps keep it maintained and improving. Thank you for using it.';
@override
String get sponsorPrompt_sponsor => 'Sponsor';
@override
String get sponsorPrompt_anytime =>
'You can support Offband anytime from About in Settings, or at offband.org/donate.';
@override
String get sponsorPrompt_notNow => 'Not now';
@override
String get sponsorPrompt_never => 'Never Show Again';
@override
String get settings_aboutLinkFailed => 'Could not open the link';
@ -1549,6 +1653,53 @@ class AppLocalizationsSv extends AppLocalizations {
@override
String get channels_searchChannels => 'Sök kanaler...';
@override
String channels_unconfirmedSlots(String slots) {
return 'The radio did not confirm channel slot(s) $slots. Those channels are unknown until a sync succeeds.';
}
@override
String get channels_retrySync => 'Retry sync';
@override
String channels_erasedSlots(String slots) {
return 'The radio has lost the channels in slot(s) $slots; it reported them as erased. Your channel list here has been kept and was not overwritten.';
}
@override
String get channels_restoreToRadio => 'Restore to radio';
@override
String channels_restoreToRadioConfirm(int count, String names) {
return 'Write $count channel(s) back to the radio: $names. Channels the radio still has are left as they are.';
}
@override
String channels_restoreToRadioDone(int count) {
return 'The radio confirmed all $count channel(s).';
}
@override
String channels_restoreToRadioPartial(
int confirmed,
int total,
String names,
) {
return 'The radio confirmed $confirmed of $total channel(s). Not restored: $names.';
}
@override
String get channels_restoreToRadioFailed =>
'Could not restore the channel list: nothing cached to restore, or the radio is not connected.';
@override
String channels_slotIdentityChanged(String changes) {
return 'Channel layout changed on the radio: $changes';
}
@override
String get common_dismiss => 'Dismiss';
@override
String get channels_noChannelsFound => 'Inga kanaler hittades';
@ -1578,7 +1729,7 @@ class AppLocalizationsSv extends AppLocalizations {
String get channels_unmuteChannel => 'Slå på ljud för kanal';
@override
String get channels_notifications => 'Notifications';
String get channels_notifications => 'Phone notifications';
@override
String get channels_notifyAll => 'All messages';
@ -1589,6 +1740,24 @@ class AppLocalizationsSv extends AppLocalizations {
@override
String get channels_notifyOff => 'Off';
@override
String get channels_radioSound => 'Radio sound';
@override
String get channels_radioSoundUnset => 'Not set';
@override
String channels_radioSoundFollows(String scope) {
return 'Follows the radio: $scope';
}
@override
String get channels_radioSoundMutedByRadio =>
'Silent: the radio is set to None';
@override
String get channels_radioSoundReading => 'Reading from the radio';
@override
String get channels_deleteChannel => 'Ta bort kanal';
@ -1707,6 +1876,57 @@ class AppLocalizationsSv extends AppLocalizations {
@override
String get channels_publicChannelAdded => 'Allmänt kanal tillagd';
@override
String get channels_slotBusySyncing =>
'The radio is still updating its channel list. Try again in a moment.';
@override
String channels_slotsAllFull(int count) {
return 'All $count channel slots are in use. Delete a channel first.';
}
@override
String get channels_publicAlreadyAdded =>
'The Public channel is already on this radio.';
@override
String get channels_publicCannotBeRemoved =>
'Public can\'t be removed from the radio. It always stays in slot 0.';
@override
String get channels_writeOccupied =>
'The radio refused: another channel already uses that slot, and it was left unchanged. Try again now that the list has been re-read.';
@override
String get channels_writeRejected =>
'The radio refused the change, so the channel is as it was.';
@override
String get channels_writeNoReply =>
'The radio did not confirm the change. Check the channel list before trying again.';
@override
String get channels_writeClearedNotAdded =>
'The old channel was removed, but the radio did not confirm the new one, so that slot is now empty. Add the channel again.';
@override
String get channels_writeHistoryNotMoved =>
'The channel was changed, but its message history could not be moved to it. The app log has the details.';
@override
String get channels_keyChangeHistoryTitle => 'Keep message history?';
@override
String channels_keyChangeHistoryBody(String name) {
return 'This gives \"$name\" a new key. Keep its message history under the new key, or delete it? Deleted history cannot be recovered.';
}
@override
String get channels_keyChangeKeepHistory => 'Keep message history';
@override
String get channels_keyChangeDeleteHistory => 'Delete message history';
@override
String get channels_sortBy => 'Sortera efter';
@ -1909,7 +2129,7 @@ class AppLocalizationsSv extends AppLocalizations {
String get debugLog_downloadLog => 'Download logs';
@override
String get debugLog_shareSubject => 'Offband Meshcore logs';
String get debugLog_shareSubject => 'Offband MeshCore logs';
@override
String get debugLog_logSaved => 'Log saved';
@ -1917,6 +2137,10 @@ class AppLocalizationsSv extends AppLocalizations {
@override
String get debugLog_logUnavailable => 'No log available to export';
@override
String get debugLog_exportFailed =>
'Couldn\'t share the log. Please try again.';
@override
String get debugLog_copied => 'Felsökningslogg kopierad';
@ -2087,6 +2311,31 @@ class AppLocalizationsSv extends AppLocalizations {
String get channel_senderAddNotConnected =>
'Not connected to a radio, so this sender can\'t be added yet';
@override
String get channels_pskDerivedFromName => 'Key derives from the channel name';
@override
String get channels_communityDerived =>
'Derived from the community secret; manage it through the community';
@override
String get channels_copyPsk => 'Copy key';
@override
String get channels_pskCopied => 'Key copied';
@override
String get channels_hashtagMustKeepHash =>
'A hashtag channel\'s name must start with a single #. Add a new channel to use a different type.';
@override
String get channels_channelChangedReopen =>
'This channel changed on the radio while the dialog was open. Nothing was saved; reopen it and try again.';
@override
String get channel_slotUnconfirmedSendBlocked =>
'Message not sent: the radio hasn\'t confirmed this channel slot yet. Retry the channel sync, then send again.';
@override
String channel_senderAddFailed(String error) {
return 'Couldn\'t add this sender: $error';
@ -4235,9 +4484,6 @@ class AppLocalizationsSv extends AppLocalizations {
@override
String get channels_invalidQr => 'Invalid QR code';
@override
String get channels_noFreeSlot => 'No free channel slot';
@override
String channels_qrAddConfirm(String name) {
return 'Add channel \"$name\"?';
@ -5463,6 +5709,10 @@ class AppLocalizationsSv extends AppLocalizations {
String get importConfig_reasonNoFreeSlot =>
'no free channel slot on the device';
@override
String get importConfig_reasonChannelsSyncing =>
'channels were still being read from the radio; import again in a moment';
@override
String get importConfig_reasonAbsentFromFile => 'not present in this file';

@ -9,7 +9,7 @@ class AppLocalizationsUk extends AppLocalizations {
AppLocalizationsUk([String locale = 'uk']) : super(locale);
@override
String get appTitle => 'Offband Meshcore';
String get appTitle => 'Offband MeshCore';
@override
String get nav_contacts => 'Контакти';
@ -308,6 +308,14 @@ class AppLocalizationsUk extends AppLocalizations {
String get scanner_bluetoothOffMessage =>
'Будь ласка, увімкніть Bluetooth, щоб сканувати пристрої.';
@override
String get scanner_scanFailedLocationOff =>
'Location is off, so Android won\'t let the app scan for Bluetooth radios. Turn on Location in your phone\'s quick settings, then tap Scan again.';
@override
String get scanner_scanFailedGeneric =>
'Could not start the Bluetooth scan. Tap Scan to try again. If it keeps failing, turn Bluetooth off and on.';
@override
String get scanner_connectFailedTransient =>
'The radio refused the connection (a common transient Bluetooth error). It was already retried; wait a few seconds and try again.';
@ -659,6 +667,13 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone';
@override
String get settings_indicators => 'Display and LED';
@override
String get settings_indicatorsSubtitle =>
'Display mode, rotation, status LED';
@override
String get settings_badgeBattery => 'Battery';
@ -790,6 +805,75 @@ class AppLocalizationsUk extends AppLocalizations {
String get settings_rebootDeviceConfirm =>
'Ви впевнені, що хочете перезавантажити пристрій? Вас буде відключено.';
@override
String get settings_factoryReset => 'Factory Reset';
@override
String get settings_factoryResetSubtitle =>
'Erase the radio and give it a new identity';
@override
String get settings_factoryResetConfirm =>
'This erases everything on the radio: its identity, contacts, channels and settings. It can\'t be undone.';
@override
String get settings_factoryResetDeleteAppData =>
'Also delete this radio\'s data stored in the app';
@override
String get settings_factoryResetNotConnected =>
'Factory reset not sent: no radio is connected.';
@override
String get factoryResetSentTitle => 'Factory reset sent';
@override
String get factoryResetSentBody =>
'The radio is erasing itself and will restart with a new identity.';
@override
String get factoryResetSentBleBody =>
'The radio is erasing itself and will restart with a new identity. Its Bluetooth pairing is erased too: remove the old pairing for this radio in your phone or computer\'s Bluetooth settings, then reconnect.';
@override
String get factoryResetDeleteFailedTitle =>
'Factory reset sent, app cleanup failed';
@override
String get factoryResetDeleteFailedBody =>
'The reset was sent, but deleting this radio\'s data from the app failed.';
@override
String get settings_blePin => 'BLE pairing PIN';
@override
String get settings_blePinDefault => 'Firmware default';
@override
String get settings_blePinHelp =>
'6 digits (100000-999999). New phones and PCs must enter it when pairing. Reset to default to return to the firmware\'s built-in behavior.';
@override
String get settings_blePinInvalid =>
'Enter a 6-digit PIN between 100000 and 999999';
@override
String settings_blePinSaved(String pin) {
return 'PIN set to $pin. Already-paired devices may need to forget the radio and pair again.';
}
@override
String get settings_blePinResetDone =>
'PIN reset to the firmware default. Already-paired devices may need to forget the radio and pair again.';
@override
String settings_blePinSendFailed(String error) {
return 'The PIN was not changed: sending to the radio failed ($error)';
}
@override
String get settings_blePinResetAction => 'Reset to default';
@override
String get settings_debug => 'Налагодження';
@ -854,7 +938,7 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String settings_aboutVersion(String version) {
return 'Offband Meshcore v$version';
return 'Offband MeshCore v$version';
}
@override
@ -876,6 +960,26 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get settings_aboutDonate => 'Donate or sponsor';
@override
String get sponsorPrompt_title => 'Support Offband';
@override
String get sponsorPrompt_body =>
'Offband MeshCore is free, open source, and built by an independent developer. If it\'s useful to you, sponsoring helps keep it maintained and improving. Thank you for using it.';
@override
String get sponsorPrompt_sponsor => 'Sponsor';
@override
String get sponsorPrompt_anytime =>
'You can support Offband anytime from About in Settings, or at offband.org/donate.';
@override
String get sponsorPrompt_notNow => 'Not now';
@override
String get sponsorPrompt_never => 'Never Show Again';
@override
String get settings_aboutLinkFailed => 'Could not open the link';
@ -1563,6 +1667,53 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get channels_searchChannels => 'Пошук каналів...';
@override
String channels_unconfirmedSlots(String slots) {
return 'The radio did not confirm channel slot(s) $slots. Those channels are unknown until a sync succeeds.';
}
@override
String get channels_retrySync => 'Retry sync';
@override
String channels_erasedSlots(String slots) {
return 'The radio has lost the channels in slot(s) $slots; it reported them as erased. Your channel list here has been kept and was not overwritten.';
}
@override
String get channels_restoreToRadio => 'Restore to radio';
@override
String channels_restoreToRadioConfirm(int count, String names) {
return 'Write $count channel(s) back to the radio: $names. Channels the radio still has are left as they are.';
}
@override
String channels_restoreToRadioDone(int count) {
return 'The radio confirmed all $count channel(s).';
}
@override
String channels_restoreToRadioPartial(
int confirmed,
int total,
String names,
) {
return 'The radio confirmed $confirmed of $total channel(s). Not restored: $names.';
}
@override
String get channels_restoreToRadioFailed =>
'Could not restore the channel list: nothing cached to restore, or the radio is not connected.';
@override
String channels_slotIdentityChanged(String changes) {
return 'Channel layout changed on the radio: $changes';
}
@override
String get common_dismiss => 'Dismiss';
@override
String get channels_noChannelsFound => 'Каналів не знайдено';
@ -1592,7 +1743,7 @@ class AppLocalizationsUk extends AppLocalizations {
String get channels_unmuteChannel => 'Увімкнути сповіщення каналу';
@override
String get channels_notifications => 'Notifications';
String get channels_notifications => 'Phone notifications';
@override
String get channels_notifyAll => 'All messages';
@ -1603,6 +1754,24 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get channels_notifyOff => 'Off';
@override
String get channels_radioSound => 'Radio sound';
@override
String get channels_radioSoundUnset => 'Not set';
@override
String channels_radioSoundFollows(String scope) {
return 'Follows the radio: $scope';
}
@override
String get channels_radioSoundMutedByRadio =>
'Silent: the radio is set to None';
@override
String get channels_radioSoundReading => 'Reading from the radio';
@override
String get channels_deleteChannel => 'Видалити канал';
@ -1722,6 +1891,57 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get channels_publicChannelAdded => 'Публічний канал додано';
@override
String get channels_slotBusySyncing =>
'The radio is still updating its channel list. Try again in a moment.';
@override
String channels_slotsAllFull(int count) {
return 'All $count channel slots are in use. Delete a channel first.';
}
@override
String get channels_publicAlreadyAdded =>
'The Public channel is already on this radio.';
@override
String get channels_publicCannotBeRemoved =>
'Public can\'t be removed from the radio. It always stays in slot 0.';
@override
String get channels_writeOccupied =>
'The radio refused: another channel already uses that slot, and it was left unchanged. Try again now that the list has been re-read.';
@override
String get channels_writeRejected =>
'The radio refused the change, so the channel is as it was.';
@override
String get channels_writeNoReply =>
'The radio did not confirm the change. Check the channel list before trying again.';
@override
String get channels_writeClearedNotAdded =>
'The old channel was removed, but the radio did not confirm the new one, so that slot is now empty. Add the channel again.';
@override
String get channels_writeHistoryNotMoved =>
'The channel was changed, but its message history could not be moved to it. The app log has the details.';
@override
String get channels_keyChangeHistoryTitle => 'Keep message history?';
@override
String channels_keyChangeHistoryBody(String name) {
return 'This gives \"$name\" a new key. Keep its message history under the new key, or delete it? Deleted history cannot be recovered.';
}
@override
String get channels_keyChangeKeepHistory => 'Keep message history';
@override
String get channels_keyChangeDeleteHistory => 'Delete message history';
@override
String get channels_sortBy => 'Сортувати за';
@ -1921,7 +2141,7 @@ class AppLocalizationsUk extends AppLocalizations {
String get debugLog_downloadLog => 'Download logs';
@override
String get debugLog_shareSubject => 'Offband Meshcore logs';
String get debugLog_shareSubject => 'Offband MeshCore logs';
@override
String get debugLog_logSaved => 'Log saved';
@ -1929,6 +2149,10 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get debugLog_logUnavailable => 'No log available to export';
@override
String get debugLog_exportFailed =>
'Couldn\'t share the log. Please try again.';
@override
String get debugLog_copied => 'Журнал налагодження скопійовано';
@ -2102,6 +2326,31 @@ class AppLocalizationsUk extends AppLocalizations {
String get channel_senderAddNotConnected =>
'Not connected to a radio, so this sender can\'t be added yet';
@override
String get channels_pskDerivedFromName => 'Key derives from the channel name';
@override
String get channels_communityDerived =>
'Derived from the community secret; manage it through the community';
@override
String get channels_copyPsk => 'Copy key';
@override
String get channels_pskCopied => 'Key copied';
@override
String get channels_hashtagMustKeepHash =>
'A hashtag channel\'s name must start with a single #. Add a new channel to use a different type.';
@override
String get channels_channelChangedReopen =>
'This channel changed on the radio while the dialog was open. Nothing was saved; reopen it and try again.';
@override
String get channel_slotUnconfirmedSendBlocked =>
'Message not sent: the radio hasn\'t confirmed this channel slot yet. Retry the channel sync, then send again.';
@override
String channel_senderAddFailed(String error) {
return 'Couldn\'t add this sender: $error';
@ -4277,9 +4526,6 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get channels_invalidQr => 'Invalid QR code';
@override
String get channels_noFreeSlot => 'No free channel slot';
@override
String channels_qrAddConfirm(String name) {
return 'Add channel \"$name\"?';
@ -5525,6 +5771,10 @@ class AppLocalizationsUk extends AppLocalizations {
String get importConfig_reasonNoFreeSlot =>
'no free channel slot on the device';
@override
String get importConfig_reasonChannelsSyncing =>
'channels were still being read from the radio; import again in a moment';
@override
String get importConfig_reasonAbsentFromFile => 'not present in this file';

@ -9,7 +9,7 @@ class AppLocalizationsZh extends AppLocalizations {
AppLocalizationsZh([String locale = 'zh']) : super(locale);
@override
String get appTitle => 'Offband Meshcore';
String get appTitle => 'Offband MeshCore';
@override
String get nav_contacts => '联系人';
@ -294,6 +294,14 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get scanner_bluetoothOffMessage => '请开启蓝牙以搜索设备';
@override
String get scanner_scanFailedLocationOff =>
'Location is off, so Android won\'t let the app scan for Bluetooth radios. Turn on Location in your phone\'s quick settings, then tap Scan again.';
@override
String get scanner_scanFailedGeneric =>
'Could not start the Bluetooth scan. Tap Scan to try again. If it keeps failing, turn Bluetooth off and on.';
@override
String get scanner_connectFailedTransient =>
'The radio refused the connection (a common transient Bluetooth error). It was already retried; wait a few seconds and try again.';
@ -641,6 +649,13 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone';
@override
String get settings_indicators => 'Display and LED';
@override
String get settings_indicatorsSubtitle =>
'Display mode, rotation, status LED';
@override
String get settings_badgeBattery => 'Battery';
@ -761,6 +776,75 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get settings_rebootDeviceConfirm => '确定要重启设备吗?这将断开与设备的连接。';
@override
String get settings_factoryReset => 'Factory Reset';
@override
String get settings_factoryResetSubtitle =>
'Erase the radio and give it a new identity';
@override
String get settings_factoryResetConfirm =>
'This erases everything on the radio: its identity, contacts, channels and settings. It can\'t be undone.';
@override
String get settings_factoryResetDeleteAppData =>
'Also delete this radio\'s data stored in the app';
@override
String get settings_factoryResetNotConnected =>
'Factory reset not sent: no radio is connected.';
@override
String get factoryResetSentTitle => 'Factory reset sent';
@override
String get factoryResetSentBody =>
'The radio is erasing itself and will restart with a new identity.';
@override
String get factoryResetSentBleBody =>
'The radio is erasing itself and will restart with a new identity. Its Bluetooth pairing is erased too: remove the old pairing for this radio in your phone or computer\'s Bluetooth settings, then reconnect.';
@override
String get factoryResetDeleteFailedTitle =>
'Factory reset sent, app cleanup failed';
@override
String get factoryResetDeleteFailedBody =>
'The reset was sent, but deleting this radio\'s data from the app failed.';
@override
String get settings_blePin => 'BLE pairing PIN';
@override
String get settings_blePinDefault => 'Firmware default';
@override
String get settings_blePinHelp =>
'6 digits (100000-999999). New phones and PCs must enter it when pairing. Reset to default to return to the firmware\'s built-in behavior.';
@override
String get settings_blePinInvalid =>
'Enter a 6-digit PIN between 100000 and 999999';
@override
String settings_blePinSaved(String pin) {
return 'PIN set to $pin. Already-paired devices may need to forget the radio and pair again.';
}
@override
String get settings_blePinResetDone =>
'PIN reset to the firmware default. Already-paired devices may need to forget the radio and pair again.';
@override
String settings_blePinSendFailed(String error) {
return 'The PIN was not changed: sending to the radio failed ($error)';
}
@override
String get settings_blePinResetAction => 'Reset to default';
@override
String get settings_debug => '调试';
@ -822,7 +906,7 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String settings_aboutVersion(String version) {
return 'Offband Meshcore v$version';
return 'Offband MeshCore v$version';
}
@override
@ -844,6 +928,26 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get settings_aboutDonate => 'Donate or sponsor';
@override
String get sponsorPrompt_title => 'Support Offband';
@override
String get sponsorPrompt_body =>
'Offband MeshCore is free, open source, and built by an independent developer. If it\'s useful to you, sponsoring helps keep it maintained and improving. Thank you for using it.';
@override
String get sponsorPrompt_sponsor => 'Sponsor';
@override
String get sponsorPrompt_anytime =>
'You can support Offband anytime from About in Settings, or at offband.org/donate.';
@override
String get sponsorPrompt_notNow => 'Not now';
@override
String get sponsorPrompt_never => 'Never Show Again';
@override
String get settings_aboutLinkFailed => 'Could not open the link';
@ -1496,6 +1600,53 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get channels_searchChannels => '搜索频道...';
@override
String channels_unconfirmedSlots(String slots) {
return 'The radio did not confirm channel slot(s) $slots. Those channels are unknown until a sync succeeds.';
}
@override
String get channels_retrySync => 'Retry sync';
@override
String channels_erasedSlots(String slots) {
return 'The radio has lost the channels in slot(s) $slots; it reported them as erased. Your channel list here has been kept and was not overwritten.';
}
@override
String get channels_restoreToRadio => 'Restore to radio';
@override
String channels_restoreToRadioConfirm(int count, String names) {
return 'Write $count channel(s) back to the radio: $names. Channels the radio still has are left as they are.';
}
@override
String channels_restoreToRadioDone(int count) {
return 'The radio confirmed all $count channel(s).';
}
@override
String channels_restoreToRadioPartial(
int confirmed,
int total,
String names,
) {
return 'The radio confirmed $confirmed of $total channel(s). Not restored: $names.';
}
@override
String get channels_restoreToRadioFailed =>
'Could not restore the channel list: nothing cached to restore, or the radio is not connected.';
@override
String channels_slotIdentityChanged(String changes) {
return 'Channel layout changed on the radio: $changes';
}
@override
String get common_dismiss => 'Dismiss';
@override
String get channels_noChannelsFound => '未找到任何频道';
@ -1525,7 +1676,7 @@ class AppLocalizationsZh extends AppLocalizations {
String get channels_unmuteChannel => '取消静音频道';
@override
String get channels_notifications => 'Notifications';
String get channels_notifications => 'Phone notifications';
@override
String get channels_notifyAll => 'All messages';
@ -1536,6 +1687,24 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get channels_notifyOff => 'Off';
@override
String get channels_radioSound => 'Radio sound';
@override
String get channels_radioSoundUnset => 'Not set';
@override
String channels_radioSoundFollows(String scope) {
return 'Follows the radio: $scope';
}
@override
String get channels_radioSoundMutedByRadio =>
'Silent: the radio is set to None';
@override
String get channels_radioSoundReading => 'Reading from the radio';
@override
String get channels_deleteChannel => '删除频道';
@ -1651,6 +1820,57 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get channels_publicChannelAdded => '已添加公共频道';
@override
String get channels_slotBusySyncing =>
'The radio is still updating its channel list. Try again in a moment.';
@override
String channels_slotsAllFull(int count) {
return 'All $count channel slots are in use. Delete a channel first.';
}
@override
String get channels_publicAlreadyAdded =>
'The Public channel is already on this radio.';
@override
String get channels_publicCannotBeRemoved =>
'Public can\'t be removed from the radio. It always stays in slot 0.';
@override
String get channels_writeOccupied =>
'The radio refused: another channel already uses that slot, and it was left unchanged. Try again now that the list has been re-read.';
@override
String get channels_writeRejected =>
'The radio refused the change, so the channel is as it was.';
@override
String get channels_writeNoReply =>
'The radio did not confirm the change. Check the channel list before trying again.';
@override
String get channels_writeClearedNotAdded =>
'The old channel was removed, but the radio did not confirm the new one, so that slot is now empty. Add the channel again.';
@override
String get channels_writeHistoryNotMoved =>
'The channel was changed, but its message history could not be moved to it. The app log has the details.';
@override
String get channels_keyChangeHistoryTitle => 'Keep message history?';
@override
String channels_keyChangeHistoryBody(String name) {
return 'This gives \"$name\" a new key. Keep its message history under the new key, or delete it? Deleted history cannot be recovered.';
}
@override
String get channels_keyChangeKeepHistory => 'Keep message history';
@override
String get channels_keyChangeDeleteHistory => 'Delete message history';
@override
String get channels_sortBy => '排序方式';
@ -1847,7 +2067,7 @@ class AppLocalizationsZh extends AppLocalizations {
String get debugLog_downloadLog => 'Download logs';
@override
String get debugLog_shareSubject => 'Offband Meshcore logs';
String get debugLog_shareSubject => 'Offband MeshCore logs';
@override
String get debugLog_logSaved => 'Log saved';
@ -1855,6 +2075,10 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get debugLog_logUnavailable => 'No log available to export';
@override
String get debugLog_exportFailed =>
'Couldn\'t share the log. Please try again.';
@override
String get debugLog_copied => '调试日志已复制';
@ -2016,6 +2240,31 @@ class AppLocalizationsZh extends AppLocalizations {
String get channel_senderAddNotConnected =>
'Not connected to a radio, so this sender can\'t be added yet';
@override
String get channels_pskDerivedFromName => 'Key derives from the channel name';
@override
String get channels_communityDerived =>
'Derived from the community secret; manage it through the community';
@override
String get channels_copyPsk => 'Copy key';
@override
String get channels_pskCopied => 'Key copied';
@override
String get channels_hashtagMustKeepHash =>
'A hashtag channel\'s name must start with a single #. Add a new channel to use a different type.';
@override
String get channels_channelChangedReopen =>
'This channel changed on the radio while the dialog was open. Nothing was saved; reopen it and try again.';
@override
String get channel_slotUnconfirmedSendBlocked =>
'Message not sent: the radio hasn\'t confirmed this channel slot yet. Retry the channel sync, then send again.';
@override
String channel_senderAddFailed(String error) {
return 'Couldn\'t add this sender: $error';
@ -3998,9 +4247,6 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get channels_invalidQr => 'Invalid QR code';
@override
String get channels_noFreeSlot => 'No free channel slot';
@override
String channels_qrAddConfirm(String name) {
return 'Add channel \"$name\"?';
@ -4592,10 +4838,10 @@ class AppLocalizationsZh extends AppLocalizations {
String get settings_gpxExportAllContacts => '所有联系人位置';
@override
String get settings_gpxExportShareText => '来自 Offband Meshcore 的地图数据导出';
String get settings_gpxExportShareText => '来自 Offband MeshCore 的地图数据导出';
@override
String get settings_gpxExportShareSubject => 'Offband Meshcore GPX 地图数据导出';
String get settings_gpxExportShareSubject => 'Offband MeshCore GPX 地图数据导出';
@override
String get snrIndicator_nearByRepeaters => '附近的重复器';
@ -5158,6 +5404,10 @@ class AppLocalizationsZh extends AppLocalizations {
String get importConfig_reasonNoFreeSlot =>
'no free channel slot on the device';
@override
String get importConfig_reasonChannelsSyncing =>
'channels were still being read from the radio; import again in a moment';
@override
String get importConfig_reasonAbsentFromFile => 'not present in this file';

@ -8,7 +8,7 @@
}
},
"@@locale": "nl",
"appTitle": "Offband Meshcore",
"appTitle": "Offband MeshCore",
"nav_contacts": "Contacten",
"nav_channels": "Kanalen",
"nav_map": "Kaart",
@ -54,7 +54,7 @@
}
}
},
"scanner_title": "Offband Meshcore",
"scanner_title": "Offband MeshCore",
"scanner_scanning": "Scannen naar apparaten...",
"scanner_connecting": "Verbinden...",
"scanner_disconnecting": "Verbinding verbreken...",
@ -125,7 +125,7 @@
"settings_appDebugLog": "Debuglog van de app",
"settings_appDebugLogSubtitle": "Toepassingsdebugberichten",
"settings_about": "Over",
"settings_aboutVersion": "Offband Meshcore versie {version}",
"settings_aboutVersion": "Offband MeshCore versie {version}",
"@settings_aboutVersion": {
"placeholders": {
"version": {

@ -8,7 +8,7 @@
}
},
"@@locale": "pl",
"appTitle": "Offband Meshcore",
"appTitle": "Offband MeshCore",
"nav_contacts": "Kontakty",
"nav_channels": "Kanały",
"nav_map": "Mapa",
@ -125,7 +125,7 @@
"settings_appDebugLog": "Dziennik debugowania aplikacji",
"settings_appDebugLogSubtitle": "Komunikaty debugowania aplikacji",
"settings_about": "O aplikacji",
"settings_aboutVersion": "Offband Meshcore w wersji {version}",
"settings_aboutVersion": "Offband MeshCore w wersji {version}",
"@settings_aboutVersion": {
"placeholders": {
"version": {

@ -8,7 +8,7 @@
}
},
"@@locale": "pt",
"appTitle": "Offband Meshcore",
"appTitle": "Offband MeshCore",
"nav_contacts": "Contactos",
"nav_channels": "Canais",
"nav_map": "Mapa",
@ -125,7 +125,7 @@
"settings_appDebugLog": "Log de Depuração do Aplicativo",
"settings_appDebugLogSubtitle": "Mensagens de depuração do aplicativo",
"settings_about": "Sobre",
"settings_aboutVersion": "Offband Meshcore v{version}",
"settings_aboutVersion": "Offband MeshCore v{version}",
"@settings_aboutVersion": {
"placeholders": {
"version": {

@ -8,7 +8,7 @@
}
},
"@@locale": "ru",
"appTitle": "Offband Meshcore",
"appTitle": "Offband MeshCore",
"nav_contacts": "Контакты",
"nav_channels": "Каналы",
"nav_map": "Карта",
@ -41,7 +41,7 @@
"common_percentValue": "{percent}%",
"common_autoRefresh": "Автообновление",
"common_interval": "Интервал",
"scanner_title": "Offband Meshcore",
"scanner_title": "Offband MeshCore",
"scanner_scanning": "Поиск устройств...",
"scanner_connecting": "Подключение...",
"scanner_disconnecting": "Отключение...",
@ -102,7 +102,7 @@
"settings_appDebugLog": "Журнал отладки приложения",
"settings_appDebugLogSubtitle": "Сообщения отладки приложения",
"settings_about": "О программе",
"settings_aboutVersion": "Offband Meshcore v{version}",
"settings_aboutVersion": "Offband MeshCore v{version}",
"settings_aboutLegalese": "2026 MeshCore Open Source Project",
"settings_aboutDescription": "Открытое клиентское приложение на Flutter для устройств MeshCore с LoRa-сетями.",
"settings_infoName": "Имя",

@ -8,7 +8,7 @@
}
},
"@@locale": "sk",
"appTitle": "Offband Meshcore",
"appTitle": "Offband MeshCore",
"nav_contacts": "Kontakty",
"nav_channels": "Kanály",
"nav_map": "Mapa",

@ -8,7 +8,7 @@
}
},
"@@locale": "sl",
"appTitle": "Offband Meshcore",
"appTitle": "Offband MeshCore",
"nav_contacts": "Stiki",
"nav_channels": "Kanali",
"nav_map": "Karta",

@ -8,7 +8,7 @@
}
},
"@@locale": "sv",
"appTitle": "Offband Meshcore",
"appTitle": "Offband MeshCore",
"nav_contacts": "Kontakter",
"nav_channels": "Kanaler",
"nav_map": "Karta",
@ -125,7 +125,7 @@
"settings_appDebugLog": "Appfelsökning",
"settings_appDebugLogSubtitle": "Applikations felsökningsmeddelanden",
"settings_about": "Om",
"settings_aboutVersion": "Offband Meshcore version {version}",
"settings_aboutVersion": "Offband MeshCore version {version}",
"@settings_aboutVersion": {
"placeholders": {
"version": {

@ -8,7 +8,7 @@
}
},
"@@locale": "uk",
"appTitle": "Offband Meshcore",
"appTitle": "Offband MeshCore",
"nav_contacts": "Контакти",
"nav_channels": "Канали",
"nav_map": "Карта",
@ -126,7 +126,7 @@
"settings_appDebugLog": "Журнал налагодження застосунку",
"settings_appDebugLogSubtitle": "Повідомлення налагодження застосунку",
"settings_about": "Про застосунок",
"settings_aboutVersion": "Offband Meshcore v{version}",
"settings_aboutVersion": "Offband MeshCore v{version}",
"@settings_aboutVersion": {
"placeholders": {
"version": {

@ -8,7 +8,7 @@
}
},
"@@locale": "zh",
"appTitle": "Offband Meshcore",
"appTitle": "Offband MeshCore",
"nav_contacts": "联系人",
"nav_channels": "频道",
"nav_map": "地图",
@ -130,7 +130,7 @@
"settings_appDebugLog": "应用调试日志",
"settings_appDebugLogSubtitle": "应用调试消息",
"settings_about": "关于",
"settings_aboutVersion": "Offband Meshcore v{version}",
"settings_aboutVersion": "Offband MeshCore v{version}",
"@settings_aboutVersion": {
"placeholders": {
"version": {
@ -1728,8 +1728,8 @@
"settings_gpxExportAllSubtitle": "导出所有带位置的联系人到 GPX 文件",
"settings_gpxExportAllContacts": "所有联系人位置",
"settings_gpxExportNoContacts": "没有可导出的联系人",
"settings_gpxExportShareText": "来自 Offband Meshcore 的地图数据导出",
"settings_gpxExportShareSubject": "Offband Meshcore GPX 地图数据导出",
"settings_gpxExportShareText": "来自 Offband MeshCore 的地图数据导出",
"settings_gpxExportShareSubject": "Offband MeshCore GPX 地图数据导出",
"pathTrace_someHopsNoLocation": "某些跳缺少位置信息!",
"map_tapToAdd": "点击节点以添加到路径",
"pathTrace_clearTooltip": "清除路径",

@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'l10n/app_localizations.dart';
import 'package:provider/provider.dart';
@ -37,10 +38,12 @@ import 'services/storage_health_service.dart';
import 'services/radio_preset_service.dart';
import 'storage/drift/blob_store.dart';
import 'widgets/contact_sync_shortfall_banner.dart';
import 'widgets/factory_reset_banner.dart';
import 'widgets/storage_unavailable_banner.dart';
import 'storage/prefs_manager.dart';
import 'utils/app_logger.dart';
import 'widgets/keep_screen_awake.dart';
import 'widgets/sponsor_prompt.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
@ -114,6 +117,14 @@ void main() async {
appDebugLogService,
enabled: appSettingsService.settings.appDebugLogEnabled,
);
try {
final packageInfo = await PackageInfo.fromPlatform();
appLogger.appVersion = '${packageInfo.version}+${packageInfo.buildNumber}';
} catch (e) {
appLogger.error('Could not read the app version: $e', tag: 'App');
}
appLogger.info('App ${appLogger.appVersion} started', tag: 'App');
appSettingsService.logLoadReport();
// Initialize notification service
final notificationService = NotificationService();
@ -316,7 +327,7 @@ class MeshCoreApp extends StatelessWidget {
child: Consumer<AppSettingsService>(
builder: (context, settingsService, child) {
return MaterialApp(
title: 'Offband Meshcore',
title: 'Offband MeshCore',
debugShowCheckedModeBanner: false,
localizationsDelegates: const [
AppLocalizations.delegate,
@ -369,7 +380,11 @@ class MeshCoreApp extends StatelessWidget {
onDismiss: connector.dismissContactSyncShortfall,
onKeep: connector.resolveContactSyncKeepLocal,
onUseRadio: connector.resolveContactSyncUseRadio,
child: child ?? const SizedBox.shrink(),
child: FactoryResetBanner(
status: connector.factoryResetStatus,
onDismiss: connector.dismissFactoryResetStatus,
child: child ?? const SizedBox.shrink(),
),
),
),
),
@ -379,7 +394,7 @@ class MeshCoreApp extends StatelessWidget {
},
home: (PlatformInfo.isWeb && !PlatformInfo.isChrome)
? const ChromeRequiredScreen()
: const ScannerScreen(),
: const SponsorPromptGate(child: ScannerScreen()),
);
},
),

@ -25,8 +25,46 @@ class Channel {
bool get isEmpty => name.isEmpty && psk.every((b) => b == 0);
/// A slot the radio answered for that holds no usable channel.
///
/// Erased flash reads back as 0xFF, not 0x00, so such a record is NOT
/// [isEmpty]: the name decodes through `allowMalformed` into U+FFFD
/// replacement characters and the PSK is all-ones. Both halves are checked
/// because either one being unreadable makes the record unusable as a
/// channel identity, and the PSK is what every per-channel setting is keyed
/// by (#1060).
///
/// The client must never adopt one of these as a channel, and must never
/// persist a list containing one over a known-good cached list.
bool get isErased =>
name.contains('<EFBFBD>') || (psk.isNotEmpty && psk.every((b) => b == 0xFF));
/// A slot that carries no channel the client should hold, for either reason.
bool get isUnusable => isEmpty || isErased;
bool get isPublicChannel => pskHex == publicChannelPsk;
/// Whether [psk] is Public's fixed key. Public is known by its key, never by
/// its name or slot.
static bool isPublicKey(Uint8List psk) =>
_bytesToHex(psk) == publicChannelPsk;
/// The slot firmware reserves for Public (#1896): no other channel may be
/// written there, even while it is empty, and Public goes nowhere else.
static const int publicSlot = 0;
/// Whether a hashtag join of [hashtag] means Public itself, not the hashtag
/// channel `sha256("#public")` (owner 2026-10-28: "Of course add the real
/// public"). The same rule as the device's `namesPublic` (firmware #1896):
/// leading `#` and spaces and trailing spaces dropped, then "public" in any
/// case.
static bool hashtagMeansPublic(String hashtag) {
final bare = hashtag
.replaceFirst(RegExp(r'^[# ]+'), '')
.replaceFirst(RegExp(r' +$'), '');
return bare.toLowerCase() == 'public';
}
static Channel? fromFrame(Uint8List frame) {
// CHANNEL_INFO format:
// [0] = RESP_CODE_CHANNEL_INFO (18)

@ -28,12 +28,7 @@ class AppDebugLogScreen extends StatelessWidget {
icon: Icon(LogExport.icon),
onPressed: () => LogExport.shareLogs(
context,
webContent: () => entries
.map(
(entry) =>
'[${entry.formattedTime}] [${entry.levelLabel}] [${entry.tag}] ${entry.message}',
)
.join('\n'),
webContent: () => LogExport.appLogText(entries),
),
),
IconButton(
@ -41,12 +36,7 @@ class AppDebugLogScreen extends StatelessWidget {
icon: const Icon(Icons.copy),
onPressed: hasEntries
? () async {
final text = entries
.map(
(entry) =>
'[${entry.formattedTime}] [${entry.levelLabel}] [${entry.tag}] ${entry.message}',
)
.join('\n');
final text = LogExport.appLogText(entries);
await Clipboard.setData(ClipboardData(text: text));
if (!context.mounted) return;
showDismissibleSnackBar(

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert';
import 'dart:math' as math;
import 'package:flutter/foundation.dart' show defaultTargetPlatform;
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
@ -31,6 +32,7 @@ import '../services/corescope_service.dart';
import '../services/region_discovery_service.dart';
import '../widgets/channel_region_scope_dialog.dart';
import '../widgets/channel_notify_mode.dart';
import '../widgets/radio_channel_notify.dart';
import '../widgets/radio_tone.dart';
import '../services/block_service.dart';
import '../services/chat_text_scale_service.dart';
@ -181,6 +183,10 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
}
}
bool get _isTouchPlatform =>
defaultTargetPlatform == TargetPlatform.iOS ||
defaultTargetPlatform == TargetPlatform.android;
void _onTextFieldFocusChange() {
if (_textFieldFocusNode.hasFocus && mounted) {
_scrollController.handleKeyboardOpen();
@ -417,19 +423,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
MaterialPageRoute(builder: (context) => const SettingsScreen()),
),
appBarBuilder: (context, pinned) => AppBar(
// Pinned: the panel is already docked, so no hamburger is needed.
// Unpinned: this is a pushed route, so Scaffold would put a back arrow
// in this slot; the explicit hamburger replaces it. System back still
// pops to the channel list.
leading: pinned
? null
: Builder(
builder: (context) => IconButton(
icon: const Icon(Icons.menu),
tooltip: context.l10n.channels_title,
onPressed: () => Scaffold.of(context).openDrawer(),
),
),
// Explicit, because with a drawer present Scaffold would put a hamburger
// here, and iOS has no system back to fall back on (#1052).
leading: const BackButton(),
title: Row(
children: [
_channelIcon(_currentChannel),
@ -468,12 +464,23 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
centerTitle: false,
bottom: const SyncProgressAppBarBottom(),
actions: [
// Pinned: the panel is already docked, so no button is needed.
if (!pinned)
Builder(
builder: (context) => IconButton(
icon: const Icon(Icons.menu),
tooltip: context.l10n.channels_title,
onPressed: () => Scaffold.of(context).openDrawer(),
),
),
const RadioStatsIconButton(),
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert),
onSelected: (value) {
if (value == 'notifications') {
showChannelNotifyModeDialog(context, widget.channel);
} else if (value == 'radioSound') {
showRadioChannelNotifyDialog(context, _currentChannel);
} else if (value == 'radioTone') {
final connector = context.read<MeshCoreConnector>();
requestChannelRadioTone(connector, _currentChannel.index);
@ -507,6 +514,22 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
],
),
),
// #544: the RADIO's sound for this channel. Separate from
// Notifications above, which is the phone's; the two are
// independent settings and neither reads the other.
if (radioChannelNotifySupported(
context.read<MeshCoreConnector>(),
))
const PopupMenuItem(
value: 'radioSound',
child: Row(
children: [
Icon(Icons.volume_up_outlined, size: 20),
SizedBox(width: 12),
Text('Radio sound'),
],
),
),
if (context.read<MeshCoreConnector>().supportsTunes)
const PopupMenuItem(
value: 'radioTone',
@ -551,145 +574,160 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
child: Column(
children: [
Expanded(
child: Consumer<MeshCoreConnector>(
builder: (context, connector, child) {
final messages = connector.getChannelMessages(
_currentChannel,
);
final blockService = context.watch<BlockService>();
final visibleMessages = messages
.where(
(m) =>
!_isChannelSenderHidden(m, connector, blockService),
)
.toList();
if (visibleMessages.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
_currentChannel.isPublicChannel
? Icons.public
: Icons.tag,
size: 64,
color: Colors.grey[400],
// A touch outside a text field does not dismiss the keyboard on
// mobile, and the keyboard hides the bottom bar (#1052).
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: _textFieldFocusNode.unfocus,
child: Consumer<MeshCoreConnector>(
builder: (context, connector, child) {
final messages = connector.getChannelMessages(
_currentChannel,
);
final blockService = context.watch<BlockService>();
final visibleMessages = messages
.where(
(m) => !_isChannelSenderHidden(
m,
connector,
blockService,
),
const SizedBox(height: 16),
Text(
context.l10n.chat_noMessages,
style: TextStyle(
fontSize: 16,
color: Colors.grey[600],
)
.toList();
if (visibleMessages.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
_currentChannel.isPublicChannel
? Icons.public
: Icons.tag,
size: 64,
color: Colors.grey[400],
),
),
const SizedBox(height: 8),
Text(
context.l10n.chat_sendMessageToStart,
style: TextStyle(
fontSize: 14,
color: Colors.grey[500],
const SizedBox(height: 16),
Text(
context.l10n.chat_noMessages,
style: TextStyle(
fontSize: 16,
color: Colors.grey[600],
),
),
),
],
),
);
}
// Reverse messages so newest appear at bottom with reverse: true
final reversedMessages = visibleMessages.reversed.toList();
final itemCount =
reversedMessages.length + (_isLoadingOlder ? 1 : 0);
// Auto-scroll to bottom if user is already at bottom
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_channelSkipNextBottomSnap) {
_channelSkipNextBottomSnap = false;
return;
const SizedBox(height: 8),
Text(
context.l10n.chat_sendMessageToStart,
style: TextStyle(
fontSize: 14,
color: Colors.grey[500],
),
),
],
),
);
}
_scrollController.scrollToBottomIfAtBottom();
});
return Stack(
children: [
ChatZoomWrapper(
child: ListView.builder(
reverse: true, // List grows from bottom up
controller: _scrollController,
padding: const EdgeInsets.all(8),
itemCount: itemCount,
itemBuilder: (context, index) {
// Loading indicator now appears at end (bottom) of reversed list
if (_isLoadingOlder && index == itemCount - 1) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
// Reverse messages so newest appear at bottom with reverse: true
final reversedMessages = visibleMessages.reversed.toList();
final itemCount =
reversedMessages.length + (_isLoadingOlder ? 1 : 0);
// Auto-scroll to bottom if user is already at bottom
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_channelSkipNextBottomSnap) {
_channelSkipNextBottomSnap = false;
return;
}
_scrollController.scrollToBottomIfAtBottom();
});
return Stack(
children: [
ChatZoomWrapper(
child: ListView.builder(
reverse: true, // List grows from bottom up
controller: _scrollController,
keyboardDismissBehavior: _isTouchPlatform
? ScrollViewKeyboardDismissBehavior.onDrag
: ScrollViewKeyboardDismissBehavior.manual,
padding: const EdgeInsets.all(8),
itemCount: itemCount,
itemBuilder: (context, index) {
// Loading indicator now appears at end (bottom) of reversed list
if (_isLoadingOlder && index == itemCount - 1) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
),
),
),
),
);
}
final messageIndex = index;
final message = reversedMessages[messageIndex];
if (!_messageKeys.containsKey(message.messageId)) {
_messageKeys[message.messageId] = GlobalKey();
}
final isUnreadAnchor =
_unreadDividerMessageId != null &&
message.messageId == _unreadDividerMessageId;
// Day divider above the first message of each
// calendar day. List is reversed, so the older
// neighbour is at index+1.
final olderIdx = messageIndex + 1;
final showDayDivider =
olderIdx >= reversedMessages.length ||
!_isSameDay(
message.timestamp,
reversedMessages[olderIdx].timestamp,
);
return Container(
key: _messageKeys[message.messageId]!,
child: Builder(
builder: (context) {
final textScale = context
.select<ChatTextScaleService, double>(
(service) => service.scale,
);
final bubble = _buildMessageBubble(
message,
textScale,
}
final messageIndex = index;
final message = reversedMessages[messageIndex];
if (!_messageKeys.containsKey(
message.messageId,
)) {
_messageKeys[message.messageId] = GlobalKey();
}
final isUnreadAnchor =
_unreadDividerMessageId != null &&
message.messageId == _unreadDividerMessageId;
// Day divider above the first message of each
// calendar day. List is reversed, so the older
// neighbour is at index+1.
final olderIdx = messageIndex + 1;
final showDayDivider =
olderIdx >= reversedMessages.length ||
!_isSameDay(
message.timestamp,
reversedMessages[olderIdx].timestamp,
);
if (!showDayDivider && !isUnreadAnchor) {
return bubble;
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [
if (showDayDivider)
_buildDayDivider(
context,
message.timestamp,
),
if (isUnreadAnchor) const UnreadDivider(),
bubble,
],
);
},
),
);
},
return Container(
key: _messageKeys[message.messageId]!,
child: Builder(
builder: (context) {
final textScale = context
.select<ChatTextScaleService, double>(
(service) => service.scale,
);
final bubble = _buildMessageBubble(
message,
textScale,
);
if (!showDayDivider && !isUnreadAnchor) {
return bubble;
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [
if (showDayDivider)
_buildDayDivider(
context,
message.timestamp,
),
if (isUnreadAnchor)
const UnreadDivider(),
bubble,
],
);
},
),
);
},
),
),
),
JumpToBottomButton(scrollController: _scrollController),
],
);
},
JumpToBottomButton(scrollController: _scrollController),
],
);
},
),
),
),
_buildMessageComposer(),
@ -750,37 +788,41 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
showModalBottomSheet(
context: context,
showDragHandle: true,
isScrollControlled: true,
useSafeArea: true,
builder: (sheetContext) {
final l10n = sheetContext.l10n;
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: _buildAvatar(message.senderName),
title: Text(message.senderName),
subtitle: ambiguous
? Text(
l10n.channel_senderMultipleMatches(candidates.length),
)
: null,
),
const Divider(height: 1),
..._buildSenderContactEntries(sheetContext, message),
ListTile(
leading: Icon(Icons.block, color: Colors.red.shade700),
title: Text(l10n.block_sender),
onTap: () {
Navigator.pop(sheetContext);
_blockChannelSender(message);
},
),
ListTile(
leading: const Icon(Icons.close),
title: Text(l10n.common_cancel),
onTap: () => Navigator.pop(sheetContext),
),
],
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: _buildAvatar(message.senderName),
title: Text(message.senderName),
subtitle: ambiguous
? Text(
l10n.channel_senderMultipleMatches(candidates.length),
)
: null,
),
const Divider(height: 1),
..._buildSenderContactEntries(sheetContext, message),
ListTile(
leading: Icon(Icons.block, color: Colors.red.shade700),
title: Text(l10n.block_sender),
onTap: () {
Navigator.pop(sheetContext);
_blockChannelSender(message);
},
),
ListTile(
leading: const Icon(Icons.close),
title: Text(l10n.common_cancel),
onTap: () => Navigator.pop(sheetContext),
),
],
),
),
);
},
@ -1771,6 +1813,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
}
// end transform
// Refuse before clearing so the draft survives the refusal.
if (_refuseIfSlotUnconfirmed(connector)) return;
_textController.clear();
_cancelReply();
_textFieldFocusNode.requestFocus();
@ -1783,6 +1828,21 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
);
}
/// #1063: an outgoing write on a slot this connection has not confirmed
/// would persist under an unknown identity and be orphaned, so refuse at
/// the attempt and say why (a sync can confirm the slot seconds later, so
/// greying the composer would mislead).
bool _refuseIfSlotUnconfirmed(MeshCoreConnector connector) {
if (connector.canSendOnChannel(_currentChannel.index)) return false;
showDismissibleSnackBar(
context,
content: Text(context.l10n.channel_slotUnconfirmedSendBlocked),
backgroundColor: Theme.of(context).colorScheme.error,
persist: true,
);
return true;
}
/// One-click reply quoting the route a message took, hop count + path,
/// truncated with … to fit the channel byte budget, fired directly. (#106)
void _sendRouteReply(ChannelMessage message) {
@ -1816,6 +1876,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
if (connector.isChannelCyr2LatEnabled(_currentChannel.index)) {
text = Cyr2Lat.encode(text);
}
if (_refuseIfSlotUnconfirmed(connector)) return;
connector.sendChannelMessage(_currentChannel, text);
}
@ -2051,10 +2112,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
}
void _retryChannelMessage(ChannelMessage message) {
context.read<MeshCoreConnector>().sendChannelMessage(
_currentChannel,
message.text,
);
final connector = context.read<MeshCoreConnector>();
if (_refuseIfSlotUnconfirmed(connector)) return;
connector.sendChannelMessage(_currentChannel, message.text);
showDismissibleSnackBar(
context,
content: Text(context.l10n.chat_sendingAgain),
@ -2073,134 +2133,138 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
showModalBottomSheet(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.reply),
title: Text(context.l10n.chat_reply),
onTap: () {
Navigator.pop(sheetContext);
_setReplyingTo(message);
},
),
if (message.pathBytes.isNotEmpty)
ListTile(
leading: const Icon(Icons.alt_route),
title: Text(context.l10n.chat_replyWithRoute),
onTap: () {
Navigator.pop(sheetContext);
_sendRouteReply(message);
},
),
if (PlatformInfo.isDesktop)
ListTile(
leading: const Icon(Icons.route),
title: Text(context.l10n.chat_path),
onTap: () {
Navigator.pop(sheetContext);
_showMessagePathInfo(message);
},
),
// Offer resend on an outgoing channel message until a repeater
// repeats it back (repeatCount > 0). The radio-ack checkmark
// (status == sent) does NOT mean a repeat was heard, so gate on the
// repeat-back, not status. Channel sends aren't auto-retried, so
// this is the only recovery path. (#256, #555)
if (message.isOutgoing && message.repeatCount == 0)
ListTile(
leading: const Icon(Icons.refresh),
title: Text(context.l10n.message_sendAgain),
onTap: () {
Navigator.pop(sheetContext);
_retryChannelMessage(message);
},
),
// CoreScope observer count refresh (#524), owner feature. A large,
// reliable tap target vs the small inline badge.
if (message.isOutgoing && message.onAirHash != null)
ListTile(
leading: const Icon(Icons.cloud_outlined),
title: Text(context.l10n.channel_coreScopeRefresh),
subtitle: message.coreScopeObserverCount != null
? Text(_coreScopeLabel(message))
: null,
onTap: () {
Navigator.pop(sheetContext);
_refreshCoreScope(message);
},
),
// Can't react to your own messages
if (!message.isOutgoing)
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.add_reaction_outlined),
title: Text(context.l10n.chat_addReaction),
leading: const Icon(Icons.reply),
title: Text(context.l10n.chat_reply),
onTap: () {
Navigator.pop(sheetContext);
_showEmojiPicker(message);
_setReplyingTo(message);
},
),
ListTile(
leading: const Icon(Icons.copy),
title: Text(context.l10n.common_copy),
onTap: () {
Navigator.pop(sheetContext);
_copyMessageText(message.text);
},
),
if (canTranslateMessage)
if (message.pathBytes.isNotEmpty)
ListTile(
leading: const Icon(Icons.alt_route),
title: Text(context.l10n.chat_replyWithRoute),
onTap: () {
Navigator.pop(sheetContext);
_sendRouteReply(message);
},
),
if (PlatformInfo.isDesktop)
ListTile(
leading: const Icon(Icons.route),
title: Text(context.l10n.chat_path),
onTap: () {
Navigator.pop(sheetContext);
_showMessagePathInfo(message);
},
),
// Offer resend on an outgoing channel message until a repeater
// repeats it back (repeatCount > 0). The radio-ack checkmark
// (status == sent) does NOT mean a repeat was heard, so gate on the
// repeat-back, not status. Channel sends aren't auto-retried, so
// this is the only recovery path. (#256, #555)
if (message.isOutgoing && message.repeatCount == 0)
ListTile(
leading: const Icon(Icons.refresh),
title: Text(context.l10n.message_sendAgain),
onTap: () {
Navigator.pop(sheetContext);
_retryChannelMessage(message);
},
),
// CoreScope observer count refresh (#524), owner feature. A large,
// reliable tap target vs the small inline badge.
if (message.isOutgoing && message.onAirHash != null)
ListTile(
leading: const Icon(Icons.cloud_outlined),
title: Text(context.l10n.channel_coreScopeRefresh),
subtitle: message.coreScopeObserverCount != null
? Text(_coreScopeLabel(message))
: null,
onTap: () {
Navigator.pop(sheetContext);
_refreshCoreScope(message);
},
),
// Can't react to your own messages
if (!message.isOutgoing)
ListTile(
leading: const Icon(Icons.add_reaction_outlined),
title: Text(context.l10n.chat_addReaction),
onTap: () {
Navigator.pop(sheetContext);
_showEmojiPicker(message);
},
),
ListTile(
leading: const Icon(Icons.translate),
title: Text(context.l10n.translation_translateMessage),
leading: const Icon(Icons.copy),
title: Text(context.l10n.common_copy),
onTap: () {
Navigator.pop(sheetContext);
unawaited(
context.read<MeshCoreConnector>().translateChannelMessage(
_currentChannel.index,
message,
manualTranslation: true,
),
);
_copyMessageText(message.text);
},
),
if (!message.isOutgoing)
if (canTranslateMessage)
ListTile(
leading: const Icon(Icons.translate),
title: Text(context.l10n.translation_translateMessage),
onTap: () {
Navigator.pop(sheetContext);
unawaited(
context.read<MeshCoreConnector>().translateChannelMessage(
_currentChannel.index,
message,
manualTranslation: true,
),
);
},
),
if (!message.isOutgoing)
ListTile(
leading: const Icon(Icons.mark_chat_unread_outlined),
title: Text(context.l10n.chat_markAsUnread),
onTap: () {
Navigator.pop(sheetContext);
_markAsUnread(message);
},
),
ListTile(
leading: const Icon(Icons.mark_chat_unread_outlined),
title: Text(context.l10n.chat_markAsUnread),
onTap: () {
leading: const Icon(Icons.delete_outline),
title: Text(context.l10n.common_delete),
onTap: () async {
Navigator.pop(sheetContext);
_markAsUnread(message);
await _deleteMessage(message);
},
),
ListTile(
leading: const Icon(Icons.delete_outline),
title: Text(context.l10n.common_delete),
onTap: () async {
Navigator.pop(sheetContext);
await _deleteMessage(message);
},
),
// Same sender contact actions the avatar tap offers (#468), so
// long-press remains a superset of every gesture on the message.
if (!message.isOutgoing) ...[
const Divider(height: 1),
..._buildSenderContactEntries(sheetContext, message),
// Same sender contact actions the avatar tap offers (#468), so
// long-press remains a superset of every gesture on the message.
if (!message.isOutgoing) ...[
const Divider(height: 1),
..._buildSenderContactEntries(sheetContext, message),
ListTile(
leading: Icon(Icons.block, color: Colors.red.shade700),
title: Text(context.l10n.block_sender),
onTap: () {
Navigator.pop(sheetContext);
_blockChannelSender(message);
},
),
],
ListTile(
leading: Icon(Icons.block, color: Colors.red.shade700),
title: Text(context.l10n.block_sender),
onTap: () {
Navigator.pop(sheetContext);
_blockChannelSender(message);
},
leading: const Icon(Icons.close),
title: Text(context.l10n.common_cancel),
onTap: () => Navigator.pop(sheetContext),
),
],
ListTile(
leading: const Icon(Icons.close),
title: Text(context.l10n.common_cancel),
onTap: () => Navigator.pop(sheetContext),
),
],
),
),
),
);
@ -2229,6 +2293,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
message.text,
);
final reactionText = ReactionHelper.encodeReaction(hash, emojiIndex);
if (_refuseIfSlotUnconfirmed(connector)) return;
connector.sendChannelMessage(_currentChannel, reactionText);
}

@ -17,6 +17,7 @@ import '../models/channel_message.dart';
import '../models/app_settings.dart';
import '../models/contact.dart';
import '../widgets/adaptive_app_bar_title.dart';
import '../widgets/osm_attribution.dart';
class ChannelMessagePathScreen extends StatelessWidget {
final ChannelMessage message;
@ -606,8 +607,6 @@ class _ChannelMessagePathMapScreenState
TileLayer(
urlTemplate: kMapTileUrlTemplate,
tileProvider: tileCache.tileProvider,
userAgentPackageName:
MapTileCacheService.userAgentPackageName,
maxZoom: 19,
),
if (polylines.isNotEmpty)
@ -648,6 +647,7 @@ class _ChannelMessagePathMapScreenState
),
),
_buildLegendCard(context, hops, isImperial),
const OsmAttribution(),
],
),
),

File diff suppressed because it is too large Load Diff

@ -533,6 +533,12 @@ class _ChatScreenState extends State<ChatScreen> {
child: ListView.builder(
reverse: true, // List grows from bottom up
controller: _scrollController,
// A touch outside a text field does not dismiss the keyboard on mobile.
keyboardDismissBehavior:
defaultTargetPlatform == TargetPlatform.iOS ||
defaultTargetPlatform == TargetPlatform.android
? ScrollViewKeyboardDismissBehavior.onDrag
: ScrollViewKeyboardDismissBehavior.manual,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 16),
itemCount: itemCount,
itemBuilder: (context, index) {
@ -1570,94 +1576,98 @@ class _ChatScreenState extends State<ChatScreen> {
showModalBottomSheet(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Can't react to your own messages
if (!message.isOutgoing)
ListTile(
leading: const Icon(Icons.add_reaction_outlined),
title: Text(context.l10n.chat_addReaction),
onTap: () {
Navigator.pop(sheetContext);
_showEmojiPicker(message, contact);
},
),
if (PlatformInfo.isDesktop)
ListTile(
leading: const Icon(Icons.route),
title: Text(context.l10n.chat_path),
onTap: () {
Navigator.pop(sheetContext);
_openMessagePath(message, contact);
},
),
ListTile(
leading: const Icon(Icons.copy),
title: Text(context.l10n.common_copy),
onTap: () {
Navigator.pop(sheetContext);
_copyMessageText(message.text);
},
),
if (canTranslateMessage)
ListTile(
leading: const Icon(Icons.translate),
title: Text(context.l10n.translation_translateMessage),
onTap: () {
Navigator.pop(sheetContext);
unawaited(
context.read<MeshCoreConnector>().translateContactMessage(
widget.contact.publicKeyHex,
message,
manualTranslation: true,
),
);
},
),
if (!message.isOutgoing)
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Can't react to your own messages
if (!message.isOutgoing)
ListTile(
leading: const Icon(Icons.add_reaction_outlined),
title: Text(context.l10n.chat_addReaction),
onTap: () {
Navigator.pop(sheetContext);
_showEmojiPicker(message, contact);
},
),
if (PlatformInfo.isDesktop)
ListTile(
leading: const Icon(Icons.route),
title: Text(context.l10n.chat_path),
onTap: () {
Navigator.pop(sheetContext);
_openMessagePath(message, contact);
},
),
ListTile(
leading: const Icon(Icons.mark_chat_unread_outlined),
title: Text(context.l10n.chat_markAsUnread),
leading: const Icon(Icons.copy),
title: Text(context.l10n.common_copy),
onTap: () {
Navigator.pop(sheetContext);
_markAsUnread(message);
_copyMessageText(message.text);
},
),
ListTile(
leading: const Icon(Icons.delete_outline),
title: Text(context.l10n.common_delete),
onTap: () async {
Navigator.pop(sheetContext);
await _deleteMessage(message);
},
),
if (message.isOutgoing && message.status == MessageStatus.failed)
if (canTranslateMessage)
ListTile(
leading: const Icon(Icons.translate),
title: Text(context.l10n.translation_translateMessage),
onTap: () {
Navigator.pop(sheetContext);
unawaited(
context.read<MeshCoreConnector>().translateContactMessage(
widget.contact.publicKeyHex,
message,
manualTranslation: true,
),
);
},
),
if (!message.isOutgoing)
ListTile(
leading: const Icon(Icons.mark_chat_unread_outlined),
title: Text(context.l10n.chat_markAsUnread),
onTap: () {
Navigator.pop(sheetContext);
_markAsUnread(message);
},
),
ListTile(
leading: const Icon(Icons.refresh),
title: Text(context.l10n.message_sendAgain),
onTap: () {
leading: const Icon(Icons.delete_outline),
title: Text(context.l10n.common_delete),
onTap: () async {
Navigator.pop(sheetContext);
_retryMessage(message);
await _deleteMessage(message);
},
),
if (_resolveContact(context.read<MeshCoreConnector>()).type ==
advTypeRoom)
if (message.isOutgoing && message.status == MessageStatus.failed)
ListTile(
leading: const Icon(Icons.refresh),
title: Text(context.l10n.message_sendAgain),
onTap: () {
Navigator.pop(sheetContext);
_retryMessage(message);
},
),
if (_resolveContact(context.read<MeshCoreConnector>()).type ==
advTypeRoom)
ListTile(
leading: const Icon(Icons.chat),
title: Text(context.l10n.contacts_openChat),
onTap: () {
Navigator.pop(sheetContext);
_openChat(context, contact);
},
),
ListTile(
leading: const Icon(Icons.chat),
title: Text(context.l10n.contacts_openChat),
onTap: () {
Navigator.pop(sheetContext);
_openChat(context, contact);
},
leading: const Icon(Icons.close),
title: Text(context.l10n.common_cancel),
onTap: () => Navigator.pop(sheetContext),
),
ListTile(
leading: const Icon(Icons.close),
title: Text(context.l10n.common_cancel),
onTap: () => Navigator.pop(sheetContext),
),
],
],
),
),
),
);

@ -9,6 +9,7 @@ import '../models/community.dart';
import '../storage/community_store.dart';
import '../widgets/adaptive_app_bar_title.dart';
import '../widgets/qr_scanner_widget.dart';
import '../helpers/channel_slot_ui.dart';
import '../helpers/snack_bar_builder.dart';
/// Screen for scanning community QR codes to join communities.
@ -224,24 +225,41 @@ class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
_communityStore.setPublicKeyHex = connector.selfPublicKeyHex;
await _communityStore.addCommunity(community);
// Optionally add the community public channel to the device
// Optionally add the community public channel to the device. The
// community is already saved above, so a refused slot only skips the
// channel; it never loses the community.
//
// This used to skip the channel silently when the radio was full and then
// show a green "joined" toast, which told the user everything had worked
// when the channel had not been added (#1021). A refusal, or a write the
// radio did not confirm (#1026), now explains itself, and only a confirmed
// write shows the green confirmation.
var channelRefused = false;
if (addPublicChannel && context.mounted) {
final connector = context.read<MeshCoreConnector>();
final nextIndex = _findNextAvailableChannelIndex(connector);
if (nextIndex != null) {
final psk = community.deriveCommunityPublicPsk();
final psk = community.deriveCommunityPublicPsk();
final slot = allocateChannelSlotOrExplain(context, connector, psk: psk);
if (slot == null) {
channelRefused = true;
} else {
final channelName = '${community.name} Public';
connector.setChannel(nextIndex, channelName, psk);
final result = await connector.setChannel(slot, channelName, psk);
if (context.mounted) {
channelRefused = explainUnconfirmedChannelWrite(context, result);
}
}
}
if (context.mounted) {
showDismissibleSnackBar(
context,
content: Text(context.l10n.community_joined(community.name)),
backgroundColor: Colors.green,
);
// The refusal message is already showing and persists; a green toast
// on top of it would contradict it.
if (!channelRefused) {
showDismissibleSnackBar(
context,
content: Text(context.l10n.community_joined(community.name)),
backgroundColor: Colors.green,
);
}
// Return to previous screen
Navigator.pop(context, community);
@ -271,17 +289,11 @@ class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
}
return;
}
final nextIndex = _findNextAvailableChannelIndex(connector);
if (nextIndex == null) {
if (context.mounted) {
showDismissibleSnackBar(
context,
content: Text(context.l10n.channels_noFreeSlot),
backgroundColor: Colors.red,
);
}
return;
}
// No slot is chosen here. It used to be, and that is the same bug the
// channels screen had: the index was picked before the confirmation
// dialog below and written after it, so a channel sync during the
// confirmation (a channel write starts one) could leave it pointing at
// an occupied slot. The slot is now chosen after the user confirms.
if (!context.mounted) return;
final confirmed = await showDialog<bool>(
context: context,
@ -301,7 +313,22 @@ class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
),
);
if (confirmed == true && context.mounted) {
connector.setChannel(nextIndex, channel.name, channel.psk);
final slot = allocateChannelSlotOrExplain(
context,
connector,
psk: channel.psk,
);
// Refused: the persistent message explains why. Stay on this screen so
// the user can retry, rather than popping away from the explanation.
if (slot == null) return;
final result = await connector.setChannel(
slot,
channel.name,
channel.psk,
);
if (!context.mounted) return;
// Not confirmed: stay on this screen with the explanation showing.
if (explainUnconfirmedChannelWrite(context, result)) return;
showDismissibleSnackBar(
context,
content: Text(context.l10n.channels_qrAdded(channel.name)),
@ -312,12 +339,4 @@ class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
Navigator.pop(context);
}
}
int? _findNextAvailableChannelIndex(MeshCoreConnector connector) {
final usedIndices = connector.channels.map((c) => c.index).toSet();
for (int i = 0; i < connector.maxChannels; i++) {
if (!usedIndices.contains(i)) return i;
}
return null;
}
}

@ -1305,246 +1305,184 @@ class _ContactsScreenState extends State<ContactsScreen>
final isBlocked = blockService.isBlocked(contact.publicKeyHex);
final isSelf = blockService.isSelf(contact.publicKeyHex);
// Scrolls rather than clipping: a room server's 10 items outgrow the
// sheet's height on a small phone or at a large text size. (#1075)
showModalBottomSheet(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Stock parity: any node can carry a custom name, and the node's
// own name stays underneath where the user can still see it. (#750)
ListTile(
leading: const Icon(Icons.drive_file_rename_outline),
title: Text(context.l10n.contact_rename),
subtitle: contact.customName == null
? null
: Text(
context.l10n.contact_nodeCallsItself(
contact.receivedName,
),
),
onTap: () {
Navigator.pop(sheetContext);
showContactRenameDialog(context, contact);
},
),
// Same destination as the chat ellipsis -> Contact settings (#351).
// Kept above the destructive Block tile, which stays below.
ListTile(
leading: const Icon(Icons.settings_outlined),
title: Text(context.l10n.contact_settings),
onTap: () {
Navigator.pop(sheetContext);
showContactSettingsDialog(context, contact);
},
),
// Blocking your own node is never meaningful (#250).
if (!isSelf)
ListTile(
leading: Icon(
isBlocked ? Icons.check_circle_outline : Icons.block,
color: isBlocked ? null : Colors.red.shade700,
),
title: Text(
isBlocked
? context.l10n.block_unblock
: context.l10n.block_block,
),
onTap: () async {
Navigator.pop(sheetContext);
if (isBlocked) {
await blockService.unblock(contact.publicKeyHex);
} else {
await blockService.block(contact.publicKeyHex);
}
},
),
if (isRepeater) ...[
ListTile(
leading: const Icon(Icons.radar, color: Colors.green),
title: Text(context.l10n.contacts_pathTrace),
onTap: () async {
final connector = context.read<MeshCoreConnector>();
final navigator = Navigator.of(context);
final l10n = context.l10n;
final hw = connector.pathHashByteWidth;
final hopBytes = PathHelper.traceHopBytes(hw);
String hopsToHex(List<int> hops) => hops
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
// A committed route is stored non-zero; a flood route is
// all-zero = "no real route". (#150)
final committed = contact.pathBytesForDisplay;
final hasRoute =
committed.isNotEmpty && committed.any((b) => b != 0);
Uint8List? path;
var flip = false;
var title = l10n.contacts_repeaterPing;
if (hasRoute) {
// Firmware already committed a route through the mesh.
path = committed;
flip = true;
title = l10n.contacts_repeaterPathTrace;
} else {
// No committed route. Ask the passive topology oracle for a
// route inferred from traffic we've already heard. (#186)
final inferred = connector.topology?.inferRoute(
contact.publicKey,
);
final direct =
(inferred?.isEmpty ?? false) ||
connector.directRepeaters.any(
(r) => r.matchesPathStart(contact.publicKey),
);
if (direct) {
// A one-hop ping reaches a direct neighbour.
path = Uint8List.fromList(
contact.publicKey.sublist(0, hopBytes),
);
} else {
// Confirm-first: pre-fill the route builder with the
// inferred route when we have one, else an empty builder.
// The user always confirms, we never fire a silent guess.
// (#186; replaces the old strongest-repeater guess.)
final suggested =
(inferred != null && inferred.isNotEmpty)
? inferred.map(hopsToHex).join(',')
: null;
String? suggestedLabel;
if (inferred != null && inferred.isNotEmpty) {
// Resolve the inferred hops to repeater/contact NAMES so
// the user can read + verify the route instead of hex.
// (#186)
final names = PathHelper.resolvePathNames(
inferred.expand((h) => h).toList(),
connector.allContacts,
hw,
);
suggestedLabel =
'${l10n.pathTrace_you} → $names → ${contact.name}';
}
final picked = await PathSelectionDialog.show(
context,
availableContacts: connector.allContacts
.where(
(c) => c.publicKeyHex != contact.publicKeyHex,
)
.toList(),
pathHashByteWidth: hw,
initialPath: suggested,
suggestedRouteLabel: suggestedLabel,
title: l10n.contacts_repeaterPathTrace,
);
if (picked == null || picked.isEmpty) return; // cancelled
path = picked;
flip = true;
final fh = picked.length >= hopBytes
? picked.sublist(0, hopBytes)
: picked;
title = l10n.contacts_repeaterPathTraceVia(
hopsToHex(fh).toUpperCase(),
);
}
}
navigator.push(
MaterialPageRoute(
builder: (context) => PathTraceMapScreen(
title: title,
path: path!,
flipPathAround: flip,
targetContact: contact,
pathHashByteWidth: hw,
),
),
);
},
),
ListTile(
leading: const Icon(Icons.cell_tower, color: Colors.orange),
title: Text(context.l10n.contacts_manageRepeater),
onTap: () {
Navigator.pop(sheetContext);
_showRepeaterLogin(context, contact);
},
),
] else if (isRoom) ...[
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Stock parity: any node can carry a custom name, and the node's
// own name stays underneath where the user can still see it. (#750)
ListTile(
leading: const Icon(Icons.radar, color: Colors.green),
title: Text(context.l10n.contacts_pathTrace),
onTap: () {
final hw = context
.read<MeshCoreConnector>()
.pathHashByteWidth;
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PathTraceMapScreen(
title: contact.pathBytesForDisplay.isNotEmpty
? context.l10n.contacts_roomPathTrace
: context.l10n.contacts_roomPing,
path: contact.pathBytesForDisplay.isNotEmpty
? contact.pathBytesForDisplay
: Uint8List.fromList(
contact.publicKey.sublist(
0,
PathHelper.traceHopBytes(hw),
),
),
flipPathAround: contact.pathBytesForDisplay.isNotEmpty,
targetContact: contact,
pathHashByteWidth: hw,
leading: const Icon(Icons.drive_file_rename_outline),
title: Text(context.l10n.contact_rename),
subtitle: contact.customName == null
? null
: Text(
context.l10n.contact_nodeCallsItself(
contact.receivedName,
),
),
),
);
},
),
ListTile(
leading: const Icon(Icons.room, color: Colors.blue),
title: Text(context.l10n.contacts_roomLogin),
onTap: () {
Navigator.pop(sheetContext);
_showRoomLogin(context, contact, RoomLoginDestination.chat);
showContactRenameDialog(context, contact);
},
),
// Same destination as the chat ellipsis -> Contact settings (#351).
// Kept above the destructive Block tile, which stays below.
ListTile(
leading: const Icon(
Icons.room_preferences,
color: Colors.orange,
),
title: Text(context.l10n.room_management),
leading: const Icon(Icons.settings_outlined),
title: Text(context.l10n.contact_settings),
onTap: () {
Navigator.pop(sheetContext);
_showRoomLogin(
context,
contact,
RoomLoginDestination.management,
);
showContactSettingsDialog(context, contact);
},
),
] else ...[
// Sensors run the same admin login and CLI as repeaters
// (stock SensorMesh), so they share the repeater admin hub (#745).
if (contact.type == advTypeSensor)
// Blocking your own node is never meaningful (#250).
if (!isSelf)
ListTile(
leading: const Icon(
Icons.sensors_outlined,
color: Colors.orange,
leading: Icon(
isBlocked ? Icons.check_circle_outline : Icons.block,
color: isBlocked ? null : Colors.red.shade700,
),
title: Text(context.l10n.contacts_manageSensor),
title: Text(
isBlocked
? context.l10n.block_unblock
: context.l10n.block_block,
),
onTap: () async {
Navigator.pop(sheetContext);
if (isBlocked) {
await blockService.unblock(contact.publicKeyHex);
} else {
await blockService.block(contact.publicKeyHex);
}
},
),
if (isRepeater) ...[
ListTile(
leading: const Icon(Icons.radar, color: Colors.green),
title: Text(context.l10n.contacts_pathTrace),
onTap: () async {
final connector = context.read<MeshCoreConnector>();
final navigator = Navigator.of(context);
final l10n = context.l10n;
final hw = connector.pathHashByteWidth;
final hopBytes = PathHelper.traceHopBytes(hw);
String hopsToHex(List<int> hops) => hops
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
// A committed route is stored non-zero; a flood route is
// all-zero = "no real route". (#150)
final committed = contact.pathBytesForDisplay;
final hasRoute =
committed.isNotEmpty && committed.any((b) => b != 0);
Uint8List? path;
var flip = false;
var title = l10n.contacts_repeaterPing;
if (hasRoute) {
// Firmware already committed a route through the mesh.
path = committed;
flip = true;
title = l10n.contacts_repeaterPathTrace;
} else {
// No committed route. Ask the passive topology oracle for a
// route inferred from traffic we've already heard. (#186)
final inferred = connector.topology?.inferRoute(
contact.publicKey,
);
final direct =
(inferred?.isEmpty ?? false) ||
connector.directRepeaters.any(
(r) => r.matchesPathStart(contact.publicKey),
);
if (direct) {
// A one-hop ping reaches a direct neighbour.
path = Uint8List.fromList(
contact.publicKey.sublist(0, hopBytes),
);
} else {
// Confirm-first: pre-fill the route builder with the
// inferred route when we have one, else an empty builder.
// The user always confirms, we never fire a silent guess.
// (#186; replaces the old strongest-repeater guess.)
final suggested =
(inferred != null && inferred.isNotEmpty)
? inferred.map(hopsToHex).join(',')
: null;
String? suggestedLabel;
if (inferred != null && inferred.isNotEmpty) {
// Resolve the inferred hops to repeater/contact NAMES so
// the user can read + verify the route instead of hex.
// (#186)
final names = PathHelper.resolvePathNames(
inferred.expand((h) => h).toList(),
connector.allContacts,
hw,
);
suggestedLabel =
'${l10n.pathTrace_you} → $names → ${contact.name}';
}
final picked = await PathSelectionDialog.show(
context,
availableContacts: connector.allContacts
.where(
(c) => c.publicKeyHex != contact.publicKeyHex,
)
.toList(),
pathHashByteWidth: hw,
initialPath: suggested,
suggestedRouteLabel: suggestedLabel,
title: l10n.contacts_repeaterPathTrace,
);
if (picked == null || picked.isEmpty) {
return; // cancelled
}
path = picked;
flip = true;
final fh = picked.length >= hopBytes
? picked.sublist(0, hopBytes)
: picked;
title = l10n.contacts_repeaterPathTraceVia(
hopsToHex(fh).toUpperCase(),
);
}
}
navigator.push(
MaterialPageRoute(
builder: (context) => PathTraceMapScreen(
title: title,
path: path!,
flipPathAround: flip,
targetContact: contact,
pathHashByteWidth: hw,
),
),
);
},
),
ListTile(
leading: const Icon(Icons.cell_tower, color: Colors.orange),
title: Text(context.l10n.contacts_manageRepeater),
onTap: () {
Navigator.pop(sheetContext);
_showRepeaterLogin(context, contact);
},
),
if (contact.pathLength > 0)
] else if (isRoom) ...[
ListTile(
leading: const Icon(Icons.radar, color: Colors.green),
title: Text(context.l10n.contacts_chatTraceRoute),
title: Text(context.l10n.contacts_pathTrace),
onTap: () {
final hw = context
.read<MeshCoreConnector>()
@ -1553,11 +1491,19 @@ class _ContactsScreenState extends State<ContactsScreen>
context,
MaterialPageRoute(
builder: (context) => PathTraceMapScreen(
title: context.l10n.contacts_pathTraceTo(
contact.name,
),
path: contact.pathBytesForDisplay,
flipPathAround: true,
title: contact.pathBytesForDisplay.isNotEmpty
? context.l10n.contacts_roomPathTrace
: context.l10n.contacts_roomPing,
path: contact.pathBytesForDisplay.isNotEmpty
? contact.pathBytesForDisplay
: Uint8List.fromList(
contact.publicKey.sublist(
0,
PathHelper.traceHopBytes(hw),
),
),
flipPathAround:
contact.pathBytesForDisplay.isNotEmpty,
targetContact: contact,
pathHashByteWidth: hw,
),
@ -1565,61 +1511,124 @@ class _ContactsScreenState extends State<ContactsScreen>
);
},
),
ListTile(
leading: const Icon(Icons.room, color: Colors.blue),
title: Text(context.l10n.contacts_roomLogin),
onTap: () {
Navigator.pop(sheetContext);
_showRoomLogin(context, contact, RoomLoginDestination.chat);
},
),
ListTile(
leading: const Icon(
Icons.room_preferences,
color: Colors.orange,
),
title: Text(context.l10n.room_management),
onTap: () {
Navigator.pop(sheetContext);
_showRoomLogin(
context,
contact,
RoomLoginDestination.management,
);
},
),
] else ...[
// Sensors run the same admin login and CLI as repeaters
// (stock SensorMesh), so they share the repeater admin hub (#745).
if (contact.type == advTypeSensor)
ListTile(
leading: const Icon(
Icons.sensors_outlined,
color: Colors.orange,
),
title: Text(context.l10n.contacts_manageSensor),
onTap: () {
Navigator.pop(sheetContext);
_showRepeaterLogin(context, contact);
},
),
if (contact.pathLength > 0)
ListTile(
leading: const Icon(Icons.radar, color: Colors.green),
title: Text(context.l10n.contacts_chatTraceRoute),
onTap: () {
final hw = context
.read<MeshCoreConnector>()
.pathHashByteWidth;
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PathTraceMapScreen(
title: context.l10n.contacts_pathTraceTo(
contact.name,
),
path: contact.pathBytesForDisplay,
flipPathAround: true,
targetContact: contact,
pathHashByteWidth: hw,
),
),
);
},
),
ListTile(
leading: const Icon(Icons.chat),
title: Text(context.l10n.contacts_openChat),
onTap: () {
Navigator.pop(sheetContext);
_openChat(context, contact);
},
),
],
ListTile(
leading: const Icon(Icons.chat),
title: Text(context.l10n.contacts_openChat),
onTap: () {
leading: Icon(
isFavorite ? Icons.star : Icons.star_border,
color: Colors.amber[700],
),
title: Text(
isFavorite
? context.l10n.listFilter_removeFromFavorites
: context.l10n.listFilter_addToFavorites,
),
onTap: () async {
Navigator.pop(sheetContext);
_openChat(context, contact);
await connector.setContactFlags(
contact,
isFavorite: !isFavorite,
);
},
),
],
ListTile(
leading: Icon(
isFavorite ? Icons.star : Icons.star_border,
color: Colors.amber[700],
ListTile(
leading: const Icon(Icons.copy),
title: Text(context.l10n.contacts_ShareContact),
onTap: () {
Navigator.pop(sheetContext);
_contactExport(contact.publicKey);
},
),
title: Text(
isFavorite
? context.l10n.listFilter_removeFromFavorites
: context.l10n.listFilter_addToFavorites,
ListTile(
leading: const Icon(Icons.connect_without_contact),
title: Text(context.l10n.contacts_ShareContactZeroHop),
onTap: () {
Navigator.pop(sheetContext);
_contactZeroHop(contact.publicKey);
},
),
onTap: () async {
Navigator.pop(sheetContext);
await connector.setContactFlags(
contact,
isFavorite: !isFavorite,
);
},
),
ListTile(
leading: const Icon(Icons.copy),
title: Text(context.l10n.contacts_ShareContact),
onTap: () {
Navigator.pop(sheetContext);
_contactExport(contact.publicKey);
},
),
ListTile(
leading: const Icon(Icons.connect_without_contact),
title: Text(context.l10n.contacts_ShareContactZeroHop),
onTap: () {
Navigator.pop(sheetContext);
_contactZeroHop(contact.publicKey);
},
),
ListTile(
leading: const Icon(Icons.delete, color: Colors.red),
title: Text(
context.l10n.contacts_deleteContact,
style: const TextStyle(color: Colors.red),
ListTile(
leading: const Icon(Icons.delete, color: Colors.red),
title: Text(
context.l10n.contacts_deleteContact,
style: const TextStyle(color: Colors.red),
),
onTap: () {
Navigator.pop(sheetContext);
_confirmDelete(context, connector, contact);
},
),
onTap: () {
Navigator.pop(sheetContext);
_confirmDelete(context, connector, contact);
},
),
],
],
),
),
),
);

@ -323,6 +323,8 @@ String _reasonText(BuildContext context, StockConfigImportIssue reason) {
return l10n.importConfig_reasonAlreadyPresent;
case StockConfigImportIssue.noFreeSlot:
return l10n.importConfig_reasonNoFreeSlot;
case StockConfigImportIssue.channelsSyncing:
return l10n.importConfig_reasonChannelsSyncing;
case StockConfigImportIssue.notWritable:
return l10n.importConfig_reasonNotWritable;
}

@ -18,6 +18,7 @@ import '../utils/route_transitions.dart';
import '../connector/meshcore_connector.dart';
import '../widgets/app_bar.dart';
import '../widgets/app_shell.dart';
import '../widgets/osm_attribution.dart';
import '../icons/los_icon.dart';
class LineOfSightEndpoint {
@ -422,6 +423,9 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
.watch<MeshCoreConnector>()
.getTotalChannelsUnreadCount(),
appBar: AppBar(
// Explicit, because with a drawer present Scaffold would put a hamburger
// here, opening an empty panel, and iOS has no system back (#1052).
leading: const BackButton(),
title: AppBarTitle(widget.title),
centerTitle: true,
actions: [
@ -483,7 +487,6 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
TileLayer(
urlTemplate: kMapTileUrlTemplate,
tileProvider: tileCache.tileProvider,
userAgentPackageName: MapTileCacheService.userAgentPackageName,
maxZoom: 19,
),
if (_result != null && _result!.segments.isNotEmpty)
@ -514,7 +517,8 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
if (!_showHud && _result != null && _result!.segments.isNotEmpty)
Positioned(
left: 12,
bottom: 12,
// Clears the OSM credit below it.
bottom: 32,
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.black54,
@ -532,6 +536,8 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
),
),
),
// Bottom-left: the action button sits bottom-right.
const OsmAttribution(alignment: Alignment.bottomLeft),
],
),
floatingActionButton: FloatingActionButton(

@ -9,6 +9,7 @@ import '../l10n/l10n.dart';
import '../services/app_settings_service.dart';
import '../services/map_tile_cache_service.dart';
import '../widgets/adaptive_app_bar_title.dart';
import '../widgets/osm_attribution.dart';
import '../helpers/snack_bar_builder.dart';
class MapCacheScreen extends StatefulWidget {
@ -321,8 +322,6 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
TileLayer(
urlTemplate: kMapTileUrlTemplate,
tileProvider: tileCache.tileProvider,
userAgentPackageName:
MapTileCacheService.userAgentPackageName,
maxZoom: 19,
),
if (selectedBounds != null)
@ -354,6 +353,7 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
),
),
),
const OsmAttribution(),
],
),
),

@ -30,6 +30,7 @@ import '../icons/los_icon.dart';
import 'channels_screen.dart';
import 'chat_screen.dart';
import 'contacts_screen.dart';
import '../widgets/osm_attribution.dart';
import '../widgets/repeater_login_dialog.dart';
import '../widgets/room_login_dialog.dart';
import '../helpers/snack_bar_builder.dart';
@ -551,8 +552,6 @@ class _MapScreenState extends State<MapScreen> {
TileLayer(
urlTemplate: kMapTileUrlTemplate,
tileProvider: tileCache.tileProvider,
userAgentPackageName:
MapTileCacheService.userAgentPackageName,
maxZoom: 19,
),
if (_polylines.isNotEmpty && _isBuildingPathTrace)
@ -654,6 +653,8 @@ class _MapScreenState extends State<MapScreen> {
hasPathSelector: _isBuildingPathTrace,
),
if (_isBuildingPathTrace) _buildPathTraceOverlay(),
// Bottom-left: the filter button sits bottom-right.
const OsmAttribution(alignment: Alignment.bottomLeft),
],
),
floatingActionButton: FloatingActionButton(

@ -14,6 +14,7 @@ import 'package:meshcore_open/models/contact.dart';
import 'package:meshcore_open/services/app_settings_service.dart';
import 'package:meshcore_open/services/map_tile_cache_service.dart';
import 'package:meshcore_open/utils/app_logger.dart';
import 'package:meshcore_open/widgets/osm_attribution.dart';
import 'package:meshcore_open/widgets/snr_indicator.dart';
import 'package:provider/provider.dart';
@ -667,6 +668,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
),
if (_hasData)
_buildLegendCard(context, _traceData!, isImperial),
if (_hasData) const OsmAttribution(),
],
),
),
@ -967,7 +969,6 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
TileLayer(
urlTemplate: kMapTileUrlTemplate,
tileProvider: tileCache.tileProvider,
userAgentPackageName: MapTileCacheService.userAgentPackageName,
maxZoom: 19,
),
if (_polylines.isNotEmpty) PolylineLayer(polylines: _polylines),

@ -1,5 +1,6 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../utils/platform_info.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:provider/provider.dart';
@ -13,6 +14,7 @@ import '../widgets/battery_optimization_banner.dart';
import '../widgets/device_tile.dart';
import '../helpers/snack_bar_builder.dart';
import 'channels_screen.dart';
import 'settings/app_only_settings_screen.dart';
import 'tcp_screen.dart';
import 'usb_screen.dart';
@ -58,17 +60,7 @@ class _ScannerScreenState extends State<ScannerScreen> {
// out of it either. The first-connect path for those transports is not
// affected: their own screen sits on top while connecting, so
// isCurrentRoute is false here and they still navigate themselves.
_changedNavigation = true;
_routingIntoApp = true;
if (mounted) {
Navigator.of(context)
.push(
MaterialPageRoute(builder: (context) => const ChannelsScreen()),
)
.then((_) {
if (mounted) _routingIntoApp = false;
});
}
_routeIntoApp();
}
};
@ -92,6 +84,38 @@ class _ScannerScreenState extends State<ScannerScreen> {
);
}
void _routeIntoApp() {
_changedNavigation = true;
_routingIntoApp = true;
if (mounted) {
Navigator.of(context)
.push(MaterialPageRoute(builder: (context) => const ChannelsScreen()))
.then((_) {
if (mounted) _routingIntoApp = false;
});
}
}
/// App-level settings, reachable with no radio so a user whose radio will
/// not connect can still share the log (#693).
Future<void> _openAppSettings() async {
await Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => const AppOnlySettingsScreen()));
// A connection that completed while settings were open routed nowhere,
// because this screen was not the current route. Route in now, as the
// connection listener would have, and under the same condition: only if
// this screen is current again, not covered by some other route. The
// settings route is already out of the "present" set by the time its
// future completes, so it does not count against that check.
if (mounted &&
(ModalRoute.of(context)?.isCurrent ?? true) &&
_connector.state == MeshCoreConnectionState.connected &&
!_routingIntoApp) {
_routeIntoApp();
}
}
@override
void dispose() {
_connector.removeListener(_connectionListener);
@ -121,6 +145,13 @@ class _ScannerScreenState extends State<ScannerScreen> {
title: AdaptiveAppBarTitle(context.l10n.scanner_title),
centerTitle: true,
automaticallyImplyLeading: false,
actions: [
IconButton(
icon: const Icon(Icons.settings),
tooltip: context.l10n.settings_title,
onPressed: _openAppSettings,
),
],
),
body: BatteryOptimizationBanner(
child: SafeArea(
@ -205,11 +236,8 @@ class _ScannerScreenState extends State<ScannerScreen> {
connector.stopScan();
} else {
unawaited(
connector.startScan().catchError((e) {
appLogger.warn(
'startScan error: $e',
tag: 'ScannerScreen',
);
connector.startScan().catchError((Object e) {
_reportScanFailure(e);
}),
);
}
@ -391,6 +419,35 @@ class _ScannerScreenState extends State<ScannerScreen> {
}
}
/// A scan that cannot start must say so (#1033). It used to be logged only,
/// so a phone with Location off showed nothing while every tap failed. The
/// detail goes to the log; the user gets a plain-language message that
/// stays until dismissed. Scan stays pressable: a later tap tries again.
void _reportScanFailure(Object e) {
appLogger.error('Scan failed: $e', tag: 'ScannerScreen');
if (!mounted) return;
showDismissibleSnackBar(
context,
content: Text(_describeScanError(e)),
backgroundColor: Colors.red,
persist: true,
);
}
/// On Android 11 and below the BLE plugin refuses to scan while system
/// Location is off, with this exact message
/// (`FlutterBluePlusPlugin.java:505-507`). Anything else, including a
/// reworded plugin message, gets the generic text rather than nothing.
String _describeScanError(Object e) {
final l10n = context.l10n;
if (e is PlatformException &&
e.code == 'startScan' &&
(e.message ?? '').contains('Location services are required')) {
return l10n.scanner_scanFailedLocationOff;
}
return l10n.scanner_scanFailedGeneric;
}
String _describeConnectError(Object e) {
final l10n = context.l10n;
if (e is FlutterBluePlusException && e.code == 133) {

@ -0,0 +1,139 @@
import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../helpers/snack_bar_builder.dart';
import '../../l10n/l10n.dart';
import '../../utils/platform_info.dart';
/// The About pane: app identity, version, outbound links and licenses.
///
/// Needs no radio, so both the device Settings screen and the app-only
/// settings reachable from the scanner show it (#693). Outbound links
/// (offband.org / Play / donate) added by #527.
class AboutView extends StatefulWidget {
const AboutView({super.key});
@override
State<AboutView> createState() => _AboutViewState();
}
class _AboutViewState extends State<AboutView> {
String _appVersion = '';
@override
void initState() {
super.initState();
_loadVersionInfo();
}
Future<void> _loadVersionInfo() async {
final packageInfo = await PackageInfo.fromPlatform();
if (!mounted) return;
setState(() {
_appVersion = '${packageInfo.version}+${packageInfo.buildNumber}';
});
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
leading: const Icon(Icons.info_outline),
title: Text(l10n.appTitle),
subtitle: Text(
l10n.settings_aboutVersion(
_appVersion.isEmpty ? l10n.common_loading : _appVersion,
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.settings_aboutDescription),
const SizedBox(height: 12),
Text(
l10n.settings_aboutLegalese,
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 4),
Text(
l10n.settings_aboutPresetsAttribution,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.language),
title: Text(l10n.settings_aboutWebsite),
trailing: const Icon(Icons.open_in_new, size: 18),
onTap: () => _openExternalUrl('https://offband.org'),
),
// App Store guideline 2.3.10 bars naming other marketplaces (#1048).
if (!PlatformInfo.isIOS)
ListTile(
leading: const Icon(Icons.shop_outlined),
title: Text(l10n.settings_aboutPlayStore),
trailing: const Icon(Icons.open_in_new, size: 18),
onTap: () => _openExternalUrl(
'https://play.google.com/store/apps/details?id=app.offband.meshcore',
),
),
// Hidden on iOS for the first App Store submission (#1051).
if (!PlatformInfo.isIOS)
ListTile(
leading: const Icon(Icons.favorite_outline),
title: Text(l10n.settings_aboutDonate),
trailing: const Icon(Icons.open_in_new, size: 18),
onTap: () => _openExternalUrl('https://offband.org/donate'),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.description_outlined),
title: Text(l10n.settings_aboutLicenses),
trailing: const Icon(Icons.chevron_right),
onTap: () => showLicensePage(
context: context,
applicationName: l10n.appTitle,
applicationVersion: _appVersion.isEmpty ? null : _appVersion,
applicationLegalese: l10n.settings_aboutLegalese,
),
),
],
),
),
],
);
}
Future<void> _openExternalUrl(String url) async {
try {
if (await launchUrl(
Uri.parse(url),
mode: LaunchMode.externalApplication,
)) {
return;
}
} catch (_) {
// fall through to the failure message
}
if (mounted) {
showDismissibleSnackBar(
context,
content: Text(context.l10n.settings_aboutLinkFailed),
persist: true,
);
}
}
}

@ -0,0 +1,43 @@
import 'package:flutter/material.dart';
import '../../l10n/l10n.dart';
import 'about_view.dart';
import 'app_settings_view.dart';
import 'debug_logs_view.dart';
import 'settings_shell.dart';
/// The settings that need no radio, opened from the scanner (#693).
///
/// A user whose radio will not connect still has to be able to share the log,
/// so this offers only app-level categories. Everything that reads or writes
/// the radio stays in the device Settings screen, which is reachable only
/// while connected. Add a category here only if it works with no radio.
class AppOnlySettingsScreen extends StatelessWidget {
const AppOnlySettingsScreen({super.key});
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return SettingsShell(
title: l10n.settings_title,
categories: [
SettingsCategory(
icon: Icons.tune,
title: l10n.settings_appSettings,
subtitle: l10n.settings_appSettingsSubtitle,
builder: (_) => const AppSettingsView(),
),
SettingsCategory(
icon: Icons.build_outlined,
title: l10n.settings_debug,
builder: (_) => const DebugLogsView(),
),
SettingsCategory(
icon: Icons.info_outline,
title: l10n.settings_about,
builder: (_) => const AboutView(),
),
],
);
}
}

@ -0,0 +1,136 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../helpers/snack_bar_builder.dart';
import '../../l10n/l10n.dart';
import '../../services/app_debug_log_service.dart';
import '../../services/app_settings_service.dart';
import '../../utils/log_export.dart';
import '../app_debug_log_screen.dart';
import '../ble_debug_log_screen.dart';
/// The radio-independent rows of the Debug pane: share the log, the app debug
/// logging switch, and the two log viewers.
///
/// Split out of the device Settings Debug pane (#693) so the scanner's
/// app-only settings can offer them with no radio connected. The radio rows
/// (Device CLI, serial capture, mesh topology) stay in device Settings.
class DebugLogTiles extends StatelessWidget {
const DebugLogTiles({super.key});
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final settingsService = context.watch<AppSettingsService>();
return Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: Icon(LogExport.icon),
title: Text(LogExport.tooltip(context)),
onTap: () => LogExport.shareLogs(
context,
webContent: () => LogExport.appLogText(
context.read<AppDebugLogService>().entries.reversed,
),
),
),
const Divider(height: 1),
SwitchListTile(
secondary: const Icon(Icons.bug_report_outlined),
title: Text(l10n.appSettings_appDebugLogging),
subtitle: Text(l10n.appSettings_appDebugLoggingSubtitle),
value: settingsService.settings.appDebugLogEnabled,
onChanged: (value) async {
try {
await settingsService.setAppDebugLogEnabled(value);
} catch (_) {
if (context.mounted) {
showDismissibleSnackBar(
context,
content: const Text(
'Could not change debug logging. Please try again.',
),
persist: true,
);
}
return;
}
if (!context.mounted) return;
showDismissibleSnackBar(
context,
content: Text(
value
? l10n.appSettings_appDebugLoggingEnabled
: l10n.appSettings_appDebugLoggingDisabled,
),
duration: const Duration(seconds: 2),
);
},
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.bluetooth_outlined),
title: Text(l10n.settings_companionDebugLog),
subtitle: Text(l10n.settings_companionDebugLogSubtitle),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const BleDebugLogScreen(),
),
);
},
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.code_outlined),
title: Text(l10n.settings_appDebugLog),
subtitle: Text(l10n.settings_appDebugLogSubtitle),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const AppDebugLogScreen(),
),
);
},
),
],
);
}
}
/// The Debug pane of the app-only settings: the log rows in their own card.
class DebugLogsView extends StatelessWidget {
const DebugLogsView({super.key});
@override
Widget build(BuildContext context) {
return ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
context.l10n.settings_debug,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
),
const DebugLogTiles(),
],
),
),
],
);
}
}

@ -65,7 +65,15 @@ class _DeviceUiViewState extends State<DeviceUiView> {
DeviceUiSurface.eventTone ||
DeviceUiSurface.tunePlay ||
DeviceUiSurface.tuneSlot => widget.part == DeviceUiPart.buzzer,
DeviceUiSurface.gpsMode || DeviceUiSurface.screenSettings => false,
// The radio's per-channel notify control lives beside the app's own
// per-channel control on the Channels screen, not in this pane (#544
// Epic C), so a refusal for it is not this screen's to show.
DeviceUiSurface.gpsMode ||
DeviceUiSurface.screenSettings ||
DeviceUiSurface.channelNotify ||
DeviceUiSurface.display ||
DeviceUiSurface.led ||
DeviceUiSurface.rotation => false,
};
@override

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

Loading…
Cancel
Save

Powered by TurnKey Linux.