Compare commits

...

2 Commits

Author SHA1 Message Date
Janez T
f7b6143a57 chore: bump app version and update Android/iOS build tooling
- Bump version to 2026.0613.2+57
- Gradle wrapper 8.12 -> 8.14; Flutter migrator flags (builtInKotlin/newDsl)
  and relax kotlin.jvm.target.validation to warning for third-party plugins
- Refresh iOS Xcode project, scheme, Info.plist, Podfile.lock and SwiftPM
  Package.resolved

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 13:19:20 +02:00
Janez T
3c08e150ef feat: align fast-GPS beacons with MeshUI firmware (16-byte format + cadence parity)
Switch the fast-GPS beacon to the firmware's canonical 16-byte wire format
(magic, key6, lat/lon microdegrees, speed km/h) — no on-wire timestamp; the
receiver stamps its own RX time. Drops compatibility with the older 19-byte app
format.

Mirror the firmware's adaptive send cadence in the app-fallback path
(LocationTrackingService): anchor + dwell motion hysteresis, EMA ground speed,
9-min stationary keepalive, fix-quality (accuracy) gate, and a post-RX hold-off
to avoid channel collisions. Parked nodes now beacon the stable anchor instead
of GPS wander.

Add the fast_gps_region config: region-scope picker in settings backed by the
firmware's fast_gps_region / fast_gps_regions custom vars.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 13:17:02 +02:00
20 changed files with 703 additions and 285 deletions

View File

@@ -16,6 +16,24 @@ subprojects {
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
// flutter_avif_android 3.1.0 (latest) ships duplicate Java and Kotlin
// copies of FlutterAvifPlugin. Kotlin 2.2+ rejects the redeclaration. The
// plugin's Java source dir contains only this redundant copy, so empty it
// and let the identical Kotlin implementation compile. kotlinc reads the
// module's Java source roots for joint compilation, so clearing srcDirs
// (rather than an exclude filter, which AGP doesn't forward to kotlinc) is
// what actually removes the duplicate from its view. Registered before
// evaluationDependsOn so it also covers :app, which that call evaluates
// eagerly.
afterEvaluate {
if (project.name == "flutter_avif_android") {
extensions.findByName("android")?.let { ext ->
(ext as com.android.build.gradle.BaseExtension)
.sourceSets.getByName("main").java
.setSrcDirs(emptyList<String>())
}
}
}
project.evaluationDependsOn(":app")
}

View File

@@ -1,3 +1,13 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.enableJetifier=true
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false
# Kotlin 2.2+ fails the build when a module's Java and Kotlin compile tasks
# target different JVM versions. Several Flutter plugins still pin mismatched
# targets (e.g. Java 1.8 vs Kotlin 1.8, or Java 11 vs Kotlin 17). On Android
# every class is dexed by D8/R8, so the class-file version mismatch is benign;
# downgrade the strict check to a warning so third-party plugins build.
kotlin.jvm.target.validation.mode=warning

View File

@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip

View File

@@ -19,8 +19,8 @@ pluginManagement {
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.9.1" apply false
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
}
include(":app")

View File

@@ -1,206 +1,51 @@
PODS:
- audioplayers_darwin (0.0.1):
- Flutter
- FlutterMacOS
- codec2_flutter (0.1.0):
- Flutter
- device_info_plus (0.0.1):
- Flutter
- DKImagePickerController/Core (4.3.9):
- DKImagePickerController/ImageDataManager
- DKImagePickerController/Resource
- DKImagePickerController/ImageDataManager (4.3.9)
- DKImagePickerController/PhotoGallery (4.3.9):
- DKImagePickerController/Core
- DKPhotoGallery
- DKImagePickerController/Resource (4.3.9)
- DKPhotoGallery (0.0.19):
- DKPhotoGallery/Core (= 0.0.19)
- DKPhotoGallery/Model (= 0.0.19)
- DKPhotoGallery/Preview (= 0.0.19)
- DKPhotoGallery/Resource (= 0.0.19)
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Core (0.0.19):
- DKPhotoGallery/Model
- DKPhotoGallery/Preview
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Model (0.0.19):
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Preview (0.0.19):
- DKPhotoGallery/Model
- DKPhotoGallery/Resource
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Resource (0.0.19):
- SDWebImage
- SwiftyGif
- file_picker (0.0.1):
- DKImagePickerController/PhotoGallery
- Flutter
- Flutter (1.0.0)
- flutter_avif_ios (0.0.1):
- Flutter
- flutter_background_service_ios (0.0.3):
- Flutter
- flutter_blue_plus_darwin (0.0.2):
- Flutter
- FlutterMacOS
- flutter_compass (0.0.1):
- Flutter
- flutter_local_notifications (0.0.1):
- Flutter
- geolocator_apple (1.2.0):
- Flutter
- FlutterMacOS
- image_picker_ios (0.0.1):
- Flutter
- integration_test (0.0.1):
- Flutter
- nsd_ios (0.0.1):
- Flutter
- 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):
- Flutter
- SDWebImage (5.21.7):
- SDWebImage/Core (= 5.21.7)
- SDWebImage/Core (5.21.7)
- share_plus (0.0.1):
- Flutter
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- sqflite_darwin (0.0.4):
- Flutter
- FlutterMacOS
- SwiftyGif (5.4.5)
- url_launcher_ios (0.0.1):
- Flutter
- vibration (3.0.0):
- Flutter
- wakelock_plus (0.0.1):
- Flutter
DEPENDENCIES:
- audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/darwin`)
- codec2_flutter (from `.symlinks/plugins/codec2_flutter/ios`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- file_picker (from `.symlinks/plugins/file_picker/ios`)
- Flutter (from `Flutter`)
- flutter_avif_ios (from `.symlinks/plugins/flutter_avif_ios/ios`)
- flutter_background_service_ios (from `.symlinks/plugins/flutter_background_service_ios/ios`)
- flutter_blue_plus_darwin (from `.symlinks/plugins/flutter_blue_plus_darwin/darwin`)
- flutter_compass (from `.symlinks/plugins/flutter_compass/ios`)
- 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`)
- 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`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- 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
- SDWebImage
- SwiftyGif
EXTERNAL SOURCES:
audioplayers_darwin:
:path: ".symlinks/plugins/audioplayers_darwin/darwin"
codec2_flutter:
:path: ".symlinks/plugins/codec2_flutter/ios"
device_info_plus:
:path: ".symlinks/plugins/device_info_plus/ios"
file_picker:
:path: ".symlinks/plugins/file_picker/ios"
Flutter:
:path: Flutter
flutter_avif_ios:
:path: ".symlinks/plugins/flutter_avif_ios/ios"
flutter_background_service_ios:
:path: ".symlinks/plugins/flutter_background_service_ios/ios"
flutter_blue_plus_darwin:
:path: ".symlinks/plugins/flutter_blue_plus_darwin/darwin"
flutter_compass:
:path: ".symlinks/plugins/flutter_compass/ios"
flutter_local_notifications:
:path: ".symlinks/plugins/flutter_local_notifications/ios"
geolocator_apple:
: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"
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:
:path: ".symlinks/plugins/record_ios/ios"
share_plus:
:path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
sqflite_darwin:
:path: ".symlinks/plugins/sqflite_darwin/darwin"
url_launcher_ios:
: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: 835ced6edd4c9fc8ebb0a7cc9e294a91d99917d5
codec2_flutter: 15e24fa897d9d903a2afb1cc5a17ae3ac88b6d6f
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_avif_ios: 2553d2aafda56339cf924e943da89ad3f40af2dd
flutter_background_service_ios: 00d31bdff7b4bfe06d32375df358abe0329cf87e
flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3
flutter_compass: b236ab69b61545cce89fd58527f401a7587d5cc1
flutter_local_notifications: 643a3eda1ce1c0599413ca31672536d423dee214
geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e
nsd_ios: 596ad79109ddd3e52d665f650f36428049e3653e
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
record_ios: 412daca2350b228e698fffcd08f1f94ceb1e3844
SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
vibration: ca8104a8875b9c493e15b21b04e456befd0ff6eb
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
PODFILE CHECKSUM: 2d56c9747241a29800bf539d978b210bda037878

View File

@@ -16,6 +16,7 @@
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
B117D6FC7E214B5BDF078335 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5E125ECD1538D6FD469DAD40 /* Pods_RunnerTests.framework */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -65,6 +66,7 @@
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -80,6 +82,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
6E07C83B534F125C2CBE6788 /* Pods_Runner.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -111,6 +114,7 @@
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
@@ -187,6 +191,9 @@
productType = "com.apple.product-type.bundle.unit-test";
};
97C146ED1CF9000F007C117D /* Runner */ = {
packageProductDependencies = (
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
);
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
@@ -213,6 +220,9 @@
/* Begin PBXProject section */
97C146E61CF9000F007C117D /* Project object */ = {
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
);
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
@@ -489,7 +499,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 141;
CURRENT_PROJECT_VERSION = 142;
DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -511,7 +521,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 141;
CURRENT_PROJECT_VERSION = 142;
DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
@@ -530,7 +540,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 141;
CURRENT_PROJECT_VERSION = 142;
DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
@@ -547,7 +557,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 141;
CURRENT_PROJECT_VERSION = 142;
DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
@@ -679,7 +689,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 141;
CURRENT_PROJECT_VERSION = 142;
DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -702,7 +712,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 141;
CURRENT_PROJECT_VERSION = 142;
DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -752,6 +762,18 @@
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
isa = XCSwiftPackageProductDependency;
productName = FlutterGeneratedPluginSwiftPackage;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
}

View File

@@ -0,0 +1,59 @@
{
"pins" : [
{
"identity" : "dkcamera",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKCamera",
"state" : {
"branch" : "master",
"revision" : "5c691d11014b910aff69f960475d70e65d9dcc96"
}
},
{
"identity" : "dkimagepickercontroller",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKImagePickerController",
"state" : {
"branch" : "4.3.9",
"revision" : "0bdfeacefa308545adde07bef86e349186335915"
}
},
{
"identity" : "dkphotogallery",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKPhotoGallery",
"state" : {
"branch" : "master",
"revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d"
}
},
{
"identity" : "sdwebimage",
"kind" : "remoteSourceControl",
"location" : "https://github.com/SDWebImage/SDWebImage",
"state" : {
"revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0",
"version" : "5.21.7"
}
},
{
"identity" : "swiftygif",
"kind" : "remoteSourceControl",
"location" : "https://github.com/kirualex/SwiftyGif.git",
"state" : {
"revision" : "4430cbc148baa3907651d40562d96325426f409a",
"version" : "5.4.5"
}
},
{
"identity" : "tocropviewcontroller",
"kind" : "remoteSourceControl",
"location" : "https://github.com/TimOliver/TOCropViewController",
"state" : {
"revision" : "d4a6d8100f4b886fdbc8ae399bf144ff3e9afb7e",
"version" : "2.8.0"
}
}
],
"version" : 2
}

View File

@@ -5,6 +5,24 @@
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<PreActions>
<ExecutionAction
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
<ActionContent
title = "Run Prepare Flutter Framework Script"
scriptText = "/bin/sh &quot;$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh&quot; prepare&#10;">
<EnvironmentBuildable>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</EnvironmentBuildable>
</ActionContent>
</ExecutionAction>
</PreActions>
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"

View File

@@ -0,0 +1,59 @@
{
"pins" : [
{
"identity" : "dkcamera",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKCamera",
"state" : {
"branch" : "master",
"revision" : "5c691d11014b910aff69f960475d70e65d9dcc96"
}
},
{
"identity" : "dkimagepickercontroller",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKImagePickerController",
"state" : {
"branch" : "4.3.9",
"revision" : "0bdfeacefa308545adde07bef86e349186335915"
}
},
{
"identity" : "dkphotogallery",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKPhotoGallery",
"state" : {
"branch" : "master",
"revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d"
}
},
{
"identity" : "sdwebimage",
"kind" : "remoteSourceControl",
"location" : "https://github.com/SDWebImage/SDWebImage",
"state" : {
"revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0",
"version" : "5.21.7"
}
},
{
"identity" : "swiftygif",
"kind" : "remoteSourceControl",
"location" : "https://github.com/kirualex/SwiftyGif.git",
"state" : {
"revision" : "4430cbc148baa3907651d40562d96325426f409a",
"version" : "5.4.5"
}
},
{
"identity" : "tocropviewcontroller",
"kind" : "remoteSourceControl",
"location" : "https://github.com/TimOliver/TOCropViewController",
"state" : {
"revision" : "d4a6d8100f4b886fdbc8ae399bf144ff3e9afb7e",
"version" : "2.8.0"
}
}
],
"version" : 2
}

View File

@@ -43,7 +43,7 @@
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>141</string>
<string>142</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>ITSAppUsesNonExemptEncryption</key>

View File

@@ -5,12 +5,22 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000199">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000247">
</testcase>
<testcase classname="fastlane.lanes" name="1: upload_to_app_store" time="37.324492">
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.741448">
</testcase>
<testcase classname="fastlane.lanes" name="2: build_app" time="110.585223">
</testcase>
<testcase classname="fastlane.lanes" name="3: upload_to_testflight" time="353.311697">
</testcase>

View File

@@ -147,6 +147,22 @@ class ChannelLocationSharingResult {
});
}
/// Radio fast-GPS region scope, mirroring the firmware's `fast_gps_region`
/// custom var. Index 0 is unscoped (flood everywhere); higher indices restrict
/// beacons to a named region. `labels` comes from the firmware so the picker
/// always matches the regions that build supports.
class FastGpsRegionState {
final List<String> labels;
final int selectedIndex;
const FastGpsRegionState({required this.labels, required this.selectedIndex});
String get selectedLabel =>
(selectedIndex >= 0 && selectedIndex < labels.length)
? labels[selectedIndex]
: (labels.isNotEmpty ? labels.first : 'Unscoped');
}
/// Main App Provider - coordinates all other providers
class AppProvider with ChangeNotifier {
static const int _maxDirectPayloadHops = 3;
@@ -981,8 +997,16 @@ class AppProvider with ChangeNotifier {
);
};
locationTrackingService.onFastLocationUpdate = (position, reason) {
unawaited(_sendFastLocationUpdate(position, reason: reason));
locationTrackingService.onFastLocationUpdate =
(latitude, longitude, speedKmh, reason) {
unawaited(
_sendFastLocationUpdate(
latitude: latitude,
longitude: longitude,
speedKmh: speedKmh,
reason: reason,
),
);
};
debugPrint('✅ [AppProvider] Location tracking service initialized');
@@ -2903,6 +2927,34 @@ class AppProvider with ChangeNotifier {
}
}
/// Reads the radio's fast-GPS region scope, or `null` when the firmware does
/// not expose region scoping (older builds only know `fast_gps_channel`).
Future<FastGpsRegionState?> getFastGpsRegionState() async {
if (!connectionProvider.deviceInfo.isConnected) {
return null;
}
final vars = await connectionProvider.getCustomVars();
final labelsRaw = vars['fast_gps_regions'];
if (labelsRaw == null || labelsRaw.isEmpty) {
return null;
}
final labels = labelsRaw.split(',');
final index = int.tryParse(vars['fast_gps_region'] ?? '0') ?? 0;
return FastGpsRegionState(
labels: labels,
selectedIndex: (index >= 0 && index < labels.length) ? index : 0,
);
}
/// Sets the radio's fast-GPS region scope by index (0 = unscoped).
Future<void> setFastGpsRegion(int index) async {
if (!connectionProvider.deviceInfo.isConnected) {
throw StateError('Connect to a device first');
}
await _setDeviceCustomVarOrThrow('fast_gps_region', index.toString());
notifyListeners();
}
Future<ChannelLocationSharingState> getChannelLocationSharingState(
int channelIdx,
) async {
@@ -3074,8 +3126,10 @@ class AppProvider with ChangeNotifier {
);
}
Future<void> _sendFastLocationUpdate(
dynamic position, {
Future<void> _sendFastLocationUpdate({
required double latitude,
required double longitude,
required int speedKmh,
required String reason,
}) async {
if (!connectionProvider.deviceInfo.isConnected) {
@@ -3108,20 +3162,21 @@ class AppProvider with ChangeNotifier {
.sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
final clampedSpeedKmh = speedKmh.clamp(0, 255);
final packet = FastGpsPacket(
senderKey6: senderKey6,
latitude: position.latitude as double,
longitude: position.longitude as double,
timestampSeconds: DateTime.now().millisecondsSinceEpoch ~/ 1000,
latitude: latitude,
longitude: longitude,
speedKmh: clampedSpeedKmh,
);
debugPrint(
'📤 [AppProvider] Fast GPS send '
'reason=$reason '
'sender=$senderKey6 '
'channel=$channelIdx '
'lat=${position.latitude} '
'lon=${position.longitude} '
'ts=${packet.timestampSeconds}',
'lat=$latitude '
'lon=$longitude '
'speed=${clampedSpeedKmh}km/h',
);
try {
await connectionProvider.sendChannelData(
@@ -3131,7 +3186,7 @@ class AppProvider with ChangeNotifier {
);
debugPrint(
'✅ [AppProvider] Fast GPS sent '
'sender=$senderKey6 channel=$channelIdx ts=${packet.timestampSeconds}',
'sender=$senderKey6 channel=$channelIdx speed=${speedKmh}km/h',
);
} catch (e) {
debugPrint('⚠️ [AppProvider] Fast GPS send failed: $e');
@@ -3151,7 +3206,17 @@ class AppProvider with ChangeNotifier {
return false;
}
await _sendFastLocationUpdate(position, reason: 'test');
// Geolocator reports ground speed in m/s; the beacon carries km/h (0..255).
final speedMs = position.speed;
final speedKmh = (speedMs.isFinite && speedMs > 0)
? (speedMs * 3.6).round().clamp(0, 255)
: 0;
await _sendFastLocationUpdate(
latitude: position.latitude,
longitude: position.longitude,
speedKmh: speedKmh,
reason: 'test',
);
return true;
}
@@ -3693,6 +3758,10 @@ class AppProvider with ChangeNotifier {
return false;
}
// Heard a peer beacon — yield the channel briefly before our own send, to
// avoid collisions (mirrors the firmware's post-RX hold-off).
locationTrackingService.noteFastGpsBeaconHeard();
final sender = _resolveContactByPrefixHex(fastGpsPacket.senderKey6);
if (sender != null) {
contactsProvider.updateFastGps(

View File

@@ -1164,6 +1164,10 @@ class ContactsProvider with ChangeNotifier {
return;
}
// The fast-GPS beacon carries no timestamp — stamp our own receive time.
final receivedAt = DateTime.now();
final receivedAtSeconds = receivedAt.millisecondsSinceEpoch ~/ 1000;
final updatedTelemetry = _mergeTelemetryForContact(
existingTelemetry: contact.telemetry,
incomingTelemetry: ContactTelemetry(
@@ -1171,9 +1175,7 @@ class ContactsProvider with ChangeNotifier {
batteryPercentage: null,
batteryMilliVolts: null,
temperature: null,
timestamp: DateTime.fromMillisecondsSinceEpoch(
packet.timestampSeconds * 1000,
),
timestamp: receivedAt,
humidity: null,
pressure: null,
extraSensorData: null,
@@ -1187,13 +1189,13 @@ class ContactsProvider with ChangeNotifier {
'contactKey=${contact.publicKeyHex} '
'lat=${packet.latitude} '
'lon=${packet.longitude} '
'ts=${packet.timestampSeconds}',
'speed=${packet.speedKmh}km/h',
);
final updatedContact = contact.copyWith(
telemetry: updatedTelemetry,
lastAdvert: packet.timestampSeconds,
lastMod: packet.timestampSeconds,
lastAdvert: receivedAtSeconds,
lastMod: receivedAtSeconds,
advLat: _coordinateToAdvertMicrodegrees(packet.latitude),
advLon: _coordinateToAdvertMicrodegrees(packet.longitude),
);

View File

@@ -85,6 +85,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
double _fastLocationMovementThresholdMeters = 10.0;
int _fastLocationActiveCadenceSeconds = 10;
int? _fastLocationChannelIdx;
FastGpsRegionState? _fastGpsRegionState;
bool _rotateMapWithHeading = false;
bool _showMapDebugInfo = false;
bool _openMapInFullscreen = false;
@@ -410,6 +411,58 @@ class _SettingsScreenState extends State<SettingsScreen> {
_locationService.fastLocationActiveCadenceSeconds;
_fastLocationChannelIdx = _locationService.fastLocationChannelIdx;
});
await _loadFastGpsRegionState();
}
Future<void> _loadFastGpsRegionState() async {
final appProvider = context.read<AppProvider>();
FastGpsRegionState? regionState;
try {
regionState = await appProvider.getFastGpsRegionState();
} catch (_) {
regionState = null;
}
if (!mounted) return;
setState(() => _fastGpsRegionState = regionState);
}
Future<void> _editFastGpsRegion() async {
final regionState = _fastGpsRegionState;
if (regionState == null) return;
final selected = await showModalBottomSheet<int>(
context: context,
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (var i = 0; i < regionState.labels.length; i++)
ListTile(
leading: Icon(i == 0 ? Icons.public : Icons.travel_explore),
title: Text(regionState.labels[i]),
trailing: regionState.selectedIndex == i
? const Icon(Icons.check)
: null,
onTap: () => Navigator.pop(sheetContext, i),
),
],
),
),
);
if (selected == null || selected == regionState.selectedIndex) return;
try {
await context.read<AppProvider>().setFastGpsRegion(selected);
await _loadFastGpsRegionState();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to set region: $e'),
backgroundColor: Colors.orange,
),
);
}
}
Future<void> _setFastLocationUpdatesEnabled(bool enabled) async {
@@ -1766,6 +1819,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
trailing: const Icon(Icons.chevron_right),
onTap: _editFastLocationChannel,
),
if (_fastGpsRegionState != null)
ListTile(
dense: true,
leading: const Icon(Icons.travel_explore, size: 20),
title: const Text('Region scope'),
subtitle: Text(_fastGpsRegionState!.selectedLabel),
trailing: const Icon(Icons.chevron_right),
onTap: _editFastGpsRegion,
),
ListTile(
dense: true,
leading: const Icon(Icons.send, size: 20),

View File

@@ -25,15 +25,43 @@ class LocationTrackingService {
static const int _defaultFastLocationActiveCadenceSeconds = 60;
static const int _minFastLocationActiveCadenceSeconds = 60;
static const int _maxFastLocationActiveCadenceSeconds = 60;
static const Duration _fastLocationSlowInterval = Duration(
seconds: 60,
);
// Moving cadence tiers — identical to the MeshUI firmware
// (FAST_GPS_*_INTERVAL_MS / FAST_GPS_SPEED_*_MAX_MPS in MyMesh.cpp).
static const Duration _fastLocationWalkingInterval = Duration(seconds: 30);
static const Duration _fastLocationFastInterval = Duration(seconds: 15);
static const Duration _fastLocationVeryFastInterval = Duration(seconds: 5);
static const double _fastLocationIdleSpeedMaxMetersPerSecond = 0.75;
static const double _fastLocationWalkingSpeedMaxMetersPerSecond = 1.8;
static const double _fastLocationFastSpeedMaxMetersPerSecond = 4.0;
// Stationary keepalive — a parked node still beacons, but only every 9 min
// (FAST_GPS_STATIONARY_*_INTERVAL_MS), not on GPS jitter.
static const Duration _fastLocationStationaryInterval = Duration(minutes: 9);
// Moving/stationary detection: anchor + dwell hysteresis (position-only, no
// Doppler), mirroring FAST_GPS_MOVE_* in MyMesh.cpp. A parked node holds an
// anchor; jitter inside the radius is absorbed and the stable anchor is
// reported, so multipath wander can't paint a fake track.
static const double _fastLocationMoveRadiusMeters = 25.0;
static const Duration _fastLocationMoveDwell = Duration(seconds: 15);
static const Duration _fastLocationStopDwell = Duration(seconds: 60);
static const Duration _fastLocationMoveEval = Duration(seconds: 1);
static const double _fastLocationSpeedGateMeters = 5.0;
static const Duration _fastLocationSpeedRebaseline = Duration(seconds: 8);
static const double _fastLocationSpeedGlitchMaxKmh = 300.0;
// Fix-quality gate. The firmware requires >= 5 satellites (FAST_GPS_MIN_SATS);
// Geolocator does not expose a satellite count on all platforms, so we gate on
// horizontal accuracy as the closest available proxy.
static const double _fastLocationMaxAccuracyMeters = 50.0;
// Yield ~5s after hearing a peer beacon, to avoid channel collisions
// (FAST_GPS_CHANNEL_RX_HOLDOFF_MS).
static const Duration _fastLocationRxHoldoff = Duration(seconds: 5);
// Background keepalive tick when not in active use — cheap, just enough to fire
// the 9-min stationary beacon while parked.
static const Duration _fastLocationActiveTick = Duration(seconds: 15);
// ============================================================================
// Singleton Pattern
// ============================================================================
@@ -128,7 +156,22 @@ class LocationTrackingService {
Timer? _fastLocationTimer;
bool _isFastLocationActiveUse = false;
DateTime? _lastFastLocationSentAt;
Position? _lastFastLocationSentPosition;
int? _lastFastLocationSentLatE6;
int? _lastFastLocationSentLonE6;
// Firmware-mirrored fast-GPS motion state (app-fallback path). See
// MyMesh::updateGpsStatusCache / maybeSendFastGpsUpdate.
int? _fastGpsAnchorLatE6;
int? _fastGpsAnchorLonE6;
bool _fastGpsRefValid = false;
bool _fastGpsIsMoving = false;
DateTime? _fastGpsMoveStateSince;
DateTime? _fastGpsMoveEvalAt;
double _fastGpsSpeedKmh = 0.0;
int? _fastGpsSpeedPrevLatE6;
int? _fastGpsSpeedPrevLonE6;
DateTime? _fastGpsSpeedPrevAt;
DateTime? _fastGpsRxHoldoffUntil;
// ============================================================================
// Callback Properties
@@ -146,8 +189,11 @@ class LocationTrackingService {
/// Called when tracking state changes
void Function(bool isTracking)? onTrackingStateChanged;
/// Called when a fast private GPS update should be sent
void Function(Position position, String reason)? onFastLocationUpdate;
/// Called when a fast private GPS update should be sent. Carries the resolved
/// beacon coordinates (de-jittered anchor when parked) and EMA ground speed in
/// km/h, so the caller just encodes and transmits.
void Function(double latitude, double longitude, int speedKmh, String reason)?
onFastLocationUpdate;
// ============================================================================
// Initialization
@@ -418,6 +464,7 @@ class LocationTrackingService {
isTracking = false;
onTrackingStateChanged?.call(false);
_refreshFastLocationTimer();
_resetFastGpsMotionState();
// Reset first position flag so next connection starts fresh
_firstPositionSet = false;
@@ -533,6 +580,9 @@ class LocationTrackingService {
Future<void> setFastLocationUpdatesEnabled(bool enabled) async {
fastLocationUpdatesEnabled = enabled;
if (!enabled) {
_resetFastGpsMotionState();
}
await saveSettings();
_refreshFastLocationTimer();
}
@@ -560,23 +610,35 @@ class LocationTrackingService {
_refreshFastLocationTimer();
}
void _evaluateFastLocationMovement(Position position) {
if (!fastLocationUpdatesEnabled || fastLocationChannelIdx == null) return;
final previous = _lastFastLocationSentPosition;
if (previous == null) {
_emitFastLocationUpdate(position, reason: 'initial');
return;
/// Arms the post-RX send hold-off after hearing a peer's fast-GPS beacon, so
/// the phone yields the channel briefly to avoid collisions
/// (FAST_GPS_CHANNEL_RX_HOLDOFF_MS). Called by the app's incoming-beacon path.
void noteFastGpsBeaconHeard() {
_fastGpsRxHoldoffUntil = DateTime.now().add(_fastLocationRxHoldoff);
}
final distance = Geolocator.distanceBetween(
previous.latitude,
previous.longitude,
position.latitude,
position.longitude,
);
if (distance >= fastLocationMovementThresholdMeters) {
_emitFastLocationUpdate(position, reason: 'movement');
/// Clears all fast-GPS motion/anchor state. Mirrors resetFastGpsShareState.
void _resetFastGpsMotionState() {
_lastFastLocationSentAt = null;
_lastFastLocationSentLatE6 = null;
_lastFastLocationSentLonE6 = null;
_fastGpsAnchorLatE6 = null;
_fastGpsAnchorLonE6 = null;
_fastGpsRefValid = false;
_fastGpsIsMoving = false;
_fastGpsMoveStateSince = null;
_fastGpsMoveEvalAt = null;
_fastGpsSpeedKmh = 0.0;
_fastGpsSpeedPrevLatE6 = null;
_fastGpsSpeedPrevLonE6 = null;
_fastGpsSpeedPrevAt = null;
_fastGpsRxHoldoffUntil = null;
}
void _evaluateFastLocationMovement(Position position) {
if (!fastLocationUpdatesEnabled || fastLocationChannelIdx == null) return;
_updateFastGpsMotionState(position);
_maybeSendFastLocation();
}
void _refreshFastLocationTimer() {
@@ -584,75 +646,213 @@ class LocationTrackingService {
_fastLocationTimer = null;
if (!isTracking ||
!fastLocationUpdatesEnabled ||
fastLocationChannelIdx == null ||
!_isFastLocationActiveUse) {
fastLocationChannelIdx == null) {
return;
}
_fastLocationTimer = Timer.periodic(
Duration(seconds: fastLocationActiveCadenceSeconds),
(_) {
final position = currentPosition;
if (position == null) return;
_emitFastLocationUpdate(position, reason: 'active_use');
},
);
// Always tick (even when parked) so the stationary keepalive fires without a
// position-stream event. Tick fast while in active use; otherwise just often
// enough to drive the 9-min keepalive cheaply.
final period = _isFastLocationActiveUse
? _fastLocationActiveTick
: _fastLocationStationaryInterval;
_fastLocationTimer = Timer.periodic(period, (_) => _maybeSendFastLocation());
}
void _emitFastLocationUpdate(Position position, {required String reason}) {
if (!fastLocationUpdatesEnabled || fastLocationChannelIdx == null) return;
/// Updates the EMA ground speed and the moving/stationary anchor state machine
/// from a fresh fix. Faithful port of MyMesh::updateGpsStatusCache.
void _updateFastGpsMotionState(Position position) {
// Fix-quality gate (firmware: satellitesCount >= FAST_GPS_MIN_SATS).
final accuracy = position.accuracy;
if (accuracy.isFinite &&
accuracy > 0 &&
accuracy > _fastLocationMaxAccuracyMeters) {
return;
}
final now = DateTime.now();
final previous = _lastFastLocationSentPosition;
final previousTime = _lastFastLocationSentAt;
if (previous != null && previousTime != null) {
final distance = Geolocator.distanceBetween(
previous.latitude,
previous.longitude,
position.latitude,
position.longitude,
final latE6 = (position.latitude * 1e6).round();
final lonE6 = (position.longitude * 1e6).round();
// Ground speed from successive fixes, EMA-smoothed (0.4 prev / 0.6 inst).
if (_fastGpsSpeedPrevLatE6 == null || _fastGpsSpeedPrevAt == null) {
_fastGpsSpeedPrevLatE6 = latE6;
_fastGpsSpeedPrevLonE6 = lonE6;
_fastGpsSpeedPrevAt = now;
} else {
final distM = _distanceE6(
_fastGpsSpeedPrevLatE6!,
_fastGpsSpeedPrevLonE6!,
latE6,
lonE6,
);
final elapsedMs = now.difference(previousTime).inMilliseconds;
final minimumInterval = _fastLocationMinimumIntervalFor(
distanceMeters: distance,
elapsedMs: elapsedMs,
);
if (elapsedMs < minimumInterval.inMilliseconds) {
final dtMs = now.difference(_fastGpsSpeedPrevAt!).inMilliseconds;
if (distM >= _fastLocationSpeedGateMeters && dtMs >= 1000) {
final instKmh = (distM / 1000.0) / (dtMs / 3600000.0);
if (instKmh <= _fastLocationSpeedGlitchMaxKmh) {
_fastGpsSpeedKmh = _fastGpsSpeedKmh * 0.4 + instKmh * 0.6;
}
_fastGpsSpeedPrevLatE6 = latE6;
_fastGpsSpeedPrevLonE6 = lonE6;
_fastGpsSpeedPrevAt = now;
} else if (dtMs >= _fastLocationSpeedRebaseline.inMilliseconds) {
_fastGpsSpeedKmh = 0.0;
_fastGpsSpeedPrevLatE6 = latE6;
_fastGpsSpeedPrevLonE6 = lonE6;
_fastGpsSpeedPrevAt = now;
}
}
// Moving/stationary state machine (anchor + dwell hysteresis), ~1 Hz.
final evalAt = _fastGpsMoveEvalAt;
if (evalAt != null && now.difference(evalAt) < _fastLocationMoveEval) {
return;
}
if (distance < 1.0 && elapsedMs < 3000) {
return;
}
} else if (previousTime != null &&
now.difference(previousTime) < _fastLocationSlowInterval) {
_fastGpsMoveEvalAt = now;
if (!_fastGpsRefValid) {
_fastGpsAnchorLatE6 = latE6;
_fastGpsAnchorLonE6 = lonE6;
_fastGpsRefValid = true;
_fastGpsMoveStateSince = null;
_fastGpsIsMoving = false;
return;
}
_lastFastLocationSentPosition = position;
final refDist = _distanceE6(
_fastGpsAnchorLatE6!,
_fastGpsAnchorLonE6!,
latE6,
lonE6,
);
if (!_fastGpsIsMoving) {
if (refDist > _fastLocationMoveRadiusMeters) {
if (_fastGpsMoveStateSince == null) {
_fastGpsMoveStateSince = now;
} else if (now.difference(_fastGpsMoveStateSince!) >=
_fastLocationMoveDwell) {
_fastGpsIsMoving = true;
_fastGpsAnchorLatE6 = latE6;
_fastGpsAnchorLonE6 = lonE6;
_fastGpsMoveStateSince = null;
}
} else {
_fastGpsMoveStateSince = null;
// Track slow GPS bias so cumulative drift never reaches the radius.
_fastGpsAnchorLatE6 =
_fastGpsAnchorLatE6! + ((latE6 - _fastGpsAnchorLatE6!) ~/ 8);
_fastGpsAnchorLonE6 =
_fastGpsAnchorLonE6! + ((lonE6 - _fastGpsAnchorLonE6!) ~/ 8);
}
} else {
if (refDist > _fastLocationMoveRadiusMeters) {
_fastGpsAnchorLatE6 = latE6;
_fastGpsAnchorLonE6 = lonE6;
_fastGpsMoveStateSince = null;
} else if (_fastGpsMoveStateSince == null) {
_fastGpsMoveStateSince = now;
} else if (now.difference(_fastGpsMoveStateSince!) >=
_fastLocationStopDwell) {
_fastGpsIsMoving = false;
_fastGpsAnchorLatE6 = latE6;
_fastGpsAnchorLonE6 = lonE6;
_fastGpsMoveStateSince = null;
}
}
}
/// Decides whether to emit a beacon now, mirroring maybeSendFastGpsUpdate:
/// moving → speed-tier cadence once past the movement threshold; parked →
/// flat 9-min keepalive of the stable anchor. Honours the RX hold-off.
void _maybeSendFastLocation() {
if (!fastLocationUpdatesEnabled || fastLocationChannelIdx == null) return;
final position = currentPosition;
if (position == null) return;
// No valid anchor yet means no good fix has landed — don't beacon (the
// firmware likewise bails until it has a usable fix).
if (!_fastGpsRefValid) return;
final int reportLatE6;
final int reportLonE6;
if (_fastGpsIsMoving) {
reportLatE6 = (position.latitude * 1e6).round();
reportLonE6 = (position.longitude * 1e6).round();
} else {
// Parked: report the stable anchor, not the wander.
reportLatE6 = _fastGpsAnchorLatE6!;
reportLonE6 = _fastGpsAnchorLonE6!;
}
final now = DateTime.now();
final lastAt = _lastFastLocationSentAt;
bool shouldSend = lastAt == null || _lastFastLocationSentLatE6 == null;
String reason = 'initial';
if (!shouldSend) {
if (_fastGpsIsMoving) {
final distM = _distanceE6(
_lastFastLocationSentLatE6!,
_lastFastLocationSentLonE6!,
reportLatE6,
reportLonE6,
);
if (distM > fastLocationMovementThresholdMeters) {
final elapsed = now.difference(lastAt!);
final interval = _movingIntervalForSpeedKmh(_fastGpsSpeedKmh);
shouldSend = elapsed >= interval;
reason = 'movement';
}
} else if (now.difference(lastAt!) >= _fastLocationStationaryInterval) {
shouldSend = true;
reason = 'stationary';
}
}
if (!shouldSend) return;
// Yield the channel briefly after hearing a peer beacon.
final holdoff = _fastGpsRxHoldoffUntil;
if (holdoff != null) {
if (now.isBefore(holdoff)) return;
_fastGpsRxHoldoffUntil = null;
}
_lastFastLocationSentLatE6 = reportLatE6;
_lastFastLocationSentLonE6 = reportLonE6;
_lastFastLocationSentAt = now;
onFastLocationUpdate?.call(position, reason);
final speedKmh = _fastGpsSpeedKmh < 0
? 0
: (_fastGpsSpeedKmh > 255.0 ? 255 : (_fastGpsSpeedKmh + 0.5).floor());
onFastLocationUpdate?.call(
reportLatE6 / 1e6,
reportLonE6 / 1e6,
speedKmh,
reason,
);
}
Duration _fastLocationMinimumIntervalFor({
required double distanceMeters,
required int elapsedMs,
}) {
if (elapsedMs <= 0) {
return _fastLocationSlowInterval;
Duration _movingIntervalForSpeedKmh(double speedKmh) {
final speedMps = speedKmh / 3.6;
if (speedMps < _fastLocationIdleSpeedMaxMetersPerSecond) {
return _fastLocationStationaryInterval;
}
final speedMetersPerSecond = distanceMeters / (elapsedMs / 1000.0);
if (speedMetersPerSecond < _fastLocationIdleSpeedMaxMetersPerSecond) {
return _fastLocationSlowInterval;
}
if (speedMetersPerSecond < _fastLocationWalkingSpeedMaxMetersPerSecond) {
if (speedMps < _fastLocationWalkingSpeedMaxMetersPerSecond) {
return _fastLocationWalkingInterval;
}
if (speedMetersPerSecond < _fastLocationFastSpeedMaxMetersPerSecond) {
if (speedMps < _fastLocationFastSpeedMaxMetersPerSecond) {
return _fastLocationFastInterval;
}
return _fastLocationVeryFastInterval;
}
double _distanceE6(int latA, int lonA, int latB, int lonB) {
return Geolocator.distanceBetween(
latA / 1e6,
lonA / 1e6,
latB / 1e6,
lonB / 1e6,
);
}
// ============================================================================
// Mesh Network Broadcasting
// ============================================================================

View File

@@ -1,8 +1,23 @@
import 'dart:typed_data';
/// Fast-GPS position beacon, wire-compatible with the MeshUI firmware
/// (`maybeSendFastGpsUpdate` in MyMesh.cpp).
///
/// 16-byte binary payload sent as a `DATA_TYPE_DEV` group datagram on a
/// non-public channel:
///
/// ```
/// [0] magic 0x47 ('G')
/// [1..6] sender public-key prefix (6 bytes)
/// [7..10] latitude in microdegrees (int32, little-endian)
/// [11..14] longitude in microdegrees (int32, little-endian)
/// [15] ground speed in km/h (uint8, clamped 0..255)
/// ```
///
/// The beacon carries no timestamp — receivers stamp their own RX time.
class FastGpsPacket {
static const int magic = 0x47; // 'G'
static const int _payloadLength = 19;
static const int _payloadLength = 16;
// Store coordinates in microdegrees. This preserves sub-meter precision,
// which comfortably satisfies the meter-accuracy requirement.
static const double coordinateScale = 1e6;
@@ -10,13 +25,15 @@ class FastGpsPacket {
final String senderKey6;
final double latitude;
final double longitude;
final int timestampSeconds;
/// Ground speed in km/h (0..255), as carried in the beacon's trailing byte.
final int speedKmh;
const FastGpsPacket({
required this.senderKey6,
required this.latitude,
required this.longitude,
required this.timestampSeconds,
this.speedKmh = 0,
});
static bool isFastGpsBinary(Uint8List payload) =>
@@ -32,7 +49,7 @@ class FastGpsPacket {
final data = ByteData.sublistView(payload);
final latitude = data.getInt32(7, Endian.little) / coordinateScale;
final longitude = data.getInt32(11, Endian.little) / coordinateScale;
final timestampSeconds = data.getUint32(15, Endian.little);
final speedKmh = data.getUint8(15);
if (!_isValidCoordinate(latitude, longitude)) {
return null;
@@ -42,7 +59,7 @@ class FastGpsPacket {
senderKey6: key6,
latitude: latitude,
longitude: longitude,
timestampSeconds: timestampSeconds,
speedKmh: speedKmh,
);
}
@@ -55,7 +72,7 @@ class FastGpsPacket {
}
data.setInt32(7, (latitude * coordinateScale).round(), Endian.little);
data.setInt32(11, (longitude * coordinateScale).round(), Endian.little);
data.setUint32(15, timestampSeconds, Endian.little);
data.setUint8(15, speedKmh.clamp(0, 255));
return out;
}

View File

@@ -883,10 +883,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev"
source: hosted
version: "1.17.0"
version: "1.18.0"
mgrs_dart:
dependency: transitive
description:
@@ -1456,10 +1456,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev"
source: hosted
version: "0.7.10"
version: "0.7.11"
timezone:
dependency: "direct main"
description:

View File

@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 2026.0420.1+55
version: 2026.0613.2+57
environment:
sdk: ^3.9.2

View File

@@ -762,10 +762,11 @@ void main() {
senderKey6: '323334353637',
latitude: 44.123456,
longitude: 13.654321,
timestampSeconds: 1700001234,
speedKmh: 12,
),
);
final nowSeconds = DateTime.now().millisecondsSinceEpoch ~/ 1000;
final updated = provider.findContactByKey(publicKey)!;
expect(updated.telemetry, isNotNull);
expect(updated.telemetry!.gpsLocation, isNotNull);
@@ -780,8 +781,9 @@ void main() {
expect(updated.telemetry!.batteryMilliVolts, isNotNull);
expect(updated.advLat, equals((44.123456 * 1e6).round()));
expect(updated.advLon, equals((13.654321 * 1e6).round()));
expect(updated.lastAdvert, equals(1700001234));
expect(updated.lastMod, equals(1700001234));
// The beacon carries no timestamp; the receiver stamps its own RX time.
expect(updated.lastAdvert, closeTo(nowSeconds, 5));
expect(updated.lastMod, closeTo(nowSeconds, 5));
expect(
provider.estimatedLocationFor(updated.publicKeyHex),
isNotNull,
@@ -804,7 +806,7 @@ void main() {
senderKey6: '010203040506',
latitude: 10,
longitude: 20,
timestampSeconds: 99,
speedKmh: 0,
),
);
final after = provider.findContactByKey(publicKey)!;

View File

@@ -6,22 +6,38 @@ import 'package:meshcore_sar_app/utils/fast_gps_packet.dart';
void main() {
group('FastGpsPacket', () {
test('encodes and parses a valid packet', () {
test('encodes and parses a valid 16-byte packet', () {
final packet = FastGpsPacket(
senderKey6: 'aabbccddeeff',
latitude: 46.0569,
longitude: 14.5058,
timestampSeconds: 1700000000,
speedKmh: 37,
);
final encoded = packet.encodeBinary();
expect(encoded.length, equals(16));
expect(encoded[0], equals(FastGpsPacket.magic));
final parsed = FastGpsPacket.tryParseBinary(encoded);
expect(parsed, isNotNull);
expect(parsed!.senderKey6, equals('aabbccddeeff'));
expect(parsed.latitude, closeTo(46.0569, 0.000001));
expect(parsed.longitude, closeTo(14.5058, 0.000001));
expect(parsed.timestampSeconds, equals(1700000000));
expect(parsed.speedKmh, equals(37));
});
test('clamps speed to a single unsigned byte', () {
final packet = FastGpsPacket(
senderKey6: '001122334455',
latitude: 1.0,
longitude: 2.0,
speedKmh: 999,
);
final parsed = FastGpsPacket.tryParseBinary(packet.encodeBinary());
expect(parsed, isNotNull);
expect(parsed!.speedKmh, equals(255));
});
test('supports negative coordinates', () {
@@ -29,7 +45,7 @@ void main() {
senderKey6: '001122334455',
latitude: -33.8688,
longitude: -151.2093,
timestampSeconds: 42,
speedKmh: 0,
);
final parsed = FastGpsPacket.tryParseBinary(packet.encodeBinary());
@@ -39,20 +55,29 @@ void main() {
});
test('rejects malformed payloads', () {
// Too short.
expect(
FastGpsPacket.tryParseBinary(Uint8List.fromList([0x47, 0x01])),
isNull,
);
// Wrong magic at the correct length.
expect(
FastGpsPacket.tryParseBinary(
Uint8List.fromList(List<int>.filled(19, 0)..[0] = 0x48),
Uint8List.fromList(List<int>.filled(16, 0)..[0] = 0x48),
),
isNull,
);
// Legacy 19-byte format is no longer accepted.
expect(
FastGpsPacket.tryParseBinary(
Uint8List.fromList(List<int>.filled(19, 0)..[0] = 0x47),
),
isNull,
);
});
test('rejects invalid coordinate ranges', () {
final payload = Uint8List(19);
final payload = Uint8List(16);
payload[0] = FastGpsPacket.magic;
payload.setRange(1, 7, [0, 1, 2, 3, 4, 5]);
final data = ByteData.sublistView(payload);
@@ -66,7 +91,7 @@ void main() {
(14.5 * FastGpsPacket.coordinateScale).round(),
Endian.little,
);
data.setUint32(15, 1, Endian.little);
data.setUint8(15, 1);
expect(FastGpsPacket.tryParseBinary(payload), isNull);
});
@@ -78,7 +103,7 @@ void main() {
senderKey6: 'aabbccddeeff',
latitude: latitude,
longitude: longitude,
timestampSeconds: 1700000000,
speedKmh: 5,
);
final parsed = FastGpsPacket.tryParseBinary(packet.encodeBinary());