Compare commits
362 Commits
v2026.0302
...
365bd76cbc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
365bd76cbc | ||
|
|
ba27843166 | ||
|
|
3e1113035d | ||
|
|
89359c5e34 | ||
|
|
0406b59d3b | ||
|
|
c176ed0775 | ||
|
|
6062f91c2d | ||
|
|
e1400cf1ec | ||
|
|
b040a8bd3a | ||
|
|
c847bd6465 | ||
|
|
132a576785 | ||
|
|
9b8b1bf473 | ||
|
|
0008eff1b8 | ||
|
|
2fe125d6ba | ||
|
|
5834454087 | ||
|
|
73e78691ed | ||
|
|
d067095345 | ||
|
|
0b463f6454 | ||
|
|
a472b0c05c | ||
|
|
4181b33da1 | ||
|
|
f9dfdc2340 | ||
|
|
b333b3737e | ||
|
|
7245d76e17 | ||
|
|
7a76ba97f8 | ||
|
|
d00198c829 | ||
|
|
e6cd59e96f | ||
|
|
b863cda139 | ||
|
|
4bd7f36b98 | ||
|
|
44a668df93 | ||
|
|
e5514b4ef9 | ||
|
|
d92e52c5c2 | ||
|
|
daa88fb471 | ||
|
|
c182813c35 | ||
|
|
81944bfd3c | ||
|
|
43eb857663 | ||
|
|
faa396769e | ||
|
|
1a35b790a7 | ||
|
|
573921e002 | ||
|
|
14a41b210e | ||
|
|
56a7fa2fc6 | ||
|
|
7f4b4c4560 | ||
|
|
7f7f4aa975 | ||
|
|
1baab06627 | ||
|
|
5320fa1a2b | ||
|
|
512b64db9f | ||
|
|
4690ceead1 | ||
|
|
d431a79aa8 | ||
|
|
d66bbca82c | ||
|
|
d2fee94678 | ||
|
|
61a856e84c | ||
|
|
5feb07aad7 | ||
|
|
59931f402a | ||
|
|
0cd563063a | ||
|
|
9244368b49 | ||
|
|
c089db5c62 | ||
|
|
7c1bc3b84f | ||
|
|
724f4b38e8 | ||
|
|
caefbdbcef | ||
|
|
79f8f8dd04 | ||
|
|
e4eb40cd34 | ||
|
|
7c9dfd6f63 | ||
|
|
3e8d4c2d11 | ||
|
|
3a28acac2d | ||
|
|
5ee03d4b2f | ||
|
|
3dfdbd7700 | ||
|
|
043faea06a | ||
|
|
05ddb6841a | ||
|
|
3392e5f9c1 | ||
|
|
7df750c8d5 | ||
|
|
e09d418084 | ||
|
|
96aeec1301 | ||
|
|
6978f24440 | ||
|
|
ba0f1fc141 | ||
|
|
28a9235168 | ||
|
|
3e3c9a34d5 | ||
|
|
e85dccea35 | ||
|
|
019224d955 | ||
|
|
4c048a5a50 | ||
|
|
2b42e7dd15 | ||
|
|
7ea6334f02 | ||
|
|
6f0a66dc68 | ||
|
|
a26ae96acd | ||
|
|
a6dc137e51 | ||
|
|
c2501442e3 | ||
|
|
71eac21df9 | ||
|
|
c299349b6a | ||
|
|
ab05f13de9 | ||
|
|
0e61dc5bf1 | ||
|
|
d45924d7b4 | ||
|
|
a24e699e22 | ||
|
|
7486837ad0 | ||
|
|
059125c714 | ||
|
|
9279ff2158 | ||
|
|
dc297b0b9f | ||
|
|
2d24481aba | ||
|
|
20a1df94eb | ||
|
|
5cc81bef65 | ||
|
|
6145462555 | ||
|
|
721fb7e7a2 | ||
|
|
5a58e45e13 | ||
|
|
de4258c0a4 | ||
|
|
d8cb35c595 | ||
|
|
8da69705ee | ||
|
|
e474168ba8 | ||
|
|
03dba8b3ce | ||
|
|
bc9e37bd22 | ||
|
|
a32d5d2c85 | ||
|
|
e683ab933f | ||
|
|
f286b0afd1 | ||
|
|
68ec72534d | ||
|
|
6af8b62d9b | ||
|
|
01fbb7de6c | ||
|
|
9303d24b69 | ||
|
|
140088fe2f | ||
|
|
67d8e1c906 | ||
|
|
29b75a3155 | ||
|
|
87cb890877 | ||
|
|
095458ece4 | ||
|
|
5659242672 | ||
|
|
3da4ba8fb2 | ||
|
|
89dc1f2862 | ||
|
|
251932bb47 | ||
|
|
b48139964a | ||
|
|
d29b85feb8 | ||
|
|
c01ef7a059 | ||
|
|
fa2b586ab7 | ||
|
|
bbd5d108cb | ||
|
|
e17f24a6bc | ||
|
|
372224a62c | ||
|
|
e4c8709ee2 | ||
|
|
3c469b62d0 | ||
|
|
1e97ba9831 | ||
|
|
6187067017 | ||
|
|
cd165d2372 | ||
|
|
59f6df4260 | ||
|
|
464fc5e715 | ||
|
|
a76ee4f8f6 | ||
|
|
c884f81ca5 | ||
|
|
993563f059 | ||
|
|
cfd233d38e | ||
|
|
9b14ff4e1a | ||
|
|
84cd8eec00 | ||
|
|
434d3df53e | ||
|
|
1fe260b95e | ||
|
|
d3d53995a6 | ||
|
|
efbddc8831 | ||
|
|
0e3acfa4ca | ||
|
|
450b8382e8 | ||
|
|
420690d683 | ||
|
|
fedd7b9a2b | ||
|
|
c82e0c3452 | ||
|
|
a55cfffa59 | ||
|
|
2bb5b353bd | ||
|
|
991421d097 | ||
|
|
1dcd85f178 | ||
|
|
5d2f61749e | ||
|
|
69377d90a4 | ||
|
|
3e559a4f4e | ||
|
|
a124e3d9f2 | ||
|
|
3bf4971279 | ||
|
|
e355f28ca9 | ||
|
|
7a9be3eb76 | ||
|
|
650f3c66aa | ||
|
|
df993cd176 | ||
|
|
feb3145854 | ||
|
|
da40acf950 | ||
|
|
66eb32bf44 | ||
|
|
c2e0fa952e | ||
|
|
4557fb4b9e | ||
|
|
c05de2cf0a | ||
|
|
c5145c2fca | ||
|
|
e502bc23b7 | ||
|
|
62bf33c9d1 | ||
|
|
7be1460ef6 | ||
|
|
30c273c656 | ||
|
|
5712feba53 | ||
|
|
6de12225fc | ||
|
|
6e9e9e397d | ||
|
|
a3147e9d7c | ||
|
|
5e1912d9f2 | ||
|
|
b42328bd82 | ||
|
|
1b801a10ef | ||
|
|
06632ee32d | ||
|
|
e7a481f97b | ||
|
|
9f31f50e61 | ||
|
|
d4237b7569 | ||
|
|
b35c11c804 | ||
|
|
be2cf53374 | ||
|
|
13a729492f | ||
|
|
9d8594fe05 | ||
|
|
249268dd00 | ||
|
|
63aa9619e6 | ||
|
|
a7ad4bb8ec | ||
|
|
636e114428 | ||
|
|
d5684c51c7 | ||
|
|
ced39b6992 | ||
|
|
083b715039 | ||
|
|
36584be0ab | ||
|
|
dc19296804 | ||
|
|
40072175e3 | ||
|
|
11f3bf9f97 | ||
|
|
24e715d01e | ||
|
|
6e17de3866 | ||
|
|
90877a1891 | ||
|
|
77e43843bf | ||
|
|
1e70f52069 | ||
|
|
5e404944e9 | ||
|
|
53daf2c9a7 | ||
|
|
c36d63a0ad | ||
|
|
69ec2dfdb5 | ||
|
|
d2e6b692f3 | ||
|
|
baa1aa6edd | ||
|
|
38cb3dc031 | ||
|
|
abb73a4c65 | ||
|
|
2d1e6c0ca4 | ||
|
|
b38a8c55d6 | ||
|
|
ca872f2049 | ||
|
|
1b6ff59af3 | ||
|
|
e31bfac2ac | ||
|
|
99bb446ee9 | ||
|
|
088f3d37ef | ||
|
|
7f200e5334 | ||
|
|
ae16417886 | ||
|
|
467371946d | ||
|
|
2d23328ad0 | ||
|
|
0dfb84bf4e | ||
|
|
193a193e8a | ||
|
|
c1d1ec7d0c | ||
|
|
b415b63b4b | ||
|
|
818b9dbfb2 | ||
|
|
8085b89c07 | ||
|
|
86771a3c55 | ||
|
|
4b668ac2e9 | ||
|
|
9deb563c05 | ||
|
|
15ac3e2798 | ||
|
|
97addf7ba6 | ||
|
|
025bd737e7 | ||
|
|
07b675dec6 | ||
|
|
d675ce18dd | ||
|
|
a8e31bb2e8 | ||
|
|
3cd96ba961 | ||
|
|
a98fc6314e | ||
|
|
ae1ea71f58 | ||
|
|
4dff79cd7b | ||
|
|
cbcb329c70 | ||
|
|
7abf96abf2 | ||
|
|
13bf74178c | ||
|
|
66a00c9569 | ||
|
|
b60fa008eb | ||
|
|
d113f7a881 | ||
|
|
2ab8681ac4 | ||
|
|
de5fb1e164 | ||
|
|
dae7e67a8a | ||
|
|
d51924021f | ||
|
|
0f91292fd7 | ||
|
|
b471ab0990 | ||
|
|
771cddb80d | ||
|
|
bfd15065f0 | ||
|
|
f72db76538 | ||
|
|
a96f96ca4c | ||
|
|
9f62055eef | ||
|
|
6b2f18130c | ||
|
|
922b40b755 | ||
|
|
17d7c43745 | ||
|
|
e026c1dbbd | ||
|
|
9ff24c1fcb | ||
|
|
948a22c4d2 | ||
|
|
05153c9ceb | ||
|
|
cd508d5f74 | ||
|
|
937f91e496 | ||
|
|
1f826ae4c2 | ||
|
|
2e15ccb3f3 | ||
|
|
aa0777d4a3 | ||
|
|
e904d53d8b | ||
|
|
c4aef05dcf | ||
|
|
1c3af4f927 | ||
|
|
fe2e08c4e4 | ||
|
|
3433eae3a6 | ||
|
|
79c8664d04 | ||
|
|
162376cc47 | ||
|
|
adb8fe32f4 | ||
|
|
5966ddfbc1 | ||
|
|
1867f815d2 | ||
|
|
4010680da0 | ||
|
|
6bfc08f3ae | ||
|
|
fa37a20fc7 | ||
|
|
09f61740d2 | ||
|
|
97c1f3b27d | ||
|
|
cf70aa2bfb | ||
|
|
40e4db7b7f | ||
|
|
2c2e7b392a | ||
|
|
fd7d6afb70 | ||
|
|
3307a93640 | ||
|
|
8a4b29b968 | ||
|
|
0e4f727e26 | ||
|
|
4e76898c8d | ||
|
|
7214c73aea | ||
|
|
beb960cf62 | ||
|
|
30cac1bcbf | ||
|
|
95e6aa35e2 | ||
|
|
2bc0e21cf0 | ||
|
|
f6c5a3a4ca | ||
|
|
810897d348 | ||
|
|
bb1c1f455d | ||
|
|
b0eded3780 | ||
|
|
f1d56e2000 | ||
|
|
01afcd2ea7 | ||
|
|
70a6156e8a | ||
|
|
1856dce27a | ||
|
|
485ae995c3 | ||
|
|
0e1a56d765 | ||
|
|
ca8e8e6ecb | ||
|
|
43f5702666 | ||
|
|
85d0b26c67 | ||
|
|
7cd5351921 | ||
|
|
96feb75712 | ||
|
|
f6779e8776 | ||
|
|
a9d062dc34 | ||
|
|
3680b38302 | ||
|
|
e3902cea1e | ||
|
|
2482f94cd0 | ||
|
|
2b34bc4162 | ||
|
|
03c0c7ad38 | ||
|
|
42fc1f9cd2 | ||
|
|
7807ad7471 | ||
|
|
310eee1ff5 | ||
|
|
b8f03079e7 | ||
|
|
ab0c531a4b | ||
|
|
9709511e90 | ||
|
|
162d5333ce | ||
|
|
0cb1d49804 | ||
|
|
234edf5bb0 | ||
|
|
2d9cb0ddb9 | ||
|
|
76b093685e | ||
|
|
55a85659d3 | ||
|
|
972a9ba944 | ||
|
|
f4e3f2e834 | ||
|
|
50a4322f44 | ||
|
|
c83eaa4d98 | ||
|
|
5beac70644 | ||
|
|
99cd378e6a | ||
|
|
6a52e7cfa1 | ||
|
|
af2e969640 | ||
|
|
b12567f613 | ||
|
|
13b91e3cc9 | ||
|
|
40d786eb25 | ||
|
|
b6aa0bcfaa | ||
|
|
f31083694f | ||
|
|
1460bdb51f | ||
|
|
537d98e2d4 | ||
|
|
7ffc46438f | ||
|
|
efab2cd057 | ||
|
|
d6077d1588 | ||
|
|
84a6f81c26 | ||
|
|
c6b3b86c2f | ||
|
|
d1e6aaafbe | ||
|
|
3036ba620c | ||
|
|
9854dd2034 | ||
|
|
e9a08fab4d | ||
|
|
9814c95cee | ||
|
|
85ebf51bcb | ||
|
|
e85e290265 |
140
.github/workflows/build-artifacts.yml
vendored
@@ -8,9 +8,11 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
env:
|
||||
FLUTTER_VERSION: "3.35.6"
|
||||
FLUTTER_VERSION: "3.41.4"
|
||||
APP_NAME: meshcore-sar
|
||||
ANDROID_NDK_VERSION: "27.0.12077973"
|
||||
|
||||
@@ -24,7 +26,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
@@ -38,6 +40,17 @@ jobs:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
channel: stable
|
||||
cache: true
|
||||
pub-cache: true
|
||||
cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}
|
||||
pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }}
|
||||
|
||||
- name: Setup Ruby
|
||||
if: github.event_name == 'release'
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: "3.3"
|
||||
bundler-cache: true
|
||||
working-directory: android
|
||||
|
||||
- name: Cache Android NDK
|
||||
uses: actions/cache@v4
|
||||
@@ -50,22 +63,76 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Validate Android release secrets
|
||||
if: github.event_name == 'release'
|
||||
env:
|
||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
|
||||
run: |
|
||||
test -n "$ANDROID_KEYSTORE_BASE64"
|
||||
test -n "$ANDROID_KEYSTORE_PASSWORD"
|
||||
test -n "$ANDROID_KEY_ALIAS"
|
||||
test -n "$ANDROID_KEY_PASSWORD"
|
||||
test -n "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON"
|
||||
|
||||
- name: Create Android signing files
|
||||
if: github.event_name == 'release'
|
||||
env:
|
||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
run: |
|
||||
echo "$ANDROID_KEYSTORE_BASE64" | base64 --decode > android/release-keystore.jks
|
||||
cat <<EOF > android/key.properties
|
||||
storePassword=$ANDROID_KEYSTORE_PASSWORD
|
||||
keyPassword=$ANDROID_KEY_PASSWORD
|
||||
keyAlias=$ANDROID_KEY_ALIAS
|
||||
storeFile=../release-keystore.jks
|
||||
EOF
|
||||
|
||||
- name: Build APK
|
||||
run: flutter build apk --release
|
||||
|
||||
- name: Prepare APK artifact
|
||||
- name: Build Play release AAB
|
||||
if: github.event_name == 'release'
|
||||
run: flutter build appbundle --release
|
||||
|
||||
- name: Prepare Android artifacts
|
||||
run: |
|
||||
RAW_TAG="${{ github.event.release.tag_name || github.ref_name }}"
|
||||
TAG="${RAW_TAG//\//-}"
|
||||
cp build/app/outputs/flutter-apk/app-release.apk "${APP_NAME}-${TAG}-android.apk"
|
||||
if [ -f build/app/outputs/bundle/release/app-release.aab ]; then
|
||||
cp build/app/outputs/bundle/release/app-release.aab "${APP_NAME}-${TAG}-play.aab"
|
||||
fi
|
||||
|
||||
- name: Upload APK artifact
|
||||
- name: Upload Android artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-android
|
||||
path: "${{ env.APP_NAME }}-*-android.apk"
|
||||
path: |
|
||||
${{ env.APP_NAME }}-*-android.apk
|
||||
${{ env.APP_NAME }}-*-play.aab
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload Play release to internal testing
|
||||
if: github.event_name == 'release'
|
||||
env:
|
||||
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
|
||||
SKIP_ANDROID_BUILD: "1"
|
||||
run: cd android && bundle exec fastlane android internal
|
||||
|
||||
- name: Upload Play release to production
|
||||
if: github.event_name == 'release' && !github.event.release.prerelease
|
||||
env:
|
||||
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
|
||||
SKIP_ANDROID_BUILD: "1"
|
||||
run: cd android && bundle exec fastlane android production
|
||||
|
||||
build-linux:
|
||||
name: Build Linux App
|
||||
runs-on: ubuntu-latest
|
||||
@@ -93,6 +160,9 @@ jobs:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
channel: stable
|
||||
cache: true
|
||||
pub-cache: true
|
||||
cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}
|
||||
pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }}
|
||||
|
||||
- name: Enable Linux desktop
|
||||
run: flutter config --enable-linux-desktop
|
||||
@@ -130,6 +200,9 @@ jobs:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
channel: stable
|
||||
cache: true
|
||||
pub-cache: true
|
||||
cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}
|
||||
pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }}
|
||||
|
||||
- name: Enable Windows desktop
|
||||
shell: pwsh
|
||||
@@ -172,6 +245,9 @@ jobs:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
channel: stable
|
||||
cache: true
|
||||
pub-cache: true
|
||||
cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}
|
||||
pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }}
|
||||
|
||||
- name: Enable macOS desktop
|
||||
run: flutter config --enable-macos-desktop
|
||||
@@ -179,6 +255,9 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Install macOS native build tools
|
||||
run: brew install automake libtool
|
||||
|
||||
- name: Build macOS release
|
||||
run: flutter build macos --release
|
||||
|
||||
@@ -214,6 +293,9 @@ jobs:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
channel: stable
|
||||
cache: true
|
||||
pub-cache: true
|
||||
cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}
|
||||
pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
@@ -256,6 +338,52 @@ jobs:
|
||||
${{ env.IOS_RUNNER_ZIP }}
|
||||
if-no-files-found: error
|
||||
|
||||
build-web:
|
||||
name: Build Web App
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
channel: stable
|
||||
cache: true
|
||||
pub-cache: true
|
||||
cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}
|
||||
pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }}
|
||||
|
||||
- name: Enable web
|
||||
run: flutter config --enable-web
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Build web release
|
||||
run: flutter build web --wasm --release --base-href /meshcore-sar/
|
||||
|
||||
- name: Upload pages artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: build/web
|
||||
|
||||
deploy-pages:
|
||||
name: Deploy to GitHub Pages
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-web
|
||||
if: github.ref == 'refs/heads/main'
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
|
||||
upload-release-assets:
|
||||
name: Upload Assets To Release
|
||||
if: github.event_name == 'release'
|
||||
@@ -282,6 +410,7 @@ jobs:
|
||||
body: |
|
||||
Release assets are published for all supported build targets:
|
||||
- Android (`.apk`)
|
||||
- Google Play (`.aab`)
|
||||
- Linux (`.tar.gz`)
|
||||
- macOS (`.dmg`)
|
||||
- Windows (`.zip`)
|
||||
@@ -296,6 +425,7 @@ jobs:
|
||||
4. Install the signed `.ipa` on your iPhone using Apple Configurator 2 or Xcode (Devices and Simulators).
|
||||
files: |
|
||||
dist/*.apk
|
||||
dist/*.aab
|
||||
dist/*.tar.gz
|
||||
dist/*.zip
|
||||
dist/*.dmg
|
||||
|
||||
5
.github/workflows/build-multiplatform.yml
vendored
@@ -10,7 +10,7 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
FLUTTER_VERSION: '3.35.6'
|
||||
FLUTTER_VERSION: '3.41.4'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
@@ -27,6 +27,9 @@ jobs:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
channel: 'stable'
|
||||
cache: true
|
||||
pub-cache: true
|
||||
cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}
|
||||
pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
12
.gitignore
vendored
@@ -28,6 +28,7 @@ migrate_working_dir/
|
||||
**/ios/Flutter/.last_build_id
|
||||
.dart_tool/
|
||||
.flutter-plugins-dependencies
|
||||
pubspec_overrides.yaml
|
||||
.pub-cache/
|
||||
.pub/
|
||||
/build/
|
||||
@@ -72,18 +73,27 @@ GoogleService-Info.plist
|
||||
**/fastlane/.env
|
||||
**/fastlane/.env.*
|
||||
fastlane/README.md
|
||||
ios/.bundle/
|
||||
ios/vendor/
|
||||
|
||||
# Play Store release assets & scripts
|
||||
play-store/
|
||||
create_feature_graphic.py
|
||||
|
||||
# Tool caches and local state
|
||||
.devenv/
|
||||
.cachebro/
|
||||
.osgrep/
|
||||
third_party/lpcnet_flutter/
|
||||
worker/node_modules/
|
||||
worker/.wrangler/
|
||||
worker/.bun/
|
||||
worker/.astro/
|
||||
worker/dist/
|
||||
|
||||
# Claude Code local settings (permissions, personal config)
|
||||
.claude/settings.local.json
|
||||
|
||||
# Dart code coverage
|
||||
coverage/
|
||||
lcov.info
|
||||
lcov.info
|
||||
|
||||
25
CHANGELOG.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-03-15 to 2026-03-22
|
||||
|
||||
- Device connectivity and routing: added Android USB serial companion support, serial compass support, GPS module controls, per-contact path size selection, and favourites support via firmware flags.
|
||||
- Location and map intelligence: added RSSI-based repeater trilateration and contact location estimates, isolated MET history, matched the official advert import/discovery flow, and refreshed map presentation by removing contact trails.
|
||||
- Telemetry and device info: surfaced self telemetry and sensor defaults, opened device info from signal and battery taps, improved signal chip behavior, fixed duplicate telemetry delivery, and restored telemetry refresh behavior.
|
||||
- Contacts and messaging: added meshcore:// contact sharing and repeater neighbour views, supported MeshCore message links, fixed DM retry behavior, corrected room poster naming, and compacted the recipient selector.
|
||||
- UI polish and platform upkeep: improved device settings layout, tightened sensor and contact presentation, expanded localization coverage, and refreshed platform dependencies and build numbers.
|
||||
|
||||
## Key PRs
|
||||
|
||||
- [#26](https://github.com/dz0ny/meshcore-sar/pull/26) Merge pull request #26 from `dz0ny/feat/sensor-telemetry-preview`
|
||||
|
||||
## 2026-03-08 to 2026-03-14
|
||||
|
||||
- Discovery and contacts: finished the discovery flow, removed artificial UI delays, added profile storage and sync, and allowed contact name overrides.
|
||||
- Messaging and channels: fixed channel sync, deduplication, overwrite and deletion issues, cleared message input after send, and added delayed-message warning tracking.
|
||||
- Voice and transport: improved voice playback quality, improved scan and advert parsing, and kept route path data visible in logs.
|
||||
- Maps and field operations: enabled iOS background GPS updates, added GPX trail import/export, introduced a fullscreen repeater map, and added live mesh traffic views.
|
||||
- Localization and UI polish: added multilingual localization coverage, updated locale strings, fixed onboarding layout issues, and refined channel and contacts presentation.
|
||||
|
||||
## Key PRs
|
||||
|
||||
- [#16](https://github.com/dz0ny/meshcore-sar/pull/16) Merge pull request #16 from `MGJ520/main`
|
||||
18
Makefile
@@ -34,8 +34,9 @@ DAILY_BUILD := $(shell \
|
||||
# Version format: YYYY.MMDD.DAILY+BUILD
|
||||
# DAILY resets each day (for readability), BUILD always increments (for Android)
|
||||
NEW_VERSION := $(YEAR).$(MMDD).$(DAILY_BUILD)+$(NEW_BUILD_NUMBER)
|
||||
NEW_BUILD_NAME := $(shell echo $(NEW_VERSION) | cut -d'+' -f1)
|
||||
|
||||
.PHONY: help version bump build release release-android release-ios clean deps analyze test icon bundle bundle-no-bump
|
||||
.PHONY: help version bump make-bump sync-ios-version build release release-android release-ios clean deps analyze test icon bundle bundle-no-bump
|
||||
|
||||
help: ## Show this help
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-15s\033[0m %s\n", $$1, $$2}'
|
||||
@@ -49,8 +50,19 @@ version: ## Show current and next version
|
||||
bump: ## Bump version in pubspec.yaml
|
||||
@echo "Bumping version to $(NEW_VERSION)..."
|
||||
@sed -i '' 's/^version: .*/version: $(NEW_VERSION)/' $(PUBSPEC)
|
||||
@$(MAKE) sync-ios-version BUILD_NAME=$(NEW_BUILD_NAME) BUILD_NUMBER=$(NEW_BUILD_NUMBER)
|
||||
@echo "Version bumped to $(NEW_VERSION)"
|
||||
|
||||
make-bump: bump ## Alias for bump
|
||||
|
||||
sync-ios-version: ## Sync iOS FLUTTER_BUILD_NAME/NUMBER from pubspec or provided BUILD_NAME/BUILD_NUMBER
|
||||
@$(eval IOS_VERSION := $(shell grep '^version:' $(PUBSPEC) | sed 's/version: //'))
|
||||
@$(eval IOS_BUILD_NAME := $(if $(BUILD_NAME),$(BUILD_NAME),$(shell echo $(IOS_VERSION) | cut -d'+' -f1)))
|
||||
@$(eval IOS_BUILD_NUMBER := $(if $(BUILD_NUMBER),$(BUILD_NUMBER),$(shell echo $(IOS_VERSION) | cut -d'+' -f2)))
|
||||
@echo "Syncing iOS version to $(IOS_BUILD_NAME)+$(IOS_BUILD_NUMBER)..."
|
||||
@flutter build ios --config-only --build-name "$(IOS_BUILD_NAME)" --build-number "$(IOS_BUILD_NUMBER)" > /dev/null
|
||||
@echo "iOS version synced"
|
||||
|
||||
deps: ## Install dependencies
|
||||
flutter pub get
|
||||
|
||||
@@ -115,6 +127,7 @@ release-android: build ## Build APK and create GitHub release (Android only)
|
||||
|
||||
release-ios: ## Build iOS and upload to TestFlight
|
||||
@echo "Building iOS and uploading to TestFlight..."
|
||||
@$(MAKE) sync-ios-version
|
||||
cd ios && fastlane release
|
||||
@echo "iOS release uploaded!"
|
||||
|
||||
@@ -122,6 +135,9 @@ clean: ## Clean build artifacts
|
||||
flutter clean
|
||||
rm -rf $(BUILD_DIR)
|
||||
|
||||
build-web: ## Build web with WebAssembly
|
||||
flutter build web --wasm --release
|
||||
|
||||
# Build for all platforms
|
||||
build-all: bump ## Build for Android and iOS
|
||||
flutter build apk --release
|
||||
|
||||
18
README.md
@@ -15,13 +15,19 @@
|
||||
</p>
|
||||
|
||||
MeshCore SAR helps teams coordinate in low-connectivity or no-connectivity environments with messaging, voice, images, maps, and live location context in one app.
|
||||
It uses the MeshCore protocol over LoRa for long-range, infrastructure-free communication.
|
||||
`iOS TestFlight:` https://testflight.apple.com/join/tngpPF12
|
||||
|
||||
## Highlights
|
||||
|
||||
- Fast mesh messaging for direct and group coordination
|
||||
- On-demand voice and image transfer optimized for constrained links
|
||||
- Offline-first mapping with tactical overlays and SAR markers
|
||||
- Live team tracking, trails, and shareable map drawings
|
||||
- Rapid mesh chat for both 1:1 and group coordination
|
||||
- On-demand voice (Codec2) and image (AVIF) transfer tuned for low-bandwidth links
|
||||
- Offline-first mapping with tactical overlays and SAR incident markers
|
||||
- Live team location, movement trails, and shareable tactical drawings
|
||||
|
||||
## Demo Video
|
||||
|
||||
[<img width="1280" height="720" alt="MeshCore SAR demo preview" src="https://github.com/user-attachments/assets/13ccacee-7306-4976-a408-f31f3336828a" />](https://youtu.be/rLsKeLJBpFg)
|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -38,8 +44,8 @@ MeshCore SAR helps teams coordinate in low-connectivity or no-connectivity envir
|
||||
| Area | What you get |
|
||||
|---|---|
|
||||
| Messaging | Direct and group chat over mesh, with contact/room awareness from live telemetry |
|
||||
| Voice | Push-to-talk voice clips, fetched on demand when play is pressed, auto-play on completion |
|
||||
| Images | Camera/gallery image sending, auto-compression, tap-to-load receiving, full-screen viewer |
|
||||
| Voice | Push-to-talk voice clips (Codec2), fetched on demand when play is pressed, auto-play on completion |
|
||||
| Images | Camera/gallery image sending (AVIF), auto-compression, tap-to-load receiving, full-screen viewer |
|
||||
| Maps | Street/topo/satellite/terrain layers, offline tile downloads, optional MBTiles import |
|
||||
| SAR Operations | Team markers with freshness indicators, SAR markers for incidents and staging points |
|
||||
| Tracking | Continuous GPS updates, personal trails, distance/duration trail stats |
|
||||
|
||||
4
android/Gemfile
Normal file
@@ -0,0 +1,4 @@
|
||||
source "https://rubygems.org"
|
||||
|
||||
gem "fastlane"
|
||||
gem "ostruct"
|
||||
@@ -1,4 +1,5 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- Bluetooth permissions -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
|
||||
@@ -23,6 +24,7 @@
|
||||
|
||||
<!-- Microphone for voice messages -->
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
|
||||
<!-- Notifications -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
@@ -61,6 +63,12 @@
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
<service
|
||||
android:name="id.flutter.flutter_background_service.BackgroundService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="location"
|
||||
android:stopWithTask="false"
|
||||
tools:replace="android:exported" />
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
|
||||
@@ -16,44 +16,72 @@
|
||||
default_platform(:android)
|
||||
|
||||
platform :android do
|
||||
def project_root
|
||||
File.expand_path("../..", __dir__)
|
||||
end
|
||||
|
||||
def play_service_account_json
|
||||
ENV.fetch("GOOGLE_PLAY_SERVICE_ACCOUNT_JSON")
|
||||
end
|
||||
|
||||
def play_build_command
|
||||
"cd #{project_root} && flutter build appbundle --release"
|
||||
end
|
||||
|
||||
def play_aab_path
|
||||
File.join(project_root, "build/app/outputs/bundle/release/app-release.aab")
|
||||
end
|
||||
|
||||
def play_package_name
|
||||
"com.meshcore.sar.meshcore_sar_app"
|
||||
end
|
||||
|
||||
def direct_apk_build_command
|
||||
"cd #{project_root} && flutter build apk --release"
|
||||
end
|
||||
|
||||
def direct_apk_path
|
||||
File.join(project_root, "build/app/outputs/flutter-apk/app-release.apk")
|
||||
end
|
||||
|
||||
def skip_android_build?
|
||||
ENV["SKIP_ANDROID_BUILD"] == "1"
|
||||
end
|
||||
|
||||
desc "Runs all the tests"
|
||||
lane :test do
|
||||
gradle(task: "test")
|
||||
end
|
||||
|
||||
desc "Build release APK"
|
||||
lane :beta do
|
||||
# Get the project root directory (two levels up from android/fastlane/)
|
||||
project_root = File.expand_path("../..", __dir__)
|
||||
lane :direct_apk do
|
||||
sh(direct_apk_build_command)
|
||||
|
||||
# Build APK with Flutter
|
||||
sh("cd #{project_root} && flutter build apk --release")
|
||||
|
||||
apk_path = File.join(project_root, "build/app/outputs/flutter-apk/app-release.apk")
|
||||
|
||||
# Uncomment to distribute via Firebase App Distribution:
|
||||
# firebase_app_distribution(
|
||||
# app: "YOUR_FIREBASE_APP_ID",
|
||||
# apk_path: apk_path,
|
||||
# groups: "testers"
|
||||
# )
|
||||
|
||||
UI.success("APK built at: #{apk_path}")
|
||||
UI.success("APK built successfully at: #{direct_apk_path}")
|
||||
end
|
||||
|
||||
desc "Deploy a new version to the Google Play"
|
||||
lane :deploy do
|
||||
# Get the project root directory (two levels up from android/fastlane/)
|
||||
project_root = File.expand_path("../..", __dir__)
|
||||
|
||||
# Build AAB with Flutter (required for Play Store since 2021)
|
||||
sh("cd #{project_root} && flutter build appbundle --release")
|
||||
|
||||
aab_path = File.join(project_root, "build/app/outputs/bundle/release/app-release.aab")
|
||||
desc "Build Play release AAB and upload to internal testing"
|
||||
lane :internal do
|
||||
sh(play_build_command) unless skip_android_build?
|
||||
|
||||
upload_to_play_store(
|
||||
aab: aab_path,
|
||||
track: "internal"
|
||||
aab: play_aab_path,
|
||||
package_name: play_package_name,
|
||||
track: "internal",
|
||||
release_status: "draft",
|
||||
json_key_data: play_service_account_json
|
||||
)
|
||||
end
|
||||
|
||||
desc "Build Play release AAB and upload to production"
|
||||
lane :production do
|
||||
sh(play_build_command) unless skip_android_build?
|
||||
|
||||
upload_to_play_store(
|
||||
aab: play_aab_path,
|
||||
package_name: play_package_name,
|
||||
track: "production",
|
||||
json_key_data: play_service_account_json
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
128
docs/google-play-release.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# Google Play Release Runbook
|
||||
|
||||
## Purpose
|
||||
|
||||
This runbook prepares and ships the Android release build for `com.meshcore.sar.meshcore_sar_app`.
|
||||
|
||||
## Distribution Modes
|
||||
|
||||
- GitHub release: signed APK for direct install or manual distribution.
|
||||
- Google Play: signed AAB uploaded through Fastlane to Play internal testing and, for non-prerelease GitHub releases, production.
|
||||
|
||||
## Required Secrets and Local Files
|
||||
|
||||
GitHub Actions secrets for `dz0ny/meshcore-sar`:
|
||||
|
||||
- `ANDROID_KEYSTORE_BASE64`
|
||||
- `ANDROID_KEYSTORE_PASSWORD`
|
||||
- `ANDROID_KEY_ALIAS`
|
||||
- `ANDROID_KEY_PASSWORD`
|
||||
- `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON`
|
||||
|
||||
Local files used to seed the secrets:
|
||||
|
||||
- `android/key.properties`
|
||||
- release keystore referenced by `android/key.properties`
|
||||
- `/Users/dz0ny/android-keystores/fastlane-480919-0cb30c62db50.json`
|
||||
|
||||
The Play Console service account must have release access for `com.meshcore.sar.meshcore_sar_app`.
|
||||
|
||||
## CI Release Flow
|
||||
|
||||
The release flow runs from `.github/workflows/build-artifacts.yml` when a GitHub release is published.
|
||||
|
||||
Android release steps:
|
||||
|
||||
1. Validate all Android signing and Google Play secrets.
|
||||
2. Recreate `android/release-keystore.jks` and `android/key.properties` from GitHub secrets.
|
||||
3. Build the signed APK with `flutter build apk --release`.
|
||||
4. Build the signed Play AAB with `flutter build appbundle --release`.
|
||||
5. Upload the APK and AAB as GitHub release assets.
|
||||
6. Upload the AAB to Play internal testing with `bundle exec fastlane android internal`.
|
||||
7. Upload the AAB to Play production when the GitHub release is not marked as prerelease.
|
||||
|
||||
The same workflow also builds Linux, macOS, Windows, iOS unsigned, and web artifacts.
|
||||
|
||||
## Versioning Rules
|
||||
|
||||
- Version source of truth: `pubspec.yaml`.
|
||||
- Android `versionCode` is the number after `+`.
|
||||
- `versionCode` must always increase for Play uploads.
|
||||
- `make bump`, `make build`, and `make bundle` increment the version.
|
||||
- Use `make build-no-bump` or `make bundle-no-bump` only when intentionally rebuilding the same version locally.
|
||||
|
||||
## Manual Build Commands
|
||||
|
||||
Run commands from the repo root.
|
||||
|
||||
```bash
|
||||
flutter build apk --release
|
||||
flutter build appbundle --release
|
||||
```
|
||||
|
||||
Repo shortcuts:
|
||||
|
||||
```bash
|
||||
make build
|
||||
make bundle
|
||||
make build-no-bump
|
||||
make bundle-no-bump
|
||||
```
|
||||
|
||||
## Fastlane Lanes
|
||||
|
||||
Run commands from `android/`.
|
||||
|
||||
```bash
|
||||
bundle exec fastlane android direct_apk
|
||||
bundle exec fastlane android internal
|
||||
bundle exec fastlane android production
|
||||
```
|
||||
|
||||
Set `SKIP_ANDROID_BUILD=1` when an AAB has already been built and Fastlane should only upload it.
|
||||
|
||||
```bash
|
||||
SKIP_ANDROID_BUILD=1 GOOGLE_PLAY_SERVICE_ACCOUNT_JSON="$(cat /Users/dz0ny/android-keystores/fastlane-480919-0cb30c62db50.json)" bundle exec fastlane android internal
|
||||
```
|
||||
|
||||
## Expected Artifacts
|
||||
|
||||
- APK: `build/app/outputs/flutter-apk/app-release.apk`
|
||||
- AAB: `build/app/outputs/bundle/release/app-release.aab`
|
||||
- Release APK asset: `meshcore-sar-<tag>-android.apk`
|
||||
- Release AAB asset: `meshcore-sar-<tag>-play.aab`
|
||||
|
||||
## Internal Testing Release Checklist
|
||||
|
||||
1. Confirm `pubspec.yaml` version is correct and `versionCode` increased.
|
||||
2. Confirm `android/key.properties` points to the release keystore for local builds.
|
||||
3. Confirm all five GitHub Actions secrets exist.
|
||||
4. Publish a prerelease in GitHub to build assets and upload Play internal testing without production promotion.
|
||||
5. Install from the internal testing track on a real Android device.
|
||||
6. Smoke test startup, permissions, map, messaging, telemetry, and offline map behavior.
|
||||
|
||||
## Production Release Checklist
|
||||
|
||||
1. Complete internal testing validation first.
|
||||
2. Confirm Play Console forms are current:
|
||||
- App content
|
||||
- Data safety
|
||||
- App access
|
||||
- Ads declaration
|
||||
- Content rating
|
||||
3. Confirm store assets are current:
|
||||
- app icon
|
||||
- feature graphic
|
||||
- phone screenshots
|
||||
- tablet screenshots if used
|
||||
- support URL
|
||||
- privacy policy URL
|
||||
4. Publish a non-prerelease GitHub release.
|
||||
5. Confirm the GitHub release has the APK and AAB assets.
|
||||
6. Confirm Play Console has the new internal and production release entries.
|
||||
|
||||
## Rollback Rules
|
||||
|
||||
- Never reuse or lower an Android `versionCode`.
|
||||
- If a Play release is bad, halt rollout in Play Console and ship a new higher-version fix.
|
||||
- Keep GitHub direct APK and Play AAB artifacts separate; do not upload APKs to Play.
|
||||
@@ -2,23 +2,28 @@
|
||||
|
||||
## 1. Overview
|
||||
|
||||
Image mode mirrors the voice on-demand architecture exactly:
|
||||
Image mode mirrors the voice on-demand architecture exactly, including
|
||||
swarm-assisted recovery for stalled partial transfers:
|
||||
|
||||
- **Control plane (text messages):**
|
||||
- `IE1:` image envelope announces image availability in chat.
|
||||
- `IR1:` direct fetch request asks sender to stream image fragments.
|
||||
- `IE4:` image envelope announces image availability in chat.
|
||||
- **Control plane (raw binary request):**
|
||||
- Binary image fetch request (same raw route as image fragments).
|
||||
- **Data plane (raw binary packets):**
|
||||
- `ImagePacket` binary payload streamed via `cmdSendRawData` / `pushRawData`.
|
||||
|
||||
Images are never broadcast in full to channels. Chat carries only metadata;
|
||||
pixels are fetched on demand when the user taps the image bubble.
|
||||
|
||||
Shared swarm fallback is documented in
|
||||
[Swarm Mode Technical Design](./swarm-mode-technical.md).
|
||||
|
||||
## 2. Key Modules
|
||||
|
||||
- `lib/utils/image_message_parser.dart`
|
||||
- `ImagePacket` (binary fragment format)
|
||||
- `ImageEnvelope` (`IE1`)
|
||||
- `ImageFetchRequest` (`IR1`)
|
||||
- `ImageEnvelope` (`IE4`)
|
||||
- `ImageFetchRequest` (binary)
|
||||
- `fragmentImage()` — split compressed bytes into packets
|
||||
- `reassembleImage()` — join received fragments into bytes
|
||||
- `lib/screens/messages_tab.dart`
|
||||
@@ -26,8 +31,9 @@ pixels are fetched on demand when the user taps the image bubble.
|
||||
- `lib/providers/image_provider.dart`
|
||||
- Reassembly sessions, outgoing cache, deferred serving
|
||||
- Outgoing sessions also registered as complete incoming sessions for immediate local display
|
||||
- Received partial sessions can be re-served during swarm recovery
|
||||
- `lib/providers/app_provider.dart`
|
||||
- Incoming routing for `IE1`, `IR1`, binary `0x49` packets
|
||||
- Incoming routing for `IE4`, binary image fetch requests, binary `0x49` packets, and raw swarm control payloads
|
||||
- `lib/widgets/messages/image_message_bubble.dart`
|
||||
- Square cover thumbnail (up to 256 px); tap-to-load for received images;
|
||||
progress ring during fetch; full-screen `InteractiveViewer` on tap
|
||||
@@ -39,65 +45,64 @@ pixels are fetched on demand when the user taps the image bubble.
|
||||
|
||||
## 3. Wire Formats
|
||||
|
||||
### 3.1 Image Envelope (`IE1`)
|
||||
### 3.1 Image Envelope (`IE4`)
|
||||
|
||||
Prefix: `IE1:` + colon-delimited payload
|
||||
Prefix: `IE4:` + colon-delimited compact payload (base36 numeric fields)
|
||||
|
||||
Fields:
|
||||
|
||||
| Field | Type | Description |
|
||||
|--------------|--------|------------------------------------------------|
|
||||
| `sid` | string | 8 hex chars (4 bytes), session ID |
|
||||
| `fmt` | int | `ImageFormat.id` (0 = AVIF, 1 = JPEG) |
|
||||
| `total` | int | Fragment count (1..255) |
|
||||
| `w` | int | Actual image width after compression (pixels) |
|
||||
| `h` | int | Actual image height after compression (pixels) |
|
||||
| `bytes` | int | Total compressed size in bytes |
|
||||
| `senderKey6` | string | 12 hex chars (6 bytes sender prefix) |
|
||||
| `ts` | int | Unix timestamp (seconds) |
|
||||
| `ver` | int | Protocol version (currently `1`) |
|
||||
|
||||
| `sid` | string | base36 token for 32-bit session ID |
|
||||
| `fmt` | base36 | `ImageFormat.id` (0 = AVIF, 1 = JPEG) |
|
||||
| `total` | base36 | Fragment count (1..255) |
|
||||
| `w` | base36 | Actual image width after compression (pixels) |
|
||||
| `h` | base36 | Actual image height after compression (pixels) |
|
||||
| `bytes` | base36 | Total compressed size in bytes |
|
||||
Compact format:
|
||||
|
||||
```text
|
||||
IE1:{sid}:{fmt}:{total}:{w}:{h}:{bytes}:{senderKey6}:{ts}:{ver}
|
||||
IE4:{sid}:{fmt}:{total}:{w}:{h}:{bytes}
|
||||
```
|
||||
|
||||
Example (256×171 landscape image, 14 fragments):
|
||||
|
||||
```text
|
||||
IE1:deadbeef:0:14:256:171:2100:aabbccddeeff:1700000000:1
|
||||
IE4:a:0:e:74:4r:1mc
|
||||
```
|
||||
|
||||
Note: `w` and `h` reflect the actual post-compression dimensions, which preserve
|
||||
Note: `sid` is base36 on wire and expands to 8-hex internally.
|
||||
`w` and `h` reflect the actual post-compression dimensions, which preserve
|
||||
the source aspect ratio (contain within the configured max size).
|
||||
|
||||
### 3.2 Image Fetch Request (`IR1`)
|
||||
### 3.2 Image Fetch Request (`IR4` + binary)
|
||||
|
||||
Same structure as `VR1`:
|
||||
Text format:
|
||||
|
||||
```text
|
||||
IR1:{sid}:{want}:{requesterKey6}:{ts}:{ver}
|
||||
IR4:{sid}:{want}:{requesterKey6}
|
||||
```
|
||||
|
||||
Binary payload format:
|
||||
|
||||
```text
|
||||
[magic=0x69][sid:4B][flags:1B][requesterKey6:6B][missingCount:1B][missingIndices...]
|
||||
```
|
||||
|
||||
| Field | Value |
|
||||
|------------------|--------------------------|
|
||||
| `want` | `a` (= "all fragments") |
|
||||
| `requesterKey6` | 12 hex chars |
|
||||
| `ver` | `1` |
|
||||
|
||||
| `flags` | bit0=1 => request missing indices, else all |
|
||||
| `requesterKey6` | 6-byte requester key prefix |
|
||||
### 3.3 Raw Image Packet (data plane)
|
||||
|
||||
Binary payload structure:
|
||||
|
||||
- Byte 0: magic `0x49` (`'I'`)
|
||||
- Bytes 1..4: session ID (4 bytes)
|
||||
- Byte 5: format ID
|
||||
- Byte 6: fragment index (0-based)
|
||||
- Byte 7: total fragments
|
||||
- Bytes 8..N: image data (max 152 bytes per fragment)
|
||||
- Byte 5: fragment index (0-based)
|
||||
- Bytes 6..N: image data (max 152 bytes per fragment)
|
||||
|
||||
Header is 8 bytes — identical layout to `VoicePacket`.
|
||||
Header is 6 bytes. Image format and total fragment count come from the `IE4` envelope.
|
||||
|
||||
## 4. Compression Pipeline
|
||||
|
||||
@@ -151,24 +156,34 @@ only the shorter axis is padded — no cropping occurs.
|
||||
7. Envelope sent via normal message path:
|
||||
- Channel: `sendChannelMessage`
|
||||
- Direct: `sendTextMessage`
|
||||
8. Local placeholder message added (`IE1:` text, `deliveryStatus.sending`).
|
||||
8. Local placeholder message added (`IE4:` text, `deliveryStatus.sending`).
|
||||
|
||||
## 6. Incoming Flow (Receive)
|
||||
|
||||
### 6.1 `IE1` envelope received
|
||||
### 6.1 `IE4` envelope received
|
||||
|
||||
`AppProvider` calls `imageProvider.registerEnvelope()` and adds the message to
|
||||
chat. The bubble shows a grey square placeholder with a download icon.
|
||||
|
||||
### 6.2 `IR1` request received
|
||||
### 6.2 Binary image fetch request received
|
||||
|
||||
`AppProvider` treats it as control-plane only (not added to chat):
|
||||
|
||||
- Validates requester key prefix.
|
||||
- Resolves requester contact.
|
||||
- Calls `imageProvider.serveSessionTo()` which streams all cached fragments.
|
||||
|
||||
### 6.3 Raw packet received (`pushRawData`, magic `0x49`)
|
||||
### 6.3 Swarm control messages received
|
||||
|
||||
`AppProvider` also handles shared raw swarm discovery payloads:
|
||||
|
||||
- binary swarm requests advertise which image fragments are still missing
|
||||
- binary swarm availability responses advertise which fragments another peer can relay
|
||||
- swarm payloads arrive via `pushRawData` and are intercepted instead of being added to chat
|
||||
|
||||
Shared discovery and responder semantics are documented in
|
||||
[Swarm Mode Technical Design](./swarm-mode-technical.md).
|
||||
|
||||
### 6.4 Raw packet received (`pushRawData`, magic `0x49`)
|
||||
|
||||
`AppProvider.onRawDataReceived` parses `ImagePacket` binary and calls
|
||||
`imageProvider.addFragment()`. When the session becomes complete, the bubble
|
||||
@@ -187,6 +202,9 @@ When `cacheOutgoingSession()` is called it also writes all fragments into
|
||||
`_sessions[sessionId]`, so the sender sees the image immediately in the bubble
|
||||
(no tap-to-load required for own messages).
|
||||
|
||||
If a peer later receives only part of an image session, those received fragments
|
||||
can also be served onward to another requester during swarm recovery.
|
||||
|
||||
## 8. Display
|
||||
|
||||
`ImageMessageBubble` (max width 256 px):
|
||||
@@ -194,7 +212,8 @@ When `cacheOutgoingSession()` is called it also writes all fragments into
|
||||
- **Complete session**: `AspectRatio(1.0)` → `AvifImage.memory(fit: cover)`
|
||||
square thumbnail; tap → full-screen `InteractiveViewer` with fade transition.
|
||||
- **Incomplete/missing**: grey square placeholder with download icon;
|
||||
tap → sends `IR1` fetch request.
|
||||
tap → sends binary fetch request, with raw swarm fallback if the original
|
||||
sender path stalls.
|
||||
- **Loading**: circular progress indicator showing `received/total` count.
|
||||
- **Error**: broken-image icon.
|
||||
|
||||
@@ -212,11 +231,12 @@ Image bubbles and Message Technical Details show an **estimated transmit time**
|
||||
The estimate is airtime-based (LoRa packet model), not just compressed image size:
|
||||
|
||||
- Source inputs:
|
||||
- `total` fragments and `bytes` from `IE1` envelope
|
||||
- `total` fragments and `bytes` from `IE4` envelope
|
||||
- all numeric envelope values are decoded from base36
|
||||
- `pathLen` from message metadata
|
||||
- current radio params from `deviceInfo`: `radioBw`, `radioSf`, `radioCr`
|
||||
- Per-fragment payload model:
|
||||
- `meshHeader(2)` + `pathLen` + `imageHeader(8)` + `fragmentBytes`
|
||||
- `meshHeader(2)` + `pathLen` + `imageHeader(6)` + `fragmentBytes`
|
||||
- LoRa airtime:
|
||||
- standard symbol-time formula (preamble + payload symbols)
|
||||
- Mesh pacing/hops:
|
||||
@@ -244,11 +264,45 @@ Fallback defaults are used when radio params are unavailable: `SF10`, `BW250kHz`
|
||||
## 11. Operational Constraints
|
||||
|
||||
- No firmware changes required (reuses `cmdSendRawData` / `pushRawData`).
|
||||
- On-demand fetch works only if sender app is online and has cached session.
|
||||
- On-demand fetch prefers the original sender, but a partial image can also be
|
||||
completed from alternate peers that already hold matching fragments.
|
||||
- Raw return path requires a valid direct route to requester.
|
||||
- Available on iOS and Android (`image_picker` + `flutter_avif`).
|
||||
- Swarm discovery uses the same `cmdSendRawData` / `pushRawData` path as image
|
||||
fetch and fragment delivery.
|
||||
|
||||
## 12. High-Level Sequence
|
||||
### 11.1 Raw Binary Routing Semantics
|
||||
|
||||
- Image fragment payloads use companion command `CMD_SEND_RAW_DATA` (`25` / `0x19`).
|
||||
- Companion push back to the app is `PUSH_CODE_RAW_DATA` (`0x84`).
|
||||
- Over-the-air packet type for this flow is `PAYLOAD_TYPE_RAW_CUSTOM` (`0x0F`).
|
||||
- This flow is direct-route only, not flood/broadcast:
|
||||
- it is sent to one destination path;
|
||||
- only nodes on that path relay it;
|
||||
- it is **not** received by everyone in the mesh.
|
||||
|
||||
## 12. Swarm Fallback Sequence
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant A as Original Sender
|
||||
participant P as Peer With Fragments
|
||||
participant N as Reachable Peers
|
||||
participant B as Receiver App
|
||||
|
||||
A->>M: Send IE4 envelope
|
||||
M->>B: Deliver IE4
|
||||
B->>A: Direct binary image fetch request
|
||||
A->>B: Stream raw ImagePacket fragments (partial)
|
||||
Note over A,B: Sender path stops responding
|
||||
B->>N: Raw swarm requests with missing image fragment indices
|
||||
P->>B: Raw swarm availability response
|
||||
B->>P: Direct binary fetch request for missing subset
|
||||
P->>B: Stream remaining raw ImagePacket fragments
|
||||
B->>B: Reassemble completed image
|
||||
```
|
||||
|
||||
## 13. High-Level Sequence
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
@@ -260,10 +314,10 @@ sequenceDiagram
|
||||
A->>A: Compress: contain resize → grayscale → PNG → AVIF
|
||||
A->>A: Fragment into ≤152B packets
|
||||
A->>A: Cache outgoing + populate local session (immediate display)
|
||||
A->>M: Send IE1 envelope (actual w×h, fragment count)
|
||||
M->>B: Deliver IE1
|
||||
A->>M: Send IE4 envelope (actual w×h, fragment count)
|
||||
M->>B: Deliver IE4
|
||||
B->>B: Render grey placeholder bubble
|
||||
B->>A: Tap → send IR1 fetch request
|
||||
B->>A: Tap → send binary fetch request
|
||||
A->>B: Stream binary ImagePackets
|
||||
B->>B: Reassemble fragments
|
||||
B->>B: Display AVIF image (cover thumbnail)
|
||||
|
||||
148
docs/swarm-mode-technical.md
Normal file
@@ -0,0 +1,148 @@
|
||||
# Swarm Mode Technical Design
|
||||
|
||||
## 1. Overview
|
||||
|
||||
Swarm mode is a shared media-recovery transport used by both voice and image
|
||||
sessions when the original sender path stops responding after some fragments
|
||||
have already propagated through the mesh.
|
||||
|
||||
It adds a lightweight **swarm discovery plane** on top of the existing
|
||||
**direct raw-data transfer plane**:
|
||||
|
||||
- **Discovery plane (raw custom control payloads):**
|
||||
- `MediaSwarmRequest`
|
||||
- `MediaSwarmAvailability`
|
||||
- **Transfer plane (direct raw binary):**
|
||||
- existing `VoiceFetchRequest` / `ImageFetchRequest`
|
||||
- existing `VoicePacket` / `ImagePacket`
|
||||
|
||||
Swarm mode does not broadcast media payloads. It fans out raw control requests
|
||||
to reachable peers, collects raw availability responses, and then fetches media
|
||||
from the best responder.
|
||||
|
||||
## 2. Problem It Solves
|
||||
|
||||
Without swarm mode, media fetch is limited to the original sender's direct raw
|
||||
path. If that sender goes offline, moves, or stops responding, a receiver can
|
||||
be left with a partial session even when other peers already hold useful
|
||||
fragments.
|
||||
|
||||
Swarm mode lets the receiver discover alternate peers and fetch the missing
|
||||
subset directly from them.
|
||||
|
||||
## 3. Control Messages
|
||||
|
||||
### 3.1 Media Swarm Request (binary)
|
||||
|
||||
```text
|
||||
[magic=0x6d][kind=0x01][mediaType:1B][sessionId:4B][requesterKey6:6B][missingCount:1B][missingIndices...]
|
||||
```
|
||||
|
||||
Fields:
|
||||
|
||||
- `mediaType` — `voice` or `image`
|
||||
- `sessionId` — 8 hex chars
|
||||
- `requesterKey6` — 12 hex chars identifying the requesting device
|
||||
- `missingCount` — `0` means the requester needs all fragments
|
||||
- `missingIndices` — exact missing fragment indices when `missingCount > 0`
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
6d 01 02 de ad be ef aa bb cc dd ee ff 03 00 02 09
|
||||
```
|
||||
|
||||
### 3.2 Media Swarm Availability (binary)
|
||||
|
||||
```text
|
||||
[magic=0x6d][kind=0x02][mediaType:1B][sessionId:4B][requesterKey6:6B][responderKey6:6B][availableCount:1B][availableIndices...]
|
||||
```
|
||||
|
||||
Fields:
|
||||
|
||||
- `mediaType` — `voice` or `image`
|
||||
- `sessionId` — 8 hex chars
|
||||
- `requesterKey6` — copied from the swarm request
|
||||
- `responderKey6` — 12 hex chars identifying the responding peer
|
||||
- `availableCount` — `0` means the responder can satisfy the full request
|
||||
- `availableIndices` — exact fragment indices held when `availableCount > 0`
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
6d 02 02 de ad be ef aa bb cc dd ee ff 11 22 33 44 55 66 02 02 09
|
||||
```
|
||||
|
||||
## 4. Requester Behavior
|
||||
|
||||
When a voice or image session is incomplete:
|
||||
|
||||
1. Prefer the original sender if its direct raw route is healthy.
|
||||
2. If the original sender path is unavailable or not responding, send a raw
|
||||
swarm request to reachable peers with the exact missing fragment indices.
|
||||
3. Wait up to **10 seconds** for raw availability responses.
|
||||
4. Rank responders by overlap with the current missing set.
|
||||
5. Skip the original sender when choosing alternate peers.
|
||||
6. Send a direct binary fetch request to the best responder for only the
|
||||
missing subset that responder advertised.
|
||||
|
||||
Actual media transfer uses the same `cmdSendRawData` / `pushRawData` path as the
|
||||
swarm control messages.
|
||||
|
||||
## 5. Responder Behavior
|
||||
|
||||
A peer that receives a swarm request:
|
||||
|
||||
- checks whether it has fragments for the requested media session
|
||||
- replies only if it has at least one requested fragment
|
||||
- advertises only fragments it actually holds
|
||||
- may reply from:
|
||||
- its outgoing cache, or
|
||||
- a partially/fully received incoming session
|
||||
|
||||
This enables torrent-like relay behavior without requiring the peer to be the
|
||||
original sender.
|
||||
|
||||
## 6. Visibility and Routing
|
||||
|
||||
- Swarm control payloads are carried by `cmdSendRawData` and received through
|
||||
`pushRawData`.
|
||||
- They are intercepted by the app and **must not be surfaced in chat**.
|
||||
- Discovery is a direct raw fan-out to reachable peers, not a public-channel
|
||||
broadcast.
|
||||
- Media packets themselves remain direct-route raw packets.
|
||||
|
||||
## 7. Constraints
|
||||
|
||||
- No firmware changes are required.
|
||||
- Swarm mode only helps if at least one peer has already received some useful
|
||||
fragments.
|
||||
- Peers can only relay fragments they actually have.
|
||||
- Alternate peers still need a valid direct raw route back to the requester.
|
||||
- Swarm mode improves recovery from sender-path failure, but it does not change
|
||||
the underlying raw-packet size, airtime, or hop constraints.
|
||||
|
||||
## 8. High-Level Sequence
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant S as Original Sender
|
||||
participant P as Peer With Fragments
|
||||
participant N as Reachable Peers
|
||||
participant R as Requester
|
||||
|
||||
S->>R: Direct raw fragments (partial)
|
||||
Note over S,R: Original sender path stops responding
|
||||
R->>N: Raw swarm requests with exact missing fragments
|
||||
P->>R: Raw swarm availability response
|
||||
R->>P: Direct binary fetch request for missing subset
|
||||
P->>R: Direct raw fragments
|
||||
R->>R: Reassemble completed session
|
||||
```
|
||||
|
||||
## 9. Integration Points
|
||||
|
||||
- Voice-specific behavior is summarized in `docs/voice-mode-technical.md`.
|
||||
- Image-specific behavior is summarized in `docs/image-mode-technical.md`.
|
||||
- This document is the shared source of truth for swarm discovery and fallback
|
||||
semantics.
|
||||
@@ -2,87 +2,74 @@
|
||||
|
||||
## 1. Overview
|
||||
|
||||
Voice mode uses a **two-plane architecture**:
|
||||
Voice mode uses a **two-plane architecture** with optional swarm-assisted
|
||||
recovery:
|
||||
|
||||
- **Control plane (text messages):**
|
||||
- `VE1:` voice envelope announces voice availability in chat.
|
||||
- `VR1:` direct fetch request asks sender to stream voice payload.
|
||||
- `VE3:` voice envelope announces voice availability in chat.
|
||||
- **Control plane (raw binary request):**
|
||||
- Binary voice fetch request (same raw route as voice packets).
|
||||
- **Data plane (raw binary packets):**
|
||||
- `VoicePacket` payload streamed via `cmdSendRawData` and received through `pushRawData`.
|
||||
|
||||
This design avoids broadcasting full voice payloads to channels/rooms. Chat carries only metadata; audio is fetched on demand when user presses play.
|
||||
|
||||
Swarm fallback is documented in [Swarm Mode Technical Design](./swarm-mode-technical.md).
|
||||
|
||||
## 2. Key Modules
|
||||
|
||||
- `lib/utils/voice_message_parser.dart`
|
||||
- `VoicePacket` (legacy text + binary packet format)
|
||||
- `VoiceEnvelope` (`VE1`)
|
||||
- `VoiceFetchRequest` (`VR1`)
|
||||
- `VoicePacket` (binary direct-packet format)
|
||||
- `VoiceEnvelope` (`VE3`)
|
||||
- `VoiceFetchRequest` (binary)
|
||||
- `lib/screens/messages_tab.dart`
|
||||
- Capture/encode voice, cache encoded packets, send envelope only
|
||||
- `lib/providers/voice_provider.dart`
|
||||
- Reassembly/playback sessions
|
||||
- Outgoing session cache + deferred serving
|
||||
- `lib/providers/app_provider.dart`
|
||||
- Incoming routing for `VE1` and `VR1`
|
||||
- Incoming routing for `VE3`, binary voice fetch requests, and raw swarm control payloads
|
||||
- Handles raw packet ingestion
|
||||
- `lib/widgets/messages/voice_message_bubble.dart`
|
||||
- Play behavior (immediate play if complete, otherwise fetch + auto-play)
|
||||
- `lib/providers/messages_provider.dart`
|
||||
- Message-level voice detection (`VE1` + legacy `V:`)
|
||||
- Message-level voice detection (`VE3`)
|
||||
- `lib/services/message_storage_service.dart`
|
||||
- Persists `isVoice` and `voiceId`
|
||||
|
||||
## 3. Wire Formats
|
||||
|
||||
### 3.1 Voice Envelope (`VE1`)
|
||||
### 3.1 Voice Envelope (`VE3`)
|
||||
|
||||
Prefix: `VE1:` + colon-delimited compact payload
|
||||
Prefix: `VE3:` + colon-delimited compact payload (base36 numeric fields)
|
||||
|
||||
Fields:
|
||||
|
||||
- `sid` (string, 8 hex chars): session ID
|
||||
- `mode` (int): codec mode ID (`VoicePacketMode.id`)
|
||||
- `total` (int): packet count (1..255)
|
||||
- `durMs` (int): estimated duration in ms
|
||||
- `senderKey6` (string, 12 hex chars): sender public-key prefix (6 bytes)
|
||||
- `ts` (int): unix timestamp seconds
|
||||
- `ver` (int): protocol version (currently `1`)
|
||||
- `sid` (string): base36 token for 32-bit session ID
|
||||
- `mode` (base36): codec mode ID (`VoicePacketMode.id`)
|
||||
- `total` (base36): packet count (1..255)
|
||||
- `durS` (base36): estimated duration in seconds
|
||||
|
||||
`sid` is base36 on wire and expands to 8-hex internally.
|
||||
|
||||
Compact format:
|
||||
|
||||
```text
|
||||
VE1:{sid}:{mode}:{total}:{durMs}:{senderKey6}:{ts}:{ver}
|
||||
VE3:{sid}:{mode}:{total}:{durS}
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
VE1:deadbeef:1:4:3200:aabbccddeeff:1700000000:1
|
||||
VE3:a:1:4:4
|
||||
```
|
||||
|
||||
### 3.2 Voice Fetch Request (`VR1`)
|
||||
### 3.2 Voice Fetch Request (binary)
|
||||
|
||||
Prefix: `VR1:` + colon-delimited compact payload
|
||||
|
||||
Fields:
|
||||
|
||||
- `sid` (string, 8 hex chars): requested session
|
||||
- `want` (string): currently `a` (compact token for `all`)
|
||||
- `requesterKey6` (string, 12 hex chars): requester key prefix
|
||||
- `ts` (int): unix timestamp seconds
|
||||
- `ver` (int): protocol version (`1`)
|
||||
|
||||
Compact format:
|
||||
Binary payload format:
|
||||
|
||||
```text
|
||||
VR1:{sid}:{want}:{requesterKey6}:{ts}:{ver}
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
VR1:deadbeef:a:112233445566:1700000010:1
|
||||
[magic=0x72][sid:4B][flags:1B][requesterKey6:6B][missingCount:1B][missingIndices...]
|
||||
```
|
||||
|
||||
### 3.3 Raw Voice Packet (data plane)
|
||||
@@ -91,10 +78,10 @@ Binary payload structure:
|
||||
|
||||
- Byte 0: magic `0x56` (`'V'`)
|
||||
- Bytes 1..4: session ID (4 bytes)
|
||||
- Byte 5: mode ID
|
||||
- Byte 6: packet index
|
||||
- Byte 7: total packets
|
||||
- Bytes 8..N: codec2 data
|
||||
- Byte 5: packet index
|
||||
- Bytes 6..N: codec2 data
|
||||
|
||||
Header is 6 bytes. Mode and total packet count come from the `VE3` envelope.
|
||||
|
||||
## 4. Outgoing Flow (Send)
|
||||
|
||||
@@ -102,27 +89,38 @@ Binary payload structure:
|
||||
2. Each chunk is codec2-encoded into `VoicePacket` objects.
|
||||
3. Packets are cached in `VoiceProvider` outgoing cache (TTL 15 min).
|
||||
4. Sender inserts local voice placeholder message (`isVoice=true`, `voiceId=sessionId`).
|
||||
5. Sender sends one envelope (`VE1`) through normal message path:
|
||||
5. Sender sends one envelope (`VE3`) through normal message path:
|
||||
- channel/room: `sendChannelMessage`
|
||||
- direct: `sendTextMessage`
|
||||
6. **No raw audio packets are sent during initial send.**
|
||||
|
||||
## 5. Incoming Routing
|
||||
|
||||
### 5.1 `VE1` envelope received
|
||||
### 5.1 `VE3` envelope received
|
||||
|
||||
`AppProvider` marks message as voice (`isVoice`, `voiceId`) and adds it to chat.
|
||||
`AppProvider` records sender identity from message metadata, registers the voice
|
||||
session envelope, marks the message as voice (`isVoice`, `voiceId`), and adds it to chat.
|
||||
|
||||
### 5.2 `VR1` request received
|
||||
### 5.2 Binary voice fetch request received
|
||||
|
||||
`AppProvider` treats it as control-plane only:
|
||||
|
||||
- request is not added to chat
|
||||
- validates requester prefix match against sender metadata
|
||||
- resolves requester contact via key prefix
|
||||
- calls `voiceProvider.serveSessionTo(...)`
|
||||
|
||||
### 5.3 Raw packet received (`pushRawData`)
|
||||
### 5.3 Swarm control messages received
|
||||
|
||||
`AppProvider` also handles raw swarm control payloads:
|
||||
|
||||
- binary swarm requests advertise which voice fragments are still missing
|
||||
- binary swarm availability responses advertise which fragments a peer can relay
|
||||
- swarm control payloads arrive via `pushRawData` and are not added to chat history
|
||||
|
||||
Swarm semantics are shared with image mode and documented in
|
||||
[Swarm Mode Technical Design](./swarm-mode-technical.md).
|
||||
|
||||
### 5.4 Raw packet received (`pushRawData`)
|
||||
|
||||
`AppProvider.onRawDataReceived` parses `VoicePacket` binary and appends to session in `VoiceProvider`.
|
||||
|
||||
@@ -132,10 +130,15 @@ In `VoiceMessageBubble`:
|
||||
|
||||
- If session already complete: play immediately.
|
||||
- If incomplete/missing:
|
||||
1. Resolve sender contact (message sender prefix or `VE1.senderKey6` fallback)
|
||||
2. Send direct `VR1` fetch request
|
||||
3. Show requesting state in UI
|
||||
4. Auto-play when session becomes complete
|
||||
1. Resolve sender contact from message sender metadata
|
||||
2. Prefer a direct fetch from the original sender if its raw route is healthy
|
||||
3. If the sender path does not respond, fan out a raw swarm request with the
|
||||
exact missing packet indices to reachable peers
|
||||
4. Wait up to 10 seconds for raw peer availability responses
|
||||
5. Send a direct binary fetch request to the best alternate peer for the
|
||||
missing subset it advertised
|
||||
6. Show requesting state in UI
|
||||
7. Auto-play when session becomes complete
|
||||
|
||||
If sender cannot be resolved or request cannot be sent, bubble remains and shows: **"Voice unavailable right now"**.
|
||||
|
||||
@@ -150,10 +153,13 @@ If sender cannot be resolved or request cannot be sent, bubble remains and shows
|
||||
|
||||
Serving prerequisites:
|
||||
|
||||
- session exists in cache
|
||||
- session exists in outgoing cache or already-received session state
|
||||
- `sendRawPacketCallback` configured
|
||||
- requester has direct path (`outPathLen >= 0`)
|
||||
|
||||
Received partial sessions can therefore act as relay sources during swarm
|
||||
recovery.
|
||||
|
||||
## 8. Persistence
|
||||
|
||||
`MessageStorageService` now stores and restores:
|
||||
@@ -170,10 +176,9 @@ Parser validation enforces:
|
||||
- strict hex lengths for IDs and key prefixes
|
||||
- valid mode range
|
||||
- valid packet counts and duration bounds
|
||||
- fixed protocol version (`ver == 1`)
|
||||
- `VR1.want` token `a` (internally normalized to `all`)
|
||||
|
||||
`VR1` handling verifies sender prefix matches `requesterKey6` to reduce spoofing risk.
|
||||
- compact base36 numeric fields in envelope/request
|
||||
- Binary request flags specify `all` or `missing` indices.
|
||||
- Request payload includes `requesterKey6` to resolve return route.
|
||||
|
||||
## 10. Transmit Time Estimate (UI)
|
||||
|
||||
@@ -182,12 +187,13 @@ Voice bubbles and Message Technical Details show an **estimated transmit time**
|
||||
The estimate is airtime-based (LoRa packet model), not file-duration-only:
|
||||
|
||||
- Source inputs:
|
||||
- `packetCount` and `durationMs` from `VE1` envelope, or
|
||||
- `packetCount` and `durationMs` from `VE3` envelope, or
|
||||
- numeric envelope values decoded from base36
|
||||
- actual received `VoicePacket.codec2Data.length` bytes when local session packets exist
|
||||
- `pathLen` from message metadata
|
||||
- current radio params from `deviceInfo`: `radioBw`, `radioSf`, `radioCr`
|
||||
- Per-packet payload model:
|
||||
- `meshHeader(2)` + `pathLen` + `voiceHeader(8)` + `codec2Bytes`
|
||||
- `meshHeader(2)` + `pathLen` + `voiceHeader(6)` + `codec2Bytes`
|
||||
- LoRa airtime:
|
||||
- standard symbol-time formula (preamble + payload symbols)
|
||||
- Mesh pacing/hops:
|
||||
@@ -206,30 +212,44 @@ Fallback defaults are used when radio params are unavailable: `SF10`, `BW250kHz`
|
||||
## 11. Operational Constraints
|
||||
|
||||
- No firmware changes required.
|
||||
- On-demand fetch works only if sender app is online and has cached session.
|
||||
- On-demand fetch prefers the original sender, but a partial session can also be
|
||||
completed from alternate peers that already hold packets.
|
||||
- Raw return path needs a currently valid direct route to requester.
|
||||
- Voice capture is available on iOS and Android (`Platform.isIOS || Platform.isAndroid`).
|
||||
- Swarm discovery uses the same `cmdSendRawData` / `pushRawData` path as voice
|
||||
fetch and packet delivery.
|
||||
|
||||
## 12. Backward Compatibility
|
||||
### 11.1 Raw Binary Routing Semantics
|
||||
|
||||
- Legacy `V:` text packet parsing is still supported.
|
||||
- Message voice detection accepts both new `VE1` and legacy `V:` formats.
|
||||
- Voice payload packets use companion command `CMD_SEND_RAW_DATA` (`25` / `0x19`).
|
||||
- Companion push back to the app is `PUSH_CODE_RAW_DATA` (`0x84`).
|
||||
- Over-the-air packet type for this flow is `PAYLOAD_TYPE_RAW_CUSTOM` (`0x0F`).
|
||||
- This flow is direct-route only, not flood/broadcast:
|
||||
- it is sent to one destination path;
|
||||
- only nodes on that path relay it;
|
||||
- it is **not** received by everyone in the mesh.
|
||||
|
||||
## 13. High-Level Sequence
|
||||
## 12. High-Level Sequence
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant A as Sender App
|
||||
participant M as Mesh Chat
|
||||
participant P as Peer With Packets
|
||||
participant N as Reachable Peers
|
||||
participant B as Receiver App
|
||||
|
||||
A->>A: Record + encode voice packets
|
||||
A->>A: Cache session packets (TTL 15m)
|
||||
A->>M: Send VE1 envelope
|
||||
M->>B: Deliver VE1
|
||||
A->>M: Send VE3 envelope
|
||||
M->>B: Deliver VE3
|
||||
B->>B: Render voice bubble (metadata only)
|
||||
B->>A: Send VR1 request on Play
|
||||
A->>B: Stream raw VoicePacket packets
|
||||
B->>A: Send binary fetch request on Play
|
||||
A->>B: Stream raw VoicePacket packets (partial)
|
||||
Note over A,B: Sender path stops responding
|
||||
B->>N: Raw swarm requests with missing voice packet indices
|
||||
P->>B: Raw swarm availability response
|
||||
B->>P: Direct binary fetch request for missing subset
|
||||
P->>B: Stream remaining raw VoicePacket packets
|
||||
B->>B: Reassemble session
|
||||
B->>B: Auto-play when complete
|
||||
```
|
||||
|
||||
116
integration_test/app_screenshots_test.dart
Normal file
@@ -0,0 +1,116 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:meshcore_sar_app/l10n/app_localizations.dart';
|
||||
import 'package:meshcore_sar_app/main.dart';
|
||||
import 'package:meshcore_sar_app/providers/contacts_provider.dart';
|
||||
import 'package:meshcore_sar_app/providers/messages_provider.dart';
|
||||
import 'package:meshcore_sar_app/screens/home_screen.dart';
|
||||
import 'package:meshcore_sar_app/services/wizard_preferences.dart';
|
||||
import 'package:meshcore_sar_app/utils/sample_data_generator.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
const screenshotPrefix = String.fromEnvironment('SCREENSHOT_PREFIX');
|
||||
|
||||
testWidgets('captures App Store screenshots', (tester) async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'wizard_completed': true,
|
||||
'wizard_version': 1,
|
||||
});
|
||||
await WizardPreferences.setWizardCompleted(true);
|
||||
|
||||
await tester.pumpWidget(const MeshCoreSarApp());
|
||||
await _pumpUntilFound(tester, find.byType(HomeScreen));
|
||||
await binding.convertFlutterSurfaceToImage();
|
||||
|
||||
final homeContext = tester.element(find.byType(HomeScreen));
|
||||
_loadSampleData(homeContext);
|
||||
await tester.pumpAndSettle(const Duration(seconds: 1));
|
||||
|
||||
await _takeScreenshot(binding, tester, '${screenshotPrefix}01-messages');
|
||||
|
||||
await _tapTab(tester, 'Contacts');
|
||||
await _takeScreenshot(binding, tester, '${screenshotPrefix}02-contacts');
|
||||
|
||||
await _tapTab(tester, 'Map');
|
||||
await tester.pumpAndSettle(const Duration(seconds: 2));
|
||||
await _takeScreenshot(binding, tester, '${screenshotPrefix}03-map');
|
||||
|
||||
await _openOverflowItem(tester, Icons.more_vert, 'Settings');
|
||||
await _takeScreenshot(binding, tester, '${screenshotPrefix}04-settings');
|
||||
});
|
||||
}
|
||||
|
||||
void _loadSampleData(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
const centerLocation = LatLng(46.0569, 14.5058);
|
||||
final contacts = SampleDataGenerator.generateContacts(
|
||||
centerLocation: centerLocation,
|
||||
l10n: l10n,
|
||||
teamMemberCount: 5,
|
||||
channelCount: 2,
|
||||
);
|
||||
final sampleMessages = SampleDataGenerator.generateAllMessages(
|
||||
centerLocation: centerLocation,
|
||||
l10n: l10n,
|
||||
foundPersonCount: 2,
|
||||
fireCount: 1,
|
||||
stagingCount: 1,
|
||||
objectCount: 1,
|
||||
generalChannelMessages: 8,
|
||||
emergencyChannelMessages: 5,
|
||||
);
|
||||
context.read<ContactsProvider>().addContacts(contacts);
|
||||
for (final message in sampleMessages.messages) {
|
||||
context.read<MessagesProvider>().addMessage(
|
||||
message,
|
||||
contactLocationSnapshot: sampleMessages.contactLocations[message.id],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pumpUntilFound(
|
||||
WidgetTester tester,
|
||||
Finder finder, {
|
||||
Duration timeout = const Duration(seconds: 10),
|
||||
}) async {
|
||||
final end = DateTime.now().add(timeout);
|
||||
while (DateTime.now().isBefore(end)) {
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
if (finder.evaluate().isNotEmpty) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
expect(finder, findsOneWidget);
|
||||
}
|
||||
|
||||
Future<void> _tapTab(WidgetTester tester, String label) async {
|
||||
final tab = find.text(label).last;
|
||||
await tester.tap(tab);
|
||||
await tester.pumpAndSettle(const Duration(milliseconds: 500));
|
||||
}
|
||||
|
||||
Future<void> _openOverflowItem(
|
||||
WidgetTester tester,
|
||||
IconData buttonIcon,
|
||||
String label,
|
||||
) async {
|
||||
await tester.tap(find.byIcon(buttonIcon));
|
||||
await tester.pumpAndSettle(const Duration(milliseconds: 500));
|
||||
await tester.tap(find.text(label));
|
||||
await tester.pumpAndSettle(const Duration(seconds: 1));
|
||||
}
|
||||
|
||||
Future<void> _takeScreenshot(
|
||||
IntegrationTestWidgetsFlutterBinding binding,
|
||||
WidgetTester tester,
|
||||
String name,
|
||||
) async {
|
||||
await tester.pumpAndSettle(const Duration(milliseconds: 500));
|
||||
final bytes = await binding.takeScreenshot(name);
|
||||
expect(bytes.isNotEmpty, isTrue);
|
||||
}
|
||||
@@ -1,49 +1,47 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
CFPropertyList (3.0.7)
|
||||
base64
|
||||
nkf
|
||||
rexml
|
||||
addressable (2.8.7)
|
||||
public_suffix (>= 2.0.2, < 7.0)
|
||||
CFPropertyList (3.0.9)
|
||||
abbrev (0.1.2)
|
||||
addressable (2.9.0)
|
||||
public_suffix (>= 2.0.2, < 8.0)
|
||||
artifactory (3.0.17)
|
||||
atomos (0.1.3)
|
||||
aws-eventstream (1.4.0)
|
||||
aws-partitions (1.1172.0)
|
||||
aws-sdk-core (3.233.0)
|
||||
aws-eventstream (1.3.2)
|
||||
aws-partitions (1.1109.0)
|
||||
aws-sdk-core (3.224.1)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
base64
|
||||
bigdecimal
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
logger
|
||||
aws-sdk-kms (1.113.0)
|
||||
aws-sdk-core (~> 3, >= 3.231.0)
|
||||
aws-sdk-kms (1.101.0)
|
||||
aws-sdk-core (~> 3, >= 3.216.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.199.1)
|
||||
aws-sdk-core (~> 3, >= 3.231.0)
|
||||
aws-sdk-s3 (1.188.0)
|
||||
aws-sdk-core (~> 3, >= 3.224.1)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.12.1)
|
||||
aws-sigv4 (1.11.0)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
babosa (1.0.4)
|
||||
base64 (0.3.0)
|
||||
bigdecimal (3.3.1)
|
||||
claide (1.1.0)
|
||||
colored (1.2)
|
||||
colored2 (3.1.2)
|
||||
commander (4.6.0)
|
||||
highline (~> 2.0.0)
|
||||
csv (3.3.5)
|
||||
declarative (0.0.20)
|
||||
digest-crc (0.7.0)
|
||||
rake (>= 12.0.0, < 14.0.0)
|
||||
domain_name (0.6.20240107)
|
||||
domain_name (0.5.20190701)
|
||||
unf (>= 0.0.5, < 1.0.0)
|
||||
dotenv (2.8.1)
|
||||
emoji_regex (3.2.3)
|
||||
excon (0.112.0)
|
||||
faraday (1.10.4)
|
||||
excon (0.109.0)
|
||||
faraday (1.10.5)
|
||||
faraday-em_http (~> 1.0)
|
||||
faraday-em_synchrony (~> 1.0)
|
||||
faraday-excon (~> 1.1)
|
||||
@@ -55,25 +53,26 @@ GEM
|
||||
faraday-rack (~> 1.0)
|
||||
faraday-retry (~> 1.0)
|
||||
ruby2_keywords (>= 0.0.4)
|
||||
faraday-cookie_jar (0.0.7)
|
||||
faraday-cookie_jar (0.0.8)
|
||||
faraday (>= 0.8.0)
|
||||
http-cookie (~> 1.0.0)
|
||||
http-cookie (>= 1.0.0)
|
||||
faraday-em_http (1.0.0)
|
||||
faraday-em_synchrony (1.0.1)
|
||||
faraday-excon (1.1.0)
|
||||
faraday-httpclient (1.0.1)
|
||||
faraday-multipart (1.1.1)
|
||||
faraday-multipart (1.2.0)
|
||||
multipart-post (~> 2.0)
|
||||
faraday-net_http (1.0.2)
|
||||
faraday-net_http_persistent (1.2.0)
|
||||
faraday-patron (1.0.0)
|
||||
faraday-rack (1.0.0)
|
||||
faraday-retry (1.0.3)
|
||||
faraday-retry (1.0.4)
|
||||
faraday_middleware (1.2.1)
|
||||
faraday (~> 1.0)
|
||||
fastimage (2.4.0)
|
||||
fastlane (2.228.0)
|
||||
fastimage (2.4.1)
|
||||
fastlane (2.229.0)
|
||||
CFPropertyList (>= 2.3, < 4.0.0)
|
||||
abbrev (~> 0.1.2)
|
||||
addressable (>= 2.8, < 3.0.0)
|
||||
artifactory (~> 3.0)
|
||||
aws-sdk-s3 (~> 1.0)
|
||||
@@ -81,6 +80,7 @@ GEM
|
||||
bundler (>= 1.12.0, < 3.0.0)
|
||||
colored (~> 1.2)
|
||||
commander (~> 4.6)
|
||||
csv (~> 3.3)
|
||||
dotenv (>= 2.1.1, < 3.0.0)
|
||||
emoji_regex (>= 0.1, < 4.0)
|
||||
excon (>= 0.71.0, < 1.0.0)
|
||||
@@ -100,6 +100,7 @@ GEM
|
||||
jwt (>= 2.1.0, < 3)
|
||||
mini_magick (>= 4.9.4, < 5.0.0)
|
||||
multipart-post (>= 2.0.0, < 3.0.0)
|
||||
mutex_m (~> 0.3.0)
|
||||
naturally (~> 2.2)
|
||||
optparse (>= 0.1.1, < 1.0.0)
|
||||
plist (>= 3.1.0, < 4.0.0)
|
||||
@@ -114,8 +115,7 @@ GEM
|
||||
xcodeproj (>= 1.13.0, < 2.0.0)
|
||||
xcpretty (~> 0.4.1)
|
||||
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
|
||||
fastlane-sirp (1.0.0)
|
||||
sysrandom (~> 1.0)
|
||||
fastlane-sirp (1.1.0)
|
||||
gh_inspector (1.1.3)
|
||||
google-apis-androidpublisher_v3 (0.54.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
@@ -131,19 +131,19 @@ GEM
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-playcustomapp_v1 (0.13.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-storage_v1 (0.31.0)
|
||||
google-apis-storage_v1 (0.32.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-cloud-core (1.8.0)
|
||||
google-cloud-core (1.6.1)
|
||||
google-cloud-env (>= 1.0, < 3.a)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-cloud-env (1.6.0)
|
||||
faraday (>= 0.17.3, < 3.0)
|
||||
google-cloud-errors (1.5.0)
|
||||
google-cloud-storage (1.47.0)
|
||||
google-cloud-errors (1.3.1)
|
||||
google-cloud-storage (1.37.0)
|
||||
addressable (~> 2.8)
|
||||
digest-crc (~> 0.4)
|
||||
google-apis-iamcredentials_v1 (~> 0.1)
|
||||
google-apis-storage_v1 (~> 0.31.0)
|
||||
google-apis-storage_v1 (~> 0.1)
|
||||
google-cloud-core (~> 1.6)
|
||||
googleauth (>= 0.16.2, < 2.a)
|
||||
mini_mime (~> 1.0)
|
||||
@@ -159,42 +159,40 @@ GEM
|
||||
httpclient (2.9.0)
|
||||
mutex_m
|
||||
jmespath (1.6.2)
|
||||
json (2.15.1)
|
||||
jwt (2.10.2)
|
||||
json (2.7.6)
|
||||
jwt (2.10.3)
|
||||
base64
|
||||
logger (1.7.0)
|
||||
mini_magick (4.13.2)
|
||||
mini_mime (1.1.5)
|
||||
multi_json (1.17.0)
|
||||
multi_json (1.15.0)
|
||||
multipart-post (2.4.1)
|
||||
mutex_m (0.3.0)
|
||||
nanaimo (0.4.0)
|
||||
naturally (2.3.0)
|
||||
nkf (0.2.0)
|
||||
optparse (0.6.0)
|
||||
optparse (0.8.1)
|
||||
os (1.1.4)
|
||||
plist (3.7.2)
|
||||
public_suffix (6.0.2)
|
||||
rake (13.3.0)
|
||||
public_suffix (5.1.1)
|
||||
rake (13.4.2)
|
||||
representable (3.2.0)
|
||||
declarative (< 0.1.0)
|
||||
trailblazer-option (>= 0.1.1, < 0.2.0)
|
||||
uber (< 0.2.0)
|
||||
retriable (3.1.2)
|
||||
retriable (3.8.0)
|
||||
rexml (3.4.4)
|
||||
rouge (3.28.0)
|
||||
ruby2_keywords (0.0.5)
|
||||
rubyzip (2.4.1)
|
||||
security (0.1.5)
|
||||
signet (0.21.0)
|
||||
signet (0.18.0)
|
||||
addressable (~> 2.8)
|
||||
faraday (>= 0.17.5, < 3.a)
|
||||
jwt (>= 1.5, < 4.0)
|
||||
jwt (>= 1.5, < 3.0)
|
||||
multi_json (~> 1.10)
|
||||
simctl (1.6.10)
|
||||
CFPropertyList
|
||||
naturally
|
||||
sysrandom (1.0.5)
|
||||
terminal-notifier (2.0.0)
|
||||
terminal-table (3.0.2)
|
||||
unicode-display_width (>= 1.1.1, < 3)
|
||||
@@ -204,6 +202,7 @@ GEM
|
||||
tty-spinner (0.9.3)
|
||||
tty-cursor (~> 0.7)
|
||||
uber (0.1.0)
|
||||
unf (0.2.0)
|
||||
unicode-display_width (2.6.0)
|
||||
word_wrap (1.0.0)
|
||||
xcodeproj (1.27.0)
|
||||
@@ -226,4 +225,4 @@ DEPENDENCIES
|
||||
fastlane
|
||||
|
||||
BUNDLED WITH
|
||||
2.7.2
|
||||
2.4.22
|
||||
|
||||
@@ -39,5 +39,13 @@ end
|
||||
post_install do |installer|
|
||||
installer.pods_project.targets.each do |target|
|
||||
flutter_additional_ios_build_settings(target)
|
||||
# codec2_flutter uses ARM-specific register asm("sp") in debug_alloc.h that
|
||||
# fails to compile for iOS simulators. Voice codec support is only required
|
||||
# on real iOS devices, so skip the native Codec2 sources for simulator SDKs.
|
||||
if target.name == 'codec2_flutter'
|
||||
target.build_configurations.each do |config|
|
||||
config.build_settings['EXCLUDED_SOURCE_FILE_NAMES[sdk=iphonesimulator*]'] = '*.c *.cpp'
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -57,14 +57,15 @@ PODS:
|
||||
- FlutterMacOS
|
||||
- image_picker_ios (0.0.1):
|
||||
- Flutter
|
||||
- integration_test (0.0.1):
|
||||
- Flutter
|
||||
- nsd_ios (0.0.1):
|
||||
- Flutter
|
||||
- ObjectBox (4.4.1)
|
||||
- objectbox_flutter_libs (0.0.1):
|
||||
- Flutter
|
||||
- ObjectBox (= 4.4.1)
|
||||
- package_info_plus (0.4.5):
|
||||
- Flutter
|
||||
- path_provider_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- permission_handler_apple (9.3.0):
|
||||
- Flutter
|
||||
- record_ios (1.2.0):
|
||||
@@ -85,6 +86,8 @@ PODS:
|
||||
- Flutter
|
||||
- vibration (3.0.0):
|
||||
- Flutter
|
||||
- wakelock_plus (0.0.1):
|
||||
- Flutter
|
||||
|
||||
DEPENDENCIES:
|
||||
- audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/darwin`)
|
||||
@@ -99,9 +102,10 @@ DEPENDENCIES:
|
||||
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
|
||||
- geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`)
|
||||
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
||||
- integration_test (from `.symlinks/plugins/integration_test/ios`)
|
||||
- nsd_ios (from `.symlinks/plugins/nsd_ios/ios`)
|
||||
- objectbox_flutter_libs (from `.symlinks/plugins/objectbox_flutter_libs/ios`)
|
||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
||||
- record_ios (from `.symlinks/plugins/record_ios/ios`)
|
||||
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
||||
@@ -109,12 +113,12 @@ DEPENDENCIES:
|
||||
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
|
||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||
- vibration (from `.symlinks/plugins/vibration/ios`)
|
||||
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
|
||||
|
||||
SPEC REPOS:
|
||||
trunk:
|
||||
- DKImagePickerController
|
||||
- DKPhotoGallery
|
||||
- ObjectBox
|
||||
- SDWebImage
|
||||
- SwiftyGif
|
||||
|
||||
@@ -143,12 +147,14 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/geolocator_apple/darwin"
|
||||
image_picker_ios:
|
||||
:path: ".symlinks/plugins/image_picker_ios/ios"
|
||||
integration_test:
|
||||
:path: ".symlinks/plugins/integration_test/ios"
|
||||
nsd_ios:
|
||||
:path: ".symlinks/plugins/nsd_ios/ios"
|
||||
objectbox_flutter_libs:
|
||||
:path: ".symlinks/plugins/objectbox_flutter_libs/ios"
|
||||
package_info_plus:
|
||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||
path_provider_foundation:
|
||||
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
||||
permission_handler_apple:
|
||||
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
||||
record_ios:
|
||||
@@ -163,9 +169,11 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
||||
vibration:
|
||||
:path: ".symlinks/plugins/vibration/ios"
|
||||
wakelock_plus:
|
||||
:path: ".symlinks/plugins/wakelock_plus/ios"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
audioplayers_darwin: 4f9ca89d92d3d21cec7ec580e78ca888e5fb68bd
|
||||
audioplayers_darwin: 835ced6edd4c9fc8ebb0a7cc9e294a91d99917d5
|
||||
codec2_flutter: 15e24fa897d9d903a2afb1cc5a17ae3ac88b6d6f
|
||||
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
|
||||
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
|
||||
@@ -176,13 +184,13 @@ SPEC CHECKSUMS:
|
||||
flutter_background_service_ios: 00d31bdff7b4bfe06d32375df358abe0329cf87e
|
||||
flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3
|
||||
flutter_compass: b236ab69b61545cce89fd58527f401a7587d5cc1
|
||||
flutter_local_notifications: a5a732f069baa862e728d839dd2ebb904737effb
|
||||
flutter_local_notifications: 643a3eda1ce1c0599413ca31672536d423dee214
|
||||
geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e
|
||||
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
|
||||
integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e
|
||||
nsd_ios: 596ad79109ddd3e52d665f650f36428049e3653e
|
||||
ObjectBox: 7da4aceb5013d041bfafdbc6d744a26918b09757
|
||||
objectbox_flutter_libs: 09b1dec1b4cd27bf1a5f9bae7ccaa7e43588bf31
|
||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
|
||||
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
||||
record_ios: 412daca2350b228e698fffcd08f1f94ceb1e3844
|
||||
SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf
|
||||
@@ -192,7 +200,8 @@ SPEC CHECKSUMS:
|
||||
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
|
||||
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
|
||||
vibration: ca8104a8875b9c493e15b21b04e456befd0ff6eb
|
||||
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
|
||||
|
||||
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
|
||||
PODFILE CHECKSUM: 2d56c9747241a29800bf539d978b210bda037878
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
||||
@@ -489,7 +489,7 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = 68;
|
||||
CURRENT_PROJECT_VERSION = 141;
|
||||
DEVELOPMENT_TEAM = JND55328G8;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
@@ -511,7 +511,7 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 68;
|
||||
CURRENT_PROJECT_VERSION = 141;
|
||||
DEVELOPMENT_TEAM = JND55328G8;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
@@ -530,7 +530,7 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 68;
|
||||
CURRENT_PROJECT_VERSION = 141;
|
||||
DEVELOPMENT_TEAM = JND55328G8;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
@@ -547,7 +547,7 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 68;
|
||||
CURRENT_PROJECT_VERSION = 141;
|
||||
DEVELOPMENT_TEAM = JND55328G8;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
@@ -679,7 +679,7 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = 68;
|
||||
CURRENT_PROJECT_VERSION = 141;
|
||||
DEVELOPMENT_TEAM = JND55328G8;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
@@ -702,7 +702,7 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = 68;
|
||||
CURRENT_PROJECT_VERSION = 141;
|
||||
DEVELOPMENT_TEAM = JND55328G8;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import UIKit
|
||||
import Flutter
|
||||
import UserNotifications
|
||||
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate {
|
||||
@@ -8,6 +9,7 @@ import Flutter
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
GeneratedPluginRegistrant.register(with: self)
|
||||
UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,9 +43,11 @@
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>68</string>
|
||||
<string>141</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||
<string>MeshCore SAR needs Bluetooth to communicate with MeshCore devices for Search & Rescue operations</string>
|
||||
<key>NSBluetoothPeripheralUsageDescription</key>
|
||||
@@ -68,8 +70,12 @@
|
||||
<string>MeshCore SAR needs access to the compass to show your heading direction on the map</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>MeshCore SAR needs microphone access to send voice messages over the mesh radio network during SAR operations</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>MeshCore SAR needs camera access to take photos and attach them to SAR messages</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>MeshCore SAR may need access to your photo library to attach images to messages or save map screenshots for documentation during SAR operations</string>
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>MeshCore SAR needs permission to save exported screenshots and SAR documentation images to your photo library</string>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
@@ -96,6 +102,7 @@
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>location</string>
|
||||
<string>bluetooth-central</string>
|
||||
<string>bluetooth-peripheral</string>
|
||||
</array>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
|
||||
@@ -16,17 +16,66 @@
|
||||
default_platform(:ios)
|
||||
|
||||
platform :ios do
|
||||
def project_root
|
||||
File.expand_path("../..", __dir__)
|
||||
end
|
||||
|
||||
def beta_app_info
|
||||
{
|
||||
"en-US" => {
|
||||
description: "Field-ready SAR coordination over MeshCore with chat, voice, images, offline maps, and live team context.",
|
||||
feedback_email: "hey@dz0ny.dev",
|
||||
marketing_url: "https://github.com/dz0ny/meshcore-sar",
|
||||
privacy_policy_url: "https://github.com/dz0ny/meshcore-sar"
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def beta_build_info
|
||||
{
|
||||
"en-US" => {
|
||||
whats_new: "Test MeshCore SAR field coordination flows: messaging, maps, contacts, voice, images, and SAR markers."
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
desc "Push a new release build to the App Store"
|
||||
lane :release do
|
||||
increment_build_number(xcodeproj: "Runner.xcodeproj")
|
||||
build_app(workspace: "Runner.xcworkspace", scheme: "Runner")
|
||||
upload_to_app_store(skip_metadata: true, skip_screenshots: true)
|
||||
upload_to_testflight(
|
||||
skip_waiting_for_build_processing: true,
|
||||
localized_app_info: beta_app_info,
|
||||
localized_build_info: beta_build_info,
|
||||
changelog: "Test MeshCore SAR field coordination flows: messaging, maps, contacts, voice, images, and SAR markers."
|
||||
)
|
||||
end
|
||||
|
||||
desc "Push a new beta build to TestFlight"
|
||||
lane :beta do
|
||||
increment_build_number(xcodeproj: "Runner.xcodeproj")
|
||||
build_app(workspace: "Runner.xcworkspace", scheme: "Runner")
|
||||
upload_to_testflight(skip_waiting_for_build_processing: true)
|
||||
upload_to_testflight(
|
||||
skip_waiting_for_build_processing: true,
|
||||
localized_app_info: beta_app_info,
|
||||
localized_build_info: beta_build_info,
|
||||
changelog: "Test MeshCore SAR field coordination flows: messaging, maps, contacts, voice, images, and SAR markers."
|
||||
)
|
||||
end
|
||||
|
||||
desc "Capture iOS screenshots for App Store Connect"
|
||||
lane :screenshots do
|
||||
sh("cd #{project_root} && scripts/take_screenshots.sh --ios")
|
||||
end
|
||||
|
||||
desc "Upload App Store screenshots"
|
||||
lane :store_assets do
|
||||
upload_to_app_store(
|
||||
skip_binary_upload: true,
|
||||
skip_metadata: true,
|
||||
skip_screenshots: false,
|
||||
submit_for_review: false,
|
||||
force: true
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -31,6 +31,22 @@ Push a new release build to the App Store
|
||||
|
||||
Push a new beta build to TestFlight
|
||||
|
||||
### ios screenshots
|
||||
|
||||
```sh
|
||||
[bundle exec] fastlane ios screenshots
|
||||
```
|
||||
|
||||
Capture iOS screenshots for App Store Connect
|
||||
|
||||
### ios store_assets
|
||||
|
||||
```sh
|
||||
[bundle exec] fastlane ios store_assets
|
||||
```
|
||||
|
||||
Upload App Store screenshots
|
||||
|
||||
----
|
||||
|
||||
This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run.
|
||||
|
||||
1
ios/fastlane/metadata/copyright.txt
Normal file
@@ -0,0 +1 @@
|
||||
Copyright 2026 Janez Troha
|
||||
14
ios/fastlane/metadata/en-US/description.txt
Normal file
@@ -0,0 +1,14 @@
|
||||
MeshCore SAR gives search and rescue teams a field-ready coordination workspace for low-connectivity operations.
|
||||
|
||||
Use mesh chat, live location context, SAR markers, offline-first maps, tactical drawings, voice clips, and compressed image transfer in one app. The app is built around MeshCore and BLE so teams can coordinate over LoRa links when normal infrastructure is weak or unavailable.
|
||||
|
||||
Key capabilities:
|
||||
- Direct and group mesh messaging
|
||||
- Live team contacts with signal, battery, and location context
|
||||
- Offline maps with SAR markers and tactical overlays
|
||||
- Voice clips optimized for low-bandwidth links
|
||||
- Image transfer with compact encoding
|
||||
- GPX trail import/export
|
||||
- Incident, staging, and discovery workflows for field teams
|
||||
|
||||
MeshCore SAR is intended for search and rescue field teams, incident command, and operators who need practical coordination tools outside normal coverage.
|
||||
1
ios/fastlane/metadata/en-US/keywords.txt
Normal file
@@ -0,0 +1 @@
|
||||
meshcore,sar,search,rescue,mesh,lora,offline,maps,radio,gps
|
||||
1
ios/fastlane/metadata/en-US/marketing_url.txt
Normal file
@@ -0,0 +1 @@
|
||||
https://github.com/dz0ny/meshcore-sar
|
||||
1
ios/fastlane/metadata/en-US/name.txt
Normal file
@@ -0,0 +1 @@
|
||||
MeshCore SAR
|
||||
1
ios/fastlane/metadata/en-US/privacy_url.txt
Normal file
@@ -0,0 +1 @@
|
||||
https://github.com/dz0ny/meshcore-sar
|
||||
1
ios/fastlane/metadata/en-US/promotional_text.txt
Normal file
@@ -0,0 +1 @@
|
||||
Field coordination for search and rescue teams using MeshCore, BLE, offline maps, messaging, voice, and SAR markers.
|
||||
1
ios/fastlane/metadata/en-US/release_notes.txt
Normal file
@@ -0,0 +1 @@
|
||||
Adds App Store screenshots and TestFlight metadata for MeshCore SAR publishing.
|
||||
1
ios/fastlane/metadata/en-US/subtitle.txt
Normal file
@@ -0,0 +1 @@
|
||||
SAR mesh field coordination
|
||||
1
ios/fastlane/metadata/en-US/support_url.txt
Normal file
@@ -0,0 +1 @@
|
||||
https://github.com/dz0ny/meshcore-sar/issues
|
||||
@@ -5,22 +5,12 @@
|
||||
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000238">
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000199">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.34961">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="2: build_app" time="83.968525">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="186.029813">
|
||||
<testcase classname="fastlane.lanes" name="1: upload_to_app_store" time="37.324492">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
BIN
ios/fastlane/screenshots/en-US/01-messages.png
Normal file
|
After Width: | Height: | Size: 584 KiB |
BIN
ios/fastlane/screenshots/en-US/02-contacts.png
Normal file
|
After Width: | Height: | Size: 595 KiB |
BIN
ios/fastlane/screenshots/en-US/03-map.png
Normal file
|
After Width: | Height: | Size: 3.9 MiB |
BIN
ios/fastlane/screenshots/en-US/04-settings.png
Normal file
|
After Width: | Height: | Size: 538 KiB |
BIN
ios/fastlane/screenshots/en-US/ipad-01-messages.png
Normal file
|
After Width: | Height: | Size: 500 KiB |
BIN
ios/fastlane/screenshots/en-US/ipad-02-contacts.png
Normal file
|
After Width: | Height: | Size: 526 KiB |
BIN
ios/fastlane/screenshots/en-US/ipad-03-map.png
Normal file
|
After Width: | Height: | Size: 7.0 MiB |
BIN
ios/fastlane/screenshots/en-US/ipad-04-settings.png
Normal file
|
After Width: | Height: | Size: 486 KiB |
2131
lib/l10n/app_de.arb
3318
lib/l10n/app_el.arb
3294
lib/l10n/app_en.arb
2124
lib/l10n/app_es.arb
2129
lib/l10n/app_fr.arb
1277
lib/l10n/app_hr.arb
2129
lib/l10n/app_it.arb
3450
lib/l10n/app_localizations_pl.dart
Normal file
3459
lib/l10n/app_localizations_pt.dart
Normal file
3447
lib/l10n/app_localizations_ru.dart
Normal file
3424
lib/l10n/app_localizations_tr.dart
Normal file
3456
lib/l10n/app_localizations_uk.dart
Normal file
3303
lib/l10n/app_localizations_zh.dart
Normal file
2617
lib/l10n/app_pl.arb
Normal file
2617
lib/l10n/app_pt.arb
Normal file
1032
lib/l10n/app_ru.arb
Normal file
1283
lib/l10n/app_sl.arb
2617
lib/l10n/app_tr.arb
Normal file
2617
lib/l10n/app_uk.arb
Normal file
2617
lib/l10n/app_zh.arb
Normal file
124
lib/main.dart
@@ -1,10 +1,14 @@
|
||||
import 'dart:io' show Platform;
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart'
|
||||
show TargetPlatform, defaultTargetPlatform, kIsWeb;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
import 'providers/connection_provider.dart';
|
||||
import 'providers/contacts_provider.dart';
|
||||
import 'providers/messages_provider.dart';
|
||||
@@ -14,19 +18,28 @@ import 'providers/channels_provider.dart';
|
||||
import 'providers/voice_provider.dart';
|
||||
import 'providers/image_provider.dart' as ip;
|
||||
import 'providers/app_provider.dart';
|
||||
import 'providers/offline_tiles_provider.dart';
|
||||
import 'providers/sensors_provider.dart';
|
||||
import 'services/voice_codec_service.dart';
|
||||
import 'services/voice_player_service.dart';
|
||||
import 'services/tile_cache_service.dart';
|
||||
import 'services/notification_service.dart';
|
||||
import 'services/locale_preferences.dart';
|
||||
import 'services/mesh_map_nodes_service.dart';
|
||||
import 'services/profile_manager.dart';
|
||||
import 'services/profile_workspace_coordinator.dart';
|
||||
import 'services/profiles_feature_service.dart';
|
||||
import 'services/update_checker_service.dart';
|
||||
import 'services/wizard_preferences.dart';
|
||||
import 'screens/discovery_screen.dart';
|
||||
import 'screens/home_screen.dart';
|
||||
import 'screens/welcome_wizard_screen.dart';
|
||||
import 'theme/app_theme.dart';
|
||||
import 'utils/toast_logger.dart';
|
||||
import 'l10n/app_localizations.dart';
|
||||
|
||||
void main() {
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await WakelockPlus.enable();
|
||||
runApp(const MeshCoreSarApp());
|
||||
}
|
||||
|
||||
@@ -38,11 +51,16 @@ class MeshCoreSarApp extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
||||
static const bool _isScreenshotRun = bool.fromEnvironment(
|
||||
'MESHCORE_SCREENSHOTS',
|
||||
);
|
||||
final GlobalKey<NavigatorState> _navigatorKey = GlobalKey<NavigatorState>();
|
||||
AppThemeMode _themeMode = AppThemeMode.system;
|
||||
Locale? _locale;
|
||||
bool _isInitialized = false;
|
||||
bool _shouldShowPermissionDialog = false;
|
||||
bool _wizardCompleted = true; // Will be updated in _initializeApp()
|
||||
String? _pendingNotificationPayload;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -51,6 +69,14 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
||||
}
|
||||
|
||||
Future<void> _initializeApp() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final profilesEnabled = await ProfilesFeatureService.isEnabled();
|
||||
final activeProfileId =
|
||||
prefs.getString(ProfileManager.activeProfileIdKey) ?? 'default';
|
||||
await ProfileStorageScope.bootstrap(
|
||||
profilesEnabled: profilesEnabled,
|
||||
activeProfileId: activeProfileId,
|
||||
);
|
||||
await _loadThemePreference();
|
||||
await _loadLocalePreference();
|
||||
|
||||
@@ -62,18 +88,32 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
||||
|
||||
// Set up notification tap handler for update notifications
|
||||
NotificationService().onNotificationTapped = _handleNotificationTap;
|
||||
_pendingNotificationPayload = NotificationService().consumeLaunchPayload();
|
||||
|
||||
// Check if we need to request location permissions
|
||||
await _checkLocationPermissions();
|
||||
if (!_isScreenshotRun) {
|
||||
await _checkLocationPermissions();
|
||||
}
|
||||
|
||||
// Check for app updates (Android only) - runs in background
|
||||
// Shows notification if update is available
|
||||
_checkForUpdates();
|
||||
|
||||
// Refresh remote mesh node cache in background when the app starts.
|
||||
unawaited(MeshMapNodesService.syncInBackgroundIfStale());
|
||||
|
||||
setState(() {
|
||||
_wizardCompleted = wizardCompleted;
|
||||
_isInitialized = true;
|
||||
});
|
||||
|
||||
if (_pendingNotificationPayload != null) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final payload = _pendingNotificationPayload;
|
||||
_pendingNotificationPayload = null;
|
||||
_handleNotificationTap(payload);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle notification tap
|
||||
@@ -82,10 +122,27 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
||||
|
||||
debugPrint('[Main] Notification tapped: $payload');
|
||||
|
||||
if (!_wizardCompleted) {
|
||||
_pendingNotificationPayload = payload;
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle update notification tap
|
||||
if (payload.startsWith('update:')) {
|
||||
final downloadUrl = payload.substring(7); // Remove 'update:' prefix
|
||||
_launchUpdateDownload(downloadUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.startsWith('discovery:')) {
|
||||
final navigator = _navigatorKey.currentState;
|
||||
if (navigator == null) {
|
||||
_pendingNotificationPayload = payload;
|
||||
return;
|
||||
}
|
||||
navigator.push(
|
||||
MaterialPageRoute(builder: (context) => const DiscoveryScreen()),
|
||||
);
|
||||
}
|
||||
// SAR and message notifications handled by their respective providers
|
||||
}
|
||||
@@ -158,7 +215,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
||||
/// Shows notification if update is available
|
||||
Future<void> _checkForUpdates() async {
|
||||
// Only check for updates on Android
|
||||
if (!Platform.isAndroid) {
|
||||
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
|
||||
debugPrint('[UpdateChecker] Skipping update check (not Android)');
|
||||
return;
|
||||
}
|
||||
@@ -234,6 +291,15 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
||||
},
|
||||
),
|
||||
ChangeNotifierProvider(create: (_) => ChannelsProvider()),
|
||||
ChangeNotifierProvider(create: (_) => SensorsProvider()),
|
||||
ChangeNotifierProvider(create: (_) => OfflineTilesProvider()),
|
||||
ChangeNotifierProvider(
|
||||
create: (_) {
|
||||
final manager = ProfileManager();
|
||||
manager.initialize();
|
||||
return manager;
|
||||
},
|
||||
),
|
||||
|
||||
// Voice provider (packet reassembly + playback)
|
||||
ChangeNotifierProvider(
|
||||
@@ -246,18 +312,14 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
||||
// Image provider (fragment reassembly + outgoing session cache)
|
||||
ChangeNotifierProvider(create: (_) => ip.ImageProvider()),
|
||||
|
||||
// Tile cache service
|
||||
Provider(create: (_) => TileCacheService()),
|
||||
|
||||
// App provider that coordinates everything
|
||||
// VoiceProvider is read via context.read inside create since it's already registered above
|
||||
ChangeNotifierProxyProvider6<
|
||||
ChangeNotifierProxyProvider5<
|
||||
ConnectionProvider,
|
||||
ContactsProvider,
|
||||
MessagesProvider,
|
||||
DrawingProvider,
|
||||
ChannelsProvider,
|
||||
TileCacheService,
|
||||
AppProvider
|
||||
>(
|
||||
create: (context) => AppProvider(
|
||||
@@ -268,7 +330,6 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
||||
channelsProvider: context.read<ChannelsProvider>(),
|
||||
voiceProvider: context.read<VoiceProvider>(),
|
||||
imageProvider: context.read<ip.ImageProvider>(),
|
||||
tileCacheService: context.read<TileCacheService>(),
|
||||
),
|
||||
update:
|
||||
(
|
||||
@@ -278,7 +339,6 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
||||
messages,
|
||||
drawings,
|
||||
channels,
|
||||
tileCache,
|
||||
previous,
|
||||
) =>
|
||||
previous ??
|
||||
@@ -290,9 +350,41 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
||||
channelsProvider: channels,
|
||||
voiceProvider: context.read<VoiceProvider>(),
|
||||
imageProvider: context.read<ip.ImageProvider>(),
|
||||
tileCacheService: tileCache,
|
||||
),
|
||||
),
|
||||
ProxyProvider6<
|
||||
ProfileManager,
|
||||
AppProvider,
|
||||
ConnectionProvider,
|
||||
ContactsProvider,
|
||||
MessagesProvider,
|
||||
MapProvider,
|
||||
ProfileWorkspaceCoordinator
|
||||
>(
|
||||
update:
|
||||
(
|
||||
context,
|
||||
profileManager,
|
||||
appProvider,
|
||||
connectionProvider,
|
||||
contactsProvider,
|
||||
messagesProvider,
|
||||
mapProvider,
|
||||
previous,
|
||||
) => ProfileWorkspaceCoordinator(
|
||||
profileManager: profileManager,
|
||||
connectionProvider: connectionProvider,
|
||||
contactsProvider: contactsProvider,
|
||||
messagesProvider: messagesProvider,
|
||||
sensorsProvider: context.read<SensorsProvider>(),
|
||||
mapProvider: mapProvider,
|
||||
drawingProvider: context.read<DrawingProvider>(),
|
||||
channelsProvider: context.read<ChannelsProvider>(),
|
||||
voiceProvider: context.read<VoiceProvider>(),
|
||||
imageProvider: context.read<ip.ImageProvider>(),
|
||||
appProvider: appProvider,
|
||||
),
|
||||
),
|
||||
],
|
||||
child: _buildMaterialApp(),
|
||||
);
|
||||
@@ -303,10 +395,12 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
||||
builder: (context) {
|
||||
final systemBrightness = MediaQuery.platformBrightnessOf(context);
|
||||
final materialApp = MaterialApp(
|
||||
navigatorKey: _navigatorKey,
|
||||
scaffoldMessengerKey: rootScaffoldMessengerKey,
|
||||
key: ValueKey<String?>(
|
||||
'${_locale?.languageCode ?? 'system'}_${_themeMode.name}',
|
||||
),
|
||||
title: 'MeshCore SAR',
|
||||
onGenerateTitle: (context) => AppLocalizations.of(context)?.appTitle ?? 'MeshCore SAR',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: AppTheme.getTheme(_themeMode, systemBrightness),
|
||||
locale: _locale,
|
||||
@@ -330,7 +424,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
||||
|
||||
// Wrap in SafeArea for Android only to fix navigation bar overlap (API ≥36)
|
||||
// iOS doesn't need SafeArea wrapping (causes extra black space at bottom)
|
||||
if (Platform.isAndroid) {
|
||||
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
|
||||
return SafeArea(
|
||||
left: false,
|
||||
right: false,
|
||||
|
||||
@@ -57,9 +57,7 @@ class Channel {
|
||||
} else {
|
||||
// Normal channel: require explicit secret
|
||||
if (explicitSecret == null || explicitSecret.length != 16) {
|
||||
throw ArgumentError(
|
||||
'Normal channels require a 16-byte secret',
|
||||
);
|
||||
throw ArgumentError('Normal channels require a 16-byte secret');
|
||||
}
|
||||
return Channel(
|
||||
index: index,
|
||||
@@ -78,6 +76,18 @@ class Channel {
|
||||
return Uint8List.fromList(digest.bytes.sublist(0, 16));
|
||||
}
|
||||
|
||||
static bool isHashChannelName(String channelName) {
|
||||
return channelName.trim().startsWith('#');
|
||||
}
|
||||
|
||||
static String pskBase64ForHashChannelName(String channelName) {
|
||||
final normalized = channelName.trim();
|
||||
if (!isHashChannelName(normalized)) {
|
||||
throw ArgumentError('Only #channels can export derived psk_base64');
|
||||
}
|
||||
return base64.encode(_generateHashChannelSecret(normalized));
|
||||
}
|
||||
|
||||
/// Create the default public channel (channel 0)
|
||||
/// Uses the well-known pre-shared key from MeshCore
|
||||
factory Channel.publicChannel() {
|
||||
@@ -85,8 +95,22 @@ class Channel {
|
||||
index: 0,
|
||||
name: 'Public Channel',
|
||||
secret: Uint8List.fromList([
|
||||
0x8b, 0x33, 0x87, 0xe9, 0xc5, 0xcd, 0xea, 0x6a,
|
||||
0xc9, 0xe5, 0xed, 0xba, 0xa1, 0x15, 0xcd, 0x72,
|
||||
0x8b,
|
||||
0x33,
|
||||
0x87,
|
||||
0xe9,
|
||||
0xc5,
|
||||
0xcd,
|
||||
0xea,
|
||||
0x6a,
|
||||
0xc9,
|
||||
0xe5,
|
||||
0xed,
|
||||
0xba,
|
||||
0xa1,
|
||||
0x15,
|
||||
0xcd,
|
||||
0x72,
|
||||
]),
|
||||
flags: null,
|
||||
);
|
||||
@@ -95,6 +119,18 @@ class Channel {
|
||||
/// Check if this is a hash-based channel (name starts with '#')
|
||||
bool get isHashChannel => name.startsWith('#');
|
||||
|
||||
/// Base64-encoded PSK for sharing with firmware CLI and related tooling.
|
||||
String get pskBase64 => base64.encode(secret);
|
||||
|
||||
/// MeshCore group packets carry the first byte of SHA256(channel secret).
|
||||
int get hashByte {
|
||||
final digest = sha256.convert(secret);
|
||||
return digest.bytes.first;
|
||||
}
|
||||
|
||||
String get hashHex =>
|
||||
hashByte.toRadixString(16).padLeft(2, '0').toUpperCase();
|
||||
|
||||
/// Display name for the channel
|
||||
/// Returns "Public" for channel 0, otherwise returns the custom name or "Channel N"
|
||||
String get displayName {
|
||||
@@ -131,12 +167,7 @@ class Channel {
|
||||
}
|
||||
|
||||
/// Create a copy with modified fields
|
||||
Channel copyWith({
|
||||
int? index,
|
||||
String? name,
|
||||
Uint8List? secret,
|
||||
int? flags,
|
||||
}) {
|
||||
Channel copyWith({int? index, String? name, Uint8List? secret, int? flags}) {
|
||||
return Channel(
|
||||
index: index ?? this.index,
|
||||
name: name ?? this.name,
|
||||
|
||||
518
lib/models/config_profile.dart
Normal file
@@ -0,0 +1,518 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'channel.dart';
|
||||
|
||||
@immutable
|
||||
class DeviceConfigProfileSection {
|
||||
final int? frequencyKhz;
|
||||
final int? bandwidth;
|
||||
final int? spreadingFactor;
|
||||
final int? codingRate;
|
||||
final bool? repeatEnabled;
|
||||
final int? txPower;
|
||||
final int? telemetryModes;
|
||||
final int? advertLocationPolicy;
|
||||
final int? multiAcks;
|
||||
final bool? manualAddContacts;
|
||||
final bool? autoAddUsers;
|
||||
final bool? autoAddRepeaters;
|
||||
final bool? autoAddRoomServers;
|
||||
final bool? autoAddSensors;
|
||||
final bool? autoAddOverwriteOldest;
|
||||
final double? publicLatitude;
|
||||
final double? publicLongitude;
|
||||
|
||||
const DeviceConfigProfileSection({
|
||||
this.frequencyKhz,
|
||||
this.bandwidth,
|
||||
this.spreadingFactor,
|
||||
this.codingRate,
|
||||
this.repeatEnabled,
|
||||
this.txPower,
|
||||
this.telemetryModes,
|
||||
this.advertLocationPolicy,
|
||||
this.multiAcks,
|
||||
this.manualAddContacts,
|
||||
this.autoAddUsers,
|
||||
this.autoAddRepeaters,
|
||||
this.autoAddRoomServers,
|
||||
this.autoAddSensors,
|
||||
this.autoAddOverwriteOldest,
|
||||
this.publicLatitude,
|
||||
this.publicLongitude,
|
||||
});
|
||||
|
||||
bool get isEmpty =>
|
||||
frequencyKhz == null &&
|
||||
bandwidth == null &&
|
||||
spreadingFactor == null &&
|
||||
codingRate == null &&
|
||||
repeatEnabled == null &&
|
||||
txPower == null &&
|
||||
telemetryModes == null &&
|
||||
advertLocationPolicy == null &&
|
||||
multiAcks == null &&
|
||||
manualAddContacts == null &&
|
||||
autoAddUsers == null &&
|
||||
autoAddRepeaters == null &&
|
||||
autoAddRoomServers == null &&
|
||||
autoAddSensors == null &&
|
||||
autoAddOverwriteOldest == null &&
|
||||
publicLatitude == null &&
|
||||
publicLongitude == null;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'frequencyKhz': frequencyKhz,
|
||||
'bandwidth': bandwidth,
|
||||
'spreadingFactor': spreadingFactor,
|
||||
'codingRate': codingRate,
|
||||
'repeatEnabled': repeatEnabled,
|
||||
'txPower': txPower,
|
||||
'telemetryModes': telemetryModes,
|
||||
'advertLocationPolicy': advertLocationPolicy,
|
||||
'multiAcks': multiAcks,
|
||||
'manualAddContacts': manualAddContacts,
|
||||
'autoAddUsers': autoAddUsers,
|
||||
'autoAddRepeaters': autoAddRepeaters,
|
||||
'autoAddRoomServers': autoAddRoomServers,
|
||||
'autoAddSensors': autoAddSensors,
|
||||
'autoAddOverwriteOldest': autoAddOverwriteOldest,
|
||||
'publicLatitude': publicLatitude,
|
||||
'publicLongitude': publicLongitude,
|
||||
};
|
||||
|
||||
factory DeviceConfigProfileSection.fromJson(Map<String, dynamic> json) {
|
||||
return DeviceConfigProfileSection(
|
||||
frequencyKhz: json['frequencyKhz'] as int?,
|
||||
bandwidth: json['bandwidth'] as int?,
|
||||
spreadingFactor: json['spreadingFactor'] as int?,
|
||||
codingRate: json['codingRate'] as int?,
|
||||
repeatEnabled: json['repeatEnabled'] as bool?,
|
||||
txPower: json['txPower'] as int?,
|
||||
telemetryModes: json['telemetryModes'] as int?,
|
||||
advertLocationPolicy: json['advertLocationPolicy'] as int?,
|
||||
multiAcks: json['multiAcks'] as int?,
|
||||
manualAddContacts: json['manualAddContacts'] as bool?,
|
||||
autoAddUsers: json['autoAddUsers'] as bool?,
|
||||
autoAddRepeaters: json['autoAddRepeaters'] as bool?,
|
||||
autoAddRoomServers: json['autoAddRoomServers'] as bool?,
|
||||
autoAddSensors: json['autoAddSensors'] as bool?,
|
||||
autoAddOverwriteOldest: json['autoAddOverwriteOldest'] as bool?,
|
||||
publicLatitude: (json['publicLatitude'] as num?)?.toDouble(),
|
||||
publicLongitude: (json['publicLongitude'] as num?)?.toDouble(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@immutable
|
||||
class AppSettingsProfileSection {
|
||||
final bool? mapEnabled;
|
||||
final bool? contactsEnabled;
|
||||
final bool? sensorsEnabled;
|
||||
final bool? voiceSilenceTrimmingEnabled;
|
||||
final bool? voiceBandPassFilterEnabled;
|
||||
final bool? voiceCompressorEnabled;
|
||||
final bool? voiceLimiterEnabled;
|
||||
final bool? voiceAutoGainEnabled;
|
||||
final bool? voiceEchoCancellationEnabled;
|
||||
final bool? voiceNoiseSuppressionEnabled;
|
||||
final double? messageFontScale;
|
||||
final bool? clearPathOnMaxRetry;
|
||||
final bool? nearestRelayFallbackEnabled;
|
||||
final int? voiceBitrate;
|
||||
final int? routeHashSize;
|
||||
final int? imageMaxSize;
|
||||
final int? imageCompression;
|
||||
final bool? imageGrayscale;
|
||||
final bool? imageUltraMode;
|
||||
final bool? showRxTxIndicators;
|
||||
final bool? fastLocationUpdatesEnabled;
|
||||
final double? fastLocationMovementThresholdMeters;
|
||||
final int? fastLocationActiveCadenceSeconds;
|
||||
|
||||
const AppSettingsProfileSection({
|
||||
this.mapEnabled,
|
||||
this.contactsEnabled,
|
||||
this.sensorsEnabled,
|
||||
this.voiceSilenceTrimmingEnabled,
|
||||
this.voiceBandPassFilterEnabled,
|
||||
this.voiceCompressorEnabled,
|
||||
this.voiceLimiterEnabled,
|
||||
this.voiceAutoGainEnabled,
|
||||
this.voiceEchoCancellationEnabled,
|
||||
this.voiceNoiseSuppressionEnabled,
|
||||
this.messageFontScale,
|
||||
this.clearPathOnMaxRetry,
|
||||
this.nearestRelayFallbackEnabled,
|
||||
this.voiceBitrate,
|
||||
this.routeHashSize,
|
||||
this.imageMaxSize,
|
||||
this.imageCompression,
|
||||
this.imageGrayscale,
|
||||
this.imageUltraMode,
|
||||
this.showRxTxIndicators,
|
||||
this.fastLocationUpdatesEnabled,
|
||||
this.fastLocationMovementThresholdMeters,
|
||||
this.fastLocationActiveCadenceSeconds,
|
||||
});
|
||||
|
||||
bool get isEmpty =>
|
||||
mapEnabled == null &&
|
||||
contactsEnabled == null &&
|
||||
sensorsEnabled == null &&
|
||||
voiceSilenceTrimmingEnabled == null &&
|
||||
voiceBandPassFilterEnabled == null &&
|
||||
voiceCompressorEnabled == null &&
|
||||
voiceLimiterEnabled == null &&
|
||||
voiceAutoGainEnabled == null &&
|
||||
voiceEchoCancellationEnabled == null &&
|
||||
voiceNoiseSuppressionEnabled == null &&
|
||||
messageFontScale == null &&
|
||||
clearPathOnMaxRetry == null &&
|
||||
nearestRelayFallbackEnabled == null &&
|
||||
voiceBitrate == null &&
|
||||
routeHashSize == null &&
|
||||
imageMaxSize == null &&
|
||||
imageCompression == null &&
|
||||
imageGrayscale == null &&
|
||||
imageUltraMode == null &&
|
||||
showRxTxIndicators == null &&
|
||||
fastLocationUpdatesEnabled == null &&
|
||||
fastLocationMovementThresholdMeters == null &&
|
||||
fastLocationActiveCadenceSeconds == null;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'mapEnabled': mapEnabled,
|
||||
'contactsEnabled': contactsEnabled,
|
||||
'sensorsEnabled': sensorsEnabled,
|
||||
'voiceSilenceTrimmingEnabled': voiceSilenceTrimmingEnabled,
|
||||
'voiceBandPassFilterEnabled': voiceBandPassFilterEnabled,
|
||||
'voiceCompressorEnabled': voiceCompressorEnabled,
|
||||
'voiceLimiterEnabled': voiceLimiterEnabled,
|
||||
'voiceAutoGainEnabled': voiceAutoGainEnabled,
|
||||
'voiceEchoCancellationEnabled': voiceEchoCancellationEnabled,
|
||||
'voiceNoiseSuppressionEnabled': voiceNoiseSuppressionEnabled,
|
||||
'messageFontScale': messageFontScale,
|
||||
'clearPathOnMaxRetry': clearPathOnMaxRetry,
|
||||
'nearestRelayFallbackEnabled': nearestRelayFallbackEnabled,
|
||||
'voiceBitrate': voiceBitrate,
|
||||
'routeHashSize': routeHashSize,
|
||||
'imageMaxSize': imageMaxSize,
|
||||
'imageCompression': imageCompression,
|
||||
'imageGrayscale': imageGrayscale,
|
||||
'imageUltraMode': imageUltraMode,
|
||||
'showRxTxIndicators': showRxTxIndicators,
|
||||
'fastLocationUpdatesEnabled': fastLocationUpdatesEnabled,
|
||||
'fastLocationMovementThresholdMeters': fastLocationMovementThresholdMeters,
|
||||
'fastLocationActiveCadenceSeconds': fastLocationActiveCadenceSeconds,
|
||||
};
|
||||
|
||||
factory AppSettingsProfileSection.fromJson(Map<String, dynamic> json) {
|
||||
return AppSettingsProfileSection(
|
||||
mapEnabled: json['mapEnabled'] as bool?,
|
||||
contactsEnabled: json['contactsEnabled'] as bool?,
|
||||
sensorsEnabled: json['sensorsEnabled'] as bool?,
|
||||
voiceSilenceTrimmingEnabled: json['voiceSilenceTrimmingEnabled'] as bool?,
|
||||
voiceBandPassFilterEnabled: json['voiceBandPassFilterEnabled'] as bool?,
|
||||
voiceCompressorEnabled: json['voiceCompressorEnabled'] as bool?,
|
||||
voiceLimiterEnabled: json['voiceLimiterEnabled'] as bool?,
|
||||
voiceAutoGainEnabled: json['voiceAutoGainEnabled'] as bool?,
|
||||
voiceEchoCancellationEnabled:
|
||||
json['voiceEchoCancellationEnabled'] as bool?,
|
||||
voiceNoiseSuppressionEnabled:
|
||||
json['voiceNoiseSuppressionEnabled'] as bool?,
|
||||
messageFontScale: (json['messageFontScale'] as num?)?.toDouble(),
|
||||
clearPathOnMaxRetry: json['clearPathOnMaxRetry'] as bool?,
|
||||
nearestRelayFallbackEnabled: json['nearestRelayFallbackEnabled'] as bool?,
|
||||
voiceBitrate: json['voiceBitrate'] as int?,
|
||||
routeHashSize: json['routeHashSize'] as int?,
|
||||
imageMaxSize: json['imageMaxSize'] as int?,
|
||||
imageCompression: json['imageCompression'] as int?,
|
||||
imageGrayscale: json['imageGrayscale'] as bool?,
|
||||
imageUltraMode: json['imageUltraMode'] as bool?,
|
||||
showRxTxIndicators: json['showRxTxIndicators'] as bool?,
|
||||
fastLocationUpdatesEnabled: json['fastLocationUpdatesEnabled'] as bool?,
|
||||
fastLocationMovementThresholdMeters:
|
||||
(json['fastLocationMovementThresholdMeters'] as num?)?.toDouble(),
|
||||
fastLocationActiveCadenceSeconds:
|
||||
json['fastLocationActiveCadenceSeconds'] as int?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@immutable
|
||||
class MapWorkspaceProfileSection {
|
||||
final Map<String, dynamic>? mapPrefs;
|
||||
final List<Map<String, dynamic>> drawings;
|
||||
final Map<String, dynamic>? currentTrail;
|
||||
final List<Map<String, dynamic>> trailHistory;
|
||||
final Map<String, dynamic>? importedTrail;
|
||||
final bool? isTrailVisible;
|
||||
final bool? showCadastralOverlay;
|
||||
final bool? showForestRoadsOverlay;
|
||||
final bool? showHikingTrailsOverlay;
|
||||
final bool? showMainRoadsOverlay;
|
||||
final bool? showHouseNumbersOverlay;
|
||||
final bool? showFireHazardZonesOverlay;
|
||||
final bool? showHistoricalFiresOverlay;
|
||||
final bool? showFirebreaksOverlay;
|
||||
final bool? showKrasFireZonesOverlay;
|
||||
final bool? showPlaceNamesOverlay;
|
||||
final bool? showMunicipalityBordersOverlay;
|
||||
final bool? hideRepeatersOnMap;
|
||||
|
||||
const MapWorkspaceProfileSection({
|
||||
this.mapPrefs,
|
||||
this.drawings = const [],
|
||||
this.currentTrail,
|
||||
this.trailHistory = const [],
|
||||
this.importedTrail,
|
||||
this.isTrailVisible,
|
||||
this.showCadastralOverlay,
|
||||
this.showForestRoadsOverlay,
|
||||
this.showHikingTrailsOverlay,
|
||||
this.showMainRoadsOverlay,
|
||||
this.showHouseNumbersOverlay,
|
||||
this.showFireHazardZonesOverlay,
|
||||
this.showHistoricalFiresOverlay,
|
||||
this.showFirebreaksOverlay,
|
||||
this.showKrasFireZonesOverlay,
|
||||
this.showPlaceNamesOverlay,
|
||||
this.showMunicipalityBordersOverlay,
|
||||
this.hideRepeatersOnMap,
|
||||
});
|
||||
|
||||
bool get isEmpty =>
|
||||
mapPrefs == null &&
|
||||
drawings.isEmpty &&
|
||||
currentTrail == null &&
|
||||
trailHistory.isEmpty &&
|
||||
importedTrail == null &&
|
||||
isTrailVisible == null &&
|
||||
showCadastralOverlay == null &&
|
||||
showForestRoadsOverlay == null &&
|
||||
showHikingTrailsOverlay == null &&
|
||||
showMainRoadsOverlay == null &&
|
||||
showHouseNumbersOverlay == null &&
|
||||
showFireHazardZonesOverlay == null &&
|
||||
showHistoricalFiresOverlay == null &&
|
||||
showFirebreaksOverlay == null &&
|
||||
showKrasFireZonesOverlay == null &&
|
||||
showPlaceNamesOverlay == null &&
|
||||
showMunicipalityBordersOverlay == null &&
|
||||
hideRepeatersOnMap == null;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'mapPrefs': mapPrefs,
|
||||
'drawings': drawings,
|
||||
'currentTrail': currentTrail,
|
||||
'trailHistory': trailHistory,
|
||||
'importedTrail': importedTrail,
|
||||
'isTrailVisible': isTrailVisible,
|
||||
'showCadastralOverlay': showCadastralOverlay,
|
||||
'showForestRoadsOverlay': showForestRoadsOverlay,
|
||||
'showHikingTrailsOverlay': showHikingTrailsOverlay,
|
||||
'showMainRoadsOverlay': showMainRoadsOverlay,
|
||||
'showHouseNumbersOverlay': showHouseNumbersOverlay,
|
||||
'showFireHazardZonesOverlay': showFireHazardZonesOverlay,
|
||||
'showHistoricalFiresOverlay': showHistoricalFiresOverlay,
|
||||
'showFirebreaksOverlay': showFirebreaksOverlay,
|
||||
'showKrasFireZonesOverlay': showKrasFireZonesOverlay,
|
||||
'showPlaceNamesOverlay': showPlaceNamesOverlay,
|
||||
'showMunicipalityBordersOverlay': showMunicipalityBordersOverlay,
|
||||
'hideRepeatersOnMap': hideRepeatersOnMap,
|
||||
};
|
||||
|
||||
factory MapWorkspaceProfileSection.fromJson(Map<String, dynamic> json) {
|
||||
List<Map<String, dynamic>> decodeList(String key) {
|
||||
final raw = json[key] as List<dynamic>? ?? const [];
|
||||
return raw.whereType<Map<String, dynamic>>().toList();
|
||||
}
|
||||
|
||||
return MapWorkspaceProfileSection(
|
||||
mapPrefs: json['mapPrefs'] as Map<String, dynamic>?,
|
||||
drawings: decodeList('drawings'),
|
||||
currentTrail: json['currentTrail'] as Map<String, dynamic>?,
|
||||
trailHistory: decodeList('trailHistory'),
|
||||
importedTrail: json['importedTrail'] as Map<String, dynamic>?,
|
||||
isTrailVisible: json['isTrailVisible'] as bool?,
|
||||
showCadastralOverlay: json['showCadastralOverlay'] as bool?,
|
||||
showForestRoadsOverlay: json['showForestRoadsOverlay'] as bool?,
|
||||
showHikingTrailsOverlay: json['showHikingTrailsOverlay'] as bool?,
|
||||
showMainRoadsOverlay: json['showMainRoadsOverlay'] as bool?,
|
||||
showHouseNumbersOverlay: json['showHouseNumbersOverlay'] as bool?,
|
||||
showFireHazardZonesOverlay: json['showFireHazardZonesOverlay'] as bool?,
|
||||
showHistoricalFiresOverlay: json['showHistoricalFiresOverlay'] as bool?,
|
||||
showFirebreaksOverlay: json['showFirebreaksOverlay'] as bool?,
|
||||
showKrasFireZonesOverlay: json['showKrasFireZonesOverlay'] as bool?,
|
||||
showPlaceNamesOverlay: json['showPlaceNamesOverlay'] as bool?,
|
||||
showMunicipalityBordersOverlay:
|
||||
json['showMunicipalityBordersOverlay'] as bool?,
|
||||
hideRepeatersOnMap: json['hideRepeatersOnMap'] as bool?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@immutable
|
||||
class ConfigProfileSections {
|
||||
final DeviceConfigProfileSection? deviceConfig;
|
||||
final AppSettingsProfileSection? appSettings;
|
||||
final MapWorkspaceProfileSection? mapWorkspace;
|
||||
final List<Channel> channels;
|
||||
|
||||
const ConfigProfileSections({
|
||||
this.deviceConfig,
|
||||
this.appSettings,
|
||||
this.mapWorkspace,
|
||||
this.channels = const [],
|
||||
});
|
||||
|
||||
bool get isEmpty =>
|
||||
(deviceConfig == null || deviceConfig!.isEmpty) &&
|
||||
(appSettings == null || appSettings!.isEmpty) &&
|
||||
(mapWorkspace == null || mapWorkspace!.isEmpty) &&
|
||||
channels.isEmpty;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'deviceConfig': deviceConfig?.toJson(),
|
||||
'appSettings': appSettings?.toJson(),
|
||||
'mapWorkspace': mapWorkspace?.toJson(),
|
||||
'channels': channels.map((channel) => channel.toJson()).toList(),
|
||||
};
|
||||
|
||||
factory ConfigProfileSections.fromJson(Map<String, dynamic> json) {
|
||||
final rawChannels = json['channels'] as List<dynamic>? ?? const [];
|
||||
return ConfigProfileSections(
|
||||
deviceConfig: json['deviceConfig'] is Map<String, dynamic>
|
||||
? DeviceConfigProfileSection.fromJson(
|
||||
json['deviceConfig'] as Map<String, dynamic>,
|
||||
)
|
||||
: null,
|
||||
appSettings: json['appSettings'] is Map<String, dynamic>
|
||||
? AppSettingsProfileSection.fromJson(
|
||||
json['appSettings'] as Map<String, dynamic>,
|
||||
)
|
||||
: null,
|
||||
mapWorkspace: json['mapWorkspace'] is Map<String, dynamic>
|
||||
? MapWorkspaceProfileSection.fromJson(
|
||||
json['mapWorkspace'] as Map<String, dynamic>,
|
||||
)
|
||||
: null,
|
||||
channels: rawChannels
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(Channel.fromJson)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@immutable
|
||||
class ConfigProfile {
|
||||
static const String defaultProfileId = 'default';
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
final String? notes;
|
||||
final ConfigProfileSections sections;
|
||||
|
||||
const ConfigProfile({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
required this.sections,
|
||||
this.notes,
|
||||
});
|
||||
|
||||
bool get isDefault => id == defaultProfileId;
|
||||
|
||||
factory ConfigProfile.defaultProfile() {
|
||||
final now = DateTime.now();
|
||||
return ConfigProfile(
|
||||
id: defaultProfileId,
|
||||
name: 'Default',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
sections: const ConfigProfileSections(),
|
||||
);
|
||||
}
|
||||
|
||||
ConfigProfile copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
String? notes,
|
||||
ConfigProfileSections? sections,
|
||||
}) {
|
||||
return ConfigProfile(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
notes: notes ?? this.notes,
|
||||
sections: sections ?? this.sections,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
'updatedAt': updatedAt.toIso8601String(),
|
||||
'notes': notes,
|
||||
'sections': sections.toJson(),
|
||||
};
|
||||
|
||||
factory ConfigProfile.fromJson(Map<String, dynamic> json) {
|
||||
return ConfigProfile(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
updatedAt: DateTime.parse(json['updatedAt'] as String),
|
||||
notes: json['notes'] as String?,
|
||||
sections: ConfigProfileSections.fromJson(
|
||||
json['sections'] as Map<String, dynamic>? ?? const {},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@immutable
|
||||
class ProfileDiff {
|
||||
final List<String> changedSections;
|
||||
|
||||
const ProfileDiff({required this.changedSections});
|
||||
}
|
||||
|
||||
@immutable
|
||||
class ProfileTransferRecord {
|
||||
final String profileId;
|
||||
final String direction;
|
||||
final DateTime timestamp;
|
||||
final String detail;
|
||||
|
||||
const ProfileTransferRecord({
|
||||
required this.profileId,
|
||||
required this.direction,
|
||||
required this.timestamp,
|
||||
required this.detail,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'profileId': profileId,
|
||||
'direction': direction,
|
||||
'timestamp': timestamp.toIso8601String(),
|
||||
'detail': detail,
|
||||
};
|
||||
|
||||
factory ProfileTransferRecord.fromJson(Map<String, dynamic> json) {
|
||||
return ProfileTransferRecord(
|
||||
profileId: json['profileId'] as String,
|
||||
direction: json['direction'] as String,
|
||||
timestamp: DateTime.parse(json['timestamp'] as String),
|
||||
detail: json['detail'] as String,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,203 @@
|
||||
export 'package:meshcore_client/meshcore_client.dart'
|
||||
show Contact, ContactType, ContactTelemetry, AdvertLocation;
|
||||
|
||||
import 'dart:math' as math;
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import 'package:meshcore_client/meshcore_client.dart';
|
||||
|
||||
class ParsedContactRoute {
|
||||
final String canonicalText;
|
||||
final int hashSize;
|
||||
final int hopCount;
|
||||
final int encodedPathLen;
|
||||
final int signedEncodedPathLen;
|
||||
final Uint8List pathBytes;
|
||||
final Uint8List paddedPathBytes;
|
||||
|
||||
const ParsedContactRoute({
|
||||
required this.canonicalText,
|
||||
required this.hashSize,
|
||||
required this.hopCount,
|
||||
required this.encodedPathLen,
|
||||
required this.signedEncodedPathLen,
|
||||
required this.pathBytes,
|
||||
required this.paddedPathBytes,
|
||||
});
|
||||
|
||||
int get byteLength => pathBytes.length;
|
||||
String get summary => hopCount == 0
|
||||
? 'Direct'
|
||||
: '$hopCount hop${hopCount == 1 ? '' : 's'} via $hashSize-byte hashes';
|
||||
}
|
||||
|
||||
class ContactRouteFormatException implements Exception {
|
||||
final String message;
|
||||
|
||||
const ContactRouteFormatException(this.message);
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
class ContactRouteCodec {
|
||||
static const int maxHashSize = 3;
|
||||
static const int maxPathBytes = 64;
|
||||
static const int _unknownDescriptor = 0xFF;
|
||||
|
||||
static ParsedContactRoute parse(String input, {int? expectedHashSize}) {
|
||||
final normalized = input.trim().toUpperCase();
|
||||
if (normalized.isEmpty) {
|
||||
throw const ContactRouteFormatException('Route cannot be empty.');
|
||||
}
|
||||
|
||||
if (expectedHashSize != null &&
|
||||
(expectedHashSize < 1 || expectedHashSize > maxHashSize)) {
|
||||
throw const ContactRouteFormatException(
|
||||
'Hash size must be 1, 2, or 3 bytes.',
|
||||
);
|
||||
}
|
||||
|
||||
final hopTokens = normalized
|
||||
.split(',')
|
||||
.map((token) => token.trim())
|
||||
.toList();
|
||||
if (hopTokens.any((token) => token.isEmpty)) {
|
||||
throw const ContactRouteFormatException('Route contains an empty hop.');
|
||||
}
|
||||
|
||||
final hopBytes = <List<int>>[];
|
||||
int? hashSize;
|
||||
for (final token in hopTokens) {
|
||||
final compact = token.replaceAll(':', '');
|
||||
if (compact.isEmpty || !RegExp(r'^[0-9A-F]+$').hasMatch(compact)) {
|
||||
throw ContactRouteFormatException('Invalid hop "$token".');
|
||||
}
|
||||
if (compact.length.isOdd) {
|
||||
throw ContactRouteFormatException(
|
||||
'Hop "$token" must contain full bytes.',
|
||||
);
|
||||
}
|
||||
|
||||
final currentHashSize = compact.length ~/ 2;
|
||||
if (currentHashSize < 1 || currentHashSize > maxHashSize) {
|
||||
throw ContactRouteFormatException(
|
||||
'Hop "$token" must be 1, 2, or 3 bytes.',
|
||||
);
|
||||
}
|
||||
|
||||
if (expectedHashSize != null && currentHashSize != expectedHashSize) {
|
||||
throw ContactRouteFormatException(
|
||||
'Hop "$token" must be $expectedHashSize '
|
||||
'byte${expectedHashSize == 1 ? '' : 's'}.',
|
||||
);
|
||||
}
|
||||
|
||||
hashSize ??= currentHashSize;
|
||||
if (hashSize != currentHashSize) {
|
||||
throw const ContactRouteFormatException(
|
||||
'All hops in a route must use the same hash size.',
|
||||
);
|
||||
}
|
||||
|
||||
final bytes = <int>[];
|
||||
for (var i = 0; i < compact.length; i += 2) {
|
||||
bytes.add(int.parse(compact.substring(i, i + 2), radix: 16));
|
||||
}
|
||||
hopBytes.add(bytes);
|
||||
}
|
||||
|
||||
final resolvedHashSize = hashSize ?? 1;
|
||||
final flatBytes = Uint8List.fromList(
|
||||
hopBytes.expand((hop) => hop).toList(),
|
||||
);
|
||||
if (flatBytes.length > maxPathBytes) {
|
||||
throw const ContactRouteFormatException(
|
||||
'Route exceeds the 64-byte firmware limit.',
|
||||
);
|
||||
}
|
||||
|
||||
final encodedPathLen =
|
||||
((resolvedHashSize - 1) << 6) | (hopBytes.length & 0x3F);
|
||||
final padded = Uint8List(maxPathBytes);
|
||||
padded.setRange(0, flatBytes.length, flatBytes);
|
||||
|
||||
return ParsedContactRoute(
|
||||
canonicalText: hopBytes
|
||||
.map(
|
||||
(hop) => hop
|
||||
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
||||
.join()
|
||||
.toUpperCase(),
|
||||
)
|
||||
.join(','),
|
||||
hashSize: resolvedHashSize,
|
||||
hopCount: hopBytes.length,
|
||||
encodedPathLen: encodedPathLen,
|
||||
signedEncodedPathLen: toSignedDescriptor(encodedPathLen),
|
||||
pathBytes: flatBytes,
|
||||
paddedPathBytes: padded,
|
||||
);
|
||||
}
|
||||
|
||||
static ParsedContactRoute direct({int hashSize = 1}) {
|
||||
if (hashSize < 1 || hashSize > maxHashSize) {
|
||||
throw ArgumentError.value(hashSize, 'hashSize', 'Must be 1, 2, or 3.');
|
||||
}
|
||||
|
||||
final encodedPathLen = (hashSize - 1) << 6;
|
||||
return ParsedContactRoute(
|
||||
canonicalText: '',
|
||||
hashSize: hashSize,
|
||||
hopCount: 0,
|
||||
encodedPathLen: encodedPathLen,
|
||||
signedEncodedPathLen: toSignedDescriptor(encodedPathLen),
|
||||
pathBytes: Uint8List(0),
|
||||
paddedPathBytes: Uint8List(maxPathBytes),
|
||||
);
|
||||
}
|
||||
|
||||
static ParsedContactRoute? fromContact(Contact contact) {
|
||||
if (!contact.routeHasPath || contact.routeHopCount == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ParsedContactRoute(
|
||||
canonicalText: contact.routeCanonicalText,
|
||||
hashSize: contact.routeHashSize,
|
||||
hopCount: contact.routeHopCount,
|
||||
encodedPathLen: contact.routeEncodedPathLen,
|
||||
signedEncodedPathLen: contact.routeSignedPathLen,
|
||||
pathBytes: contact.routePathBytes,
|
||||
paddedPathBytes: _padPath(contact.routePathBytes),
|
||||
);
|
||||
}
|
||||
|
||||
static Uint8List _padPath(Uint8List bytes) {
|
||||
final padded = Uint8List(maxPathBytes);
|
||||
padded.setRange(0, math.min(bytes.length, maxPathBytes), bytes);
|
||||
return padded;
|
||||
}
|
||||
|
||||
static int toSignedDescriptor(int encodedPathLen) =>
|
||||
encodedPathLen > 127 ? encodedPathLen - 256 : encodedPathLen;
|
||||
|
||||
static int toUnsignedDescriptor(int signedPathLen) => signedPathLen & 0xFF;
|
||||
|
||||
static bool isUnknownDescriptor(int signedPathLen) =>
|
||||
toUnsignedDescriptor(signedPathLen) == _unknownDescriptor;
|
||||
|
||||
static bool isValidDescriptor(int signedPathLen) {
|
||||
final raw = toUnsignedDescriptor(signedPathLen);
|
||||
if (raw == _unknownDescriptor) return false;
|
||||
final hashSize = ((raw >> 6) + 1);
|
||||
if (hashSize > maxHashSize) return false;
|
||||
final hopCount = raw & 0x3F;
|
||||
return hopCount * hashSize <= maxPathBytes;
|
||||
}
|
||||
}
|
||||
|
||||
extension ContactLocalization on Contact {
|
||||
/// Returns the localized display name for special contacts (e.g. Public Channel).
|
||||
/// For all other contacts, returns [displayName].
|
||||
@@ -14,4 +207,55 @@ extension ContactLocalization on Contact {
|
||||
}
|
||||
return displayName;
|
||||
}
|
||||
|
||||
int get routeEncodedPathLen =>
|
||||
ContactRouteCodec.toUnsignedDescriptor(outPathLen);
|
||||
|
||||
int get routeSignedPathLen =>
|
||||
ContactRouteCodec.toSignedDescriptor(routeEncodedPathLen);
|
||||
|
||||
bool get routeIsUnknown => ContactRouteCodec.isUnknownDescriptor(outPathLen);
|
||||
|
||||
bool get routeHasPath =>
|
||||
!routeIsUnknown && ContactRouteCodec.isValidDescriptor(outPathLen);
|
||||
|
||||
int get routeHashSize => routeHasPath ? ((routeEncodedPathLen >> 6) + 1) : 1;
|
||||
|
||||
int get routeHopCount => routeHasPath ? (routeEncodedPathLen & 0x3F) : -1;
|
||||
|
||||
int get routeByteLength => routeHasPath
|
||||
? math.min(routeHopCount * routeHashSize, outPath.length)
|
||||
: 0;
|
||||
|
||||
Uint8List get routePathBytes => routeByteLength <= 0
|
||||
? Uint8List(0)
|
||||
: Uint8List.fromList(outPath.sublist(0, routeByteLength));
|
||||
|
||||
String get routeCanonicalText {
|
||||
if (!routeHasPath || routeHopCount <= 0) return '';
|
||||
final bytes = routePathBytes;
|
||||
final hops = <String>[];
|
||||
for (var i = 0; i < bytes.length; i += routeHashSize) {
|
||||
hops.add(
|
||||
bytes
|
||||
.sublist(i, i + routeHashSize)
|
||||
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
||||
.join()
|
||||
.toUpperCase(),
|
||||
);
|
||||
}
|
||||
return hops.join(',');
|
||||
}
|
||||
|
||||
String get routeSummary {
|
||||
if (routeIsUnknown) {
|
||||
return 'Flood/Unknown';
|
||||
}
|
||||
if (!routeHasPath || routeHopCount == 0) {
|
||||
return 'Direct';
|
||||
}
|
||||
return '$routeHopCount hop${routeHopCount == 1 ? '' : 's'} via $routeHashSize-byte hashes';
|
||||
}
|
||||
|
||||
bool get routeSupportsLegacyRawTransport => routeHasPath;
|
||||
}
|
||||
|
||||
39
lib/models/contact_group.dart
Normal file
@@ -0,0 +1,39 @@
|
||||
class SavedContactGroup {
|
||||
final String id;
|
||||
final String sectionKey;
|
||||
final String label;
|
||||
final String query;
|
||||
final DateTime createdAt;
|
||||
final List<String>? matchPrefixes;
|
||||
final bool isAutoGroup;
|
||||
|
||||
const SavedContactGroup({
|
||||
required this.id,
|
||||
required this.sectionKey,
|
||||
required this.label,
|
||||
required this.query,
|
||||
required this.createdAt,
|
||||
this.matchPrefixes,
|
||||
this.isAutoGroup = false,
|
||||
});
|
||||
|
||||
SavedContactGroup copyWith({
|
||||
String? id,
|
||||
String? sectionKey,
|
||||
String? label,
|
||||
String? query,
|
||||
DateTime? createdAt,
|
||||
List<String>? matchPrefixes,
|
||||
bool? isAutoGroup,
|
||||
}) {
|
||||
return SavedContactGroup(
|
||||
id: id ?? this.id,
|
||||
sectionKey: sectionKey ?? this.sectionKey,
|
||||
label: label ?? this.label,
|
||||
query: query ?? this.query,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
matchPrefixes: matchPrefixes ?? this.matchPrefixes,
|
||||
isAutoGroup: isAutoGroup ?? this.isAutoGroup,
|
||||
);
|
||||
}
|
||||
}
|
||||
151
lib/models/custom_map_config.dart
Normal file
@@ -0,0 +1,151 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../utils/custom_map_id.dart';
|
||||
|
||||
class CustomMapConfig {
|
||||
final String filePath;
|
||||
final String displayName;
|
||||
final String mapId;
|
||||
final int imageWidth;
|
||||
final int imageHeight;
|
||||
final double? metersPerPixel;
|
||||
final LatLng? calibrationPointA;
|
||||
final LatLng? calibrationPointB;
|
||||
|
||||
const CustomMapConfig({
|
||||
required this.filePath,
|
||||
required this.displayName,
|
||||
required this.mapId,
|
||||
required this.imageWidth,
|
||||
required this.imageHeight,
|
||||
this.metersPerPixel,
|
||||
this.calibrationPointA,
|
||||
this.calibrationPointB,
|
||||
});
|
||||
|
||||
bool get isCalibrated => metersPerPixel != null && metersPerPixel! > 0;
|
||||
|
||||
double get _displayScale {
|
||||
final heightScale = imageHeight / 90.0;
|
||||
final widthScale = imageWidth / 180.0;
|
||||
return math.max(1.0, math.max(heightScale, widthScale));
|
||||
}
|
||||
|
||||
double get _displayHeight => imageHeight / _displayScale;
|
||||
|
||||
double get _displayWidth => imageWidth / _displayScale;
|
||||
|
||||
LatLngBounds get bounds =>
|
||||
LatLngBounds(const LatLng(0, 0), LatLng(_displayHeight, _displayWidth));
|
||||
|
||||
LatLngBounds get displayBounds =>
|
||||
LatLngBounds(LatLng(_displayHeight, 0), LatLng(0, _displayWidth));
|
||||
|
||||
LatLng toDisplayPoint(LatLng point) {
|
||||
return LatLng(
|
||||
_displayHeight - (point.latitude / _displayScale),
|
||||
point.longitude / _displayScale,
|
||||
);
|
||||
}
|
||||
|
||||
LatLng fromDisplayPoint(LatLng point) {
|
||||
return LatLng(
|
||||
(_displayHeight - point.latitude) * _displayScale,
|
||||
point.longitude * _displayScale,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'filePath': filePath,
|
||||
'displayName': displayName,
|
||||
'mapId': mapId,
|
||||
'imageWidth': imageWidth,
|
||||
'imageHeight': imageHeight,
|
||||
'metersPerPixel': metersPerPixel,
|
||||
'calibrationPointA': calibrationPointA != null
|
||||
? {
|
||||
'lat': calibrationPointA!.latitude,
|
||||
'lon': calibrationPointA!.longitude,
|
||||
}
|
||||
: null,
|
||||
'calibrationPointB': calibrationPointB != null
|
||||
? {
|
||||
'lat': calibrationPointB!.latitude,
|
||||
'lon': calibrationPointB!.longitude,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
CustomMapConfig copyWith({
|
||||
String? filePath,
|
||||
String? displayName,
|
||||
String? mapId,
|
||||
int? imageWidth,
|
||||
int? imageHeight,
|
||||
double? metersPerPixel,
|
||||
bool clearMetersPerPixel = false,
|
||||
LatLng? calibrationPointA,
|
||||
bool clearCalibrationPointA = false,
|
||||
LatLng? calibrationPointB,
|
||||
bool clearCalibrationPointB = false,
|
||||
}) {
|
||||
return CustomMapConfig(
|
||||
filePath: filePath ?? this.filePath,
|
||||
displayName: displayName ?? this.displayName,
|
||||
mapId: normalizeCustomMapId(mapId) ?? this.mapId,
|
||||
imageWidth: imageWidth ?? this.imageWidth,
|
||||
imageHeight: imageHeight ?? this.imageHeight,
|
||||
metersPerPixel: clearMetersPerPixel
|
||||
? null
|
||||
: (metersPerPixel ?? this.metersPerPixel),
|
||||
calibrationPointA: clearCalibrationPointA
|
||||
? null
|
||||
: (calibrationPointA ?? this.calibrationPointA),
|
||||
calibrationPointB: clearCalibrationPointB
|
||||
? null
|
||||
: (calibrationPointB ?? this.calibrationPointB),
|
||||
);
|
||||
}
|
||||
|
||||
static CustomMapConfig? fromJson(Map<String, dynamic>? json) {
|
||||
if (json == null) return null;
|
||||
|
||||
final filePath = json['filePath'];
|
||||
final displayName = json['displayName'];
|
||||
final mapId = json['mapId'];
|
||||
final imageWidth = json['imageWidth'];
|
||||
final imageHeight = json['imageHeight'];
|
||||
if (filePath is! String ||
|
||||
displayName is! String ||
|
||||
mapId is! String ||
|
||||
imageWidth is! int ||
|
||||
imageHeight is! int) {
|
||||
return null;
|
||||
}
|
||||
|
||||
LatLng? parsePoint(dynamic raw) {
|
||||
if (raw is! Map<String, dynamic>) return null;
|
||||
final lat = raw['lat'];
|
||||
final lon = raw['lon'];
|
||||
if (lat is! num || lon is! num) return null;
|
||||
return LatLng(lat.toDouble(), lon.toDouble());
|
||||
}
|
||||
|
||||
final metersPerPixel = json['metersPerPixel'];
|
||||
return CustomMapConfig(
|
||||
filePath: filePath,
|
||||
displayName: displayName,
|
||||
mapId: normalizeCustomMapId(mapId) ?? mapId,
|
||||
imageWidth: imageWidth,
|
||||
imageHeight: imageHeight,
|
||||
metersPerPixel: metersPerPixel is num ? metersPerPixel.toDouble() : null,
|
||||
calibrationPointA: parsePoint(json['calibrationPointA']),
|
||||
calibrationPointB: parsePoint(json['calibrationPointB']),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,11 +14,11 @@ enum ConnectionMode {
|
||||
/// Direct BLE connection to MeshCore device (default)
|
||||
ble,
|
||||
|
||||
/// Act as SSE server - share BLE device with multiple clients
|
||||
sseServer,
|
||||
/// Direct TCP/WiFi connection to MeshCore device (port 5000)
|
||||
tcp,
|
||||
|
||||
/// Connect to remote SSE server - no direct BLE connection
|
||||
sseClient,
|
||||
/// USB serial connection (Android OTG or Web Serial API)
|
||||
usb,
|
||||
}
|
||||
|
||||
extension ConnectionModeExtension on ConnectionMode {
|
||||
@@ -26,10 +26,10 @@ extension ConnectionModeExtension on ConnectionMode {
|
||||
switch (this) {
|
||||
case ConnectionMode.ble:
|
||||
return 'Direct (BLE)';
|
||||
case ConnectionMode.sseServer:
|
||||
return 'Share Device (Server)';
|
||||
case ConnectionMode.sseClient:
|
||||
return 'Connect to Server';
|
||||
case ConnectionMode.tcp:
|
||||
return 'Direct (WiFi)';
|
||||
case ConnectionMode.usb:
|
||||
return 'Direct (Serial)';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,10 +37,10 @@ extension ConnectionModeExtension on ConnectionMode {
|
||||
switch (this) {
|
||||
case ConnectionMode.ble:
|
||||
return 'Direct BLE connection to MeshCore device';
|
||||
case ConnectionMode.sseServer:
|
||||
return 'Share BLE device with multiple clients over network';
|
||||
case ConnectionMode.sseClient:
|
||||
return 'Connect to remote server without BLE';
|
||||
case ConnectionMode.tcp:
|
||||
return 'Direct WiFi/TCP connection to MeshCore device';
|
||||
case ConnectionMode.usb:
|
||||
return 'Direct serial connection to MeshCore device';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,12 @@ class DeviceInfo {
|
||||
final int? advLat;
|
||||
final int? advLon;
|
||||
final bool? manualAddContacts;
|
||||
final bool? autoAddUsers;
|
||||
final bool? autoAddRepeaters;
|
||||
final bool? autoAddRoomServers;
|
||||
final bool? autoAddSensors;
|
||||
final bool? autoAddOverwriteOldest;
|
||||
final int? autoAddMaxHops;
|
||||
final int? radioFreq;
|
||||
final int? radioBw;
|
||||
final int? radioSf;
|
||||
@@ -73,12 +79,14 @@ class DeviceInfo {
|
||||
final String? selfName;
|
||||
|
||||
// Additional device capabilities (from RESP_CODE_DEVICE_INFO)
|
||||
final int? maxContacts; // Max contacts device supports
|
||||
final int? maxChannels; // Max channels device supports
|
||||
final int? telemetryModes; // Telemetry permission modes (bits 0-1: Base, bits 2-3: Location)
|
||||
final int? blePin; // BLE PIN code
|
||||
final int? multiAcks; // Extra ACK mode (0=no, 1=yes)
|
||||
final int? advertLocPolicy; // Location sharing policy (0=don't share, 1=share)
|
||||
final int? maxContacts; // Max contacts device supports
|
||||
final int? maxChannels; // Max channels device supports
|
||||
final int?
|
||||
telemetryModes; // Telemetry permission modes (bits 0-1: Base, bits 2-3: Location)
|
||||
final int? blePin; // BLE PIN code
|
||||
final int? multiAcks; // Extra ACK mode (0=no, 1=yes)
|
||||
final int?
|
||||
advertLocPolicy; // Location sharing policy (0=don't share, 1=share)
|
||||
|
||||
// Firmware info
|
||||
final int? firmwareVersion;
|
||||
@@ -88,6 +96,10 @@ class DeviceInfo {
|
||||
|
||||
// Repeat mode (firmware v9+)
|
||||
final bool? clientRepeat;
|
||||
final int? pathHashMode;
|
||||
final bool? supportsSpectrumScan;
|
||||
final int? spectrumScanMinKhz;
|
||||
final int? spectrumScanMaxKhz;
|
||||
final List<({int lower, int upper})>? allowedRepeatFreqRanges;
|
||||
|
||||
DeviceInfo({
|
||||
@@ -108,6 +120,12 @@ class DeviceInfo {
|
||||
this.advLat,
|
||||
this.advLon,
|
||||
this.manualAddContacts,
|
||||
this.autoAddUsers,
|
||||
this.autoAddRepeaters,
|
||||
this.autoAddRoomServers,
|
||||
this.autoAddSensors,
|
||||
this.autoAddOverwriteOldest,
|
||||
this.autoAddMaxHops,
|
||||
this.radioFreq,
|
||||
this.radioBw,
|
||||
this.radioSf,
|
||||
@@ -124,6 +142,10 @@ class DeviceInfo {
|
||||
this.manufacturerModel,
|
||||
this.semanticVersion,
|
||||
this.clientRepeat,
|
||||
this.pathHashMode,
|
||||
this.supportsSpectrumScan,
|
||||
this.spectrumScanMinKhz,
|
||||
this.spectrumScanMaxKhz,
|
||||
this.allowedRepeatFreqRanges,
|
||||
});
|
||||
|
||||
@@ -160,7 +182,9 @@ class DeviceInfo {
|
||||
|
||||
/// Get storage usage percentage (0-100)
|
||||
double? get storageUsedPercent {
|
||||
if (storageUsedKb == null || storageTotalKb == null || storageTotalKb == 0) {
|
||||
if (storageUsedKb == null ||
|
||||
storageTotalKb == null ||
|
||||
storageTotalKb == 0) {
|
||||
return null;
|
||||
}
|
||||
return (storageUsedKb! / storageTotalKb!) * 100.0;
|
||||
@@ -229,6 +253,12 @@ class DeviceInfo {
|
||||
int? advLat,
|
||||
int? advLon,
|
||||
bool? manualAddContacts,
|
||||
bool? autoAddUsers,
|
||||
bool? autoAddRepeaters,
|
||||
bool? autoAddRoomServers,
|
||||
bool? autoAddSensors,
|
||||
bool? autoAddOverwriteOldest,
|
||||
int? autoAddMaxHops,
|
||||
int? radioFreq,
|
||||
int? radioBw,
|
||||
int? radioSf,
|
||||
@@ -245,6 +275,10 @@ class DeviceInfo {
|
||||
String? manufacturerModel,
|
||||
String? semanticVersion,
|
||||
bool? clientRepeat,
|
||||
int? pathHashMode,
|
||||
bool? supportsSpectrumScan,
|
||||
int? spectrumScanMinKhz,
|
||||
int? spectrumScanMaxKhz,
|
||||
List<({int lower, int upper})>? allowedRepeatFreqRanges,
|
||||
}) {
|
||||
return DeviceInfo(
|
||||
@@ -265,6 +299,13 @@ class DeviceInfo {
|
||||
advLat: advLat ?? this.advLat,
|
||||
advLon: advLon ?? this.advLon,
|
||||
manualAddContacts: manualAddContacts ?? this.manualAddContacts,
|
||||
autoAddUsers: autoAddUsers ?? this.autoAddUsers,
|
||||
autoAddRepeaters: autoAddRepeaters ?? this.autoAddRepeaters,
|
||||
autoAddRoomServers: autoAddRoomServers ?? this.autoAddRoomServers,
|
||||
autoAddSensors: autoAddSensors ?? this.autoAddSensors,
|
||||
autoAddOverwriteOldest:
|
||||
autoAddOverwriteOldest ?? this.autoAddOverwriteOldest,
|
||||
autoAddMaxHops: autoAddMaxHops ?? this.autoAddMaxHops,
|
||||
radioFreq: radioFreq ?? this.radioFreq,
|
||||
radioBw: radioBw ?? this.radioBw,
|
||||
radioSf: radioSf ?? this.radioSf,
|
||||
@@ -281,7 +322,12 @@ class DeviceInfo {
|
||||
manufacturerModel: manufacturerModel ?? this.manufacturerModel,
|
||||
semanticVersion: semanticVersion ?? this.semanticVersion,
|
||||
clientRepeat: clientRepeat ?? this.clientRepeat,
|
||||
allowedRepeatFreqRanges: allowedRepeatFreqRanges ?? this.allowedRepeatFreqRanges,
|
||||
pathHashMode: pathHashMode ?? this.pathHashMode,
|
||||
supportsSpectrumScan: supportsSpectrumScan ?? this.supportsSpectrumScan,
|
||||
spectrumScanMinKhz: spectrumScanMinKhz ?? this.spectrumScanMinKhz,
|
||||
spectrumScanMaxKhz: spectrumScanMaxKhz ?? this.spectrumScanMaxKhz,
|
||||
allowedRepeatFreqRanges:
|
||||
allowedRepeatFreqRanges ?? this.allowedRepeatFreqRanges,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
11
lib/models/map_coordinate_space.dart
Normal file
@@ -0,0 +1,11 @@
|
||||
enum MapCoordinateSpace {
|
||||
geo,
|
||||
customMap;
|
||||
|
||||
static MapCoordinateSpace fromName(String? value) {
|
||||
return MapCoordinateSpace.values.firstWhere(
|
||||
(space) => space.name == value,
|
||||
orElse: () => MapCoordinateSpace.geo,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,35 +2,26 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import 'map_coordinate_space.dart';
|
||||
import '../utils/custom_map_id.dart';
|
||||
|
||||
/// Drawing shape type
|
||||
enum DrawingShapeType {
|
||||
line,
|
||||
rectangle,
|
||||
}
|
||||
enum DrawingShapeType { line, rectangle }
|
||||
|
||||
/// Drawing color enum for compact network transmission
|
||||
enum DrawingColor {
|
||||
red, // 0
|
||||
blue, // 1
|
||||
green, // 2
|
||||
yellow, // 3
|
||||
orange, // 4
|
||||
purple, // 5
|
||||
pink, // 6
|
||||
cyan, // 7
|
||||
}
|
||||
enum DrawingColor { red, blue, green, yellow, orange, purple, pink, cyan }
|
||||
|
||||
/// Drawing colors available for user selection
|
||||
class DrawingColors {
|
||||
static const List<Color> palette = [
|
||||
Colors.red, // index 0
|
||||
Colors.blue, // index 1
|
||||
Colors.green, // index 2
|
||||
Colors.yellow, // index 3
|
||||
Colors.orange, // index 4
|
||||
Colors.purple, // index 5
|
||||
Colors.pink, // index 6
|
||||
Colors.cyan, // index 7
|
||||
Colors.red,
|
||||
Colors.blue,
|
||||
Colors.green,
|
||||
Colors.yellow,
|
||||
Colors.orange,
|
||||
Colors.purple,
|
||||
Colors.pink,
|
||||
Colors.cyan,
|
||||
];
|
||||
|
||||
static String colorToName(Color color) {
|
||||
@@ -45,37 +36,36 @@ class DrawingColors {
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
/// Convert Color to enum index for network transmission
|
||||
static int colorToIndex(Color color) {
|
||||
for (int i = 0; i < palette.length; i++) {
|
||||
if (palette[i].toARGB32() == color.toARGB32()) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return 0; // Default to red if not found
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// Convert enum index to Color for network reception
|
||||
static Color indexToColor(int index) {
|
||||
if (index >= 0 && index < palette.length) {
|
||||
return palette[index];
|
||||
}
|
||||
return palette[0]; // Default to red if invalid index
|
||||
return palette[0];
|
||||
}
|
||||
}
|
||||
|
||||
/// Base class for map drawings
|
||||
abstract class MapDrawing {
|
||||
final String id;
|
||||
final DrawingShapeType type;
|
||||
final Color color;
|
||||
final DateTime createdAt;
|
||||
final String? senderName; // Name of sender (null if local drawing)
|
||||
final bool isReceived; // True if drawing was received from another node
|
||||
final String? messageId; // ID of the source message (for navigation)
|
||||
final bool isShared; // Whether drawing has been broadcast over mesh
|
||||
final bool isSent; // Whether this is a sent drawing (vs received)
|
||||
final bool isHidden; // Temporary visibility toggle (session only, not persisted)
|
||||
final String? senderName;
|
||||
final bool isReceived;
|
||||
final String? messageId;
|
||||
final bool isShared;
|
||||
final bool isSent;
|
||||
final bool isHidden;
|
||||
final MapCoordinateSpace coordinateSpace;
|
||||
final String? mapId;
|
||||
|
||||
MapDrawing({
|
||||
required this.id,
|
||||
@@ -88,79 +78,79 @@ abstract class MapDrawing {
|
||||
this.isShared = false,
|
||||
this.isSent = false,
|
||||
this.isHidden = false,
|
||||
this.coordinateSpace = MapCoordinateSpace.geo,
|
||||
this.mapId,
|
||||
});
|
||||
|
||||
/// Convert to JSON for persistence
|
||||
bool get isCustomMap => coordinateSpace == MapCoordinateSpace.customMap;
|
||||
|
||||
Map<String, dynamic> toJson();
|
||||
|
||||
/// Convert to JSON for network transmission (compact format)
|
||||
/// Uses short field names and excludes createdAt to minimize message size
|
||||
/// Sender will be fetched from packet metadata
|
||||
Map<String, dynamic> toNetworkJson();
|
||||
|
||||
/// Parse network JSON (compact format)
|
||||
/// senderName and messageId will be populated from packet metadata
|
||||
static MapDrawing? fromNetworkJson(
|
||||
Map<String, dynamic> json, {
|
||||
String? senderName,
|
||||
String? messageId,
|
||||
MapCoordinateSpace coordinateSpace = MapCoordinateSpace.geo,
|
||||
String? mapId,
|
||||
}) {
|
||||
final typeNum = json['t'] as int?;
|
||||
if (typeNum == null || typeNum < 0 || typeNum >= DrawingShapeType.values.length) {
|
||||
if (typeNum == null ||
|
||||
typeNum < 0 ||
|
||||
typeNum >= DrawingShapeType.values.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
final type = DrawingShapeType.values[typeNum];
|
||||
|
||||
switch (type) {
|
||||
case DrawingShapeType.line:
|
||||
return LineDrawing.fromNetworkJson(
|
||||
json,
|
||||
senderName: senderName,
|
||||
messageId: messageId,
|
||||
coordinateSpace: coordinateSpace,
|
||||
mapId: mapId,
|
||||
);
|
||||
case DrawingShapeType.rectangle:
|
||||
return RectangleDrawing.fromNetworkJson(
|
||||
json,
|
||||
senderName: senderName,
|
||||
messageId: messageId,
|
||||
coordinateSpace: coordinateSpace,
|
||||
mapId: mapId,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Create from JSON
|
||||
static MapDrawing? fromJson(Map<String, dynamic> json) {
|
||||
final typeStr = json['type'] as String?;
|
||||
if (typeStr == null) return null;
|
||||
|
||||
try {
|
||||
final type = DrawingShapeType.values.firstWhere(
|
||||
(e) => e.toString() == 'DrawingShapeType.$typeStr',
|
||||
(value) => value.name == typeStr,
|
||||
);
|
||||
|
||||
switch (type) {
|
||||
case DrawingShapeType.line:
|
||||
return LineDrawing.fromJson(json);
|
||||
case DrawingShapeType.rectangle:
|
||||
return RectangleDrawing.fromJson(json);
|
||||
}
|
||||
} catch (e) {
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the center point of the drawing
|
||||
LatLng getCenter();
|
||||
|
||||
/// Get the bounds of the drawing
|
||||
LatLngBounds getBounds();
|
||||
}
|
||||
|
||||
/// Line drawing on map
|
||||
class LineDrawing extends MapDrawing {
|
||||
final List<LatLng> points;
|
||||
|
||||
@@ -175,6 +165,8 @@ class LineDrawing extends MapDrawing {
|
||||
super.isShared,
|
||||
super.isSent,
|
||||
super.isHidden,
|
||||
super.coordinateSpace,
|
||||
super.mapId,
|
||||
}) : super(type: DrawingShapeType.line);
|
||||
|
||||
@override
|
||||
@@ -184,31 +176,51 @@ class LineDrawing extends MapDrawing {
|
||||
'type': type.name,
|
||||
'color': color.toARGB32(),
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
'points': points.map((p) => {'lat': p.latitude, 'lon': p.longitude}).toList(),
|
||||
'points': points
|
||||
.map((point) => {'lat': point.latitude, 'lon': point.longitude})
|
||||
.toList(),
|
||||
'isShared': isShared,
|
||||
// Note: isHidden is not persisted - it's session-only
|
||||
'coordinateSpace': coordinateSpace.name,
|
||||
'mapId': normalizeCustomMapId(mapId),
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toNetworkJson() {
|
||||
// Ultra-compact format: t=type (0=line, 1=rect), c=color index (0-7), p=points
|
||||
// Points are encoded as flat array [lat1,lon1,lat2,lon2,...]
|
||||
// Coordinates rounded to 5 decimal places (~1m precision, like SAR markers)
|
||||
// Sender is fetched from packet metadata, not included in JSON
|
||||
return {
|
||||
final payload = <String, dynamic>{
|
||||
't': type.index,
|
||||
'c': DrawingColors.colorToIndex(color),
|
||||
'p': points.expand((p) => [
|
||||
double.parse(p.latitude.toStringAsFixed(5)),
|
||||
double.parse(p.longitude.toStringAsFixed(5)),
|
||||
]).toList(),
|
||||
};
|
||||
|
||||
if (coordinateSpace == MapCoordinateSpace.customMap) {
|
||||
payload['m'] = normalizeCustomMapId(mapId);
|
||||
payload['p'] = points
|
||||
.expand((point) => [point.latitude.round(), point.longitude.round()])
|
||||
.toList();
|
||||
return payload;
|
||||
}
|
||||
|
||||
payload['p'] = points
|
||||
.expand(
|
||||
(point) => [
|
||||
double.parse(point.latitude.toStringAsFixed(5)),
|
||||
double.parse(point.longitude.toStringAsFixed(5)),
|
||||
],
|
||||
)
|
||||
.toList();
|
||||
return payload;
|
||||
}
|
||||
|
||||
static LineDrawing fromJson(Map<String, dynamic> json) {
|
||||
final pointsJson = json['points'] as List<dynamic>;
|
||||
final points = pointsJson.map((p) => LatLng(p['lat'] as double, p['lon'] as double)).toList();
|
||||
final points = pointsJson
|
||||
.map(
|
||||
(point) => LatLng(
|
||||
(point['lat'] as num).toDouble(),
|
||||
(point['lon'] as num).toDouble(),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
final senderName = json['sender'] as String?;
|
||||
|
||||
return LineDrawing(
|
||||
@@ -217,8 +229,12 @@ class LineDrawing extends MapDrawing {
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
points: points,
|
||||
senderName: senderName,
|
||||
isReceived: senderName != null, // Mark as received if sender is present
|
||||
isReceived: senderName != null,
|
||||
isShared: json['isShared'] as bool? ?? false,
|
||||
coordinateSpace: MapCoordinateSpace.fromName(
|
||||
json['coordinateSpace'] as String?,
|
||||
),
|
||||
mapId: normalizeCustomMapId(json['mapId'] as String?),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -226,42 +242,60 @@ class LineDrawing extends MapDrawing {
|
||||
Map<String, dynamic> json, {
|
||||
String? senderName,
|
||||
String? messageId,
|
||||
MapCoordinateSpace coordinateSpace = MapCoordinateSpace.geo,
|
||||
String? mapId,
|
||||
}) {
|
||||
// Parse ultra-compact format
|
||||
final pointsFlat = (json['p'] as List<dynamic>).cast<double>();
|
||||
final flatPoints = (json['p'] as List<dynamic>).cast<num>();
|
||||
final points = <LatLng>[];
|
||||
for (int i = 0; i < pointsFlat.length; i += 2) {
|
||||
points.add(LatLng(pointsFlat[i], pointsFlat[i + 1]));
|
||||
for (int i = 0; i < flatPoints.length; i += 2) {
|
||||
points.add(
|
||||
LatLng(flatPoints[i].toDouble(), flatPoints[i + 1].toDouble()),
|
||||
);
|
||||
}
|
||||
|
||||
return LineDrawing(
|
||||
id: DateTime.now().millisecondsSinceEpoch.toString(), // Generate new ID
|
||||
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
color: DrawingColors.indexToColor(json['c'] as int),
|
||||
createdAt: DateTime.now(),
|
||||
points: points,
|
||||
senderName: senderName,
|
||||
isReceived: true,
|
||||
messageId: messageId, // Link to source message
|
||||
isShared: false, // Received drawings are not marked as shared
|
||||
messageId: messageId,
|
||||
coordinateSpace: coordinateSpace,
|
||||
mapId: normalizeCustomMapId(mapId),
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a copy with updated points
|
||||
LineDrawing copyWith({List<LatLng>? points}) {
|
||||
LineDrawing copyWith({
|
||||
List<LatLng>? points,
|
||||
bool? isHidden,
|
||||
bool? isShared,
|
||||
bool? isReceived,
|
||||
String? messageId,
|
||||
String? senderName,
|
||||
MapCoordinateSpace? coordinateSpace,
|
||||
String? mapId,
|
||||
}) {
|
||||
return LineDrawing(
|
||||
id: id,
|
||||
color: color,
|
||||
createdAt: createdAt,
|
||||
points: points ?? this.points,
|
||||
isHidden: isHidden ?? this.isHidden,
|
||||
isShared: isShared ?? this.isShared,
|
||||
isReceived: isReceived ?? this.isReceived,
|
||||
messageId: messageId ?? this.messageId,
|
||||
senderName: senderName ?? this.senderName,
|
||||
coordinateSpace: coordinateSpace ?? this.coordinateSpace,
|
||||
mapId: mapId ?? this.mapId,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
LatLng getCenter() {
|
||||
if (points.isEmpty) return LatLng(0, 0);
|
||||
if (points.isEmpty) return const LatLng(0, 0);
|
||||
if (points.length == 1) return points[0];
|
||||
|
||||
// Calculate center as average of all points
|
||||
double sumLat = 0;
|
||||
double sumLon = 0;
|
||||
for (final point in points) {
|
||||
@@ -273,8 +307,12 @@ class LineDrawing extends MapDrawing {
|
||||
|
||||
@override
|
||||
LatLngBounds getBounds() {
|
||||
if (points.isEmpty) return LatLngBounds(LatLng(0, 0), LatLng(0, 0));
|
||||
if (points.length == 1) return LatLngBounds(points[0], points[0]);
|
||||
if (points.isEmpty) {
|
||||
return LatLngBounds(const LatLng(0, 0), const LatLng(0, 0));
|
||||
}
|
||||
if (points.length == 1) {
|
||||
return LatLngBounds(points[0], points[0]);
|
||||
}
|
||||
|
||||
double minLat = points[0].latitude;
|
||||
double maxLat = points[0].latitude;
|
||||
@@ -292,7 +330,6 @@ class LineDrawing extends MapDrawing {
|
||||
}
|
||||
}
|
||||
|
||||
/// Rectangle drawing on map
|
||||
class RectangleDrawing extends MapDrawing {
|
||||
final LatLng topLeft;
|
||||
final LatLng bottomRight;
|
||||
@@ -309,16 +346,17 @@ class RectangleDrawing extends MapDrawing {
|
||||
super.isShared,
|
||||
super.isSent,
|
||||
super.isHidden,
|
||||
super.coordinateSpace,
|
||||
super.mapId,
|
||||
}) : super(type: DrawingShapeType.rectangle);
|
||||
|
||||
/// Get all corner points for rendering
|
||||
List<LatLng> get corners => [
|
||||
topLeft,
|
||||
LatLng(topLeft.latitude, bottomRight.longitude), // top right
|
||||
bottomRight,
|
||||
LatLng(bottomRight.latitude, topLeft.longitude), // bottom left
|
||||
topLeft, // close the rectangle
|
||||
];
|
||||
topLeft,
|
||||
LatLng(topLeft.latitude, bottomRight.longitude),
|
||||
bottomRight,
|
||||
LatLng(bottomRight.latitude, topLeft.longitude),
|
||||
topLeft,
|
||||
];
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
@@ -328,27 +366,41 @@ class RectangleDrawing extends MapDrawing {
|
||||
'color': color.toARGB32(),
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
'topLeft': {'lat': topLeft.latitude, 'lon': topLeft.longitude},
|
||||
'bottomRight': {'lat': bottomRight.latitude, 'lon': bottomRight.longitude},
|
||||
'bottomRight': {
|
||||
'lat': bottomRight.latitude,
|
||||
'lon': bottomRight.longitude,
|
||||
},
|
||||
'isShared': isShared,
|
||||
// Note: isHidden is not persisted - it's session-only
|
||||
'coordinateSpace': coordinateSpace.name,
|
||||
'mapId': normalizeCustomMapId(mapId),
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toNetworkJson() {
|
||||
// Ultra-compact format: t=type (0=line, 1=rect), c=color index (0-7), b=bounds [lat1,lon1,lat2,lon2]
|
||||
// Coordinates rounded to 5 decimal places (~1m precision, like SAR markers)
|
||||
// Sender is fetched from packet metadata, not included in JSON
|
||||
return {
|
||||
final payload = <String, dynamic>{
|
||||
't': type.index,
|
||||
'c': DrawingColors.colorToIndex(color),
|
||||
'b': [
|
||||
double.parse(topLeft.latitude.toStringAsFixed(5)),
|
||||
double.parse(topLeft.longitude.toStringAsFixed(5)),
|
||||
double.parse(bottomRight.latitude.toStringAsFixed(5)),
|
||||
double.parse(bottomRight.longitude.toStringAsFixed(5)),
|
||||
],
|
||||
};
|
||||
|
||||
if (coordinateSpace == MapCoordinateSpace.customMap) {
|
||||
payload['m'] = normalizeCustomMapId(mapId);
|
||||
payload['b'] = [
|
||||
topLeft.latitude.round(),
|
||||
topLeft.longitude.round(),
|
||||
bottomRight.latitude.round(),
|
||||
bottomRight.longitude.round(),
|
||||
];
|
||||
return payload;
|
||||
}
|
||||
|
||||
payload['b'] = [
|
||||
double.parse(topLeft.latitude.toStringAsFixed(5)),
|
||||
double.parse(topLeft.longitude.toStringAsFixed(5)),
|
||||
double.parse(bottomRight.latitude.toStringAsFixed(5)),
|
||||
double.parse(bottomRight.longitude.toStringAsFixed(5)),
|
||||
];
|
||||
return payload;
|
||||
}
|
||||
|
||||
static RectangleDrawing fromJson(Map<String, dynamic> json) {
|
||||
@@ -360,11 +412,21 @@ class RectangleDrawing extends MapDrawing {
|
||||
id: json['id'] as String,
|
||||
color: Color(json['color'] as int),
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
topLeft: LatLng(topLeftJson['lat'] as double, topLeftJson['lon'] as double),
|
||||
bottomRight: LatLng(bottomRightJson['lat'] as double, bottomRightJson['lon'] as double),
|
||||
topLeft: LatLng(
|
||||
(topLeftJson['lat'] as num).toDouble(),
|
||||
(topLeftJson['lon'] as num).toDouble(),
|
||||
),
|
||||
bottomRight: LatLng(
|
||||
(bottomRightJson['lat'] as num).toDouble(),
|
||||
(bottomRightJson['lon'] as num).toDouble(),
|
||||
),
|
||||
senderName: senderName,
|
||||
isReceived: senderName != null, // Mark as received if sender is present
|
||||
isReceived: senderName != null,
|
||||
isShared: json['isShared'] as bool? ?? false,
|
||||
coordinateSpace: MapCoordinateSpace.fromName(
|
||||
json['coordinateSpace'] as String?,
|
||||
),
|
||||
mapId: normalizeCustomMapId(json['mapId'] as String?),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -372,27 +434,35 @@ class RectangleDrawing extends MapDrawing {
|
||||
Map<String, dynamic> json, {
|
||||
String? senderName,
|
||||
String? messageId,
|
||||
MapCoordinateSpace coordinateSpace = MapCoordinateSpace.geo,
|
||||
String? mapId,
|
||||
}) {
|
||||
// Parse ultra-compact format
|
||||
final bounds = (json['b'] as List<dynamic>).cast<double>();
|
||||
final bounds = (json['b'] as List<dynamic>).cast<num>();
|
||||
|
||||
return RectangleDrawing(
|
||||
id: DateTime.now().millisecondsSinceEpoch.toString(), // Generate new ID
|
||||
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
color: DrawingColors.indexToColor(json['c'] as int),
|
||||
createdAt: DateTime.now(),
|
||||
topLeft: LatLng(bounds[0], bounds[1]),
|
||||
bottomRight: LatLng(bounds[2], bounds[3]),
|
||||
topLeft: LatLng(bounds[0].toDouble(), bounds[1].toDouble()),
|
||||
bottomRight: LatLng(bounds[2].toDouble(), bounds[3].toDouble()),
|
||||
senderName: senderName,
|
||||
isReceived: true,
|
||||
messageId: messageId, // Link to source message
|
||||
isShared: false, // Received drawings are not marked as shared
|
||||
messageId: messageId,
|
||||
coordinateSpace: coordinateSpace,
|
||||
mapId: normalizeCustomMapId(mapId),
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a copy with updated corners
|
||||
RectangleDrawing copyWith({
|
||||
LatLng? topLeft,
|
||||
LatLng? bottomRight,
|
||||
bool? isHidden,
|
||||
bool? isShared,
|
||||
bool? isReceived,
|
||||
String? messageId,
|
||||
String? senderName,
|
||||
MapCoordinateSpace? coordinateSpace,
|
||||
String? mapId,
|
||||
}) {
|
||||
return RectangleDrawing(
|
||||
id: id,
|
||||
@@ -400,12 +470,18 @@ class RectangleDrawing extends MapDrawing {
|
||||
createdAt: createdAt,
|
||||
topLeft: topLeft ?? this.topLeft,
|
||||
bottomRight: bottomRight ?? this.bottomRight,
|
||||
isHidden: isHidden ?? this.isHidden,
|
||||
isShared: isShared ?? this.isShared,
|
||||
isReceived: isReceived ?? this.isReceived,
|
||||
messageId: messageId ?? this.messageId,
|
||||
senderName: senderName ?? this.senderName,
|
||||
coordinateSpace: coordinateSpace ?? this.coordinateSpace,
|
||||
mapId: mapId ?? this.mapId,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
LatLng getCenter() {
|
||||
// Center is the midpoint between top-left and bottom-right
|
||||
return LatLng(
|
||||
(topLeft.latitude + bottomRight.latitude) / 2,
|
||||
(topLeft.longitude + bottomRight.longitude) / 2,
|
||||
@@ -414,7 +490,6 @@ class RectangleDrawing extends MapDrawing {
|
||||
|
||||
@override
|
||||
LatLngBounds getBounds() {
|
||||
// Bounds are simply the two corners
|
||||
return LatLngBounds(topLeft, bottomRight);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
@@ -10,8 +9,11 @@ enum MapLayerType {
|
||||
googleHybrid,
|
||||
googleRoadmap,
|
||||
googleTerrain,
|
||||
// Kept for stored preference compatibility after MBTiles removal.
|
||||
vectorMbtiles,
|
||||
wmsBase,
|
||||
// Appended last: stored layer preference is the enum index.
|
||||
mapyOutdoor,
|
||||
}
|
||||
|
||||
class MapLayer {
|
||||
@@ -21,12 +23,8 @@ class MapLayer {
|
||||
final String attribution;
|
||||
final double maxZoom;
|
||||
|
||||
// Vector tile specific properties
|
||||
final bool isVector;
|
||||
final File? mbtilesFile;
|
||||
final String? styleUrl;
|
||||
final String? sourceName;
|
||||
final bool? isGzipped;
|
||||
/// Extra HTTP headers required by the tile server (e.g. Referer).
|
||||
final Map<String, String>? headers;
|
||||
|
||||
// WMS specific properties
|
||||
final bool isWms;
|
||||
@@ -43,11 +41,7 @@ class MapLayer {
|
||||
required this.urlTemplate,
|
||||
required this.attribution,
|
||||
required this.maxZoom,
|
||||
this.isVector = false,
|
||||
this.mbtilesFile,
|
||||
this.styleUrl,
|
||||
this.sourceName,
|
||||
this.isGzipped,
|
||||
this.headers,
|
||||
this.isWms = false,
|
||||
this.wmsBaseUrl,
|
||||
this.wmsLayers,
|
||||
@@ -74,7 +68,10 @@ class MapLayer {
|
||||
case MapLayerType.googleTerrain:
|
||||
return localizations.googleTerrain;
|
||||
case MapLayerType.vectorMbtiles:
|
||||
// For vector tiles, use the name from metadata
|
||||
// Legacy value kept only for preference migration compatibility.
|
||||
return name;
|
||||
case MapLayerType.mapyOutdoor:
|
||||
// Brand name, not translated.
|
||||
return name;
|
||||
case MapLayerType.wmsBase:
|
||||
// For WMS layers, use the name (will be localized separately)
|
||||
@@ -98,6 +95,17 @@ class MapLayer {
|
||||
maxZoom: 17.49, // OpenTopoMap maximum (just below level 18)
|
||||
);
|
||||
|
||||
/// Mapy.cz outdoor map: OSM data with LIDAR-based shaded relief.
|
||||
/// The tile server rejects requests without a mapy.com Referer.
|
||||
static const mapyOutdoor = MapLayer(
|
||||
type: MapLayerType.mapyOutdoor,
|
||||
name: 'Mapy.cz Outdoor (OSM + LIDAR)',
|
||||
urlTemplate: 'https://mapserver.mapy.cz/turist-en/retina/{z}-{x}-{y}',
|
||||
attribution: '© Seznam.cz, a.s., © OpenStreetMap contributors',
|
||||
maxZoom: 19,
|
||||
headers: {'Referer': 'https://mapy.com/'},
|
||||
);
|
||||
|
||||
static const esriWorldImagery = MapLayer(
|
||||
type: MapLayerType.esriWorldImagery,
|
||||
name: 'ESRI Satellite',
|
||||
@@ -172,6 +180,7 @@ class MapLayer {
|
||||
static const List<MapLayer> allLayers = [
|
||||
openStreetMap,
|
||||
openTopoMap,
|
||||
mapyOutdoor,
|
||||
esriWorldImagery,
|
||||
googleHybrid,
|
||||
googleRoadmap,
|
||||
@@ -182,28 +191,4 @@ class MapLayer {
|
||||
static MapLayer fromType(MapLayerType type) {
|
||||
return allLayers.firstWhere((layer) => layer.type == type);
|
||||
}
|
||||
|
||||
/// Create a MapLayer from an MBTiles file
|
||||
static MapLayer fromMbtilesFile({
|
||||
required String name,
|
||||
required File mbtilesFile,
|
||||
required String styleUrl,
|
||||
required String sourceName,
|
||||
required double maxZoom,
|
||||
required bool isGzipped,
|
||||
String? attribution,
|
||||
}) {
|
||||
return MapLayer(
|
||||
type: MapLayerType.vectorMbtiles,
|
||||
name: name,
|
||||
urlTemplate: '', // Not used for vector tiles
|
||||
attribution: attribution ?? 'MBTiles',
|
||||
maxZoom: maxZoom,
|
||||
isVector: true,
|
||||
mbtilesFile: mbtilesFile,
|
||||
styleUrl: styleUrl,
|
||||
sourceName: sourceName,
|
||||
isGzipped: isGzipped,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export 'package:meshcore_client/meshcore_client.dart'
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:meshcore_client/meshcore_client.dart';
|
||||
import 'sar_marker.dart';
|
||||
import 'map_coordinate_space.dart';
|
||||
import '../utils/voice_message_parser.dart';
|
||||
|
||||
extension MessageVoiceExtension on Message {
|
||||
@@ -47,7 +48,7 @@ extension MessageSarExtension on Message {
|
||||
|
||||
/// Convert to a [SarMarker] if this message contains SAR data.
|
||||
SarMarker? toSarMarker() {
|
||||
if (!isSarMarker || sarMarkerType == null || sarGpsCoordinates == null) {
|
||||
if (!isSarMarker || sarMarkerType == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -57,16 +58,26 @@ extension MessageSarExtension on Message {
|
||||
debugPrint(' message.sarMarkerType: $sarMarkerType');
|
||||
debugPrint(' message.sarCustomEmoji: "$sarCustomEmoji"');
|
||||
|
||||
final coordinateSpace = sarCustomMapPoint != null && sarCustomMapId != null
|
||||
? MapCoordinateSpace.customMap
|
||||
: MapCoordinateSpace.geo;
|
||||
final location = sarCustomMapPoint ?? sarGpsCoordinates;
|
||||
if (location == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return SarMarker(
|
||||
id: id,
|
||||
type: sarMarkerType!,
|
||||
location: sarGpsCoordinates!,
|
||||
location: location,
|
||||
timestamp: sentAt,
|
||||
senderPublicKey: senderPublicKeyPrefix,
|
||||
senderName: senderName,
|
||||
notes: sarNotes,
|
||||
customEmoji: sarCustomEmoji,
|
||||
colorIndex: sarColorIndex,
|
||||
coordinateSpace: coordinateSpace,
|
||||
mapId: sarCustomMapId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
67
lib/models/message_contact_location.dart
Normal file
@@ -0,0 +1,67 @@
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
class MessageContactLocation {
|
||||
final LatLng location;
|
||||
final String source;
|
||||
final DateTime capturedAt;
|
||||
final DateTime? sourceTimestamp;
|
||||
|
||||
const MessageContactLocation({
|
||||
required this.location,
|
||||
required this.source,
|
||||
required this.capturedAt,
|
||||
this.sourceTimestamp,
|
||||
});
|
||||
|
||||
String get technicalSourceLabel {
|
||||
switch (source) {
|
||||
case 'shared':
|
||||
return 'shared location';
|
||||
case 'gps':
|
||||
return 'shared location';
|
||||
case 'telemetry':
|
||||
return 'live telemetry';
|
||||
case 'advert':
|
||||
return 'shared advert';
|
||||
default:
|
||||
return source;
|
||||
}
|
||||
}
|
||||
|
||||
String get formattedCoordinates =>
|
||||
'${location.latitude.toStringAsFixed(6)}, ${location.longitude.toStringAsFixed(6)}';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'latitude': location.latitude,
|
||||
'longitude': location.longitude,
|
||||
'source': source,
|
||||
'capturedAtMillis': capturedAt.millisecondsSinceEpoch,
|
||||
'sourceTimestampMillis': sourceTimestamp?.millisecondsSinceEpoch,
|
||||
};
|
||||
}
|
||||
|
||||
static MessageContactLocation? fromJson(Map<String, dynamic> json) {
|
||||
final latitude = json['latitude'];
|
||||
final longitude = json['longitude'];
|
||||
final source = json['source'];
|
||||
final capturedAtMillis = json['capturedAtMillis'];
|
||||
if (latitude is! num ||
|
||||
longitude is! num ||
|
||||
source is! String ||
|
||||
capturedAtMillis is! int) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return MessageContactLocation(
|
||||
location: LatLng(latitude.toDouble(), longitude.toDouble()),
|
||||
source: source,
|
||||
capturedAt: DateTime.fromMillisecondsSinceEpoch(capturedAtMillis),
|
||||
sourceTimestamp: json['sourceTimestampMillis'] is int
|
||||
? DateTime.fromMillisecondsSinceEpoch(
|
||||
json['sourceTimestampMillis'] as int,
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
140
lib/models/message_reception_details.dart
Normal file
@@ -0,0 +1,140 @@
|
||||
const int _transmitEstimateToleranceMs = 1500;
|
||||
|
||||
int? sanitizeEstimatedTransmitMs({
|
||||
required int? estimatedTransmitMs,
|
||||
required int? senderToReceiptMs,
|
||||
}) {
|
||||
if (estimatedTransmitMs == null || estimatedTransmitMs <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (senderToReceiptMs == null || senderToReceiptMs <= 0) {
|
||||
return estimatedTransmitMs;
|
||||
}
|
||||
|
||||
// Sender timestamps are second-granularity, so allow a small cushion before
|
||||
// treating the estimate as impossible for the observed delivery time.
|
||||
if (estimatedTransmitMs > senderToReceiptMs + _transmitEstimateToleranceMs) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return estimatedTransmitMs;
|
||||
}
|
||||
|
||||
class MessageReceptionDetails {
|
||||
final DateTime capturedAt;
|
||||
final DateTime? packetLoggedAt;
|
||||
final int? rssiDbm;
|
||||
final double? snrDb;
|
||||
final List<int>? pathBytes;
|
||||
final int? senderToReceiptMs;
|
||||
final int? estimatedTransmitMs;
|
||||
final int? postTransmitDelayMs;
|
||||
final int receivedCopies;
|
||||
|
||||
const MessageReceptionDetails({
|
||||
required this.capturedAt,
|
||||
this.packetLoggedAt,
|
||||
this.rssiDbm,
|
||||
this.snrDb,
|
||||
this.pathBytes,
|
||||
this.senderToReceiptMs,
|
||||
this.estimatedTransmitMs,
|
||||
this.postTransmitDelayMs,
|
||||
this.receivedCopies = 1,
|
||||
});
|
||||
|
||||
String? get pathBytesHex => pathBytes == null || pathBytes!.isEmpty
|
||||
? null
|
||||
: pathBytes!.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':');
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'capturedAtMillis': capturedAt.millisecondsSinceEpoch,
|
||||
'packetLoggedAtMillis': packetLoggedAt?.millisecondsSinceEpoch,
|
||||
'rssiDbm': rssiDbm,
|
||||
'snrDb': snrDb,
|
||||
'pathBytes': pathBytes,
|
||||
'senderToReceiptMs': senderToReceiptMs,
|
||||
'estimatedTransmitMs': estimatedTransmitMs,
|
||||
'postTransmitDelayMs': postTransmitDelayMs,
|
||||
'receivedCopies': receivedCopies,
|
||||
};
|
||||
}
|
||||
|
||||
MessageReceptionDetails copyWith({
|
||||
DateTime? capturedAt,
|
||||
DateTime? packetLoggedAt,
|
||||
int? rssiDbm,
|
||||
double? snrDb,
|
||||
List<int>? pathBytes,
|
||||
int? senderToReceiptMs,
|
||||
int? estimatedTransmitMs,
|
||||
int? postTransmitDelayMs,
|
||||
int? receivedCopies,
|
||||
}) {
|
||||
return MessageReceptionDetails(
|
||||
capturedAt: capturedAt ?? this.capturedAt,
|
||||
packetLoggedAt: packetLoggedAt ?? this.packetLoggedAt,
|
||||
rssiDbm: rssiDbm ?? this.rssiDbm,
|
||||
snrDb: snrDb ?? this.snrDb,
|
||||
pathBytes: pathBytes ?? this.pathBytes,
|
||||
senderToReceiptMs: senderToReceiptMs ?? this.senderToReceiptMs,
|
||||
estimatedTransmitMs: estimatedTransmitMs ?? this.estimatedTransmitMs,
|
||||
postTransmitDelayMs: postTransmitDelayMs ?? this.postTransmitDelayMs,
|
||||
receivedCopies: receivedCopies ?? this.receivedCopies,
|
||||
);
|
||||
}
|
||||
|
||||
static MessageReceptionDetails mergeDuplicate({
|
||||
MessageReceptionDetails? existing,
|
||||
MessageReceptionDetails? incoming,
|
||||
}) {
|
||||
final base =
|
||||
existing ??
|
||||
incoming ??
|
||||
MessageReceptionDetails(capturedAt: DateTime.now());
|
||||
|
||||
return base.copyWith(
|
||||
capturedAt: incoming?.capturedAt ?? existing?.capturedAt,
|
||||
packetLoggedAt: incoming?.packetLoggedAt ?? existing?.packetLoggedAt,
|
||||
rssiDbm: incoming?.rssiDbm ?? existing?.rssiDbm,
|
||||
snrDb: incoming?.snrDb ?? existing?.snrDb,
|
||||
pathBytes: incoming?.pathBytes ?? existing?.pathBytes,
|
||||
senderToReceiptMs:
|
||||
incoming?.senderToReceiptMs ?? existing?.senderToReceiptMs,
|
||||
estimatedTransmitMs:
|
||||
incoming?.estimatedTransmitMs ?? existing?.estimatedTransmitMs,
|
||||
postTransmitDelayMs:
|
||||
incoming?.postTransmitDelayMs ?? existing?.postTransmitDelayMs,
|
||||
receivedCopies:
|
||||
(existing?.receivedCopies ?? 1) + (incoming?.receivedCopies ?? 1),
|
||||
);
|
||||
}
|
||||
|
||||
static MessageReceptionDetails? fromJson(Map<String, dynamic> json) {
|
||||
final capturedAtMillis = json['capturedAtMillis'];
|
||||
if (capturedAtMillis is! int) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final pathBytes = json['pathBytes'];
|
||||
return MessageReceptionDetails(
|
||||
capturedAt: DateTime.fromMillisecondsSinceEpoch(capturedAtMillis),
|
||||
packetLoggedAt: json['packetLoggedAtMillis'] is int
|
||||
? DateTime.fromMillisecondsSinceEpoch(
|
||||
json['packetLoggedAtMillis'] as int,
|
||||
)
|
||||
: null,
|
||||
rssiDbm: json['rssiDbm'] as int?,
|
||||
snrDb: (json['snrDb'] as num?)?.toDouble(),
|
||||
pathBytes: pathBytes is List
|
||||
? pathBytes.whereType<num>().map((b) => b.toInt()).toList()
|
||||
: null,
|
||||
senderToReceiptMs: json['senderToReceiptMs'] as int?,
|
||||
estimatedTransmitMs: json['estimatedTransmitMs'] as int?,
|
||||
postTransmitDelayMs: json['postTransmitDelayMs'] as int?,
|
||||
receivedCopies: json['receivedCopies'] as int? ?? 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
79
lib/models/message_route_metadata.dart
Normal file
@@ -0,0 +1,79 @@
|
||||
import 'path_selection.dart';
|
||||
|
||||
class MessageRouteMetadata {
|
||||
final PathSelectionMode mode;
|
||||
final bool routerFallbackAttempted;
|
||||
final String? relayName;
|
||||
final String? relayKey6;
|
||||
final String? canonicalPath;
|
||||
final int? hopCount;
|
||||
|
||||
const MessageRouteMetadata({
|
||||
required this.mode,
|
||||
required this.routerFallbackAttempted,
|
||||
this.relayName,
|
||||
this.relayKey6,
|
||||
this.canonicalPath,
|
||||
this.hopCount,
|
||||
});
|
||||
|
||||
factory MessageRouteMetadata.fromSelection(
|
||||
PathSelection selection, {
|
||||
required bool routerFallbackAttempted,
|
||||
}) {
|
||||
return MessageRouteMetadata(
|
||||
mode: selection.mode,
|
||||
routerFallbackAttempted: routerFallbackAttempted,
|
||||
relayName: selection.relayName,
|
||||
relayKey6: selection.relayKey6,
|
||||
canonicalPath: selection.canonicalPath.isEmpty
|
||||
? null
|
||||
: selection.canonicalPath,
|
||||
hopCount: selection.hopCount > 0 ? selection.hopCount : null,
|
||||
);
|
||||
}
|
||||
|
||||
String get modeLabel {
|
||||
switch (mode) {
|
||||
case PathSelectionMode.directCurrent:
|
||||
return 'Current direct path';
|
||||
case PathSelectionMode.directHistorical:
|
||||
return 'Rotated direct path';
|
||||
case PathSelectionMode.flood:
|
||||
return 'Flood route';
|
||||
case PathSelectionMode.nearestRouter:
|
||||
final suffix = relayName?.trim().isNotEmpty == true
|
||||
? ' via $relayName'
|
||||
: relayKey6?.trim().isNotEmpty == true
|
||||
? ' via $relayKey6'
|
||||
: '';
|
||||
return 'Nearest router$suffix';
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'mode': mode.name,
|
||||
'router_fallback_attempted': routerFallbackAttempted,
|
||||
'relay_name': relayName,
|
||||
'relay_key6': relayKey6,
|
||||
'canonical_path': canonicalPath,
|
||||
'hop_count': hopCount,
|
||||
};
|
||||
}
|
||||
|
||||
factory MessageRouteMetadata.fromJson(Map<String, dynamic> json) {
|
||||
return MessageRouteMetadata(
|
||||
mode: PathSelectionMode.values.firstWhere(
|
||||
(value) => value.name == json['mode'],
|
||||
orElse: () => PathSelectionMode.directCurrent,
|
||||
),
|
||||
routerFallbackAttempted:
|
||||
json['router_fallback_attempted'] as bool? ?? false,
|
||||
relayName: json['relay_name'] as String?,
|
||||
relayKey6: json['relay_key6'] as String?,
|
||||
canonicalPath: json['canonical_path'] as String?,
|
||||
hopCount: json['hop_count'] as int?,
|
||||
);
|
||||
}
|
||||
}
|
||||
142
lib/models/message_transfer_details.dart
Normal file
@@ -0,0 +1,142 @@
|
||||
class MessageTransferDownloader {
|
||||
final String requesterKey6;
|
||||
final String? requesterName;
|
||||
final int transferCount;
|
||||
final DateTime lastTransferredAt;
|
||||
|
||||
const MessageTransferDownloader({
|
||||
required this.requesterKey6,
|
||||
this.requesterName,
|
||||
required this.transferCount,
|
||||
required this.lastTransferredAt,
|
||||
});
|
||||
|
||||
MessageTransferDownloader copyWith({
|
||||
String? requesterKey6,
|
||||
String? requesterName,
|
||||
int? transferCount,
|
||||
DateTime? lastTransferredAt,
|
||||
}) {
|
||||
return MessageTransferDownloader(
|
||||
requesterKey6: requesterKey6 ?? this.requesterKey6,
|
||||
requesterName: requesterName ?? this.requesterName,
|
||||
transferCount: transferCount ?? this.transferCount,
|
||||
lastTransferredAt: lastTransferredAt ?? this.lastTransferredAt,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'requesterKey6': requesterKey6,
|
||||
'requesterName': requesterName,
|
||||
'transferCount': transferCount,
|
||||
'lastTransferredAtMillis': lastTransferredAt.millisecondsSinceEpoch,
|
||||
};
|
||||
}
|
||||
|
||||
static MessageTransferDownloader? fromJson(Map<String, dynamic> json) {
|
||||
final requesterKey6 = json['requesterKey6'];
|
||||
final transferCount = json['transferCount'];
|
||||
final lastTransferredAtMillis = json['lastTransferredAtMillis'];
|
||||
if (requesterKey6 is! String ||
|
||||
transferCount is! int ||
|
||||
lastTransferredAtMillis is! int) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return MessageTransferDownloader(
|
||||
requesterKey6: requesterKey6,
|
||||
requesterName: json['requesterName'] as String?,
|
||||
transferCount: transferCount,
|
||||
lastTransferredAt: DateTime.fromMillisecondsSinceEpoch(
|
||||
lastTransferredAtMillis,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MessageTransferDetails {
|
||||
final int totalTransfers;
|
||||
final List<MessageTransferDownloader> downloaders;
|
||||
|
||||
const MessageTransferDetails({
|
||||
required this.totalTransfers,
|
||||
required this.downloaders,
|
||||
});
|
||||
|
||||
const MessageTransferDetails.empty()
|
||||
: totalTransfers = 0,
|
||||
downloaders = const [];
|
||||
|
||||
MessageTransferDetails registerTransfer({
|
||||
required String requesterKey6,
|
||||
String? requesterName,
|
||||
DateTime? transferredAt,
|
||||
}) {
|
||||
final eventAt = transferredAt ?? DateTime.now();
|
||||
final normalizedName = requesterName?.trim();
|
||||
final updatedDownloaders = List<MessageTransferDownloader>.from(
|
||||
downloaders,
|
||||
);
|
||||
final index = updatedDownloaders.indexWhere(
|
||||
(entry) => entry.requesterKey6 == requesterKey6,
|
||||
);
|
||||
|
||||
if (index == -1) {
|
||||
updatedDownloaders.add(
|
||||
MessageTransferDownloader(
|
||||
requesterKey6: requesterKey6,
|
||||
requesterName: normalizedName?.isEmpty ?? true
|
||||
? null
|
||||
: normalizedName,
|
||||
transferCount: 1,
|
||||
lastTransferredAt: eventAt,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
final existing = updatedDownloaders[index];
|
||||
updatedDownloaders[index] = existing.copyWith(
|
||||
requesterName: normalizedName?.isEmpty ?? true
|
||||
? existing.requesterName
|
||||
: normalizedName,
|
||||
transferCount: existing.transferCount + 1,
|
||||
lastTransferredAt: eventAt,
|
||||
);
|
||||
}
|
||||
|
||||
updatedDownloaders.sort(
|
||||
(a, b) => b.lastTransferredAt.compareTo(a.lastTransferredAt),
|
||||
);
|
||||
|
||||
return MessageTransferDetails(
|
||||
totalTransfers: totalTransfers + 1,
|
||||
downloaders: updatedDownloaders,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'totalTransfers': totalTransfers,
|
||||
'downloaders': downloaders.map((entry) => entry.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
static MessageTransferDetails? fromJson(Map<String, dynamic> json) {
|
||||
final totalTransfers = json['totalTransfers'];
|
||||
if (totalTransfers is! int) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final rawDownloaders = json['downloaders'] as List<dynamic>? ?? const [];
|
||||
final downloaders = rawDownloaders
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(MessageTransferDownloader.fromJson)
|
||||
.whereType<MessageTransferDownloader>()
|
||||
.toList();
|
||||
|
||||
return MessageTransferDetails(
|
||||
totalTransfers: totalTransfers,
|
||||
downloaders: downloaders,
|
||||
);
|
||||
}
|
||||
}
|
||||
65
lib/models/path_selection.dart
Normal file
@@ -0,0 +1,65 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
enum PathSelectionMode { directCurrent, directHistorical, flood, nearestRouter }
|
||||
|
||||
class PathSelection {
|
||||
final PathSelectionMode mode;
|
||||
final Uint8List pathBytes;
|
||||
final int hopCount;
|
||||
final int hashSize;
|
||||
final String? relayName;
|
||||
final String? relayKey6;
|
||||
|
||||
const PathSelection({
|
||||
required this.mode,
|
||||
required this.pathBytes,
|
||||
required this.hopCount,
|
||||
required this.hashSize,
|
||||
this.relayName,
|
||||
this.relayKey6,
|
||||
});
|
||||
|
||||
PathSelection.flood()
|
||||
: mode = PathSelectionMode.flood,
|
||||
pathBytes = Uint8List(0),
|
||||
hopCount = -1,
|
||||
hashSize = 1,
|
||||
relayName = null,
|
||||
relayKey6 = null;
|
||||
|
||||
bool get usesFlood => mode == PathSelectionMode.flood;
|
||||
bool get hasDirectPath => !usesFlood && pathBytes.isNotEmpty && hopCount > 0;
|
||||
|
||||
String get canonicalPath {
|
||||
if (!hasDirectPath) return '';
|
||||
final hops = <String>[];
|
||||
for (var index = 0; index < pathBytes.length; index += hashSize) {
|
||||
hops.add(
|
||||
pathBytes
|
||||
.sublist(index, index + hashSize)
|
||||
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
||||
.join()
|
||||
.toUpperCase(),
|
||||
);
|
||||
}
|
||||
return hops.join(',');
|
||||
}
|
||||
|
||||
PathSelection copyWith({
|
||||
PathSelectionMode? mode,
|
||||
Uint8List? pathBytes,
|
||||
int? hopCount,
|
||||
int? hashSize,
|
||||
String? relayName,
|
||||
String? relayKey6,
|
||||
}) {
|
||||
return PathSelection(
|
||||
mode: mode ?? this.mode,
|
||||
pathBytes: pathBytes ?? this.pathBytes,
|
||||
hopCount: hopCount ?? this.hopCount,
|
||||
hashSize: hashSize ?? this.hashSize,
|
||||
relayName: relayName ?? this.relayName,
|
||||
relayKey6: relayKey6 ?? this.relayKey6,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import 'map_coordinate_space.dart';
|
||||
import '../services/sar_template_service.dart';
|
||||
|
||||
/// SAR (Search & Rescue) marker types
|
||||
@@ -78,6 +79,8 @@ class SarMarker {
|
||||
final String? notes;
|
||||
final String? customEmoji; // For custom SAR markers not in predefined types
|
||||
final int? colorIndex; // Color index (0-7) from standard palette
|
||||
final MapCoordinateSpace coordinateSpace;
|
||||
final String? mapId;
|
||||
|
||||
SarMarker({
|
||||
required this.id,
|
||||
@@ -89,8 +92,13 @@ class SarMarker {
|
||||
this.notes,
|
||||
this.customEmoji,
|
||||
this.colorIndex,
|
||||
this.coordinateSpace = MapCoordinateSpace.geo,
|
||||
this.mapId,
|
||||
});
|
||||
|
||||
bool get isCustomMapMarker =>
|
||||
coordinateSpace == MapCoordinateSpace.customMap && mapId != null;
|
||||
|
||||
/// Get sender public key as hex string (short)
|
||||
String? get senderKeyShort {
|
||||
if (senderPublicKey == null || senderPublicKey!.length < 8) return null;
|
||||
@@ -167,6 +175,8 @@ class SarMarker {
|
||||
String? notes,
|
||||
String? customEmoji,
|
||||
int? colorIndex,
|
||||
MapCoordinateSpace? coordinateSpace,
|
||||
String? mapId,
|
||||
}) {
|
||||
return SarMarker(
|
||||
id: id ?? this.id,
|
||||
@@ -178,6 +188,8 @@ class SarMarker {
|
||||
notes: notes ?? this.notes,
|
||||
customEmoji: customEmoji ?? this.customEmoji,
|
||||
colorIndex: colorIndex ?? this.colorIndex,
|
||||
coordinateSpace: coordinateSpace ?? this.coordinateSpace,
|
||||
mapId: mapId ?? this.mapId,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
/// SSE Server Configuration Model
|
||||
///
|
||||
/// Configuration for the SSE (Server-Sent Events) web server that enables
|
||||
/// multiple app instances to share a single MeshCore BLE device.
|
||||
class SseServerConfig {
|
||||
/// Server bind address (e.g., "0.0.0.0" for all interfaces, "127.0.0.1" for localhost)
|
||||
final String host;
|
||||
|
||||
/// Server port (default: 12929)
|
||||
final int port;
|
||||
|
||||
/// Whether the SSE server is enabled
|
||||
final bool enabled;
|
||||
|
||||
/// Optional authentication token for basic security
|
||||
/// Clients must include this token in Authorization header
|
||||
final String? authToken;
|
||||
|
||||
const SseServerConfig({
|
||||
this.host = '0.0.0.0',
|
||||
this.port = 12929,
|
||||
this.enabled = false,
|
||||
this.authToken,
|
||||
});
|
||||
|
||||
/// Create a copy with updated fields
|
||||
SseServerConfig copyWith({
|
||||
String? host,
|
||||
int? port,
|
||||
bool? enabled,
|
||||
String? authToken,
|
||||
}) {
|
||||
return SseServerConfig(
|
||||
host: host ?? this.host,
|
||||
port: port ?? this.port,
|
||||
enabled: enabled ?? this.enabled,
|
||||
authToken: authToken ?? this.authToken,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get server URL for clients to connect to
|
||||
String getServerUrl({String? ipAddress}) {
|
||||
final ip = ipAddress ?? host;
|
||||
return 'http://$ip:$port';
|
||||
}
|
||||
|
||||
/// Convert to JSON for persistence
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'host': host,
|
||||
'port': port,
|
||||
'enabled': enabled,
|
||||
'authToken': authToken,
|
||||
};
|
||||
}
|
||||
|
||||
/// Create from JSON
|
||||
factory SseServerConfig.fromJson(Map<String, dynamic> json) {
|
||||
return SseServerConfig(
|
||||
host: json['host'] as String? ?? '0.0.0.0',
|
||||
port: json['port'] as int? ?? 12929,
|
||||
enabled: json['enabled'] as bool? ?? false,
|
||||
authToken: json['authToken'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SseServerConfig(host: $host, port: $port, enabled: $enabled, hasAuth: ${authToken != null})';
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,8 @@ class ChannelsProvider with ChangeNotifier {
|
||||
int _selectedChannelIndex = 0; // Default to public channel
|
||||
|
||||
/// Get all channels
|
||||
List<Channel> get channels => _channels.values.toList()..sort((a, b) => a.index.compareTo(b.index));
|
||||
List<Channel> get channels =>
|
||||
_channels.values.toList()..sort((a, b) => a.index.compareTo(b.index));
|
||||
|
||||
/// Get a specific channel by index
|
||||
Channel? getChannel(int index) => _channels[index];
|
||||
@@ -28,6 +29,16 @@ class ChannelsProvider with ChangeNotifier {
|
||||
return index == 0 ? 'Public' : 'Channel $index';
|
||||
}
|
||||
|
||||
List<Channel> getChannelsByHashByte(int hashByte) {
|
||||
return _channels.values.where((channel) => channel.hashByte == hashByte).toList()
|
||||
..sort((a, b) => a.index.compareTo(b.index));
|
||||
}
|
||||
|
||||
String? getUniqueChannelDisplayNameByHashByte(int hashByte) {
|
||||
final matches = getChannelsByHashByte(hashByte);
|
||||
return matches.length == 1 ? matches.single.displayName : null;
|
||||
}
|
||||
|
||||
/// Add or update a channel
|
||||
void addOrUpdateChannel({
|
||||
required int index,
|
||||
@@ -54,12 +65,12 @@ class ChannelsProvider with ChangeNotifier {
|
||||
void removeChannel(int index) {
|
||||
if (_channels.containsKey(index)) {
|
||||
_channels.remove(index);
|
||||
|
||||
|
||||
// If the deleted channel was selected, switch to public channel
|
||||
if (_selectedChannelIndex == index) {
|
||||
_selectedChannelIndex = 0;
|
||||
}
|
||||
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -96,6 +107,13 @@ class ChannelsProvider with ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Clear runtime channel state before a live device sync begins.
|
||||
void prepareForDeviceSync() {
|
||||
_channels.clear();
|
||||
_selectedChannelIndex = 0;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Check if channels have been loaded
|
||||
bool get hasChannels => _channels.isNotEmpty;
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../models/map_drawing.dart';
|
||||
import '../models/map_coordinate_space.dart';
|
||||
import '../services/profiles_feature_service.dart';
|
||||
import '../utils/drawing_message_parser.dart';
|
||||
|
||||
/// Drawing mode state
|
||||
@@ -11,6 +14,8 @@ enum DrawingMode { none, line, rectangle, measure }
|
||||
/// Provider for managing map drawings
|
||||
class DrawingProvider with ChangeNotifier {
|
||||
static const String _storageKey = 'map_drawings';
|
||||
static const String _showReceivedDrawingsKey = 'map_show_received_drawings';
|
||||
static const String _showSarMarkersKey = 'map_show_sar_markers';
|
||||
|
||||
// Drawing state
|
||||
DrawingMode _drawingMode = DrawingMode.none;
|
||||
@@ -20,6 +25,7 @@ class DrawingProvider with ChangeNotifier {
|
||||
|
||||
// Completed drawings
|
||||
final List<MapDrawing> _drawings = [];
|
||||
bool _isInitialized = false;
|
||||
|
||||
// In-progress drawing
|
||||
MapDrawing? _currentDrawing;
|
||||
@@ -30,12 +36,18 @@ class DrawingProvider with ChangeNotifier {
|
||||
LatLng? _measurementPoint1;
|
||||
LatLng? _measurementPoint2;
|
||||
double? _measuredDistance; // in meters
|
||||
MapCoordinateSpace _activeCoordinateSpace = MapCoordinateSpace.geo;
|
||||
String? _activeMapId;
|
||||
double? _activeMetersPerPixel;
|
||||
|
||||
// Getters
|
||||
DrawingMode get drawingMode => _drawingMode;
|
||||
Color get selectedColor => _selectedColor;
|
||||
bool get showReceivedDrawings => _showReceivedDrawings;
|
||||
bool get showSarMarkers => _showSarMarkers;
|
||||
MapCoordinateSpace get activeCoordinateSpace => _activeCoordinateSpace;
|
||||
String? get activeMapId => _activeMapId;
|
||||
double? get activeMetersPerPixel => _activeMetersPerPixel;
|
||||
List<MapDrawing> get drawings {
|
||||
// Filter out hidden drawings first
|
||||
var visibleDrawings = _drawings.where((d) => !d.isHidden);
|
||||
@@ -45,19 +57,62 @@ class DrawingProvider with ChangeNotifier {
|
||||
visibleDrawings = visibleDrawings.where((d) => !d.isReceived);
|
||||
}
|
||||
|
||||
visibleDrawings = visibleDrawings.where((drawing) {
|
||||
if (drawing.coordinateSpace != _activeCoordinateSpace) {
|
||||
return false;
|
||||
}
|
||||
if (drawing.coordinateSpace == MapCoordinateSpace.customMap) {
|
||||
return drawing.mapId != null && drawing.mapId == _activeMapId;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return List.unmodifiable(visibleDrawings.toList());
|
||||
}
|
||||
|
||||
MapDrawing? get currentDrawing => _currentDrawing;
|
||||
List<LatLng> get currentLinePoints => List.unmodifiable(_currentLinePoints);
|
||||
LatLng? get rectangleStartPoint => _rectangleStartPoint;
|
||||
bool get isDrawing => _drawingMode != DrawingMode.none;
|
||||
bool get isInitialized => _isInitialized;
|
||||
LatLng? get measurementPoint1 => _measurementPoint1;
|
||||
LatLng? get measurementPoint2 => _measurementPoint2;
|
||||
double? get measuredDistance => _measuredDistance;
|
||||
|
||||
/// Initialize and load saved drawings
|
||||
Future<void> initialize() async {
|
||||
await _loadPreferences();
|
||||
await _loadDrawings();
|
||||
_isInitialized = true;
|
||||
}
|
||||
|
||||
Future<void> reloadProfileScopedState() async {
|
||||
await _loadPreferences();
|
||||
await _loadDrawings();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setMapContext({
|
||||
required MapCoordinateSpace coordinateSpace,
|
||||
String? mapId,
|
||||
double? metersPerPixel,
|
||||
}) {
|
||||
final changed =
|
||||
coordinateSpace != _activeCoordinateSpace ||
|
||||
mapId != _activeMapId ||
|
||||
metersPerPixel != _activeMetersPerPixel;
|
||||
_activeCoordinateSpace = coordinateSpace;
|
||||
_activeMapId = mapId;
|
||||
_activeMetersPerPixel = metersPerPixel;
|
||||
if (_measurementPoint1 != null && _measurementPoint2 != null) {
|
||||
_measuredDistance = _calculateDistance(
|
||||
_measurementPoint1!,
|
||||
_measurementPoint2!,
|
||||
);
|
||||
}
|
||||
if (changed) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Set drawing mode
|
||||
@@ -77,15 +132,34 @@ class DrawingProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
/// Toggle visibility of received drawings
|
||||
void toggleReceivedDrawings() {
|
||||
Future<void> toggleReceivedDrawings() async {
|
||||
_showReceivedDrawings = !_showReceivedDrawings;
|
||||
notifyListeners();
|
||||
await _savePreferences();
|
||||
}
|
||||
|
||||
/// Toggle visibility of SAR markers
|
||||
void toggleSarMarkers() {
|
||||
Future<void> toggleSarMarkers() async {
|
||||
_showSarMarkers = !_showSarMarkers;
|
||||
notifyListeners();
|
||||
await _savePreferences();
|
||||
}
|
||||
|
||||
Future<void> _loadPreferences() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_showReceivedDrawings =
|
||||
prefs.getBool(_scopedKey(_showReceivedDrawingsKey)) ?? true;
|
||||
_showSarMarkers = prefs.getBool(_scopedKey(_showSarMarkersKey)) ?? true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _savePreferences() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(
|
||||
_scopedKey(_showReceivedDrawingsKey),
|
||||
_showReceivedDrawings,
|
||||
);
|
||||
await prefs.setBool(_scopedKey(_showSarMarkersKey), _showSarMarkers);
|
||||
}
|
||||
|
||||
/// Start drawing a line
|
||||
@@ -116,6 +190,10 @@ class DrawingProvider with ChangeNotifier {
|
||||
color: _selectedColor,
|
||||
createdAt: DateTime.now(),
|
||||
points: List.from(_currentLinePoints),
|
||||
coordinateSpace: _activeCoordinateSpace,
|
||||
mapId: _activeCoordinateSpace == MapCoordinateSpace.customMap
|
||||
? _activeMapId
|
||||
: null,
|
||||
);
|
||||
|
||||
_drawings.add(drawing);
|
||||
@@ -159,6 +237,10 @@ class DrawingProvider with ChangeNotifier {
|
||||
? _rectangleStartPoint!.longitude
|
||||
: endPoint.longitude,
|
||||
),
|
||||
coordinateSpace: _activeCoordinateSpace,
|
||||
mapId: _activeCoordinateSpace == MapCoordinateSpace.customMap
|
||||
? _activeMapId
|
||||
: null,
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -195,6 +277,10 @@ class DrawingProvider with ChangeNotifier {
|
||||
createdAt: DateTime.now(),
|
||||
topLeft: topLeft,
|
||||
bottomRight: bottomRight,
|
||||
coordinateSpace: _activeCoordinateSpace,
|
||||
mapId: _activeCoordinateSpace == MapCoordinateSpace.customMap
|
||||
? _activeMapId
|
||||
: null,
|
||||
);
|
||||
|
||||
_drawings.add(drawing);
|
||||
@@ -216,7 +302,9 @@ class DrawingProvider with ChangeNotifier {
|
||||
|
||||
/// Set second measurement point and calculate distance
|
||||
void setMeasurementPoint2(LatLng point) {
|
||||
if (_drawingMode != DrawingMode.measure || _measurementPoint1 == null) return;
|
||||
if (_drawingMode != DrawingMode.measure || _measurementPoint1 == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
_measurementPoint2 = point;
|
||||
_measuredDistance = _calculateDistance(_measurementPoint1!, point);
|
||||
@@ -225,6 +313,13 @@ class DrawingProvider with ChangeNotifier {
|
||||
|
||||
/// Calculate distance between two points using Haversine formula
|
||||
double _calculateDistance(LatLng point1, LatLng point2) {
|
||||
if (_activeCoordinateSpace == MapCoordinateSpace.customMap &&
|
||||
_activeMetersPerPixel != null) {
|
||||
final dy = point2.latitude - point1.latitude;
|
||||
final dx = point2.longitude - point1.longitude;
|
||||
final pixelDistance = math.sqrt((dx * dx) + (dy * dy));
|
||||
return pixelDistance * _activeMetersPerPixel!;
|
||||
}
|
||||
const Distance distance = Distance();
|
||||
return distance.as(LengthUnit.Meter, point1, point2);
|
||||
}
|
||||
@@ -281,7 +376,7 @@ class DrawingProvider with ChangeNotifier {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonList = _drawings.map((d) => d.toJson()).toList();
|
||||
final jsonString = jsonEncode(jsonList);
|
||||
await prefs.setString(_storageKey, jsonString);
|
||||
await prefs.setString(_scopedKey(_storageKey), jsonString);
|
||||
} catch (e) {
|
||||
debugPrint('Error saving drawings: $e');
|
||||
}
|
||||
@@ -291,8 +386,11 @@ class DrawingProvider with ChangeNotifier {
|
||||
Future<void> _loadDrawings() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonString = prefs.getString(_storageKey);
|
||||
if (jsonString == null) return;
|
||||
final jsonString = prefs.getString(_scopedKey(_storageKey));
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
_drawings.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
final jsonList = jsonDecode(jsonString) as List<dynamic>;
|
||||
_drawings.clear();
|
||||
@@ -318,6 +416,10 @@ class DrawingProvider with ChangeNotifier {
|
||||
color: _selectedColor,
|
||||
createdAt: DateTime.now(),
|
||||
points: _currentLinePoints,
|
||||
coordinateSpace: _activeCoordinateSpace,
|
||||
mapId: _activeCoordinateSpace == MapCoordinateSpace.customMap
|
||||
? _activeMapId
|
||||
: null,
|
||||
);
|
||||
} else if (_drawingMode == DrawingMode.rectangle &&
|
||||
_currentDrawing != null) {
|
||||
@@ -355,6 +457,8 @@ class DrawingProvider with ChangeNotifier {
|
||||
isShared: drawing.isShared,
|
||||
isSent: drawing.isSent,
|
||||
isHidden: drawing.isHidden,
|
||||
coordinateSpace: drawing.coordinateSpace,
|
||||
mapId: drawing.mapId,
|
||||
);
|
||||
} else if (drawing is RectangleDrawing) {
|
||||
return RectangleDrawing(
|
||||
@@ -369,6 +473,8 @@ class DrawingProvider with ChangeNotifier {
|
||||
isShared: drawing.isShared,
|
||||
isSent: drawing.isSent,
|
||||
isHidden: drawing.isHidden,
|
||||
coordinateSpace: drawing.coordinateSpace,
|
||||
mapId: drawing.mapId,
|
||||
);
|
||||
}
|
||||
return drawing;
|
||||
@@ -383,9 +489,27 @@ class DrawingProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> exportDrawingsJson() {
|
||||
return _drawings.map((drawing) => drawing.toJson()).toList();
|
||||
}
|
||||
|
||||
Future<void> replaceDrawingsFromJson(
|
||||
List<Map<String, dynamic>> jsonList,
|
||||
) async {
|
||||
_drawings
|
||||
..clear()
|
||||
..addAll(jsonList.map(MapDrawing.fromJson).whereType<MapDrawing>());
|
||||
await _saveDrawings();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
String _scopedKey(String baseKey) {
|
||||
return ProfileStorageScope.scopedKey(baseKey);
|
||||
}
|
||||
|
||||
/// Get all unshared drawings (local drawings not yet sent)
|
||||
List<MapDrawing> getUnsharedDrawings() {
|
||||
return _drawings.where((d) => !d.isShared && !d.isReceived).toList();
|
||||
return drawings.where((d) => !d.isShared && !d.isReceived).toList();
|
||||
}
|
||||
|
||||
/// Mark a drawing as shared
|
||||
@@ -407,6 +531,8 @@ class DrawingProvider with ChangeNotifier {
|
||||
isShared: true,
|
||||
isSent: drawing.isSent,
|
||||
isHidden: drawing.isHidden,
|
||||
coordinateSpace: drawing.coordinateSpace,
|
||||
mapId: drawing.mapId,
|
||||
);
|
||||
} else if (drawing is RectangleDrawing) {
|
||||
_drawings[index] = RectangleDrawing(
|
||||
@@ -421,6 +547,8 @@ class DrawingProvider with ChangeNotifier {
|
||||
isShared: true,
|
||||
isSent: drawing.isSent,
|
||||
isHidden: drawing.isHidden,
|
||||
coordinateSpace: drawing.coordinateSpace,
|
||||
mapId: drawing.mapId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -448,6 +576,8 @@ class DrawingProvider with ChangeNotifier {
|
||||
isShared: drawing.isShared,
|
||||
isSent: drawing.isSent,
|
||||
isHidden: !drawing.isHidden,
|
||||
coordinateSpace: drawing.coordinateSpace,
|
||||
mapId: drawing.mapId,
|
||||
);
|
||||
} else if (drawing is RectangleDrawing) {
|
||||
_drawings[index] = RectangleDrawing(
|
||||
@@ -462,6 +592,8 @@ class DrawingProvider with ChangeNotifier {
|
||||
isShared: drawing.isShared,
|
||||
isSent: drawing.isSent,
|
||||
isHidden: !drawing.isHidden,
|
||||
coordinateSpace: drawing.coordinateSpace,
|
||||
mapId: drawing.mapId,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
43
lib/providers/helpers/fragment_ack_wait_registry.dart
Normal file
@@ -0,0 +1,43 @@
|
||||
import 'dart:async';
|
||||
|
||||
/// Tracks one or more in-flight waiters for the same fragment ACK key.
|
||||
///
|
||||
/// Duplicate fetch requests can race and wait on the same fragment ACK at once.
|
||||
/// Completing all registered waiters avoids losing the earlier completer when a
|
||||
/// later request registers for the same key.
|
||||
class FragmentAckWaitRegistry {
|
||||
final Map<String, List<Completer<void>>> _waiters = {};
|
||||
|
||||
Future<bool> waitFor(
|
||||
String key, {
|
||||
Duration timeout = const Duration(seconds: 8),
|
||||
}) async {
|
||||
final completer = Completer<void>();
|
||||
final waiters = _waiters.putIfAbsent(key, () => <Completer<void>>[]);
|
||||
waiters.add(completer);
|
||||
try {
|
||||
await completer.future.timeout(timeout);
|
||||
return true;
|
||||
} catch (_) {
|
||||
final pending = _waiters[key];
|
||||
pending?.remove(completer);
|
||||
if (pending != null && pending.isEmpty) {
|
||||
_waiters.remove(key);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
int complete(String key) {
|
||||
final waiters = _waiters.remove(key);
|
||||
if (waiters == null || waiters.isEmpty) {
|
||||
return 0;
|
||||
}
|
||||
for (final completer in waiters) {
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete();
|
||||
}
|
||||
}
|
||||
return waiters.length;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// Message delivery tracking helper
|
||||
///
|
||||
/// Manages message delivery tracking for sent messages, including:
|
||||
@@ -16,6 +19,9 @@ class MessageDeliveryTracker {
|
||||
/// Messages tracked here before sending, popped when RESP_CODE_SENT arrives
|
||||
final List<String> _pendingMessageIds = [];
|
||||
|
||||
/// Contact-scoped FIFOs for matching direct-message SENT responses.
|
||||
final Map<String, List<String>> _pendingMessageIdsByContact = {};
|
||||
|
||||
/// Map of ACK tag to message ID for delivery confirmation
|
||||
final Map<int, String> _ackTagToMessageId = {};
|
||||
|
||||
@@ -25,6 +31,9 @@ class MessageDeliveryTracker {
|
||||
/// Map of ACK tag to timestamp for timeout cleanup
|
||||
final Map<int, DateTime> _ackTagTimestamps = {};
|
||||
|
||||
/// Completer signalled when a pending ACK slot is freed (delivery or removal).
|
||||
Completer<void>? _slotFreedCompleter;
|
||||
|
||||
/// Track a pending message ID before sending
|
||||
///
|
||||
/// This is called BEFORE sending the message. When RESP_CODE_SENT
|
||||
@@ -33,6 +42,15 @@ class MessageDeliveryTracker {
|
||||
_pendingMessageIds.add(messageId);
|
||||
}
|
||||
|
||||
/// Track a pending direct message ID for a specific contact.
|
||||
void trackPendingDirectMessage(String messageId, Uint8List contactPublicKey) {
|
||||
trackPendingMessage(messageId);
|
||||
final contactKey = _contactKey(contactPublicKey);
|
||||
_pendingMessageIdsByContact
|
||||
.putIfAbsent(contactKey, () => [])
|
||||
.add(messageId);
|
||||
}
|
||||
|
||||
/// Pop the next pending message ID from FIFO queue
|
||||
///
|
||||
/// Called when RESP_CODE_SENT arrives. Returns null if queue empty.
|
||||
@@ -43,6 +61,24 @@ class MessageDeliveryTracker {
|
||||
return _pendingMessageIds.removeAt(0);
|
||||
}
|
||||
|
||||
/// Pop the next pending direct message ID for a specific contact.
|
||||
///
|
||||
/// Falls back to the legacy global FIFO if the contact queue is empty.
|
||||
String? popPendingDirectMessageId(Uint8List contactPublicKey) {
|
||||
final contactKey = _contactKey(contactPublicKey);
|
||||
final queue = _pendingMessageIdsByContact[contactKey];
|
||||
if (queue == null || queue.isEmpty) {
|
||||
return popPendingMessageId();
|
||||
}
|
||||
|
||||
final messageId = queue.removeAt(0);
|
||||
if (queue.isEmpty) {
|
||||
_pendingMessageIdsByContact.remove(contactKey);
|
||||
}
|
||||
_pendingMessageIds.remove(messageId);
|
||||
return messageId;
|
||||
}
|
||||
|
||||
/// Map ACK tag to message ID after RESP_CODE_SENT received
|
||||
///
|
||||
/// Creates bidirectional mapping for efficient cleanup and tracking.
|
||||
@@ -66,6 +102,11 @@ class MessageDeliveryTracker {
|
||||
return _ackTagToMessageId[ackCode];
|
||||
}
|
||||
|
||||
/// Returns true once a message has been matched to a concrete ACK tag.
|
||||
bool hasAckForMessage(String messageId) {
|
||||
return _messageIdToAckTag.containsKey(messageId);
|
||||
}
|
||||
|
||||
/// Remove ACK tag mapping after delivery confirmed or timeout
|
||||
///
|
||||
/// Cleans up both forward and reverse mappings.
|
||||
@@ -75,6 +116,7 @@ class MessageDeliveryTracker {
|
||||
_messageIdToAckTag.remove(messageId);
|
||||
}
|
||||
_ackTagTimestamps.remove(ackCode);
|
||||
_notifySlotFreed();
|
||||
}
|
||||
|
||||
/// Remove ACK tag mapping by message ID
|
||||
@@ -85,6 +127,18 @@ class MessageDeliveryTracker {
|
||||
if (ackTag != null) {
|
||||
_ackTagToMessageId.remove(ackTag);
|
||||
_ackTagTimestamps.remove(ackTag);
|
||||
_notifySlotFreed();
|
||||
}
|
||||
_pendingMessageIds.remove(messageId);
|
||||
final emptyKeys = <String>[];
|
||||
for (final entry in _pendingMessageIdsByContact.entries) {
|
||||
entry.value.remove(messageId);
|
||||
if (entry.value.isEmpty) {
|
||||
emptyKeys.add(entry.key);
|
||||
}
|
||||
}
|
||||
for (final key in emptyKeys) {
|
||||
_pendingMessageIdsByContact.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,9 +168,11 @@ class MessageDeliveryTracker {
|
||||
/// Clear all tracking state
|
||||
void clearTracking() {
|
||||
_pendingMessageIds.clear();
|
||||
_pendingMessageIdsByContact.clear();
|
||||
_ackTagToMessageId.clear();
|
||||
_messageIdToAckTag.clear();
|
||||
_ackTagTimestamps.clear();
|
||||
_notifySlotFreed();
|
||||
}
|
||||
|
||||
/// Get count of pending ACK tags
|
||||
@@ -130,12 +186,31 @@ class MessageDeliveryTracker {
|
||||
/// Returns true if >= 7 pending ACKs (stay under firmware limit of 8)
|
||||
bool get shouldRateLimit => pendingCount >= 7;
|
||||
|
||||
/// Wait until a pending ACK slot is freed, or [timeout] elapses.
|
||||
///
|
||||
/// Returns immediately if not at the rate limit.
|
||||
Future<void> waitForSlot({
|
||||
Duration timeout = const Duration(milliseconds: 500),
|
||||
}) async {
|
||||
if (!shouldRateLimit) return;
|
||||
_slotFreedCompleter ??= Completer<void>();
|
||||
await _slotFreedCompleter!.future.timeout(
|
||||
timeout,
|
||||
onTimeout: () {},
|
||||
);
|
||||
}
|
||||
|
||||
void _notifySlotFreed() {
|
||||
if (_slotFreedCompleter != null && !_slotFreedCompleter!.isCompleted) {
|
||||
_slotFreedCompleter!.complete();
|
||||
}
|
||||
_slotFreedCompleter = null;
|
||||
}
|
||||
|
||||
/// Get oldest pending ACK timestamp (for debugging)
|
||||
DateTime? get oldestPendingTimestamp {
|
||||
if (_ackTagTimestamps.isEmpty) return null;
|
||||
return _ackTagTimestamps.values.reduce(
|
||||
(a, b) => a.isBefore(b) ? a : b,
|
||||
);
|
||||
return _ackTagTimestamps.values.reduce((a, b) => a.isBefore(b) ? a : b);
|
||||
}
|
||||
|
||||
/// Get diagnostic info for debugging
|
||||
@@ -145,6 +220,15 @@ class MessageDeliveryTracker {
|
||||
'shouldRateLimit': shouldRateLimit,
|
||||
'oldestPending': oldestPendingTimestamp?.toIso8601String(),
|
||||
'ackTags': _ackTagToMessageId.keys.toList(),
|
||||
'pendingByContact': _pendingMessageIdsByContact.map(
|
||||
(key, value) => MapEntry(key, value.length),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
String _contactKey(Uint8List contactPublicKey) {
|
||||
return contactPublicKey
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,74 +1,104 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import '../../models/message.dart';
|
||||
import '../../models/contact.dart';
|
||||
|
||||
/// Manages message retry state and logic
|
||||
///
|
||||
/// This helper class centralizes retry logic for direct messages, implementing
|
||||
/// a progressive timeout strategy (4s, 8s, 12s) for messages sent to contacts
|
||||
/// with learned routing paths.
|
||||
/// This helper class centralizes retry logic for direct messages.
|
||||
///
|
||||
/// IMPORTANT: Based on MeshCore firmware analysis:
|
||||
/// - Firmware calculates timeout based on path length and airtime
|
||||
/// - Direct mode: ~(path_len * airtime * 2) + margin
|
||||
/// - Flood mode: ~10-30 seconds for multi-hop
|
||||
/// - Our timeouts (4s, 8s, 12s) are conservative for direct paths
|
||||
/// - Our retry delays (1s, 2s, 4s, 8s) are app-level backoff timers
|
||||
/// - Firmware does NOT automatically retry - app must implement
|
||||
class MessageRetryManager {
|
||||
// Track retry state for each message ID
|
||||
final Map<String, int> _retryAttempts = {};
|
||||
final Map<String, DateTime> _lastRetryTimes = {};
|
||||
final Map<String, int> _pathFailureStreaks = {};
|
||||
|
||||
// Progressive timeout values in milliseconds
|
||||
// These are app-level timeouts, separate from firmware's suggested timeout
|
||||
// Firmware timeout is for ACK arrival, these are for retry attempts
|
||||
static const List<int> _timeouts = [4000, 8000, 12000];
|
||||
/// Max retry attempts when the contact has a known path.
|
||||
/// Sequence: 2 direct attempts, flood, then last successful route.
|
||||
static const int maxRetryAttemptsWithPath = 3;
|
||||
|
||||
/// Get timeout for a specific retry attempt (0-2)
|
||||
/// Returns: 4000ms for attempt 0, 8000ms for attempt 1, 12000ms for attempt 2
|
||||
int getTimeoutForAttempt(int attempt) {
|
||||
if (attempt < 0 || attempt >= _timeouts.length) {
|
||||
return _timeouts.last; // Default to last timeout if out of range
|
||||
/// No retries for flood-only contacts (no known path).
|
||||
/// Value 0 means: don't retry at all, go straight to fallback/fail.
|
||||
static const int maxRetryAttemptsFloodOnly = 0;
|
||||
|
||||
@Deprecated('Use maxRetryAttemptsForContact instead')
|
||||
static const int maxRetryAttempts = maxRetryAttemptsWithPath;
|
||||
|
||||
// Retry backoff values in milliseconds.
|
||||
static const List<int> _retryDelays = [1000, 2000, 4000, 8000, 8000];
|
||||
static const int _defaultLoRaSf = 10;
|
||||
static const int _defaultLoRaCr = 5;
|
||||
static const int _defaultLoRaBwHz = 250000;
|
||||
static const int _defaultLoRaPreambleSymbols = 8;
|
||||
static const int _defaultLoRaCrcEnabled = 1;
|
||||
static const int _defaultLoRaExplicitHeader = 1;
|
||||
|
||||
/// Get backoff delay for the next retry attempt.
|
||||
int getDelayForAttempt(int attempt) {
|
||||
if (attempt < 0 || attempt >= _retryDelays.length) {
|
||||
return _retryDelays.last;
|
||||
}
|
||||
return _timeouts[attempt];
|
||||
return _retryDelays[attempt];
|
||||
}
|
||||
|
||||
/// Check if a message is eligible for retry
|
||||
static final math.Random _rng = math.Random();
|
||||
|
||||
/// Calculate a delivery-ACK timeout with random jitter.
|
||||
///
|
||||
/// Returns true if:
|
||||
/// - The message has retryAttempt < 3
|
||||
/// - The contact has a learned path (contact.hasPath == true)
|
||||
/// - The message hasn't used flood fallback yet
|
||||
///
|
||||
/// Messages to contacts without paths should NOT retry (flood mode already broadcasts)
|
||||
/// Matches the official MeshCore app: `suggestedTimeout + random(1-8s)`.
|
||||
/// The jitter prevents collision when multiple messages are in flight.
|
||||
int calculateAckTimeoutMs({
|
||||
required String text,
|
||||
required Contact? contact,
|
||||
int? suggestedTimeoutMs,
|
||||
}) {
|
||||
int baseTimeout;
|
||||
if (suggestedTimeoutMs != null && suggestedTimeoutMs > 0) {
|
||||
baseTimeout = suggestedTimeoutMs;
|
||||
} else {
|
||||
final payloadBytes = utf8.encode(text).length;
|
||||
final airtimeMs = _estimateLoRaAirtimeMs(payloadBytes);
|
||||
final hopCount = contact?.routeHasPath == true
|
||||
? contact!.routeHopCount
|
||||
: -1;
|
||||
|
||||
if (hopCount < 0) {
|
||||
baseTimeout = ((airtimeMs * 10) + 4000).clamp(10000, 30000);
|
||||
} else {
|
||||
baseTimeout = ((airtimeMs * (hopCount + 1) * 2) + 1500).clamp(4000, 20000);
|
||||
}
|
||||
}
|
||||
|
||||
// Add random jitter: 500-2000ms to avoid collision
|
||||
final jitterMs = 500 + _rng.nextInt(1501);
|
||||
return baseTimeout + jitterMs;
|
||||
}
|
||||
|
||||
/// Max attempts for a given contact based on whether it has a known path.
|
||||
static int maxRetryAttemptsForContact(Contact? contact) {
|
||||
final hasPath = contact?.routeHasPath ?? false;
|
||||
return hasPath ? maxRetryAttemptsWithPath : maxRetryAttemptsFloodOnly;
|
||||
}
|
||||
|
||||
bool canRetry(Message message, Contact contact) {
|
||||
// Never retry if already tried flood mode
|
||||
if (message.usedFloodFallback) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Never retry beyond 3 attempts
|
||||
if (message.retryAttempt >= 3) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Only retry if contact has a learned path
|
||||
// If no path, the device uses flood mode automatically - retrying won't help
|
||||
return contact.hasPath;
|
||||
return message.retryAttempt < maxRetryAttemptsForContact(contact);
|
||||
}
|
||||
|
||||
/// Check if should fall back to flood mode
|
||||
/// Whether this is the last retry attempt.
|
||||
/// When true, the caller should reset the path to force flood mode.
|
||||
///
|
||||
/// Returns true if:
|
||||
/// - Message has exhausted all 3 retry attempts with direct mode
|
||||
/// - Contact HAS a learned path (so direct mode was used)
|
||||
/// - Hasn't already used flood fallback
|
||||
///
|
||||
/// IMPORTANT: Only contacts WITH paths need flood fallback.
|
||||
/// Contacts without paths already use flood mode automatically.
|
||||
bool shouldUseFloodFallback(Message message, Contact contact) {
|
||||
return message.retryAttempt >= 3 &&
|
||||
contact.hasPath && // ✅ FIXED: Flood fallback for failed direct paths
|
||||
!message.usedFloodFallback;
|
||||
/// Note: called AFTER retryAttempt has been incremented, so we compare
|
||||
/// directly against maxAttempts (not +1).
|
||||
bool isLastAttempt(Message message, Contact contact) {
|
||||
final maxAttempts = maxRetryAttemptsForContact(contact);
|
||||
return maxAttempts > 1 && message.retryAttempt >= maxAttempts;
|
||||
}
|
||||
|
||||
/// Track a retry attempt for a message
|
||||
@@ -87,6 +117,7 @@ class MessageRetryManager {
|
||||
void clearAll() {
|
||||
_retryAttempts.clear();
|
||||
_lastRetryTimes.clear();
|
||||
_pathFailureStreaks.clear();
|
||||
}
|
||||
|
||||
/// Get current retry attempt for a message (for debugging)
|
||||
@@ -98,4 +129,50 @@ class MessageRetryManager {
|
||||
DateTime? getLastRetryTime(String messageId) {
|
||||
return _lastRetryTimes[messageId];
|
||||
}
|
||||
|
||||
/// Record a successful delivery for a contact and clear any accumulated
|
||||
/// route failure streak for future sends.
|
||||
void recordDeliverySuccess(Contact contact) {
|
||||
_pathFailureStreaks.remove(contact.publicKeyHex);
|
||||
}
|
||||
|
||||
/// Record a permanent route failure for a contact.
|
||||
///
|
||||
/// Returns the updated failure streak so callers can decide when to reset
|
||||
/// the learned path on the radio and in local state.
|
||||
int recordPathFailure(Contact contact) {
|
||||
final contactKey = contact.publicKeyHex;
|
||||
final next = (_pathFailureStreaks[contactKey] ?? 0) + 1;
|
||||
_pathFailureStreaks[contactKey] = next;
|
||||
return next;
|
||||
}
|
||||
|
||||
int? getPathFailureStreak(Contact contact) {
|
||||
return _pathFailureStreaks[contact.publicKeyHex];
|
||||
}
|
||||
|
||||
int _estimateLoRaAirtimeMs(int payloadLenBytes) {
|
||||
final sf = _defaultLoRaSf;
|
||||
final bw = _defaultLoRaBwHz.toDouble();
|
||||
final cr = (_defaultLoRaCr - 4).clamp(1, 4);
|
||||
final ih = _defaultLoRaExplicitHeader == 1 ? 0 : 1;
|
||||
final de = (sf >= 11 && _defaultLoRaBwHz <= 125000) ? 1 : 0;
|
||||
|
||||
final symbolMs = ((1 << sf) / bw) * 1000.0;
|
||||
final preambleMs = (_defaultLoRaPreambleSymbols + 4.25) * symbolMs;
|
||||
|
||||
final num =
|
||||
(8 * payloadLenBytes) -
|
||||
(4 * sf) +
|
||||
28 +
|
||||
(16 * _defaultLoRaCrcEnabled) -
|
||||
(20 * ih);
|
||||
final den = 4 * (sf - (2 * de));
|
||||
final payloadSymCoeff = den <= 0 ? 0 : (num / den).ceil();
|
||||
final payloadSymbols =
|
||||
8 + (payloadSymCoeff < 0 ? 0 : payloadSymCoeff) * (cr + 4);
|
||||
final payloadMs = payloadSymbols * symbolMs;
|
||||
|
||||
return (preambleMs + payloadMs).ceil();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,8 +46,8 @@ class PingTracker {
|
||||
/// Mark a ping as successful (response received)
|
||||
/// Should be called when telemetry response arrives
|
||||
void markPingSuccessful(Uint8List publicKey) {
|
||||
final String keyHex = _publicKeyToHex(publicKey);
|
||||
final request = _pendingPings.remove(keyHex);
|
||||
final requestKey = _findMatchingPendingPingKey(publicKey);
|
||||
final request = requestKey != null ? _pendingPings.remove(requestKey) : null;
|
||||
|
||||
if (request != null) {
|
||||
request.cancel();
|
||||
@@ -81,6 +81,23 @@ class PingTracker {
|
||||
String _publicKeyToHex(Uint8List publicKey) {
|
||||
return publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
|
||||
}
|
||||
|
||||
String? _findMatchingPendingPingKey(Uint8List responseKey) {
|
||||
final responseHex = _publicKeyToHex(responseKey);
|
||||
|
||||
if (_pendingPings.containsKey(responseHex)) {
|
||||
return responseHex;
|
||||
}
|
||||
|
||||
for (final entry in _pendingPings.entries) {
|
||||
final pendingHex = entry.key;
|
||||
if (pendingHex.startsWith(responseHex) || responseHex.startsWith(pendingHex)) {
|
||||
return pendingHex;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal class to track a single ping request
|
||||
|
||||
81
lib/providers/helpers/raw_session_retransmit.dart
Normal file
@@ -0,0 +1,81 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../models/contact.dart';
|
||||
|
||||
typedef RawPacketSender =
|
||||
Future<void> Function({
|
||||
required Uint8List contactPath,
|
||||
required int contactPathLen,
|
||||
required Uint8List payload,
|
||||
});
|
||||
|
||||
Future<bool> serveCachedSessionFragments<T>({
|
||||
required String providerLabel,
|
||||
required String sessionId,
|
||||
required Contact requester,
|
||||
required List<T> fragments,
|
||||
required int maxDirectPayloadHops,
|
||||
required int Function(T fragment) indexOf,
|
||||
required Uint8List Function(T fragment) encodeBinary,
|
||||
required RawPacketSender? sendRawPacket,
|
||||
Set<int>? requestedIndices,
|
||||
}) async {
|
||||
if (fragments.isEmpty) {
|
||||
debugPrint('⚠️ [$providerLabel] No cached fragments for $sessionId');
|
||||
return false;
|
||||
}
|
||||
if (sendRawPacket == null) {
|
||||
debugPrint('⚠️ [$providerLabel] sendRawPacketCallback not set');
|
||||
return false;
|
||||
}
|
||||
if (!requester.routeHasPath) {
|
||||
debugPrint('⚠️ [$providerLabel] ${requester.advName} has no direct path');
|
||||
return false;
|
||||
}
|
||||
if (requester.routeHopCount > maxDirectPayloadHops) {
|
||||
debugPrint(
|
||||
'⚠️ [$providerLabel] ${requester.advName} is too far: ${requester.routeHopCount} hops (max $maxDirectPayloadHops)',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (requester.outPath.isEmpty) {
|
||||
debugPrint(
|
||||
'⚠️ [$providerLabel] ${requester.advName} has empty outPath payload',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
var servedCount = 0;
|
||||
for (final fragment in fragments) {
|
||||
final index = indexOf(fragment);
|
||||
if (index < 0) {
|
||||
debugPrint('⚠️ [$providerLabel] Invalid fragment index $index');
|
||||
continue;
|
||||
}
|
||||
if (requestedIndices != null && !requestedIndices.contains(index)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await sendRawPacket(
|
||||
contactPath: requester.outPath,
|
||||
contactPathLen: requester.routeEncodedPathLen,
|
||||
payload: encodeBinary(fragment),
|
||||
);
|
||||
servedCount++;
|
||||
} catch (e, st) {
|
||||
debugPrint(
|
||||
'❌ [$providerLabel] Serve error for $sessionId#$index: $e\n$st',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (servedCount == 0) {
|
||||
debugPrint(
|
||||
'⚠️ [$providerLabel] No fragments matched request for $sessionId',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
debugPrint('✅ [$providerLabel] Served $servedCount fragments for $sessionId');
|
||||
return true;
|
||||
}
|
||||
57
lib/providers/helpers/session_metadata_restore.dart
Normal file
@@ -0,0 +1,57 @@
|
||||
import '../../models/message.dart';
|
||||
import '../../utils/image_message_parser.dart';
|
||||
import '../../utils/voice_message_parser.dart';
|
||||
|
||||
class RestoredSessionMetadata {
|
||||
final Map<String, String> voiceSenderKeyBySession;
|
||||
final Map<String, String> imageSenderKeyBySession;
|
||||
final Map<String, ImageEnvelope> imageEnvelopeBySession;
|
||||
|
||||
const RestoredSessionMetadata({
|
||||
required this.voiceSenderKeyBySession,
|
||||
required this.imageSenderKeyBySession,
|
||||
required this.imageEnvelopeBySession,
|
||||
});
|
||||
}
|
||||
|
||||
RestoredSessionMetadata restoreSessionMetadataFromMessages(
|
||||
Iterable<Message> messages,
|
||||
) {
|
||||
final voiceSenderKeyBySession = <String, String>{};
|
||||
final imageSenderKeyBySession = <String, String>{};
|
||||
final imageEnvelopeBySession = <String, ImageEnvelope>{};
|
||||
|
||||
for (final message in messages) {
|
||||
final text = message.text;
|
||||
final voiceEnvelope = VoiceEnvelope.tryParseText(text);
|
||||
if (voiceEnvelope != null) {
|
||||
final senderPrefix = message.senderPublicKeyPrefix;
|
||||
if (senderPrefix != null && senderPrefix.length >= 6) {
|
||||
voiceSenderKeyBySession[voiceEnvelope.sessionId] = senderPrefix
|
||||
.sublist(0, 6)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join()
|
||||
.toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
final imageEnvelope = ImageEnvelope.tryParse(text);
|
||||
if (imageEnvelope != null) {
|
||||
imageEnvelopeBySession[imageEnvelope.sessionId] = imageEnvelope;
|
||||
final senderPrefix = message.senderPublicKeyPrefix;
|
||||
if (senderPrefix != null && senderPrefix.length >= 6) {
|
||||
imageSenderKeyBySession[imageEnvelope.sessionId] = senderPrefix
|
||||
.sublist(0, 6)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join()
|
||||
.toLowerCase();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return RestoredSessionMetadata(
|
||||
voiceSenderKeyBySession: voiceSenderKeyBySession,
|
||||
imageSenderKeyBySession: imageSenderKeyBySession,
|
||||
imageEnvelopeBySession: imageEnvelopeBySession,
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../services/profiles_feature_service.dart';
|
||||
import 'helpers/raw_session_retransmit.dart';
|
||||
import '../utils/image_message_parser.dart';
|
||||
|
||||
/// Reassembly state for one incoming image session.
|
||||
@@ -13,6 +15,8 @@ class ImageSession {
|
||||
final int width;
|
||||
final int height;
|
||||
final List<ImagePacket?> fragments; // indexed by fragment.index
|
||||
DateTime? firstFragmentAt;
|
||||
DateTime? lastFragmentAt;
|
||||
|
||||
ImageSession({
|
||||
required this.sessionId,
|
||||
@@ -25,6 +29,21 @@ class ImageSession {
|
||||
int get receivedCount => fragments.where((f) => f != null).length;
|
||||
bool get isComplete => receivedCount == total;
|
||||
|
||||
Duration? estimateRemaining() {
|
||||
if (isComplete) return Duration.zero;
|
||||
if (firstFragmentAt == null || lastFragmentAt == null) return null;
|
||||
if (receivedCount < 2) return null;
|
||||
|
||||
final elapsedMs = lastFragmentAt!
|
||||
.difference(firstFragmentAt!)
|
||||
.inMilliseconds;
|
||||
if (elapsedMs <= 0) return null;
|
||||
final avgMsPerFragment = elapsedMs / (receivedCount - 1);
|
||||
final remaining = total - receivedCount;
|
||||
if (remaining <= 0) return Duration.zero;
|
||||
return Duration(milliseconds: (avgMsPerFragment * remaining).round());
|
||||
}
|
||||
|
||||
/// Reassemble the complete image bytes, or null if any fragment is missing.
|
||||
Uint8List? get imageBytes => reassembleImage(fragments);
|
||||
}
|
||||
@@ -36,9 +55,11 @@ class ImageSession {
|
||||
class ImageProvider with ChangeNotifier {
|
||||
static const String _storageKey = 'stored_image_sessions_v1';
|
||||
static const Duration _outgoingTtl = Duration(minutes: 15);
|
||||
static const int maxDirectPayloadHops = 3;
|
||||
|
||||
/// Incoming sessions keyed by sessionId.
|
||||
final Map<String, ImageSession> _sessions = {};
|
||||
final Set<String> _ignoredIncomingSessions = {};
|
||||
|
||||
/// Outgoing sessions cached for deferred serving.
|
||||
final Map<String, _OutgoingSession> _outgoing = {};
|
||||
@@ -55,12 +76,18 @@ class ImageProvider with ChangeNotifier {
|
||||
_restore();
|
||||
}
|
||||
|
||||
String _scopedStorageKey() => ProfileStorageScope.scopedKey(_storageKey);
|
||||
|
||||
// ── Accessors ────────────────────────────────────────────────────────────
|
||||
|
||||
ImageSession? session(String sessionId) => _sessions[sessionId];
|
||||
bool isComplete(String sessionId) =>
|
||||
_sessions[sessionId]?.isComplete ?? false;
|
||||
bool hasOutgoing(String sessionId) => _outgoing.containsKey(sessionId);
|
||||
Duration? estimateRemainingTransferTime(String sessionId) =>
|
||||
_sessions[sessionId]?.estimateRemaining();
|
||||
bool isReceiveCanceled(String sessionId) =>
|
||||
_ignoredIncomingSessions.contains(sessionId);
|
||||
|
||||
List<int> missingFragmentIndices(String sessionId) {
|
||||
final session = _sessions[sessionId];
|
||||
@@ -72,28 +99,61 @@ class ImageProvider with ChangeNotifier {
|
||||
return missing;
|
||||
}
|
||||
|
||||
List<int> availableFragmentIndices(String sessionId) {
|
||||
final outgoing = _outgoing[sessionId];
|
||||
if (outgoing != null) {
|
||||
return outgoing.fragments.map((fragment) => fragment.index).toList()
|
||||
..sort();
|
||||
}
|
||||
|
||||
final session = _sessions[sessionId];
|
||||
if (session == null) return const [];
|
||||
final indices = <int>[];
|
||||
for (var i = 0; i < session.fragments.length; i++) {
|
||||
if (session.fragments[i] != null) {
|
||||
indices.add(i);
|
||||
}
|
||||
}
|
||||
return indices;
|
||||
}
|
||||
|
||||
// ── Incoming fragment reception ──────────────────────────────────────────
|
||||
|
||||
/// Add a received [fragment]. Creates the session on first fragment using
|
||||
/// metadata from the fragment itself (requires envelope to have been
|
||||
/// announced first; if not, defaults width/height to 0 — corrected on save).
|
||||
/// Add a received [fragment]. New compact fragments rely on prior envelope
|
||||
/// metadata for total/format, while legacy fragments can still self-describe.
|
||||
///
|
||||
/// Returns true when the session just became complete.
|
||||
bool addFragment(ImagePacket fragment, {int width = 0, int height = 0}) {
|
||||
_sessions.putIfAbsent(
|
||||
fragment.sessionId,
|
||||
() => ImageSession(
|
||||
if (_ignoredIncomingSessions.contains(fragment.sessionId)) {
|
||||
debugPrint(
|
||||
'⏹️ [ImageProvider] Ignoring canceled incoming session ${fragment.sessionId}',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
_sessions.putIfAbsent(fragment.sessionId, () {
|
||||
if (fragment.total < 1) {
|
||||
throw StateError(
|
||||
'Image envelope missing for compact fragment ${fragment.sessionId}',
|
||||
);
|
||||
}
|
||||
return ImageSession(
|
||||
sessionId: fragment.sessionId,
|
||||
format: fragment.format,
|
||||
total: fragment.total,
|
||||
width: width,
|
||||
height: height,
|
||||
),
|
||||
);
|
||||
);
|
||||
});
|
||||
|
||||
final session = _sessions[fragment.sessionId]!;
|
||||
if (fragment.index < session.total) {
|
||||
final wasMissing = session.fragments[fragment.index] == null;
|
||||
session.fragments[fragment.index] = fragment;
|
||||
if (wasMissing) {
|
||||
final now = DateTime.now();
|
||||
session.firstFragmentAt ??= now;
|
||||
session.lastFragmentAt = now;
|
||||
}
|
||||
}
|
||||
|
||||
final justComplete = session.isComplete;
|
||||
@@ -102,22 +162,27 @@ class ImageProvider with ChangeNotifier {
|
||||
return justComplete;
|
||||
}
|
||||
|
||||
void cancelIncomingSession(String sessionId) {
|
||||
_ignoredIncomingSessions.add(sessionId);
|
||||
_sessions.remove(sessionId);
|
||||
unawaited(_persist());
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void resumeIncomingSession(String sessionId) {
|
||||
if (_ignoredIncomingSessions.remove(sessionId)) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Register envelope metadata for a session (called when IE1 is received
|
||||
/// before any binary fragments arrive).
|
||||
void registerEnvelope(ImageEnvelope envelope) {
|
||||
_sessions.putIfAbsent(
|
||||
envelope.sessionId,
|
||||
() => ImageSession(
|
||||
sessionId: envelope.sessionId,
|
||||
format: envelope.format,
|
||||
total: envelope.total,
|
||||
width: envelope.width,
|
||||
height: envelope.height,
|
||||
),
|
||||
);
|
||||
// Update dimensions if we created the session from a fragment (w/h = 0).
|
||||
final session = _sessions[envelope.sessionId]!;
|
||||
if (session.width == 0 || session.height == 0) {
|
||||
if (_ignoredIncomingSessions.contains(envelope.sessionId)) {
|
||||
return;
|
||||
}
|
||||
final existing = _sessions[envelope.sessionId];
|
||||
if (existing == null) {
|
||||
_sessions[envelope.sessionId] = ImageSession(
|
||||
sessionId: envelope.sessionId,
|
||||
format: envelope.format,
|
||||
@@ -125,12 +190,38 @@ class ImageProvider with ChangeNotifier {
|
||||
width: envelope.width,
|
||||
height: envelope.height,
|
||||
);
|
||||
// Copy existing fragments into the new session.
|
||||
final old = _sessions[envelope.sessionId]!;
|
||||
for (var i = 0; i < session.fragments.length && i < old.total; i++) {
|
||||
old.fragments[i] = session.fragments[i];
|
||||
unawaited(_persist());
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
final needsMerge =
|
||||
existing.width == 0 ||
|
||||
existing.height == 0 ||
|
||||
existing.total != envelope.total ||
|
||||
existing.format != envelope.format;
|
||||
if (!needsMerge) {
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
final merged = ImageSession(
|
||||
sessionId: envelope.sessionId,
|
||||
format: envelope.format,
|
||||
total: envelope.total,
|
||||
width: envelope.width,
|
||||
height: envelope.height,
|
||||
);
|
||||
merged.firstFragmentAt = existing.firstFragmentAt;
|
||||
merged.lastFragmentAt = existing.lastFragmentAt;
|
||||
for (final fragment in existing.fragments) {
|
||||
if (fragment == null) continue;
|
||||
if (fragment.index < merged.total) {
|
||||
merged.fragments[fragment.index] = fragment;
|
||||
}
|
||||
}
|
||||
_sessions[envelope.sessionId] = merged;
|
||||
unawaited(_persist());
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -177,56 +268,49 @@ class ImageProvider with ChangeNotifier {
|
||||
required Contact requester,
|
||||
Set<int>? requestedIndices,
|
||||
}) async {
|
||||
final cached = _outgoing[sessionId];
|
||||
if (cached == null) {
|
||||
debugPrint('⚠️ [ImageProvider] No cached session for $sessionId');
|
||||
final outgoing = _outgoing[sessionId];
|
||||
final fragments = outgoing != null
|
||||
? List<ImagePacket>.from(outgoing.fragments)
|
||||
: _sessions[sessionId]?.fragments.whereType<ImagePacket>().toList() ??
|
||||
const <ImagePacket>[];
|
||||
if (fragments.isEmpty) {
|
||||
debugPrint(
|
||||
'⚠️ [ImageProvider] No cached or received session for $sessionId',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (sendRawPacketCallback == null) {
|
||||
debugPrint('⚠️ [ImageProvider] sendRawPacketCallback not set');
|
||||
return false;
|
||||
}
|
||||
if (requester.outPathLen < 0) {
|
||||
debugPrint('⚠️ [ImageProvider] ${requester.advName} has no direct path');
|
||||
return false;
|
||||
}
|
||||
|
||||
for (final fragment in cached.fragments) {
|
||||
if (requestedIndices != null &&
|
||||
!requestedIndices.contains(fragment.index)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await sendRawPacketCallback!(
|
||||
contactPath: requester.outPath,
|
||||
contactPathLen: requester.outPathLen,
|
||||
payload: fragment.encodeBinary(),
|
||||
);
|
||||
} catch (e, st) {
|
||||
debugPrint('❌ [ImageProvider] Serve error for $sessionId: $e\n$st');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
debugPrint(
|
||||
'📷 [ImageProvider] Served ${cached.fragments.length} fragments of $sessionId',
|
||||
return serveCachedSessionFragments<ImagePacket>(
|
||||
providerLabel: 'ImageProvider',
|
||||
sessionId: sessionId,
|
||||
requester: requester,
|
||||
fragments: fragments,
|
||||
maxDirectPayloadHops: maxDirectPayloadHops,
|
||||
indexOf: (fragment) => fragment.index,
|
||||
encodeBinary: (fragment) => fragment.encodeBinary(),
|
||||
sendRawPacket: sendRawPacketCallback,
|
||||
requestedIndices: requestedIndices,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Persistence ──────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> clearAll() async {
|
||||
_sessions.clear();
|
||||
_outgoing.clear();
|
||||
_resetInMemoryState();
|
||||
notifyListeners();
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_storageKey);
|
||||
await prefs.remove(_scopedStorageKey());
|
||||
} catch (e) {
|
||||
debugPrint('❌ [ImageProvider] Failed to clear storage: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> reloadProfileScopedState() async {
|
||||
_resetInMemoryState();
|
||||
await _restore();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _evictExpiredOutgoing() {
|
||||
final now = DateTime.now();
|
||||
_outgoing.removeWhere((_, s) => now.difference(s.cachedAt) > _outgoingTtl);
|
||||
@@ -266,7 +350,7 @@ class ImageProvider with ChangeNotifier {
|
||||
)
|
||||
.toList(),
|
||||
};
|
||||
await prefs.setString(_storageKey, jsonEncode(payload));
|
||||
await prefs.setString(_scopedStorageKey(), jsonEncode(payload));
|
||||
} catch (e) {
|
||||
debugPrint('❌ [ImageProvider] Failed to persist: $e');
|
||||
}
|
||||
@@ -275,7 +359,7 @@ class ImageProvider with ChangeNotifier {
|
||||
Future<void> _restore() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString(_storageKey);
|
||||
final raw = prefs.getString(_scopedStorageKey());
|
||||
if (raw == null || raw.isEmpty) return;
|
||||
|
||||
final parsed = jsonDecode(raw) as Map<String, dynamic>;
|
||||
@@ -351,6 +435,12 @@ class ImageProvider with ChangeNotifier {
|
||||
debugPrint('❌ [ImageProvider] Failed to restore: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _resetInMemoryState() {
|
||||
_sessions.clear();
|
||||
_outgoing.clear();
|
||||
_ignoredIncomingSessions.clear();
|
||||
}
|
||||
}
|
||||
|
||||
class _OutgoingSession {
|
||||
|
||||