From 25a34aab0bdbe9cf8e9cdf219e55f444acfc62a9 Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Mon, 9 Feb 2026 15:43:24 -0700 Subject: [PATCH 01/18] chore(deps): upgrade minor/patch dependencies within existing constraints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run `flutter pub upgrade` to pull in 42 dependency updates within existing ^constraints. No pubspec.yaml changes needed. Notable updates: flutter_map 8.2.1→8.2.2, flutter_svg 2.2.0→2.2.3, http 1.5.0-beta.2→1.6.0, provider 6.1.5→6.1.5+1, shared_preferences 2.5.3→2.5.4, uuid 4.5.1→4.5.2, xml 6.5.0→6.6.1, flutter_native_splash 2.4.6→2.4.7, plus many transitive deps. Closes #78 Co-Authored-By: Claude Opus 4.6 --- pubspec.lock | 204 +++++++++++++++++++++++++++++++-------------------- 1 file changed, 126 insertions(+), 78 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 27e167b0..df71cfc9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -105,6 +105,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" collection: dependency: "direct main" description: @@ -117,10 +125,10 @@ packages: dependency: transitive description: name: crypto - sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf url: "https://pub.dev" source: hosted - version: "3.0.6" + version: "3.0.7" csslib: dependency: transitive description: @@ -157,10 +165,10 @@ packages: dependency: transitive description: name: dbus - sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" + sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.12" desktop_webview_window: dependency: transitive description: @@ -181,10 +189,10 @@ packages: dependency: transitive description: name: ffi - sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + sha256: d07d37192dbf97461359c1518788f203b0c9102cfd2c35a716b823741219542c url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.1.5" file: dependency: transitive description: @@ -258,10 +266,10 @@ packages: dependency: "direct main" description: name: flutter_map - sha256: df33e784b09fae857c6261a5521dd42bd4d3342cb6200884bb70730638af5fd5 + sha256: "391e7dc95cc3f5190748210a69d4cfeb5d8f84dcdfa9c3235d0a9d7742ccb3f8" url: "https://pub.dev" source: hosted - version: "8.2.1" + version: "8.2.2" flutter_map_animations: dependency: "direct main" description: @@ -274,10 +282,10 @@ packages: dependency: "direct dev" description: name: flutter_native_splash - sha256: "8321a6d11a8d13977fa780c89de8d257cce3d841eecfb7a4cadffcc4f12d82dc" + sha256: "4fb9f4113350d3a80841ce05ebf1976a36de622af7d19aca0ca9a9911c7ff002" url: "https://pub.dev" source: hosted - version: "2.4.6" + version: "2.4.7" flutter_secure_storage: dependency: "direct main" description: @@ -290,10 +298,10 @@ packages: dependency: transitive description: name: flutter_secure_storage_darwin - sha256: f226f2a572bed96bc6542198ebaec227150786e34311d455a7e2d3d06d951845 + sha256: "81ef5abfb9cbeb78110d8043ba29f0b36cd7ffa989baa1b2d9482542b2200051" url: "https://pub.dev" source: hosted - version: "0.1.0" + version: "0.1.1" flutter_secure_storage_linux: dependency: transitive description: @@ -314,26 +322,26 @@ packages: dependency: transitive description: name: flutter_secure_storage_web - sha256: "4c3f233e739545c6cb09286eeec1cc4744138372b985113acc904f7263bef517" + sha256: "6a1137df62b84b54261dca582c1c09ea72f4f9a4b2fcee21b025964132d5d0c3" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.1.0" flutter_secure_storage_windows: dependency: transitive description: name: flutter_secure_storage_windows - sha256: ff32af20f70a8d0e59b2938fc92de35b54a74671041c814275afd80e27df9f21 + sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "4.1.0" flutter_svg: dependency: "direct main" description: name: flutter_svg - sha256: cd57f7969b4679317c17af6fd16ee233c1e60a82ed209d8a475c54fd6fd6f845 + sha256: "87fbd7c534435b6c5d9d98b01e1fd527812b82e68ddd8bd35fc45ed0fa8f0a95" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.2.3" flutter_test: dependency: "direct dev" description: flutter @@ -351,10 +359,10 @@ packages: dependency: transitive description: name: flutter_web_auth_2_platform_interface - sha256: "45927587ebb2364cd273675ec95f6f67b81725754b416cef2b65cdc63fd3e853" + sha256: ba0fbba55bffb47242025f96852ad1ffba34bc451568f56ef36e613612baffab url: "https://pub.dev" source: hosted - version: "5.0.0-alpha.0" + version: "5.0.0" flutter_web_plugins: dependency: transitive description: flutter @@ -408,6 +416,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.5" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" gtk: dependency: transitive description: @@ -416,6 +432,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + hooks: + dependency: transitive + description: + name: hooks + sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" html: dependency: transitive description: @@ -428,10 +452,10 @@ packages: dependency: "direct main" description: name: http - sha256: "85ab0074f9bf2b24625906d8382bbec84d3d6919d285ba9c106b07b65791fb99" + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" url: "https://pub.dev" source: hosted - version: "1.5.0-beta.2" + version: "1.6.0" http_parser: dependency: transitive description: @@ -444,10 +468,10 @@ packages: dependency: transitive description: name: image - sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928" + sha256: "492bd52f6c4fbb6ee41f781ff27765ce5f627910e1e0cbecfa3d9add5562604c" url: "https://pub.dev" source: hosted - version: "4.5.4" + version: "4.7.2" intl: dependency: transitive description: @@ -460,10 +484,10 @@ packages: dependency: transitive description: name: json_annotation - sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + sha256: "805fa86df56383000f640384b282ce0cb8431f1a7a2396de92fb66186d8c57df" url: "https://pub.dev" source: hosted - version: "4.9.0" + version: "4.10.0" latlong2: dependency: "direct main" description: @@ -524,10 +548,18 @@ packages: dependency: transitive description: name: logger - sha256: "2621da01aabaf223f8f961e751f2c943dbb374dc3559b982f200ccedadaa6999" + sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3 url: "https://pub.dev" source: hosted - version: "2.6.0" + version: "2.6.2" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" matcher: dependency: transitive description: @@ -568,6 +600,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac" + url: "https://pub.dev" + source: hosted + version: "0.17.4" nested: dependency: transitive description: @@ -584,6 +624,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.2.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" + url: "https://pub.dev" + source: hosted + version: "9.3.0" package_info_plus: dependency: "direct main" description: @@ -628,18 +676,18 @@ packages: dependency: transitive description: name: path_provider_android - sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9 + sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e url: "https://pub.dev" source: hosted - version: "2.2.17" + version: "2.2.22" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.6.0" path_provider_linux: dependency: transitive description: @@ -668,10 +716,10 @@ packages: dependency: transitive description: name: petitparser - sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646" + sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" url: "https://pub.dev" source: hosted - version: "6.1.0" + version: "7.0.1" platform: dependency: transitive description: @@ -708,10 +756,18 @@ packages: dependency: "direct main" description: name: provider - sha256: "4abbd070a04e9ddc287673bf5a030c7ca8b685ff70218720abab8b092f53dd84" + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" url: "https://pub.dev" source: hosted - version: "6.1.5" + version: "6.1.5+1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" random_string: dependency: transitive description: @@ -724,26 +780,26 @@ packages: dependency: "direct main" description: name: shared_preferences - sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" url: "https://pub.dev" source: hosted - version: "2.5.3" + version: "2.5.4" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: "20cbd561f743a342c76c151d6ddb93a9ce6005751e7aa458baad3858bfbfb6ac" + sha256: cbc40be9be1c5af4dab4d6e0de4d5d3729e6f3d65b89d21e1815d57705644a6f url: "https://pub.dev" source: hosted - version: "2.4.10" + version: "2.4.20" shared_preferences_foundation: dependency: transitive description: name: shared_preferences_foundation - sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" url: "https://pub.dev" source: hosted - version: "2.5.4" + version: "2.5.6" shared_preferences_linux: dependency: transitive description: @@ -785,18 +841,10 @@ packages: dependency: transitive description: name: source_span - sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" url: "https://pub.dev" source: hosted - version: "1.10.1" - sprintf: - dependency: transitive - description: - name: sprintf - sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" - url: "https://pub.dev" - source: hosted - version: "7.0.0" + version: "1.10.2" sqflite: dependency: "direct main" description: @@ -809,10 +857,10 @@ packages: dependency: transitive description: name: sqflite_android - sha256: "2b3070c5fa881839f8b402ee4a39c1b4d561704d4ebbbcfb808a119bc2a1701b" + sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88 url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.2+2" sqflite_common: dependency: transitive description: @@ -913,10 +961,10 @@ packages: dependency: transitive description: name: universal_io - sha256: "1722b2dcc462b4b2f3ee7d188dad008b6eb4c40bbd03a3de451d82c78bba9aad" + sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2 url: "https://pub.dev" source: hosted - version: "2.2.2" + version: "2.3.1" url_launcher: dependency: "direct main" description: @@ -929,34 +977,34 @@ packages: dependency: transitive description: name: url_launcher_android - sha256: "8582d7f6fe14d2652b4c45c9b6c14c0b678c2af2d083a11b604caeba51930d79" + sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" url: "https://pub.dev" source: hosted - version: "6.3.16" + version: "6.3.28" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: "7f2022359d4c099eea7df3fdf739f7d3d3b9faf3166fb1dd390775176e0b76cb" + sha256: b1aca26728b7cc7a3af971bb6f601554a8ae9df2e0a006de8450ba06a17ad36a url: "https://pub.dev" source: hosted - version: "6.3.3" + version: "6.4.0" url_launcher_linux: dependency: transitive description: name: url_launcher_linux - sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a url: "https://pub.dev" source: hosted - version: "3.2.1" + version: "3.2.2" url_launcher_macos: dependency: transitive description: name: url_launcher_macos - sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2" + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" url: "https://pub.dev" source: hosted - version: "3.2.2" + version: "3.2.5" url_launcher_platform_interface: dependency: transitive description: @@ -969,26 +1017,26 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.2" url_launcher_windows: dependency: transitive description: name: url_launcher_windows - sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" url: "https://pub.dev" source: hosted - version: "3.1.4" + version: "3.1.5" uuid: dependency: "direct main" description: name: uuid - sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff + sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 url: "https://pub.dev" source: hosted - version: "4.5.1" + version: "4.5.2" vector_graphics: dependency: transitive description: @@ -1009,10 +1057,10 @@ packages: dependency: transitive description: name: vector_graphics_compiler - sha256: d354a7ec6931e6047785f4db12a1f61ec3d43b207fc0790f863818543f8ff0dc + sha256: "201e876b5d52753626af64b6359cd13ac6011b80728731428fd34bc840f71c9b" url: "https://pub.dev" source: hosted - version: "1.1.19" + version: "1.1.20" vector_math: dependency: transitive description: @@ -1041,10 +1089,10 @@ packages: dependency: transitive description: name: win32 - sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03" + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e url: "https://pub.dev" source: hosted - version: "5.14.0" + version: "5.15.0" window_to_front: dependency: transitive description: @@ -1073,10 +1121,10 @@ packages: dependency: "direct main" description: name: xml - sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" url: "https://pub.dev" source: hosted - version: "6.5.0" + version: "6.6.1" yaml: dependency: transitive description: @@ -1086,5 +1134,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.8.0 <4.0.0" - flutter: ">=3.27.0" + dart: ">=3.10.3 <4.0.0" + flutter: ">=3.38.4" From e72d557d2afcced00e0f04a54b34f026ba812ac2 Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Mon, 9 Feb 2026 15:53:03 -0700 Subject: [PATCH 02/18] chore(android): bump AGP to 8.9.1 and Java compatibility to 17 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Transitive AndroidX dependencies (browser:1.9.0, core-ktx:1.17.0, core:1.17.0) pulled in by the pub upgrade now require AGP 8.9.1+. - AGP: 8.7.3 → 8.9.1 - Java source/target compatibility: 11 → 17 - Gradle 8.12 already satisfies AGP 8.9.1's minimum of 8.11.1 Co-Authored-By: Claude Opus 4.6 --- android/app/build.gradle.kts | 6 +-- android/settings.gradle.kts | 2 +- pubspec.lock | 100 ++++++++--------------------------- 3 files changed, 26 insertions(+), 82 deletions(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 339fc1ca..b7f63ded 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -24,12 +24,12 @@ android { ndkVersion = "27.0.12077973" compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 isCoreLibraryDesugaringEnabled = true } kotlinOptions { - jvmTarget = JavaVersion.VERSION_11.toString() + jvmTarget = JavaVersion.VERSION_17.toString() } defaultConfig { diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index ab39a10a..43394ed5 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -18,7 +18,7 @@ pluginManagement { plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" - id("com.android.application") version "8.7.3" apply false + id("com.android.application") version "8.9.1" apply false id("org.jetbrains.kotlin.android") version "2.1.0" apply false } diff --git a/pubspec.lock b/pubspec.lock index df71cfc9..411a9515 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -45,10 +45,10 @@ packages: dependency: transitive description: name: archive - sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff url: "https://pub.dev" source: hosted - version: "4.0.7" + version: "4.0.9" args: dependency: transitive description: @@ -105,14 +105,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" - code_assets: - dependency: transitive - description: - name: code_assets - sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" - url: "https://pub.dev" - source: hosted - version: "1.0.0" collection: dependency: "direct main" description: @@ -189,10 +181,10 @@ packages: dependency: transitive description: name: ffi - sha256: d07d37192dbf97461359c1518788f203b0c9102cfd2c35a716b823741219542c + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" url: "https://pub.dev" source: hosted - version: "2.1.5" + version: "2.2.0" file: dependency: transitive description: @@ -416,14 +408,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.5" - glob: - dependency: transitive - description: - name: glob - sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de - url: "https://pub.dev" - source: hosted - version: "2.1.3" gtk: dependency: transitive description: @@ -432,14 +416,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" - hooks: - dependency: transitive - description: - name: hooks - sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6" - url: "https://pub.dev" - source: hosted - version: "1.0.1" html: dependency: transitive description: @@ -468,10 +444,10 @@ packages: dependency: transitive description: name: image - sha256: "492bd52f6c4fbb6ee41f781ff27765ce5f627910e1e0cbecfa3d9add5562604c" + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce url: "https://pub.dev" source: hosted - version: "4.7.2" + version: "4.8.0" intl: dependency: transitive description: @@ -484,10 +460,10 @@ packages: dependency: transitive description: name: json_annotation - sha256: "805fa86df56383000f640384b282ce0cb8431f1a7a2396de92fb66186d8c57df" + sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8 url: "https://pub.dev" source: hosted - version: "4.10.0" + version: "4.11.0" latlong2: dependency: "direct main" description: @@ -552,14 +528,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.6.2" - logging: - dependency: transitive - description: - name: logging - sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 - url: "https://pub.dev" - source: hosted - version: "1.3.0" matcher: dependency: transitive description: @@ -600,14 +568,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" - native_toolchain_c: - dependency: transitive - description: - name: native_toolchain_c - sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac" - url: "https://pub.dev" - source: hosted - version: "0.17.4" nested: dependency: transitive description: @@ -624,14 +584,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.2.0" - objective_c: - dependency: transitive - description: - name: objective_c - sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" - url: "https://pub.dev" - source: hosted - version: "9.3.0" package_info_plus: dependency: "direct main" description: @@ -684,10 +636,10 @@ packages: dependency: transitive description: name: path_provider_foundation - sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4" url: "https://pub.dev" source: hosted - version: "2.6.0" + version: "2.5.1" path_provider_linux: dependency: transitive description: @@ -716,10 +668,10 @@ packages: dependency: transitive description: name: petitparser - sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" url: "https://pub.dev" source: hosted - version: "7.0.1" + version: "7.0.2" platform: dependency: transitive description: @@ -740,10 +692,10 @@ packages: dependency: transitive description: name: posix - sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" + sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" url: "https://pub.dev" source: hosted - version: "6.0.3" + version: "6.5.0" proj4dart: dependency: transitive description: @@ -760,14 +712,6 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.5+1" - pub_semver: - dependency: transitive - description: - name: pub_semver - sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" - url: "https://pub.dev" - source: hosted - version: "2.2.0" random_string: dependency: transitive description: @@ -985,10 +929,10 @@ packages: dependency: transitive description: name: url_launcher_ios - sha256: b1aca26728b7cc7a3af971bb6f601554a8ae9df2e0a006de8450ba06a17ad36a + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" url: "https://pub.dev" source: hosted - version: "6.4.0" + version: "6.4.1" url_launcher_linux: dependency: transitive description: @@ -1033,10 +977,10 @@ packages: dependency: "direct main" description: name: uuid - sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" url: "https://pub.dev" source: hosted - version: "4.5.2" + version: "4.5.3" vector_graphics: dependency: transitive description: @@ -1057,10 +1001,10 @@ packages: dependency: transitive description: name: vector_graphics_compiler - sha256: "201e876b5d52753626af64b6359cd13ac6011b80728731428fd34bc840f71c9b" + sha256: "5a88dd14c0954a5398af544651c7fb51b457a2a556949bfb25369b210ef73a74" url: "https://pub.dev" source: hosted - version: "1.1.20" + version: "1.2.0" vector_math: dependency: transitive description: @@ -1134,5 +1078,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.10.3 <4.0.0" - flutter: ">=3.38.4" + dart: ">=3.10.0 <4.0.0" + flutter: ">=3.38.0" From 206b3afe9d02ee7644275bf9913db07d4350fe4f Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Mon, 9 Feb 2026 16:52:57 -0700 Subject: [PATCH 03/18] chore(deps): move flutter_web_auth_2 and flutter_secure_storage to stable releases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move auth-critical packages from pre-release pins to stable: - flutter_web_auth_2: 5.0.0-alpha.3 → ^5.0.1 - flutter_secure_storage: 10.0.0-beta.4 → ^10.0.0 - oauth2_client: 4.2.0 → 4.2.3 (auto-resolved, was blocked by pre-release pins) Co-Authored-By: Claude Opus 4.6 --- pubspec.lock | 20 ++++++++++---------- pubspec.yaml | 4 ++-- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 411a9515..de36f8e7 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -282,26 +282,26 @@ packages: dependency: "direct main" description: name: flutter_secure_storage - sha256: f7eceb0bc6f4fd0441e29d43cab9ac2a1c5ffd7ea7b64075136b718c46954874 + sha256: da922f2aab2d733db7e011a6bcc4a825b844892d4edd6df83ff156b09a9b2e40 url: "https://pub.dev" source: hosted - version: "10.0.0-beta.4" + version: "10.0.0" flutter_secure_storage_darwin: dependency: transitive description: name: flutter_secure_storage_darwin - sha256: "81ef5abfb9cbeb78110d8043ba29f0b36cd7ffa989baa1b2d9482542b2200051" + sha256: "8878c25136a79def1668c75985e8e193d9d7d095453ec28730da0315dc69aee3" url: "https://pub.dev" source: hosted - version: "0.1.1" + version: "0.2.0" flutter_secure_storage_linux: dependency: transitive description: name: flutter_secure_storage_linux - sha256: "9b4b73127e857cd3117d43a70fa3dddadb6e0b253be62e6a6ab85caa0742182c" + sha256: "2b5c76dce569ab752d55a1cee6a2242bcc11fdba927078fb88c503f150767cda" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "3.0.0" flutter_secure_storage_platform_interface: dependency: transitive description: @@ -343,10 +343,10 @@ packages: dependency: "direct main" description: name: flutter_web_auth_2 - sha256: "2483d1fd3c45fe1262446e8d5f5490f01b864f2e7868ffe05b4727e263cc0182" + sha256: "432ff8c7b2834eaeec3378d99e24a0210b9ac2f453b3f7a7d739a5c09069fba3" url: "https://pub.dev" source: hosted - version: "5.0.0-alpha.3" + version: "5.0.1" flutter_web_auth_2_platform_interface: dependency: transitive description: @@ -580,10 +580,10 @@ packages: dependency: "direct main" description: name: oauth2_client - sha256: d6a146049f36ef2da32bdc7a7a9e5671a0e66ea596d8f70a26de4cddfcab4d2e + sha256: "6667da827518047d99ce82cf7b23043ea4a4bac99fc6681d4a1bf6ee1dd9579f" url: "https://pub.dev" source: hosted - version: "4.2.0" + version: "4.2.3" package_info_plus: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 6523ba46..3780dffb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -26,8 +26,8 @@ dependencies: # Auth, storage, prefs oauth2_client: ^4.2.0 - flutter_web_auth_2: 5.0.0-alpha.3 - flutter_secure_storage: 10.0.0-beta.4 + flutter_web_auth_2: ^5.0.1 + flutter_secure_storage: ^10.0.0 # Persistence shared_preferences: ^2.2.2 From dba375c63d56dabe0a320324feb91bf7768ba405 Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Mon, 9 Feb 2026 21:43:51 -0700 Subject: [PATCH 04/18] chore(deps): update app_links and package_info_plus to latest major versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upgrade packages: - app_links: ^6.1.4 → ^7.0.0 (backward compatible with v6) - package_info_plus: ^8.0.0 → ^9.0.0 (build tooling only, no Dart API changes) Bump Android build tooling to latest Flutter 3.38-compatible versions: - AGP: 8.9.1 → 8.11.1 - Gradle: 8.12 → 8.14 - Kotlin: 2.1.0 → 2.2.20 Co-Authored-By: Claude Opus 4.6 --- android/gradle/wrapper/gradle-wrapper.properties | 2 +- android/settings.gradle.kts | 4 ++-- pubspec.lock | 8 ++++---- pubspec.yaml | 4 ++-- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index ac3b4792..e4ef43fb 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index 43394ed5..da20e6da 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -18,8 +18,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") diff --git a/pubspec.lock b/pubspec.lock index de36f8e7..a337d12f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -13,10 +13,10 @@ packages: dependency: "direct main" description: name: app_links - sha256: "5f88447519add627fe1cbcab4fd1da3d4fed15b9baf29f28b22535c95ecee3e8" + sha256: "3462d9defc61565fde4944858b59bec5be2b9d5b05f20aed190adb3ad08a7abc" url: "https://pub.dev" source: hosted - version: "6.4.1" + version: "7.0.0" app_links_linux: dependency: transitive description: @@ -588,10 +588,10 @@ packages: dependency: "direct main" description: name: package_info_plus - sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968" + sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d url: "https://pub.dev" source: hosted - version: "8.3.1" + version: "9.0.0" package_info_plus_platform_interface: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 3780dffb..055a64c2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -22,7 +22,7 @@ dependencies: flutter_local_notifications: ^17.2.2 url_launcher: ^6.3.0 flutter_linkify: ^6.0.0 - app_links: ^6.1.4 + app_links: ^7.0.0 # Auth, storage, prefs oauth2_client: ^4.2.0 @@ -35,7 +35,7 @@ dependencies: path: ^1.8.3 path_provider: ^2.1.0 uuid: ^4.0.0 - package_info_plus: ^8.0.0 + package_info_plus: ^9.0.0 csv: ^6.0.0 collection: ^1.18.0 From b6bcd236678fddc3f20b70a068553e89af52fda6 Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Mon, 9 Feb 2026 21:52:21 -0700 Subject: [PATCH 05/18] chore(android): bump Dart SDK floor, desugar_jdk_libs, and fix Kotlin DSL deprecation - Bump Dart SDK constraint from >=3.8.0 to >=3.10.3 to match resolved dependency floor - Upgrade desugar_jdk_libs from 2.0.4 to 2.1.5 (adds Stream.toList(), better locale support) - Migrate deprecated kotlinOptions { jvmTarget } to kotlin { compilerOptions { jvmTarget } } - Remove stale comments and non-breaking space characters Co-Authored-By: Claude Opus 4.6 --- android/app/build.gradle.kts | 14 ++++++-------- pubspec.lock | 2 +- pubspec.yaml | 2 +- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index b7f63ded..a700f92a 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -17,7 +17,6 @@ if (keystorePropertiesFile.exists()) { android { namespace = "me.deflock.deflockapp" - // Matches current stable Flutter (compileSdk 34 as of July 2025) compileSdk = 36 // NDK only needed if you build native plugins; keep your pinned version @@ -28,17 +27,17 @@ android { targetCompatibility = JavaVersion.VERSION_17 isCoreLibraryDesugaringEnabled = true } - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17.toString() + kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } } defaultConfig { // Application ID (package name) applicationId = "me.deflock.deflockapp" - // ──────────────────────────────────────────────────────────── - // oauth2_client 4.x & flutter_web_auth_2 5.x require minSdk 23 - // ──────────────────────────────────────────────────────────── + // oauth2_client 4.x & flutter_web_auth_2 5.x require minSdk 23 minSdk = maxOf(flutter.minSdkVersion, 23) targetSdk = 36 @@ -76,6 +75,5 @@ flutter { } dependencies { - coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4") + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5") } - diff --git a/pubspec.lock b/pubspec.lock index a337d12f..64a68bb1 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1079,4 +1079,4 @@ packages: version: "3.1.3" sdks: dart: ">=3.10.0 <4.0.0" - flutter: ">=3.38.0" + flutter: ">=3.38.1" diff --git a/pubspec.yaml b/pubspec.yaml index 055a64c2..14f81d21 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,7 +4,7 @@ publish_to: "none" version: 2.7.2+48 # The thing after the + is the version code, incremented with each release environment: - sdk: ">=3.8.0 <4.0.0" # RadioGroup widget requires Dart 3.8+ (Flutter 3.35+) + sdk: ">=3.10.3 <4.0.0" # Resolved dependency floor (Dart 3.10.3 = Flutter 3.38+) dependencies: flutter: From 90a806a10d14aff3276dc18bdc27aaaca1535036 Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Wed, 25 Feb 2026 15:23:00 -0700 Subject: [PATCH 06/18] chore(deps): upgrade minor/patch dependencies within existing constraints Co-Authored-By: Claude Opus 4.6 --- pubspec.lock | 64 ++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 60 insertions(+), 4 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 64a68bb1..76982e6e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -105,6 +105,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" collection: dependency: "direct main" description: @@ -408,6 +416,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.5" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" gtk: dependency: transitive description: @@ -416,6 +432,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + hooks: + dependency: transitive + description: + name: hooks + sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" html: dependency: transitive description: @@ -528,6 +552,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.6.2" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" matcher: dependency: transitive description: @@ -568,6 +600,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac" + url: "https://pub.dev" + source: hosted + version: "0.17.4" nested: dependency: transitive description: @@ -584,6 +624,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.2.3" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" + url: "https://pub.dev" + source: hosted + version: "9.3.0" package_info_plus: dependency: "direct main" description: @@ -636,10 +684,10 @@ packages: dependency: transitive description: name: path_provider_foundation - sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4" + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" url: "https://pub.dev" source: hosted - version: "2.5.1" + version: "2.6.0" path_provider_linux: dependency: transitive description: @@ -712,6 +760,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.5+1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" random_string: dependency: transitive description: @@ -1078,5 +1134,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.10.0 <4.0.0" - flutter: ">=3.38.1" + dart: ">=3.10.3 <4.0.0" + flutter: ">=3.38.4" From abd8682b49460a3fcfcea50bcfccfe748f8eff39 Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Sat, 14 Feb 2026 15:01:32 -0700 Subject: [PATCH 07/18] Build with iOS 26 SDK to meet App Store deadline Apple requires all iOS/iPadOS apps to be built with the iOS 26 SDK (Xcode 26+) starting April 28, 2026. Switch the build-ios and upload-to-stores jobs from macos-latest (macOS 15 / Xcode 16) to macos-26 (macOS 26 / Xcode 26). Co-Authored-By: Claude Opus 4.6 --- .github/workflows/workflow.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index 182466ff..093354b9 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -142,7 +142,7 @@ jobs: build-ios: name: Build iOS needs: get-version - runs-on: macos-latest + runs-on: macos-26 steps: - name: Checkout repository uses: actions/checkout@v5 @@ -290,7 +290,7 @@ jobs: upload-to-stores: name: Upload to App Stores needs: [get-version, build-android-aab, build-ios] - runs-on: macos-latest # Need macOS for iOS uploads + runs-on: macos-26 # Need macOS for iOS uploads if: needs.get-version.outputs.should_upload_to_stores == 'true' steps: - name: Download AAB artifact for Google Play From 30f546be290b300b49e118c14d78a094a7f41ace Mon Sep 17 00:00:00 2001 From: stopflock Date: Wed, 25 Feb 2026 19:27:59 -0600 Subject: [PATCH 08/18] Update pubspec.yaml bump version --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 14f81d21..4ad0990a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: deflockapp description: Map public surveillance infrastructure with OpenStreetMap publish_to: "none" -version: 2.7.2+48 # The thing after the + is the version code, incremented with each release +version: 2.8.0+49 # The thing after the + is the version code, incremented with each release environment: sdk: ">=3.10.3 <4.0.0" # Resolved dependency floor (Dart 3.10.3 = Flutter 3.38+) From b56e9325b385cf1044edf1b1767f5e87bba68f63 Mon Sep 17 00:00:00 2001 From: stopflock Date: Wed, 25 Feb 2026 19:28:48 -0600 Subject: [PATCH 09/18] Update changelog.json 280 --- assets/changelog.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/assets/changelog.json b/assets/changelog.json index b8586da4..9209a0c0 100644 --- a/assets/changelog.json +++ b/assets/changelog.json @@ -1,7 +1,12 @@ { + "2.8.0": { + "content": [ + "• Update dependencies and build chain tools; no code changes" + ] + }, "2.7.2": { "content": [ - "• Now following OSM UserAgent guidelines." + "• Now following OSM UserAgent guidelines" ] }, "2.7.1": { From 4941c2726d88c188b4a212597148c6064c17f7c8 Mon Sep 17 00:00:00 2001 From: jay <> Date: Sun, 15 Feb 2026 01:40:29 -0600 Subject: [PATCH 10/18] don't require trailing new line in build.keys.conf --- do_builds.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/do_builds.sh b/do_builds.sh index b3c5850d..94dbac67 100755 --- a/do_builds.sh +++ b/do_builds.sh @@ -28,7 +28,7 @@ read_from_file() { echo "$v" return 0 fi - done < "$file" + done < <(cat "$file"; echo) return 1 } From bc671c4efec3ccf38d4009ef45098ed6d2f7eac8 Mon Sep 17 00:00:00 2001 From: stopflock Date: Mon, 2 Mar 2026 12:38:49 -0600 Subject: [PATCH 11/18] Fix phantom FOVs, reorderable profiles --- assets/changelog.json | 6 + lib/app_state.dart | 4 + lib/models/node_profile.dart | 33 +++- .../sections/node_profiles_section.dart | 155 ++++++++------- lib/state/profile_state.dart | 56 +++++- pubspec.yaml | 2 +- test/models/node_profile_test.dart | 178 ++++++++++++++++++ 7 files changed, 357 insertions(+), 77 deletions(-) diff --git a/assets/changelog.json b/assets/changelog.json index 9209a0c0..4b9b9be1 100644 --- a/assets/changelog.json +++ b/assets/changelog.json @@ -1,4 +1,10 @@ { + "2.8.1": { + "content": [ + "• Fixed bug where the \"existing tags\" profile would incorrectly add default FOV ranges during submission", + "• Added drag handles so profiles can be reordered to customize dropdown order when submitting" + ] + }, "2.8.0": { "content": [ "• Update dependencies and build chain tools; no code changes" diff --git a/lib/app_state.dart b/lib/app_state.dart index 0e8ae093..a208d638 100644 --- a/lib/app_state.dart +++ b/lib/app_state.dart @@ -407,6 +407,10 @@ class AppState extends ChangeNotifier { _profileState.addOrUpdateProfile(p); } + void reorderProfiles(int oldIndex, int newIndex) { + _profileState.reorderProfiles(oldIndex, newIndex); + } + void deleteProfile(NodeProfile p) { _profileState.deleteProfile(p); } diff --git a/lib/models/node_profile.dart b/lib/models/node_profile.dart index cdf996aa..cc8612ae 100644 --- a/lib/models/node_profile.dart +++ b/lib/models/node_profile.dart @@ -269,16 +269,33 @@ class NodeProfile { /// Used as the default `` option when editing nodes /// All existing tags will flow through as additionalExistingTags static NodeProfile createExistingTagsProfile(OsmNode node) { - // Calculate FOV from existing direction ranges if applicable + // Only assign FOV if the original direction string actually contained range notation + // (e.g., "90-270" or "55-125"), not if it was just single directions (e.g., "90") double? calculatedFov; - // If node has direction/FOV pairs, check if they all have the same FOV - if (node.directionFovPairs.isNotEmpty) { - final firstFov = node.directionFovPairs.first.fovDegrees; + final raw = node.tags['direction'] ?? node.tags['camera:direction']; + if (raw != null) { + // Check if any part of the direction string contains range notation (dash with numbers) + final parts = raw.split(';'); + bool hasRangeNotation = false; - // If all directions have the same FOV, use it for the profile - if (node.directionFovPairs.every((df) => df.fovDegrees == firstFov)) { - calculatedFov = firstFov; + for (final part in parts) { + final trimmed = part.trim(); + // Look for range pattern: numbers-numbers (e.g., "90-270", "55-125") + if (trimmed.contains('-') && RegExp(r'^\d+\.?\d*-\d+\.?\d*$').hasMatch(trimmed)) { + hasRangeNotation = true; + break; + } + } + + // Only calculate FOV if the node originally had range notation + if (hasRangeNotation && node.directionFovPairs.isNotEmpty) { + final firstFov = node.directionFovPairs.first.fovDegrees; + + // If all directions have the same FOV, use it for the profile + if (node.directionFovPairs.every((df) => df.fovDegrees == firstFov)) { + calculatedFov = firstFov; + } } } @@ -290,7 +307,7 @@ class NodeProfile { requiresDirection: true, submittable: true, editable: false, - fov: calculatedFov, // Use calculated FOV from existing direction ranges + fov: calculatedFov, // Only use FOV if original had explicit range notation ); } diff --git a/lib/screens/settings/sections/node_profiles_section.dart b/lib/screens/settings/sections/node_profiles_section.dart index ed997f01..89fcdafa 100644 --- a/lib/screens/settings/sections/node_profiles_section.dart +++ b/lib/screens/settings/sections/node_profiles_section.dart @@ -34,76 +34,101 @@ class NodeProfilesSection extends StatelessWidget { ), ], ), - ...appState.profiles.map( - (p) => ListTile( - leading: Checkbox( - value: appState.isEnabled(p), - onChanged: (v) => appState.toggleProfile(p, v ?? false), - ), - title: Text(p.name), - subtitle: Text(p.builtin ? locService.t('profiles.builtIn') : locService.t('profiles.custom')), - trailing: !p.editable - ? PopupMenuButton( - itemBuilder: (context) => [ - PopupMenuItem( - value: 'view', - child: Row( - children: [ - const Icon(Icons.visibility), - const SizedBox(width: 8), - Text(locService.t('profiles.view')), - ], - ), + ReorderableListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: appState.profiles.length, + onReorder: (oldIndex, newIndex) { + appState.reorderProfiles(oldIndex, newIndex); + }, + itemBuilder: (context, index) { + final p = appState.profiles[index]; + return ListTile( + key: ValueKey(p.id), + leading: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Drag handle + ReorderableDragStartListener( + index: index, + child: const Icon( + Icons.drag_handle, + color: Colors.grey, ), - ], - onSelected: (value) { - if (value == 'view') { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => ProfileEditor(profile: p), + ), + const SizedBox(width: 8), + // Checkbox + Checkbox( + value: appState.isEnabled(p), + onChanged: (v) => appState.toggleProfile(p, v ?? false), + ), + ], + ), + title: Text(p.name), + subtitle: Text(p.builtin ? locService.t('profiles.builtIn') : locService.t('profiles.custom')), + trailing: !p.editable + ? PopupMenuButton( + itemBuilder: (context) => [ + PopupMenuItem( + value: 'view', + child: Row( + children: [ + const Icon(Icons.visibility), + const SizedBox(width: 8), + Text(locService.t('profiles.view')), + ], ), - ); - } - }, - ) - : PopupMenuButton( - itemBuilder: (context) => [ - PopupMenuItem( - value: 'edit', - child: Row( - children: [ - const Icon(Icons.edit), - const SizedBox(width: 8), - Text(locService.t('actions.edit')), - ], ), - ), - PopupMenuItem( - value: 'delete', - child: Row( - children: [ - const Icon(Icons.delete, color: Colors.red), - const SizedBox(width: 8), - Text(locService.t('profiles.deleteProfile'), style: const TextStyle(color: Colors.red)), - ], + ], + onSelected: (value) { + if (value == 'view') { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => ProfileEditor(profile: p), + ), + ); + } + }, + ) + : PopupMenuButton( + itemBuilder: (context) => [ + PopupMenuItem( + value: 'edit', + child: Row( + children: [ + const Icon(Icons.edit), + const SizedBox(width: 8), + Text(locService.t('actions.edit')), + ], + ), ), - ), - ], - onSelected: (value) { - if (value == 'edit') { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => ProfileEditor(profile: p), + PopupMenuItem( + value: 'delete', + child: Row( + children: [ + const Icon(Icons.delete, color: Colors.red), + const SizedBox(width: 8), + Text(locService.t('profiles.deleteProfile'), style: const TextStyle(color: Colors.red)), + ], ), - ); - } else if (value == 'delete') { - _showDeleteProfileDialog(context, p); - } - }, - ), - ), + ), + ], + onSelected: (value) { + if (value == 'edit') { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => ProfileEditor(profile: p), + ), + ); + } else if (value == 'delete') { + _showDeleteProfileDialog(context, p); + } + }, + ), + ); + }, ), ], ); diff --git a/lib/state/profile_state.dart b/lib/state/profile_state.dart index 41493ab2..117b6592 100644 --- a/lib/state/profile_state.dart +++ b/lib/state/profile_state.dart @@ -6,9 +6,11 @@ import '../services/profile_service.dart'; class ProfileState extends ChangeNotifier { static const String _enabledPrefsKey = 'enabled_profiles'; + static const String _profileOrderPrefsKey = 'profile_order'; final List _profiles = []; final Set _enabled = {}; + List _customOrder = []; // List of profile IDs in user's preferred order // Callback for when a profile is deleted (used to clear stale sessions) void Function(NodeProfile)? _onProfileDeleted; @@ -18,10 +20,10 @@ class ProfileState extends ChangeNotifier { } // Getters - List get profiles => List.unmodifiable(_profiles); + List get profiles => List.unmodifiable(_getOrderedProfiles()); bool isEnabled(NodeProfile p) => _enabled.contains(p); List get enabledProfiles => - _profiles.where(isEnabled).toList(growable: false); + _getOrderedProfiles().where(isEnabled).toList(growable: false); // Initialize profiles from built-in and custom sources Future init({bool addDefaults = false}) async { @@ -34,7 +36,7 @@ class ProfileState extends ChangeNotifier { await ProfileService().save(_profiles); } - // Load enabled profile IDs from prefs + // Load enabled profile IDs and custom order from prefs final prefs = await SharedPreferences.getInstance(); final enabledIds = prefs.getStringList(_enabledPrefsKey); if (enabledIds != null && enabledIds.isNotEmpty) { @@ -44,6 +46,9 @@ class ProfileState extends ChangeNotifier { // By default, all are enabled _enabled.addAll(_profiles); } + + // Load custom order + _customOrder = prefs.getStringList(_profileOrderPrefsKey) ?? []; } void toggleProfile(NodeProfile p, bool e) { @@ -92,6 +97,45 @@ class ProfileState extends ChangeNotifier { notifyListeners(); } + // Reorder profiles (for drag-and-drop in settings) + void reorderProfiles(int oldIndex, int newIndex) { + final orderedProfiles = _getOrderedProfiles(); + if (oldIndex < newIndex) { + newIndex -= 1; + } + final item = orderedProfiles.removeAt(oldIndex); + orderedProfiles.insert(newIndex, item); + + // Update custom order with new sequence + _customOrder = orderedProfiles.map((p) => p.id).toList(); + _saveCustomOrder(); + notifyListeners(); + } + + // Get profiles in custom order, with unordered profiles at the end + List _getOrderedProfiles() { + if (_customOrder.isEmpty) { + return List.from(_profiles); + } + + final ordered = []; + final profilesById = {for (final p in _profiles) p.id: p}; + + // Add profiles in custom order + for (final id in _customOrder) { + final profile = profilesById[id]; + if (profile != null) { + ordered.add(profile); + profilesById.remove(id); + } + } + + // Add any remaining profiles that weren't in the custom order + ordered.addAll(profilesById.values); + + return ordered; + } + // Save enabled profile IDs to disk Future _saveEnabledProfiles() async { final prefs = await SharedPreferences.getInstance(); @@ -100,4 +144,10 @@ class ProfileState extends ChangeNotifier { _enabled.map((p) => p.id).toList(), ); } + + // Save custom order to disk + Future _saveCustomOrder() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setStringList(_profileOrderPrefsKey, _customOrder); + } } \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index 4ad0990a..03aa82d1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: deflockapp description: Map public surveillance infrastructure with OpenStreetMap publish_to: "none" -version: 2.8.0+49 # The thing after the + is the version code, incremented with each release +version: 2.8.1+50 # The thing after the + is the version code, incremented with each release environment: sdk: ">=3.10.3 <4.0.0" # Resolved dependency floor (Dart 3.10.3 = Flutter 3.38+) diff --git a/test/models/node_profile_test.dart b/test/models/node_profile_test.dart index 28b1fb81..ea66324e 100644 --- a/test/models/node_profile_test.dart +++ b/test/models/node_profile_test.dart @@ -1,5 +1,8 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; import 'package:deflockapp/models/node_profile.dart'; +import 'package:deflockapp/models/osm_node.dart'; +import 'package:deflockapp/state/profile_state.dart'; void main() { group('NodeProfile', () { @@ -72,5 +75,180 @@ void main() { expect(a.hashCode, equals(b.hashCode)); expect(a, isNot(equals(c))); }); + + group('createExistingTagsProfile', () { + test('should NOT assign FOV for nodes with single direction', () { + // This is the core bug fix: nodes with just "direction=90" should not get a default FOV + final node = OsmNode( + id: 123, + coord: const LatLng(37.7749, -122.4194), + tags: { + 'direction': '90', + 'man_made': 'surveillance', + 'surveillance:type': 'ALPR', + }, + ); + + final profile = NodeProfile.createExistingTagsProfile(node); + + expect(profile.fov, isNull, reason: 'Single direction nodes should not get default FOV'); + expect(profile.name, equals('')); + expect(profile.tags, isEmpty, reason: 'Existing tags profile should have empty tags'); + }); + + test('should assign FOV for nodes with range notation', () { + final node = OsmNode( + id: 123, + coord: const LatLng(37.7749, -122.4194), + tags: { + 'direction': '55-125', // Range notation = explicit FOV + 'man_made': 'surveillance', + 'surveillance:type': 'ALPR', + }, + ); + + final profile = NodeProfile.createExistingTagsProfile(node); + + expect(profile.fov, isNotNull, reason: 'Range notation should preserve FOV'); + expect(profile.fov, equals(70.0), reason: 'Range 55-125 should calculate to 70 degree FOV'); + }); + + test('should assign FOV for nodes with multiple consistent ranges', () { + final node = OsmNode( + id: 123, + coord: const LatLng(37.7749, -122.4194), + tags: { + 'direction': '55-125;235-305', // Two ranges with same FOV + 'man_made': 'surveillance', + 'surveillance:type': 'ALPR', + }, + ); + + final profile = NodeProfile.createExistingTagsProfile(node); + + expect(profile.fov, equals(70.0), reason: 'Multiple consistent ranges should preserve FOV'); + }); + + test('should NOT assign FOV for mixed single directions and ranges', () { + final node = OsmNode( + id: 123, + coord: const LatLng(37.7749, -122.4194), + tags: { + 'direction': '90;180-360', // Mix of single direction and range + 'man_made': 'surveillance', + 'surveillance:type': 'ALPR', + }, + ); + + final profile = NodeProfile.createExistingTagsProfile(node); + + expect(profile.fov, isNull, reason: 'Mixed notation should not assign FOV'); + }); + + test('should NOT assign FOV for multiple single directions', () { + final node = OsmNode( + id: 123, + coord: const LatLng(37.7749, -122.4194), + tags: { + 'direction': '90;180;270', // Multiple single directions + 'man_made': 'surveillance', + 'surveillance:type': 'ALPR', + }, + ); + + final profile = NodeProfile.createExistingTagsProfile(node); + + expect(profile.fov, isNull, reason: 'Multiple single directions should not get default FOV'); + }); + + test('should handle camera:direction tag', () { + final node = OsmNode( + id: 123, + coord: const LatLng(37.7749, -122.4194), + tags: { + 'camera:direction': '180', // Using camera:direction instead of direction + 'man_made': 'surveillance', + 'surveillance:type': 'camera', + }, + ); + + final profile = NodeProfile.createExistingTagsProfile(node); + + expect(profile.fov, isNull, reason: 'Single camera:direction should not get default FOV'); + }); + + test('should fix the specific bug: direction=90 should not become direction=55-125', () { + // This tests the exact bug scenario mentioned in the issue + final node = OsmNode( + id: 123, + coord: const LatLng(37.7749, -122.4194), + tags: { + 'direction': '90', // Single direction, should stay as single direction + 'man_made': 'surveillance', + 'surveillance:type': 'ALPR', + }, + ); + + final profile = NodeProfile.createExistingTagsProfile(node); + + // Key fix: profile should NOT have an FOV, so upload won't convert to range notation + expect(profile.fov, isNull, reason: 'direction=90 should not get converted to direction=55-125'); + + // Verify the node does have directionFovPairs (for rendering), but profile ignores them + expect(node.directionFovPairs, hasLength(1)); + expect(node.directionFovPairs.first.centerDegrees, equals(90.0)); + expect(node.directionFovPairs.first.fovDegrees, equals(70.0)); // Default FOV for rendering + }); + }); + + group('ProfileState reordering', () { + test('should reorder profiles correctly', () { + final profileState = ProfileState(); + + // Add some test profiles + final profileA = NodeProfile(id: 'a', name: 'Profile A', tags: const {}); + final profileB = NodeProfile(id: 'b', name: 'Profile B', tags: const {}); + final profileC = NodeProfile(id: 'c', name: 'Profile C', tags: const {}); + + profileState.addOrUpdateProfile(profileA); + profileState.addOrUpdateProfile(profileB); + profileState.addOrUpdateProfile(profileC); + + // Initial order should be A, B, C + expect(profileState.profiles.map((p) => p.id), equals(['a', 'b', 'c'])); + + // Move profile at index 0 (A) to index 2 (should become B, C, A) + profileState.reorderProfiles(0, 2); + expect(profileState.profiles.map((p) => p.id), equals(['b', 'c', 'a'])); + + // Move profile at index 2 (A) to index 1 (should become B, A, C) + profileState.reorderProfiles(2, 1); + expect(profileState.profiles.map((p) => p.id), equals(['b', 'a', 'c'])); + }); + + test('should maintain enabled status after reordering', () { + final profileState = ProfileState(); + + final profileA = NodeProfile(id: 'a', name: 'Profile A', tags: const {}); + final profileB = NodeProfile(id: 'b', name: 'Profile B', tags: const {}); + final profileC = NodeProfile(id: 'c', name: 'Profile C', tags: const {}); + + profileState.addOrUpdateProfile(profileA); + profileState.addOrUpdateProfile(profileB); + profileState.addOrUpdateProfile(profileC); + + // Disable profile B + profileState.toggleProfile(profileB, false); + expect(profileState.isEnabled(profileB), isFalse); + + // Reorder profiles + profileState.reorderProfiles(0, 2); + + // Profile B should still be disabled after reordering + expect(profileState.isEnabled(profileB), isFalse); + expect(profileState.isEnabled(profileA), isTrue); + expect(profileState.isEnabled(profileC), isTrue); + }); + }); }); } From 57df8e83a7d16d0e6f433404f3ce0c039c91a34d Mon Sep 17 00:00:00 2001 From: stopflock Date: Mon, 2 Mar 2026 13:56:07 -0600 Subject: [PATCH 12/18] fix tests for profile order, add correct migration --- lib/migrations.dart | 30 +++++++++++++++++++ lib/services/changelog_service.dart | 4 +++ lib/state/profile_state.dart | 46 +++++++++++++++++++++++------ test/models/node_profile_test.dart | 32 +++++++++++--------- 4 files changed, 89 insertions(+), 23 deletions(-) diff --git a/lib/migrations.dart b/lib/migrations.dart index ec8e2fa5..b74a52b4 100644 --- a/lib/migrations.dart +++ b/lib/migrations.dart @@ -114,6 +114,34 @@ class OneTimeMigrations { } } + /// Initialize profile ordering for existing users (v2.7.3) + static Future migrate_2_7_3(AppState appState) async { + try { + final prefs = await SharedPreferences.getInstance(); + const orderKey = 'profile_order'; + + // Check if user already has custom profile ordering + if (prefs.containsKey(orderKey)) { + debugPrint('[Migration] 2.7.3: Profile order already exists, skipping'); + return; + } + + // Initialize with current profile order (preserves existing UI order) + final currentProfiles = appState.profiles; + final initialOrder = currentProfiles.map((p) => p.id).toList(); + + if (initialOrder.isNotEmpty) { + await prefs.setStringList(orderKey, initialOrder); + debugPrint('[Migration] 2.7.3: Initialized profile order with ${initialOrder.length} profiles'); + } + + debugPrint('[Migration] 2.7.3 completed: initialized profile ordering'); + } catch (e) { + debugPrint('[Migration] 2.7.3 ERROR: Failed to initialize profile ordering: $e'); + // Don't rethrow - this is non-critical, profiles will just use default order + } + } + /// Get the migration function for a specific version static Future Function(AppState)? getMigrationForVersion(String version) { switch (version) { @@ -127,6 +155,8 @@ class OneTimeMigrations { return migrate_1_8_0; case '2.1.0': return migrate_2_1_0; + case '2.7.3': + return migrate_2_7_3; default: return null; } diff --git a/lib/services/changelog_service.dart b/lib/services/changelog_service.dart index 9244d70e..0ee3be49 100644 --- a/lib/services/changelog_service.dart +++ b/lib/services/changelog_service.dart @@ -225,6 +225,10 @@ class ChangelogService { versionsNeedingMigration.add('1.6.3'); } + if (needsMigration(lastSeenVersion, currentVersion, '2.7.3')) { + versionsNeedingMigration.add('2.7.3'); + } + // Future versions can be added here // if (needsMigration(lastSeenVersion, currentVersion, '2.0.0')) { // versionsNeedingMigration.add('2.0.0'); diff --git a/lib/state/profile_state.dart b/lib/state/profile_state.dart index 117b6592..8b2c5c9e 100644 --- a/lib/state/profile_state.dart +++ b/lib/state/profile_state.dart @@ -12,6 +12,12 @@ class ProfileState extends ChangeNotifier { final Set _enabled = {}; List _customOrder = []; // List of profile IDs in user's preferred order + // Test-only getters for accessing private state + @visibleForTesting + List get internalProfiles => _profiles; + @visibleForTesting + Set get internalEnabled => _enabled; + // Callback for when a profile is deleted (used to clear stale sessions) void Function(NodeProfile)? _onProfileDeleted; @@ -75,7 +81,7 @@ class ProfileState extends ChangeNotifier { _enabled.add(p); _saveEnabledProfiles(); } - ProfileService().save(_profiles); + _saveProfilesToStorage(); notifyListeners(); } @@ -89,7 +95,7 @@ class ProfileState extends ChangeNotifier { _enabled.add(builtIn); } _saveEnabledProfiles(); - ProfileService().save(_profiles); + _saveProfilesToStorage(); // Notify about profile deletion so other parts can clean up _onProfileDeleted?.call(p); @@ -100,6 +106,8 @@ class ProfileState extends ChangeNotifier { // Reorder profiles (for drag-and-drop in settings) void reorderProfiles(int oldIndex, int newIndex) { final orderedProfiles = _getOrderedProfiles(); + + // Standard Flutter reordering logic if (oldIndex < newIndex) { newIndex -= 1; } @@ -138,16 +146,36 @@ class ProfileState extends ChangeNotifier { // Save enabled profile IDs to disk Future _saveEnabledProfiles() async { - final prefs = await SharedPreferences.getInstance(); - await prefs.setStringList( - _enabledPrefsKey, - _enabled.map((p) => p.id).toList(), - ); + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setStringList( + _enabledPrefsKey, + _enabled.map((p) => p.id).toList(), + ); + } catch (e) { + // Fail gracefully in tests or if SharedPreferences isn't available + debugPrint('[ProfileState] Failed to save enabled profiles: $e'); + } + } + + // Save profiles to storage + Future _saveProfilesToStorage() async { + try { + await ProfileService().save(_profiles); + } catch (e) { + // Fail gracefully in tests or if storage isn't available + debugPrint('[ProfileState] Failed to save profiles: $e'); + } } // Save custom order to disk Future _saveCustomOrder() async { - final prefs = await SharedPreferences.getInstance(); - await prefs.setStringList(_profileOrderPrefsKey, _customOrder); + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setStringList(_profileOrderPrefsKey, _customOrder); + } catch (e) { + // Fail gracefully in tests or if SharedPreferences isn't available + debugPrint('[ProfileState] Failed to save custom order: $e'); + } } } \ No newline at end of file diff --git a/test/models/node_profile_test.dart b/test/models/node_profile_test.dart index ea66324e..64a8cbee 100644 --- a/test/models/node_profile_test.dart +++ b/test/models/node_profile_test.dart @@ -5,6 +5,10 @@ import 'package:deflockapp/models/osm_node.dart'; import 'package:deflockapp/state/profile_state.dart'; void main() { + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + }); + group('NodeProfile', () { test('toJson/fromJson round-trip preserves all fields', () { final profile = NodeProfile( @@ -202,28 +206,28 @@ void main() { }); group('ProfileState reordering', () { - test('should reorder profiles correctly', () { + test('should reorder profiles correctly', () async { final profileState = ProfileState(); - // Add some test profiles + // Add some test profiles directly to avoid storage operations final profileA = NodeProfile(id: 'a', name: 'Profile A', tags: const {}); final profileB = NodeProfile(id: 'b', name: 'Profile B', tags: const {}); final profileC = NodeProfile(id: 'c', name: 'Profile C', tags: const {}); - profileState.addOrUpdateProfile(profileA); - profileState.addOrUpdateProfile(profileB); - profileState.addOrUpdateProfile(profileC); + // Add profiles directly to the internal list to avoid storage + profileState.internalProfiles.addAll([profileA, profileB, profileC]); + profileState.internalEnabled.addAll([profileA, profileB, profileC]); // Initial order should be A, B, C expect(profileState.profiles.map((p) => p.id), equals(['a', 'b', 'c'])); - // Move profile at index 0 (A) to index 2 (should become B, C, A) + // Move profile at index 0 (A) to index 2 (should become B, A, C due to Flutter's reorder logic) profileState.reorderProfiles(0, 2); - expect(profileState.profiles.map((p) => p.id), equals(['b', 'c', 'a'])); - - // Move profile at index 2 (A) to index 1 (should become B, A, C) - profileState.reorderProfiles(2, 1); expect(profileState.profiles.map((p) => p.id), equals(['b', 'a', 'c'])); + + // Move profile at index 1 (A) to index 0 (should become A, B, C) + profileState.reorderProfiles(1, 0); + expect(profileState.profiles.map((p) => p.id), equals(['a', 'b', 'c'])); }); test('should maintain enabled status after reordering', () { @@ -233,12 +237,12 @@ void main() { final profileB = NodeProfile(id: 'b', name: 'Profile B', tags: const {}); final profileC = NodeProfile(id: 'c', name: 'Profile C', tags: const {}); - profileState.addOrUpdateProfile(profileA); - profileState.addOrUpdateProfile(profileB); - profileState.addOrUpdateProfile(profileC); + // Add profiles directly to avoid storage operations + profileState.internalProfiles.addAll([profileA, profileB, profileC]); + profileState.internalEnabled.addAll([profileA, profileB, profileC]); // Disable profile B - profileState.toggleProfile(profileB, false); + profileState.internalEnabled.remove(profileB); expect(profileState.isEnabled(profileB), isFalse); // Reorder profiles From 5728b4f70fbdbdf5df26b4190363325fb1e84ef9 Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Sat, 7 Mar 2026 11:02:41 -0700 Subject: [PATCH 13/18] Force simulate mode when OSM OAuth secrets are missing Preview/PR builds don't have access to GitHub Secrets, so the OAuth client IDs are empty. Previously this caused a runtime crash from keys.dart throwing on empty values. Now we detect missing secrets and force simulate mode, which already fully supports fake auth and uploads. Also fixes a latent bug where forceLogin() would crash with LateInitializationError in simulate mode since _helper is never initialized when OAuth setup is skipped. Co-Authored-By: Claude Opus 4.6 --- lib/keys.dart | 16 ++-- .../sections/upload_mode_section.dart | 2 + lib/services/auth_service.dart | 5 +- lib/state/settings_state.dart | 25 +++++-- test/state/settings_state_test.dart | 73 +++++++++++++++++++ 5 files changed, 106 insertions(+), 15 deletions(-) create mode 100644 test/state/settings_state_test.dart diff --git a/lib/keys.dart b/lib/keys.dart index ed9a52d5..7c193815 100644 --- a/lib/keys.dart +++ b/lib/keys.dart @@ -1,16 +1,20 @@ // OpenStreetMap OAuth client IDs for this app. // These must be provided via --dart-define at build time. +/// Whether OSM OAuth secrets were provided at build time. +/// When false, the app should force simulate mode. +bool get kHasOsmSecrets { + const prod = String.fromEnvironment('OSM_PROD_CLIENTID'); + const sandbox = String.fromEnvironment('OSM_SANDBOX_CLIENTID'); + return prod.isNotEmpty && sandbox.isNotEmpty; +} + String get kOsmProdClientId { const fromBuild = String.fromEnvironment('OSM_PROD_CLIENTID'); - if (fromBuild.isNotEmpty) return fromBuild; - - throw Exception('OSM_PROD_CLIENTID not configured. Use --dart-define=OSM_PROD_CLIENTID=your_id'); + return fromBuild; } String get kOsmSandboxClientId { const fromBuild = String.fromEnvironment('OSM_SANDBOX_CLIENTID'); - if (fromBuild.isNotEmpty) return fromBuild; - - throw Exception('OSM_SANDBOX_CLIENTID not configured. Use --dart-define=OSM_SANDBOX_CLIENTID=your_id'); + return fromBuild; } \ No newline at end of file diff --git a/lib/screens/settings/sections/upload_mode_section.dart b/lib/screens/settings/sections/upload_mode_section.dart index 61da3793..568196e2 100644 --- a/lib/screens/settings/sections/upload_mode_section.dart +++ b/lib/screens/settings/sections/upload_mode_section.dart @@ -23,6 +23,8 @@ class UploadModeSection extends StatelessWidget { subtitle: Text(locService.t('uploadMode.subtitle')), trailing: DropdownButton( value: appState.uploadMode, + // This entire section is gated behind kEnableDevelopmentModes + // in osm_account_screen.dart, so all modes are always available here. items: [ DropdownMenuItem( value: UploadMode.production, diff --git a/lib/services/auth_service.dart b/lib/services/auth_service.dart index 481be1c1..e92f5d48 100644 --- a/lib/services/auth_service.dart +++ b/lib/services/auth_service.dart @@ -36,6 +36,7 @@ class AuthService { void setUploadMode(UploadMode mode) { _mode = mode; + if (mode == UploadMode.simulate || !kHasOsmSecrets) return; final isSandbox = (mode == UploadMode.sandbox); final authBase = isSandbox ? 'https://master.apis.dev.openstreetmap.org' @@ -150,7 +151,9 @@ class AuthService { // Force a fresh login by clearing stored tokens Future forceLogin() async { - await _helper.removeAllTokens(); + if (_mode != UploadMode.simulate) { + await _helper.removeAllTokens(); + } _displayName = null; return await login(); } diff --git a/lib/state/settings_state.dart b/lib/state/settings_state.dart index c2529b53..7fbc6d09 100644 --- a/lib/state/settings_state.dart +++ b/lib/state/settings_state.dart @@ -5,6 +5,7 @@ import 'package:collection/collection.dart'; import '../models/tile_provider.dart'; import '../dev_config.dart'; +import '../keys.dart'; // Enum for upload mode (Production, OSM Sandbox, Simulate) enum UploadMode { production, sandbox, simulate } @@ -41,7 +42,8 @@ class SettingsState extends ChangeNotifier { bool _offlineMode = false; bool _pauseQueueProcessing = false; int _maxNodes = kDefaultMaxNodes; - UploadMode _uploadMode = kEnableDevelopmentModes ? UploadMode.simulate : UploadMode.production; + // Default must account for missing secrets (preview builds) even before init() runs + UploadMode _uploadMode = (kEnableDevelopmentModes || !kHasOsmSecrets) ? UploadMode.simulate : UploadMode.production; FollowMeMode _followMeMode = FollowMeMode.follow; bool _proximityAlertsEnabled = false; int _proximityAlertDistance = kProximityAlertDefaultDistance; @@ -150,8 +152,16 @@ class SettingsState extends ChangeNotifier { await prefs.setInt(_uploadModePrefsKey, _uploadMode.index); } - // In production builds, force production mode if development modes are disabled - if (!kEnableDevelopmentModes && _uploadMode != UploadMode.production) { + // Override persisted upload mode when the current build configuration + // doesn't support it. This handles two cases: + // 1. Preview/PR builds without OAuth secrets — force simulate to avoid crashes + // 2. Production builds — force production (prefs may have sandbox/simulate + // from a previous dev build on the same device) + if (!kHasOsmSecrets && _uploadMode != UploadMode.simulate) { + debugPrint('SettingsState: No OSM secrets available, forcing simulate mode'); + _uploadMode = UploadMode.simulate; + await prefs.setInt(_uploadModePrefsKey, _uploadMode.index); + } else if (kHasOsmSecrets && !kEnableDevelopmentModes && _uploadMode != UploadMode.production) { debugPrint('SettingsState: Development modes disabled, forcing production mode'); _uploadMode = UploadMode.production; await prefs.setInt(_uploadModePrefsKey, _uploadMode.index); @@ -258,11 +268,10 @@ class SettingsState extends ChangeNotifier { } Future setUploadMode(UploadMode mode) async { - // In production builds, only allow production mode - if (!kEnableDevelopmentModes && mode != UploadMode.production) { - debugPrint('SettingsState: Development modes disabled, forcing production mode'); - mode = UploadMode.production; - } + // The upload mode dropdown is only visible when kEnableDevelopmentModes is + // true (gated in osm_account_screen.dart), so no secrets/dev-mode guards + // are needed here. The init() method handles forcing the correct mode on + // startup for production builds and builds without OAuth secrets. _uploadMode = mode; final prefs = await SharedPreferences.getInstance(); diff --git a/test/state/settings_state_test.dart b/test/state/settings_state_test.dart new file mode 100644 index 00000000..8e56a242 --- /dev/null +++ b/test/state/settings_state_test.dart @@ -0,0 +1,73 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:deflockapp/state/settings_state.dart'; +import 'package:deflockapp/keys.dart'; + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + group('kHasOsmSecrets (no --dart-define)', () { + test('is false when built without secrets', () { + expect(kHasOsmSecrets, isFalse); + }); + + test('client ID getters return empty strings instead of throwing', () { + expect(kOsmProdClientId, isEmpty); + expect(kOsmSandboxClientId, isEmpty); + }); + }); + + group('SettingsState without secrets', () { + test('defaults to simulate mode', () { + final state = SettingsState(); + expect(state.uploadMode, UploadMode.simulate); + }); + + test('init() forces simulate even if prefs has production stored', () async { + SharedPreferences.setMockInitialValues({ + 'upload_mode': UploadMode.production.index, + }); + + final state = SettingsState(); + await state.init(); + + expect(state.uploadMode, UploadMode.simulate); + + // Verify it persisted the override + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getInt('upload_mode'), UploadMode.simulate.index); + }); + + test('init() forces simulate even if prefs has sandbox stored', () async { + SharedPreferences.setMockInitialValues({ + 'upload_mode': UploadMode.sandbox.index, + }); + + final state = SettingsState(); + await state.init(); + + expect(state.uploadMode, UploadMode.simulate); + }); + + test('init() keeps simulate if already simulate', () async { + SharedPreferences.setMockInitialValues({ + 'upload_mode': UploadMode.simulate.index, + }); + + final state = SettingsState(); + await state.init(); + + expect(state.uploadMode, UploadMode.simulate); + }); + + test('setUploadMode() allows simulate', () async { + final state = SettingsState(); + await state.setUploadMode(UploadMode.simulate); + + expect(state.uploadMode, UploadMode.simulate); + }); + }); +} From 2d92214bedb5436a31dfee678e07c89fdea1ea62 Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Tue, 3 Mar 2026 14:33:26 -0700 Subject: [PATCH 14/18] Add offline-first tile system with per-provider caching and error retry - Add ServicePolicy framework with OSM-specific rate limiting and TTL - Add per-provider disk tile cache (ProviderTileCacheStore) with O(1) lookup, oldest-modified eviction, and ETag/304 revalidation - Rewrite DeflockTileProvider with two paths: common (NetworkTileProvider) and offline-first (disk cache -> local tiles -> network with caching) - Add zoom-aware offline routing so tiles outside offline area zoom ranges use the efficient common path instead of the overhead-heavy offline path - Fix HTTP client lifecycle: dispose() is now a no-op for flutter_map widget recycling; shutdown() handles permanent teardown - Add TileLayerManager with exponential backoff retry (2s->60s cap), provider switch detection, and backoff reset - Guard null provider/tileType in download dialog with localized error - Fix Nominatim cache key to use normalized viewbox values - Comprehensive test coverage (1800+ lines across 6 test files) Co-Authored-By: Claude Opus 4.6 --- .gitignore | 3 +- lib/localizations/de.json | 20 +- lib/localizations/en.json | 24 +- lib/localizations/es.json | 20 +- lib/localizations/fr.json | 20 +- lib/localizations/it.json | 20 +- lib/localizations/nl.json | 12 +- lib/localizations/pl.json | 12 +- lib/localizations/pt.json | 20 +- lib/localizations/tr.json | 12 +- lib/localizations/uk.json | 12 +- lib/localizations/zh.json | 20 +- lib/main.dart | 8 +- lib/models/tile_provider.dart | 13 +- lib/services/deflock_tile_provider.dart | 403 ++++++++++---- .../nodes_from_osm_api.dart | 26 +- .../map_data_submodules/tiles_from_local.dart | 59 +- lib/services/offline_area_service.dart | 29 +- .../offline_areas/offline_area_models.dart | 5 +- .../offline_areas/offline_tile_utils.dart | 25 +- lib/services/provider_tile_cache_manager.dart | 103 ++++ lib/services/provider_tile_cache_store.dart | 313 +++++++++++ lib/services/search_service.dart | 124 ++++- lib/services/service_policy.dart | 400 ++++++++++++++ lib/widgets/download_area_dialog.dart | 82 ++- lib/widgets/map/map_overlays.dart | 93 +++- lib/widgets/map/tile_layer_manager.dart | 221 +++++++- lib/widgets/map_view.dart | 13 +- pubspec.lock | 2 +- pubspec.yaml | 1 + test/services/deflock_tile_provider_test.dart | 424 +++++++++++++-- test/services/offline_area_service_test.dart | 93 ++++ .../provider_tile_cache_store_test.dart | 509 ++++++++++++++++++ test/services/service_policy_test.dart | 426 +++++++++++++++ test/services/tiles_from_local_test.dart | 227 ++++++++ test/widgets/map/tile_layer_manager_test.dart | 487 +++++++++++++++++ 36 files changed, 3935 insertions(+), 346 deletions(-) create mode 100644 lib/services/provider_tile_cache_manager.dart create mode 100644 lib/services/provider_tile_cache_store.dart create mode 100644 lib/services/service_policy.dart create mode 100644 test/services/offline_area_service_test.dart create mode 100644 test/services/provider_tile_cache_store_test.dart create mode 100644 test/services/service_policy_test.dart create mode 100644 test/services/tiles_from_local_test.dart create mode 100644 test/widgets/map/tile_layer_manager_test.dart diff --git a/.gitignore b/.gitignore index d03c8937..db2e32aa 100644 --- a/.gitignore +++ b/.gitignore @@ -73,12 +73,13 @@ fuchsia/build/ web/build/ # ─────────────────────────────── -# IDE / Editor Settings +# IDE / Editor / AI Tool Settings # ─────────────────────────────── .idea/ .idea/**/workspace.xml .idea/**/tasks.xml .vscode/ +.claude/settings.local.json # Swap files *.swp *.swo diff --git a/lib/localizations/de.json b/lib/localizations/de.json index 47b41da1..cd9bdae6 100644 --- a/lib/localizations/de.json +++ b/lib/localizations/de.json @@ -144,7 +144,10 @@ "offlineModeWarning": "Downloads im Offline-Modus deaktiviert. Deaktivieren Sie den Offline-Modus, um neue Bereiche herunterzuladen.", "areaTooBigMessage": "Zoomen Sie auf mindestens Stufe {} heran, um Offline-Bereiche herunterzuladen. Downloads großer Gebiete können die App zum Absturz bringen.", "downloadStarted": "Download gestartet! Lade Kacheln und Knoten...", - "downloadFailed": "Download konnte nicht gestartet werden: {}" + "downloadFailed": "Download konnte nicht gestartet werden: {}", + "offlineNotPermitted": "Der {}-Server erlaubt keine Offline-Downloads. Wechseln Sie zu einem Kachelanbieter, der Offline-Nutzung unterstützt (z. B. Bing Maps, Mapbox oder ein selbst gehosteter Kachelserver).", + "currentTileProvider": "aktuelle Kachel", + "noTileProviderSelected": "Kein Kachelanbieter ausgewählt. Bitte wählen Sie einen Kartenstil, bevor Sie einen Offlinebereich herunterladen." }, "downloadStarted": { "title": "Download gestartet", @@ -292,13 +295,16 @@ "addProfileChoiceMessage": "Wie möchten Sie ein Profil hinzufügen?", "createCustomProfile": "Benutzerdefiniertes Profil Erstellen", "createCustomProfileDescription": "Erstellen Sie ein Profil von Grund auf mit Ihren eigenen Tags", - "importFromWebsite": "Von Webseite Importieren", + "importFromWebsite": "Von Webseite Importieren", "importFromWebsiteDescription": "Profile von deflock.me/identify durchsuchen und importieren" }, "mapTiles": { "title": "Karten-Kacheln", "manageProviders": "Anbieter Verwalten", - "attribution": "Karten-Zuschreibung" + "attribution": "Karten-Zuschreibung", + "mapAttribution": "Kartenquelle: {}", + "couldNotOpenLink": "Link konnte nicht geöffnet werden", + "openLicense": "Lizenz öffnen: {}" }, "profileEditor": { "viewProfile": "Profil Anzeigen", @@ -325,7 +331,7 @@ }, "operatorProfileEditor": { "newOperatorProfile": "Neues Betreiber-Profil", - "editOperatorProfile": "Betreiber-Profil Bearbeiten", + "editOperatorProfile": "Betreiber-Profil Bearbeiten", "operatorName": "Betreiber-Name", "operatorNameHint": "z.B. Polizei Austin", "operatorNameRequired": "Betreiber-Name ist erforderlich", @@ -520,7 +526,7 @@ "updateFailed": "Aktualisierung der verdächtigen Standorte fehlgeschlagen", "neverFetched": "Nie abgerufen", "daysAgo": "vor {} Tagen", - "hoursAgo": "vor {} Stunden", + "hoursAgo": "vor {} Stunden", "minutesAgo": "vor {} Minuten", "justNow": "Gerade eben" }, @@ -528,7 +534,7 @@ "title": "Verdächtiger Standort #{}", "ticketNo": "Ticket-Nr.", "address": "Adresse", - "street": "Straße", + "street": "Straße", "city": "Stadt", "state": "Bundesland", "intersectingStreet": "Kreuzende Straße", @@ -552,4 +558,4 @@ "metricDescription": "Metrisch (km, m)", "imperialDescription": "Imperial (mi, ft)" } -} \ No newline at end of file +} diff --git a/lib/localizations/en.json b/lib/localizations/en.json index fa62994e..7f7023b4 100644 --- a/lib/localizations/en.json +++ b/lib/localizations/en.json @@ -70,7 +70,7 @@ "submitAnyway": "Submit Anyway", "nodeType": { "alpr": "ALPR/ANPR Camera", - "publicCamera": "Public Surveillance Camera", + "publicCamera": "Public Surveillance Camera", "camera": "Surveillance Camera", "amenity": "{}", "device": "{} Device", @@ -181,7 +181,10 @@ "offlineModeWarning": "Downloads disabled while in offline mode. Disable offline mode to download new areas.", "areaTooBigMessage": "Zoom in to at least level {} to download offline areas. Large area downloads can cause the app to become unresponsive.", "downloadStarted": "Download started! Fetching tiles and nodes...", - "downloadFailed": "Failed to start download: {}" + "downloadFailed": "Failed to start download: {}", + "offlineNotPermitted": "The {} server does not permit offline downloads. Switch to a tile provider that allows offline use (e.g., Bing Maps, Mapbox, or a self-hosted tile server).", + "currentTileProvider": "current tile", + "noTileProviderSelected": "No tile provider is selected. Please select a map style before downloading an offline area." }, "downloadStarted": { "title": "Download Started", @@ -329,13 +332,16 @@ "addProfileChoiceMessage": "How would you like to add a profile?", "createCustomProfile": "Create Custom Profile", "createCustomProfileDescription": "Build a profile from scratch with your own tags", - "importFromWebsite": "Import from Website", + "importFromWebsite": "Import from Website", "importFromWebsiteDescription": "Browse and import profiles from deflock.me/identify" }, "mapTiles": { "title": "Map Tiles", "manageProviders": "Manage Providers", - "attribution": "Map Attribution" + "attribution": "Map Attribution", + "mapAttribution": "Map attribution: {}", + "couldNotOpenLink": "Could not open link", + "openLicense": "Open license: {}" }, "profileEditor": { "viewProfile": "View Profile", @@ -362,7 +368,7 @@ }, "operatorProfileEditor": { "newOperatorProfile": "New Operator Profile", - "editOperatorProfile": "Edit Operator Profile", + "editOperatorProfile": "Edit Operator Profile", "operatorName": "Operator name", "operatorNameHint": "e.g., Austin Police Department", "operatorNameRequired": "Operator name is required", @@ -443,7 +449,7 @@ "mobileEditors": "Mobile Editors", "iDEditor": "iD Editor", "iDEditorSubtitle": "Full-featured web editor - always works", - "rapidEditor": "RapiD Editor", + "rapidEditor": "RapiD Editor", "rapidEditorSubtitle": "AI-assisted editing with Facebook data", "vespucci": "Vespucci", "vespucciSubtitle": "Advanced Android OSM editor", @@ -520,7 +526,7 @@ "updateFailed": "Failed to update suspected locations", "neverFetched": "Never fetched", "daysAgo": "{} days ago", - "hoursAgo": "{} hours ago", + "hoursAgo": "{} hours ago", "minutesAgo": "{} minutes ago", "justNow": "Just now" }, @@ -528,7 +534,7 @@ "title": "Suspected Location #{}", "ticketNo": "Ticket No", "address": "Address", - "street": "Street", + "street": "Street", "city": "City", "state": "State", "intersectingStreet": "Intersecting Street", @@ -552,4 +558,4 @@ "metricDescription": "Metric (km, m)", "imperialDescription": "Imperial (mi, ft)" } -} \ No newline at end of file +} diff --git a/lib/localizations/es.json b/lib/localizations/es.json index 8cfe386e..423ca6d8 100644 --- a/lib/localizations/es.json +++ b/lib/localizations/es.json @@ -181,7 +181,10 @@ "offlineModeWarning": "Descargas deshabilitadas en modo sin conexión. Deshabilite el modo sin conexión para descargar nuevas áreas.", "areaTooBigMessage": "Amplíe al menos al nivel {} para descargar áreas sin conexión. Las descargas de áreas grandes pueden hacer que la aplicación deje de responder.", "downloadStarted": "¡Descarga iniciada! Obteniendo mosaicos y nodos...", - "downloadFailed": "Error al iniciar la descarga: {}" + "downloadFailed": "Error al iniciar la descarga: {}", + "offlineNotPermitted": "El servidor {} no permite descargas sin conexión. Cambie a un proveedor de mosaicos que permita el uso sin conexión (p. ej., Bing Maps, Mapbox o un servidor de mosaicos propio).", + "currentTileProvider": "mosaico actual", + "noTileProviderSelected": "No hay proveedor de mosaicos seleccionado. Seleccione un estilo de mapa antes de descargar un área sin conexión." }, "downloadStarted": { "title": "Descarga Iniciada", @@ -329,13 +332,16 @@ "addProfileChoiceMessage": "¿Cómo desea añadir un perfil?", "createCustomProfile": "Crear Perfil Personalizado", "createCustomProfileDescription": "Crear un perfil desde cero con sus propias etiquetas", - "importFromWebsite": "Importar desde Sitio Web", + "importFromWebsite": "Importar desde Sitio Web", "importFromWebsiteDescription": "Explorar e importar perfiles desde deflock.me/identify" }, "mapTiles": { "title": "Tiles de Mapa", "manageProviders": "Gestionar Proveedores", - "attribution": "Atribución del Mapa" + "attribution": "Atribución del Mapa", + "mapAttribution": "Atribución del mapa: {}", + "couldNotOpenLink": "No se pudo abrir el enlace", + "openLicense": "Abrir licencia: {}" }, "profileEditor": { "viewProfile": "Ver Perfil", @@ -362,7 +368,7 @@ }, "operatorProfileEditor": { "newOperatorProfile": "Nuevo Perfil de Operador", - "editOperatorProfile": "Editar Perfil de Operador", + "editOperatorProfile": "Editar Perfil de Operador", "operatorName": "Nombre del operador", "operatorNameHint": "ej., Departamento de Policía de Austin", "operatorNameRequired": "El nombre del operador es requerido", @@ -520,7 +526,7 @@ "updateFailed": "Error al actualizar ubicaciones sospechosas", "neverFetched": "Nunca obtenido", "daysAgo": "hace {} días", - "hoursAgo": "hace {} horas", + "hoursAgo": "hace {} horas", "minutesAgo": "hace {} minutos", "justNow": "Ahora mismo" }, @@ -528,7 +534,7 @@ "title": "Ubicación Sospechosa #{}", "ticketNo": "No. de Ticket", "address": "Dirección", - "street": "Calle", + "street": "Calle", "city": "Ciudad", "state": "Estado", "intersectingStreet": "Calle que Intersecta", @@ -552,4 +558,4 @@ "metricDescription": "Métrico (km, m)", "imperialDescription": "Imperial (mi, ft)" } -} \ No newline at end of file +} diff --git a/lib/localizations/fr.json b/lib/localizations/fr.json index 2dcb8951..b314a5b0 100644 --- a/lib/localizations/fr.json +++ b/lib/localizations/fr.json @@ -181,7 +181,10 @@ "offlineModeWarning": "Téléchargements désactivés en mode hors ligne. Désactivez le mode hors ligne pour télécharger de nouvelles zones.", "areaTooBigMessage": "Zoomez au moins au niveau {} pour télécharger des zones hors ligne. Les téléchargements de grandes zones peuvent rendre l'application non réactive.", "downloadStarted": "Téléchargement démarré ! Récupération des tuiles et nœuds...", - "downloadFailed": "Échec du démarrage du téléchargement: {}" + "downloadFailed": "Échec du démarrage du téléchargement: {}", + "offlineNotPermitted": "Le serveur {} ne permet pas les téléchargements hors ligne. Passez à un fournisseur de tuiles qui autorise l'utilisation hors ligne (par ex., Bing Maps, Mapbox ou un serveur de tuiles auto-hébergé).", + "currentTileProvider": "tuile actuelle", + "noTileProviderSelected": "Aucun fournisseur de tuiles sélectionné. Veuillez choisir un style de carte avant de télécharger une zone hors ligne." }, "downloadStarted": { "title": "Téléchargement Démarré", @@ -329,13 +332,16 @@ "addProfileChoiceMessage": "Comment souhaitez-vous ajouter un profil?", "createCustomProfile": "Créer Profil Personnalisé", "createCustomProfileDescription": "Créer un profil à partir de zéro avec vos propres balises", - "importFromWebsite": "Importer depuis Site Web", + "importFromWebsite": "Importer depuis Site Web", "importFromWebsiteDescription": "Parcourir et importer des profils depuis deflock.me/identify" }, "mapTiles": { "title": "Tuiles de Carte", "manageProviders": "Gérer Fournisseurs", - "attribution": "Attribution de Carte" + "attribution": "Attribution de Carte", + "mapAttribution": "Attribution de la carte : {}", + "couldNotOpenLink": "Impossible d'ouvrir le lien", + "openLicense": "Ouvrir la licence : {}" }, "profileEditor": { "viewProfile": "Voir Profil", @@ -362,7 +368,7 @@ }, "operatorProfileEditor": { "newOperatorProfile": "Nouveau Profil d'Opérateur", - "editOperatorProfile": "Modifier Profil d'Opérateur", + "editOperatorProfile": "Modifier Profil d'Opérateur", "operatorName": "Nom de l'opérateur", "operatorNameHint": "ex., Département de Police d'Austin", "operatorNameRequired": "Le nom de l'opérateur est requis", @@ -520,7 +526,7 @@ "updateFailed": "Échec de la mise à jour des emplacements suspects", "neverFetched": "Jamais récupéré", "daysAgo": "il y a {} jours", - "hoursAgo": "il y a {} heures", + "hoursAgo": "il y a {} heures", "minutesAgo": "il y a {} minutes", "justNow": "À l'instant" }, @@ -528,7 +534,7 @@ "title": "Emplacement Suspect #{}", "ticketNo": "N° de Ticket", "address": "Adresse", - "street": "Rue", + "street": "Rue", "city": "Ville", "state": "État", "intersectingStreet": "Rue Transversale", @@ -552,4 +558,4 @@ "metricDescription": "Métrique (km, m)", "imperialDescription": "Impérial (mi, ft)" } -} \ No newline at end of file +} diff --git a/lib/localizations/it.json b/lib/localizations/it.json index c61fe7f1..6602d76c 100644 --- a/lib/localizations/it.json +++ b/lib/localizations/it.json @@ -181,7 +181,10 @@ "offlineModeWarning": "Download disabilitati in modalità offline. Disabilita la modalità offline per scaricare nuove aree.", "areaTooBigMessage": "Ingrandisci almeno al livello {} per scaricare aree offline. I download di aree grandi possono rendere l'app non reattiva.", "downloadStarted": "Download avviato! Recupero tile e nodi...", - "downloadFailed": "Impossibile avviare il download: {}" + "downloadFailed": "Impossibile avviare il download: {}", + "offlineNotPermitted": "Il server {} non consente i download offline. Passa a un fornitore di tile che consenta l'uso offline (ad es., Bing Maps, Mapbox o un server di tile auto-ospitato).", + "currentTileProvider": "tile attuale", + "noTileProviderSelected": "Nessun provider di tile selezionato. Seleziona uno stile di mappa prima di scaricare un'area offline." }, "downloadStarted": { "title": "Download Avviato", @@ -329,13 +332,16 @@ "addProfileChoiceMessage": "Come desideri aggiungere un profilo?", "createCustomProfile": "Crea Profilo Personalizzato", "createCustomProfileDescription": "Crea un profilo da zero con i tuoi tag", - "importFromWebsite": "Importa da Sito Web", + "importFromWebsite": "Importa da Sito Web", "importFromWebsiteDescription": "Sfoglia e importa profili da deflock.me/identify" }, "mapTiles": { "title": "Tile Mappa", "manageProviders": "Gestisci Fornitori", - "attribution": "Attribuzione Mappa" + "attribution": "Attribuzione Mappa", + "mapAttribution": "Attribuzione mappa: {}", + "couldNotOpenLink": "Impossibile aprire il link", + "openLicense": "Apri licenza: {}" }, "profileEditor": { "viewProfile": "Visualizza Profilo", @@ -362,7 +368,7 @@ }, "operatorProfileEditor": { "newOperatorProfile": "Nuovo Profilo Operatore", - "editOperatorProfile": "Modifica Profilo Operatore", + "editOperatorProfile": "Modifica Profilo Operatore", "operatorName": "Nome operatore", "operatorNameHint": "es., Dipartimento di Polizia di Austin", "operatorNameRequired": "Il nome dell'operatore è obbligatorio", @@ -520,7 +526,7 @@ "updateFailed": "Aggiornamento posizioni sospette fallito", "neverFetched": "Mai recuperato", "daysAgo": "{} giorni fa", - "hoursAgo": "{} ore fa", + "hoursAgo": "{} ore fa", "minutesAgo": "{} minuti fa", "justNow": "Proprio ora" }, @@ -528,7 +534,7 @@ "title": "Posizione Sospetta #{}", "ticketNo": "N. Ticket", "address": "Indirizzo", - "street": "Via", + "street": "Via", "city": "Città", "state": "Stato", "intersectingStreet": "Via che Interseca", @@ -552,4 +558,4 @@ "metricDescription": "Metrico (km, m)", "imperialDescription": "Imperiale (mi, ft)" } -} \ No newline at end of file +} diff --git a/lib/localizations/nl.json b/lib/localizations/nl.json index 558cdbad..f9d0cd55 100644 --- a/lib/localizations/nl.json +++ b/lib/localizations/nl.json @@ -181,7 +181,10 @@ "offlineModeWarning": "Downloads uitgeschakeld in offline modus. Schakel offline modus uit om nieuwe gebieden te downloaden.", "areaTooBigMessage": "Zoom in tot ten minste niveau {} om offline gebieden te downloaden. Grote gebied downloads kunnen ervoor zorgen dat de app niet meer reageert.", "downloadStarted": "Download gestart! Tiles en nodes ophalen...", - "downloadFailed": "Download starten mislukt: {}" + "downloadFailed": "Download starten mislukt: {}", + "offlineNotPermitted": "De {}-server staat geen offline downloads toe. Schakel over naar een tegelserver die offline gebruik toestaat (bijv. Bing Maps, Mapbox of een zelf gehoste tegelserver).", + "currentTileProvider": "huidige tegel", + "noTileProviderSelected": "Geen tegelprovider geselecteerd. Selecteer een kaartstijl voordat u een offlinegebied downloadt." }, "downloadStarted": { "title": "Download Gestart", @@ -335,7 +338,10 @@ "mapTiles": { "title": "Kaart Tiles", "manageProviders": "Beheer Providers", - "attribution": "Kaart Attributie" + "attribution": "Kaart Attributie", + "mapAttribution": "Kaartbron: {}", + "couldNotOpenLink": "Kon link niet openen", + "openLicense": "Open licentie: {}" }, "profileEditor": { "viewProfile": "Bekijk Profiel", @@ -552,4 +558,4 @@ "metricDescription": "Metrisch (km, m)", "imperialDescription": "Imperiaal (mijl, ft)" } -} \ No newline at end of file +} diff --git a/lib/localizations/pl.json b/lib/localizations/pl.json index 3513368e..76fc22b4 100644 --- a/lib/localizations/pl.json +++ b/lib/localizations/pl.json @@ -181,7 +181,10 @@ "offlineModeWarning": "Pobieranie wyłączone w trybie offline. Wyłącz tryb offline, aby pobierać nowe obszary.", "areaTooBigMessage": "Przybliż do co najmniej poziomu {}, aby pobierać obszary offline. Duże pobieranie obszarów może sprawić, że aplikacja przestanie odpowiadać.", "downloadStarted": "Pobieranie rozpoczęte! Pobieranie kafelków i węzłów...", - "downloadFailed": "Nie udało się rozpocząć pobierania: {}" + "downloadFailed": "Nie udało się rozpocząć pobierania: {}", + "offlineNotPermitted": "Serwer {} nie zezwala na pobieranie offline. Przełącz się na dostawcę kafelków, który obsługuje tryb offline (np. Bing Maps, Mapbox lub samodzielnie hostowany serwer kafelków).", + "currentTileProvider": "bieżący kafelek", + "noTileProviderSelected": "Nie wybrano dostawcy kafelków. Wybierz styl mapy przed pobraniem obszaru offline." }, "downloadStarted": { "title": "Pobieranie Rozpoczęte", @@ -335,7 +338,10 @@ "mapTiles": { "title": "Kafelki Mapy", "manageProviders": "Zarządzaj Dostawcami", - "attribution": "Atrybucja Mapy" + "attribution": "Atrybucja Mapy", + "mapAttribution": "Źródło mapy: {}", + "couldNotOpenLink": "Nie udało się otworzyć linku", + "openLicense": "Otwórz licencję: {}" }, "profileEditor": { "viewProfile": "Zobacz Profil", @@ -552,4 +558,4 @@ "metricDescription": "Metryczny (km, m)", "imperialDescription": "Imperialny (mila, ft)" } -} \ No newline at end of file +} diff --git a/lib/localizations/pt.json b/lib/localizations/pt.json index 38e611b7..8366a549 100644 --- a/lib/localizations/pt.json +++ b/lib/localizations/pt.json @@ -181,7 +181,10 @@ "offlineModeWarning": "Downloads desabilitados no modo offline. Desative o modo offline para baixar novas áreas.", "areaTooBigMessage": "Amplie para pelo menos o nível {} para baixar áreas offline. Downloads de áreas grandes podem tornar o aplicativo não responsivo.", "downloadStarted": "Download iniciado! Buscando tiles e nós...", - "downloadFailed": "Falha ao iniciar o download: {}" + "downloadFailed": "Falha ao iniciar o download: {}", + "offlineNotPermitted": "O servidor {} não permite downloads offline. Mude para um provedor de tiles que permita uso offline (por ex., Bing Maps, Mapbox ou um servidor de tiles próprio).", + "currentTileProvider": "tile atual", + "noTileProviderSelected": "Nenhum provedor de tiles selecionado. Selecione um estilo de mapa antes de baixar uma área offline." }, "downloadStarted": { "title": "Download Iniciado", @@ -329,13 +332,16 @@ "addProfileChoiceMessage": "Como gostaria de adicionar um perfil?", "createCustomProfile": "Criar Perfil Personalizado", "createCustomProfileDescription": "Construir um perfil do zero com suas próprias tags", - "importFromWebsite": "Importar do Site", + "importFromWebsite": "Importar do Site", "importFromWebsiteDescription": "Navegar e importar perfis do deflock.me/identify" }, "mapTiles": { "title": "Tiles do Mapa", "manageProviders": "Gerenciar Provedores", - "attribution": "Atribuição do Mapa" + "attribution": "Atribuição do Mapa", + "mapAttribution": "Atribuição do mapa: {}", + "couldNotOpenLink": "Não foi possível abrir o link", + "openLicense": "Abrir licença: {}" }, "profileEditor": { "viewProfile": "Ver Perfil", @@ -362,7 +368,7 @@ }, "operatorProfileEditor": { "newOperatorProfile": "Novo Perfil de Operador", - "editOperatorProfile": "Editar Perfil de Operador", + "editOperatorProfile": "Editar Perfil de Operador", "operatorName": "Nome do operador", "operatorNameHint": "ex., Departamento de Polícia de Austin", "operatorNameRequired": "Nome do operador é obrigatório", @@ -520,7 +526,7 @@ "updateFailed": "Falha ao atualizar localizações suspeitas", "neverFetched": "Nunca buscado", "daysAgo": "{} dias atrás", - "hoursAgo": "{} horas atrás", + "hoursAgo": "{} horas atrás", "minutesAgo": "{} minutos atrás", "justNow": "Agora mesmo" }, @@ -528,7 +534,7 @@ "title": "Localização Suspeita #{}", "ticketNo": "N° do Ticket", "address": "Endereço", - "street": "Rua", + "street": "Rua", "city": "Cidade", "state": "Estado", "intersectingStreet": "Rua que Cruza", @@ -552,4 +558,4 @@ "metricDescription": "Métrico (km, m)", "imperialDescription": "Imperial (mi, ft)" } -} \ No newline at end of file +} diff --git a/lib/localizations/tr.json b/lib/localizations/tr.json index f2934682..06fc9adf 100644 --- a/lib/localizations/tr.json +++ b/lib/localizations/tr.json @@ -181,7 +181,10 @@ "offlineModeWarning": "Çevrimdışı moddayken indirmeler devre dışı. Yeni alanları indirmek için çevrimdışı modu devre dışı bırakın.", "areaTooBigMessage": "Çevrimdışı alanları indirmek için en az {} seviyesine yakınlaştırın. Büyük alan indirmeleri uygulamanın yanıt vermemesine neden olabilir.", "downloadStarted": "İndirme başladı! Döşemeler ve düğümler getiriliyor...", - "downloadFailed": "İndirme başlatılamadı: {}" + "downloadFailed": "İndirme başlatılamadı: {}", + "offlineNotPermitted": "{} sunucusu çevrimdışı indirmelere izin vermiyor. Çevrimdışı kullanıma izin veren bir döşeme sağlayıcısına geçin (ör. Bing Maps, Mapbox veya kendi barındırdığınız bir döşeme sunucusu).", + "currentTileProvider": "mevcut döşeme", + "noTileProviderSelected": "Döşeme sağlayıcı seçilmedi. Çevrimdışı alan indirmeden önce lütfen bir harita stili seçin." }, "downloadStarted": { "title": "İndirme Başladı", @@ -335,7 +338,10 @@ "mapTiles": { "title": "Harita Döşemeleri", "manageProviders": "Sağlayıcıları Yönet", - "attribution": "Harita Atfı" + "attribution": "Harita Atfı", + "mapAttribution": "Harita kaynağı: {}", + "couldNotOpenLink": "Bağlantı açılamadı", + "openLicense": "Lisansı aç: {}" }, "profileEditor": { "viewProfile": "Profili Görüntüle", @@ -552,4 +558,4 @@ "metricDescription": "Metrik (km, m)", "imperialDescription": "İmperial (mil, ft)" } -} \ No newline at end of file +} diff --git a/lib/localizations/uk.json b/lib/localizations/uk.json index e8f208e9..499f1a24 100644 --- a/lib/localizations/uk.json +++ b/lib/localizations/uk.json @@ -181,7 +181,10 @@ "offlineModeWarning": "Завантаження вимкнено в офлайн режимі. Вимкніть офлайн режим для завантаження нових областей.", "areaTooBigMessage": "Збільште масштаб до принаймні рівня {} для завантаження офлайн областей. Великі завантаження областей можуть призвести до того, що додаток перестане відповідати.", "downloadStarted": "Завантаження почалося! Отримання плиток та вузлів...", - "downloadFailed": "Не вдалося почати завантаження: {}" + "downloadFailed": "Не вдалося почати завантаження: {}", + "offlineNotPermitted": "Сервер {} не дозволяє офлайн-завантаження. Перейдіть на постачальника плиток, який дозволяє офлайн-використання (наприклад, Bing Maps, Mapbox або власний сервер плиток).", + "currentTileProvider": "поточна плитка", + "noTileProviderSelected": "Постачальник плиток не вибраний. Виберіть стиль карти перед завантаженням офлайн-області." }, "downloadStarted": { "title": "Завантаження Почалося", @@ -335,7 +338,10 @@ "mapTiles": { "title": "Плитки Карти", "manageProviders": "Управляти Постачальниками", - "attribution": "Атрибуція Карти" + "attribution": "Атрибуція Карти", + "mapAttribution": "Джерело карти: {}", + "couldNotOpenLink": "Не вдалося відкрити посилання", + "openLicense": "Відкрити ліцензію: {}" }, "profileEditor": { "viewProfile": "Переглянути Профіль", @@ -552,4 +558,4 @@ "metricDescription": "Метричні (км, м)", "imperialDescription": "Імперські (миля, фут)" } -} \ No newline at end of file +} diff --git a/lib/localizations/zh.json b/lib/localizations/zh.json index ab446840..00695588 100644 --- a/lib/localizations/zh.json +++ b/lib/localizations/zh.json @@ -181,7 +181,10 @@ "offlineModeWarning": "离线模式下禁用下载。禁用离线模式以下载新区域。", "areaTooBigMessage": "请放大至至少第{}级来下载离线区域。下载大区域可能导致应用程序无响应。", "downloadStarted": "下载已开始!正在获取瓦片和节点...", - "downloadFailed": "启动下载失败:{}" + "downloadFailed": "启动下载失败:{}", + "offlineNotPermitted": "{}服务器不允许离线下载。请切换到允许离线使用的瓦片提供商(例如 Bing Maps、Mapbox 或自托管的瓦片服务器)。", + "currentTileProvider": "当前瓦片", + "noTileProviderSelected": "未选择瓦片提供商。请在下载离线区域之前选择地图样式。" }, "downloadStarted": { "title": "下载已开始", @@ -329,13 +332,16 @@ "addProfileChoiceMessage": "您希望如何添加配置文件?", "createCustomProfile": "创建自定义配置文件", "createCustomProfileDescription": "从头开始构建带有您自己标签的配置文件", - "importFromWebsite": "从网站导入", + "importFromWebsite": "从网站导入", "importFromWebsiteDescription": "浏览并从 deflock.me/identify 导入配置文件" }, "mapTiles": { "title": "地图瓦片", "manageProviders": "管理提供商", - "attribution": "地图归属" + "attribution": "地图归属", + "mapAttribution": "地图来源:{}", + "couldNotOpenLink": "无法打开链接", + "openLicense": "打开许可证:{}" }, "profileEditor": { "viewProfile": "查看配置文件", @@ -362,7 +368,7 @@ }, "operatorProfileEditor": { "newOperatorProfile": "新建运营商配置文件", - "editOperatorProfile": "编辑运营商配置文件", + "editOperatorProfile": "编辑运营商配置文件", "operatorName": "运营商名称", "operatorNameHint": "例如,奥斯汀警察局", "operatorNameRequired": "运营商名称为必填项", @@ -520,7 +526,7 @@ "updateFailed": "疑似位置更新失败", "neverFetched": "从未获取", "daysAgo": "{}天前", - "hoursAgo": "{}小时前", + "hoursAgo": "{}小时前", "minutesAgo": "{}分钟前", "justNow": "刚刚" }, @@ -528,7 +534,7 @@ "title": "疑似位置 #{}", "ticketNo": "工单号", "address": "地址", - "street": "街道", + "street": "街道", "city": "城市", "state": "州/省", "intersectingStreet": "交叉街道", @@ -552,4 +558,4 @@ "metricDescription": "公制 (公里, 米)", "imperialDescription": "英制 (英里, 英尺)" } -} \ No newline at end of file +} diff --git a/lib/main.dart b/lib/main.dart index 9bd2d565..ca0445b8 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -14,6 +14,7 @@ import 'screens/release_notes_screen.dart'; import 'screens/osm_account_screen.dart'; import 'screens/upload_queue_screen.dart'; import 'services/localization_service.dart'; +import 'services/provider_tile_cache_manager.dart'; import 'services/version_service.dart'; import 'services/deep_link_service.dart'; @@ -21,13 +22,16 @@ import 'services/deep_link_service.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); - + // Initialize version service await VersionService().init(); - + // Initialize localization service await LocalizationService.instance.init(); + // Resolve platform cache directory for per-provider tile caching + await ProviderTileCacheManager.init(); + // Initialize deep link service await DeepLinkService().init(); DeepLinkService().setNavigatorKey(_navigatorKey); diff --git a/lib/models/tile_provider.dart b/lib/models/tile_provider.dart index 8f7d81c4..5304d33e 100644 --- a/lib/models/tile_provider.dart +++ b/lib/models/tile_provider.dart @@ -1,6 +1,8 @@ import 'dart:convert'; import 'dart:typed_data'; +import '../services/service_policy.dart'; + /// A specific tile type within a provider class TileType { final String id; @@ -10,7 +12,7 @@ class TileType { final Uint8List? previewTile; // Single tile image data for preview final int maxZoom; // Maximum zoom level for this tile type - const TileType({ + TileType({ required this.id, required this.name, required this.urlTemplate, @@ -76,6 +78,15 @@ class TileType { /// Check if this tile type needs an API key bool get requiresApiKey => urlTemplate.contains('{api_key}'); + /// The service policy that applies to this tile type's server. + /// Cached because [urlTemplate] is immutable. + late final ServicePolicy servicePolicy = + ServicePolicyResolver.resolve(urlTemplate); + + /// Whether this tile server's usage policy permits offline/bulk downloading. + /// Resolved via [ServicePolicyResolver] from the URL template. + bool get allowsOfflineDownload => servicePolicy.allowsOfflineDownload; + Map toJson() => { 'id': id, 'name': name, diff --git a/lib/services/deflock_tile_provider.dart b/lib/services/deflock_tile_provider.dart index 707f0155..9ab86cb3 100644 --- a/lib/services/deflock_tile_provider.dart +++ b/lib/services/deflock_tile_provider.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'dart:ui'; import 'package:flutter_map/flutter_map.dart'; @@ -8,55 +9,103 @@ import 'package:http/http.dart'; import 'package:http/retry.dart'; import '../app_state.dart'; +import '../models/tile_provider.dart' as models; import 'http_client.dart'; import 'map_data_submodules/tiles_from_local.dart'; import 'offline_area_service.dart'; +/// Thrown when a tile load is cancelled (tile scrolled off screen). +/// TileLayerManager skips retry for these — the tile is already gone. +class TileLoadCancelledException implements Exception { + const TileLoadCancelledException(); +} + +/// Thrown when a tile is not available offline (no offline area or cache hit). +/// TileLayerManager skips retry for these — retrying won't help without network. +class TileNotAvailableOfflineException implements Exception { + const TileNotAvailableOfflineException(); +} + /// Custom tile provider that extends NetworkTileProvider to leverage its /// built-in disk cache, RetryClient, ETag revalidation, and abort support, /// while routing URLs through our TileType logic and supporting offline tiles. /// +/// Each instance is configured for a specific tile provider/type combination +/// with frozen config — no AppState lookups at request time (except for the +/// global offlineMode toggle). +/// /// Two runtime paths: /// 1. **Common path** (no offline areas for current provider): delegates to /// super.getImageWithCancelLoadingSupport() — full NetworkTileImageProvider /// pipeline (disk cache, ETag revalidation, RetryClient, abort support). /// 2. **Offline-first path** (has offline areas or offline mode): returns -/// DeflockOfflineTileImageProvider — checks fetchLocalTile() first, falls -/// back to HTTP via shared RetryClient on miss. +/// DeflockOfflineTileImageProvider — checks disk cache and local tiles +/// first, falls back to HTTP via shared RetryClient on miss. class DeflockTileProvider extends NetworkTileProvider { /// The shared HTTP client we own. We keep a reference because /// NetworkTileProvider._httpClient is private and _isInternallyCreatedClient /// will be false (we passed it in), so super.dispose() won't close it. final Client _sharedHttpClient; - DeflockTileProvider._({required Client httpClient}) - : _sharedHttpClient = httpClient, + /// Frozen config for this provider instance. + final String providerId; + final models.TileType tileType; + final String? apiKey; + + /// Caching provider for the offline-first path. The same instance is passed + /// to super for the common path — we keep a reference here so we can also + /// use it in [DeflockOfflineTileImageProvider]. + final MapCachingProvider? _cachingProvider; + + /// Called when a tile loads successfully via the network in the offline-first + /// path. Used by [TileLayerManager] to reset exponential backoff. + VoidCallback? onNetworkSuccess; + + // ignore: use_super_parameters + DeflockTileProvider._({ + required Client httpClient, + required this.providerId, + required this.tileType, + this.apiKey, + MapCachingProvider? cachingProvider, + this.onNetworkSuccess, + }) : _sharedHttpClient = httpClient, + _cachingProvider = cachingProvider, super( httpClient: httpClient, - silenceExceptions: true, + cachingProvider: cachingProvider, + // Let errors propagate so flutter_map marks tiles as failed + // (loadError = true) rather than caching transparent images as + // "successfully loaded". The TileLayerManager wires a reset stream + // that retries failed tiles after a debounced delay. + silenceExceptions: false, ); - factory DeflockTileProvider() { + factory DeflockTileProvider({ + required String providerId, + required models.TileType tileType, + String? apiKey, + MapCachingProvider? cachingProvider, + VoidCallback? onNetworkSuccess, + }) { final client = UserAgentClient(RetryClient(Client())); - return DeflockTileProvider._(httpClient: client); + return DeflockTileProvider._( + httpClient: client, + providerId: providerId, + tileType: tileType, + apiKey: apiKey, + cachingProvider: cachingProvider, + onNetworkSuccess: onNetworkSuccess, + ); } @override String getTileUrl(TileCoordinates coordinates, TileLayer options) { - final appState = AppState.instance; - final selectedTileType = appState.selectedTileType; - final selectedProvider = appState.selectedTileProvider; - - if (selectedTileType == null || selectedProvider == null) { - // Fallback to base implementation if no provider configured - return super.getTileUrl(coordinates, options); - } - - return selectedTileType.getTileUrl( + return tileType.getTileUrl( coordinates.z, coordinates.x, coordinates.y, - apiKey: selectedProvider.apiKey, + apiKey: apiKey, ); } @@ -66,7 +115,7 @@ class DeflockTileProvider extends NetworkTileProvider { TileLayer options, Future cancelLoading, ) { - if (!_shouldCheckOfflineCache()) { + if (!_shouldCheckOfflineCache(coordinates.z)) { // Common path: no offline areas — delegate to NetworkTileProvider's // full pipeline (disk cache, ETag, RetryClient, abort support). return super.getImageWithCancelLoadingSupport( @@ -77,20 +126,18 @@ class DeflockTileProvider extends NetworkTileProvider { } // Offline-first path: check local tiles first, fall back to network. - final appState = AppState.instance; - final providerId = appState.selectedTileProvider?.id ?? 'unknown'; - final tileTypeId = appState.selectedTileType?.id ?? 'unknown'; - return DeflockOfflineTileImageProvider( coordinates: coordinates, options: options, httpClient: _sharedHttpClient, headers: headers, cancelLoading: cancelLoading, - isOfflineOnly: appState.offlineMode, + isOfflineOnly: AppState.instance.offlineMode, providerId: providerId, - tileTypeId: tileTypeId, + tileTypeId: tileType.id, tileUrl: getTileUrl(coordinates, options), + cachingProvider: _cachingProvider, + onNetworkSuccess: onNetworkSuccess, ); } @@ -101,44 +148,67 @@ class DeflockTileProvider extends NetworkTileProvider { /// /// This avoids the offline-first path (and its filesystem searches) when /// browsing online with providers that have no offline areas. - bool _shouldCheckOfflineCache() { - final appState = AppState.instance; - + bool _shouldCheckOfflineCache(int zoom) { // Always use offline path in offline mode - if (appState.offlineMode) { + if (AppState.instance.offlineMode) { return true; } // For online mode, only use offline path if we have relevant offline data - final currentProvider = appState.selectedTileProvider; - final currentTileType = appState.selectedTileType; - - if (currentProvider == null || currentTileType == null) { - return false; - } - + // at this zoom level — tiles outside any area's zoom range go through the + // common NetworkTileProvider path for better performance. final offlineService = OfflineAreaService(); - return offlineService.hasOfflineAreasForProvider( - currentProvider.id, - currentTileType.id, + return offlineService.hasOfflineAreasForProviderAtZoom( + providerId, + tileType.id, + zoom, ); } @override Future dispose() async { - try { - await super.dispose(); - } finally { - _sharedHttpClient.close(); - } + // Only call super — do NOT close _sharedHttpClient here. + // flutter_map calls dispose() whenever the TileLayer widget is recycled + // (e.g. provider switch causes a new FlutterMap key), but + // TileLayerManager caches and reuses provider instances across switches. + // Closing the HTTP client here would leave the cached instance broken — + // all future tile requests would fail with "Client closed". + // + // Since we passed our own httpClient to NetworkTileProvider, + // _isInternallyCreatedClient is false, so super.dispose() won't close it + // either. The client is closed in [shutdown], called by + // TileLayerManager.dispose() when the map is truly torn down. + await super.dispose(); + } + + /// Permanently close the HTTP client. Called by [TileLayerManager.dispose] + /// when the map widget is being torn down — NOT by flutter_map's widget + /// recycling. + void shutdown() { + _sharedHttpClient.close(); } } /// Image provider for the offline-first path. /// -/// Tries fetchLocalTile() first. On miss (and if online), falls back to an -/// HTTP GET via the shared RetryClient. Handles cancelLoading abort and -/// returns transparent tiles on errors (consistent with silenceExceptions). +/// Checks disk cache and offline areas before falling back to the network. +/// Caches successful network fetches to disk so panning back doesn't re-fetch. +/// On cancellation, lets in-flight downloads complete and caches the result +/// (fire-and-forget) instead of discarding downloaded bytes. +/// +/// **Online mode flow:** +/// 1. Disk cache (fast hash-based file read) → hit + fresh → return +/// 2. Offline areas (file scan) → hit → return +/// 3. Network fetch with conditional headers from stale cache entry +/// 4. On cancel → fire-and-forget cache write for the in-flight download +/// 5. On 304 → return stale cached bytes, update cache metadata +/// 6. On 200 → cache to disk, decode and return +/// 7. On error → throw (flutter_map marks tile as failed) +/// +/// **Offline mode flow:** +/// 1. Offline areas (primary source — guaranteed available) +/// 2. Disk cache (tiles cached from previous online sessions) +/// 3. Throw if both miss (flutter_map marks tile as failed) class DeflockOfflineTileImageProvider extends ImageProvider { final TileCoordinates coordinates; @@ -150,6 +220,8 @@ class DeflockOfflineTileImageProvider final String providerId; final String tileTypeId; final String tileUrl; + final MapCachingProvider? cachingProvider; + final VoidCallback? onNetworkSuccess; const DeflockOfflineTileImageProvider({ required this.coordinates, @@ -161,6 +233,8 @@ class DeflockOfflineTileImageProvider required this.providerId, required this.tileTypeId, required this.tileUrl, + this.cachingProvider, + this.onNetworkSuccess, }); @override @@ -173,19 +247,47 @@ class DeflockOfflineTileImageProvider ImageStreamCompleter loadImage( DeflockOfflineTileImageProvider key, ImageDecoderCallback decode) { final chunkEvents = StreamController(); - final codecFuture = _loadAsync(key, decode, chunkEvents); - - codecFuture.whenComplete(() { - chunkEvents.close(); - }); return MultiFrameImageStreamCompleter( - codec: codecFuture, + // Chain whenComplete into the codec future so there's a single future + // for MultiFrameImageStreamCompleter to handle. Without this, the + // whenComplete creates an orphaned future whose errors go unhandled. + codec: _loadAsync(key, decode, chunkEvents).whenComplete(() { + chunkEvents.close(); + }), chunkEvents: chunkEvents.stream, scale: 1.0, ); } + /// Try to read a tile from the disk cache. Returns null on miss or error. + Future _getCachedTile() async { + if (cachingProvider == null || !cachingProvider!.isSupported) return null; + try { + return await cachingProvider!.getTile(tileUrl); + } on CachedMapTileReadFailure { + return null; + } catch (_) { + return null; + } + } + + /// Write a tile to the disk cache (best-effort, never throws). + void _putCachedTile({ + required Map responseHeaders, + Uint8List? bytes, + }) { + if (cachingProvider == null || !cachingProvider!.isSupported) return; + try { + final metadata = CachedMapTileMetadata.fromHttpHeaders(responseHeaders); + cachingProvider! + .putTile(url: tileUrl, metadata: metadata, bytes: bytes) + .catchError((_) {}); + } catch (_) { + // Best-effort: never fail the tile load due to cache write errors. + } + } + Future _loadAsync( DeflockOfflineTileImageProvider key, ImageDecoderCallback decode, @@ -194,67 +296,156 @@ class DeflockOfflineTileImageProvider Future decodeBytes(Uint8List bytes) => ImmutableBuffer.fromUint8List(bytes).then(decode); - Future transparent() => - decodeBytes(TileProvider.transparentImage); + // Track cancellation synchronously via Completer so the catch block + // can reliably check it without microtask ordering races. + final cancelled = Completer(); + cancelLoading.then((_) { + if (!cancelled.isCompleted) cancelled.complete(); + }).ignore(); try { - // Track cancellation - bool cancelled = false; - cancelLoading.then((_) => cancelled = true); - - // Try local tile first — pass captured IDs to avoid a race if the - // user switches provider while this async load is in flight. - try { - final localBytes = await fetchLocalTile( - z: coordinates.z, - x: coordinates.x, - y: coordinates.y, - providerId: providerId, - tileTypeId: tileTypeId, - ); - return await decodeBytes(Uint8List.fromList(localBytes)); - } catch (_) { - // Local miss — fall through to network if online + if (isOfflineOnly) { + return await _loadOffline(decodeBytes, cancelled); + } + return await _loadOnline(decodeBytes, cancelled); + } catch (e) { + // Cancelled tiles throw — flutter_map handles the error silently. + // Preserve TileNotAvailableOfflineException even if the tile was also + // cancelled — it has distinct semantics (genuine cache miss) that + // matter for diagnostics and future UI indicators. + if (cancelled.isCompleted && e is! TileNotAvailableOfflineException) { + throw const TileLoadCancelledException(); } - if (cancelled) return await transparent(); - if (isOfflineOnly) return await transparent(); - - // Fall back to network via shared RetryClient. - // Race the download against cancelLoading so we stop waiting if the - // tile is pruned mid-flight (the underlying TCP connection is cleaned - // up naturally by the shared client). - final request = Request('GET', Uri.parse(tileUrl)); - request.headers.addAll(headers); + // Let real errors propagate so flutter_map marks loadError = true + rethrow; + } + } - final networkFuture = httpClient.send(request).then((response) async { - final bytes = await response.stream.toBytes(); - return (statusCode: response.statusCode, bytes: bytes); - }); + /// Online mode: disk cache → offline areas → network (with caching). + Future _loadOnline( + Future Function(Uint8List) decodeBytes, + Completer cancelled, + ) async { + // 1. Check disk cache — fast hash-based file read. + final cachedTile = await _getCachedTile(); + if (cachedTile != null && !cachedTile.metadata.isStale) { + return await decodeBytes(cachedTile.bytes); + } - final result = await Future.any([ - networkFuture, - cancelLoading.then((_) => (statusCode: 0, bytes: Uint8List(0))), - ]); + // 2. Check offline areas — file scan per area. + try { + final localBytes = await fetchLocalTile( + z: coordinates.z, + x: coordinates.x, + y: coordinates.y, + providerId: providerId, + tileTypeId: tileTypeId, + ); + return await decodeBytes(Uint8List.fromList(localBytes)); + } catch (_) { + // Local miss — fall through to network + } - if (cancelled || result.statusCode == 0) return await transparent(); + // 3. If cancelled before network, bail. + if (cancelled.isCompleted) throw const TileLoadCancelledException(); - if (result.statusCode == 200 && result.bytes.isNotEmpty) { - return await decodeBytes(result.bytes); + // 4. Network fetch with conditional headers from stale cache entry. + final request = Request('GET', Uri.parse(tileUrl)); + request.headers.addAll(headers); + if (cachedTile != null) { + if (cachedTile.metadata.lastModified case final lastModified?) { + request.headers[HttpHeaders.ifModifiedSinceHeader] = + HttpDate.format(lastModified); } - - return await transparent(); - } catch (e) { - // Don't log routine offline misses - if (!e.toString().contains('offline')) { - debugPrint( - '[DeflockTileProvider] Offline-first tile failed ' - '${coordinates.z}/${coordinates.x}/${coordinates.y} ' - '(${e.runtimeType})'); + if (cachedTile.metadata.etag case final etag?) { + request.headers[HttpHeaders.ifNoneMatchHeader] = etag; } - return await ImmutableBuffer.fromUint8List(TileProvider.transparentImage) - .then(decode); } + + // 5. Race the download against cancelLoading. + final networkFuture = httpClient.send(request).then((response) async { + final bytes = await response.stream.toBytes(); + return ( + statusCode: response.statusCode, + bytes: bytes, + headers: response.headers, + ); + }); + + final result = await Future.any([ + networkFuture, + cancelLoading.then((_) => ( + statusCode: 0, + bytes: Uint8List(0), + headers: {}, + )), + ]); + + // 6. On cancel — fire-and-forget cache write for the in-flight download + // instead of discarding the downloaded bytes. + if (cancelled.isCompleted || result.statusCode == 0) { + networkFuture.then((r) { + if (r.statusCode == 200 && r.bytes.isNotEmpty) { + _putCachedTile(responseHeaders: r.headers, bytes: r.bytes); + } + }).ignore(); + throw const TileLoadCancelledException(); + } + + // 7. On 304 Not Modified → return stale cached bytes, update metadata. + if (result.statusCode == HttpStatus.notModified && cachedTile != null) { + _putCachedTile(responseHeaders: result.headers); + onNetworkSuccess?.call(); + return await decodeBytes(cachedTile.bytes); + } + + // 8. On 200 OK → cache to disk, decode and return. + if (result.statusCode == 200 && result.bytes.isNotEmpty) { + _putCachedTile(responseHeaders: result.headers, bytes: result.bytes); + onNetworkSuccess?.call(); + return await decodeBytes(result.bytes); + } + + // 9. Network error — throw so flutter_map marks the tile as failed. + // Don't include tileUrl in the exception — it may contain API keys. + throw HttpException( + 'Tile ${coordinates.z}/${coordinates.x}/${coordinates.y} ' + 'returned status ${result.statusCode}', + ); + } + + /// Offline mode: offline areas → disk cache → throw. + Future _loadOffline( + Future Function(Uint8List) decodeBytes, + Completer cancelled, + ) async { + // 1. Check offline areas (primary source — guaranteed available). + try { + final localBytes = await fetchLocalTile( + z: coordinates.z, + x: coordinates.x, + y: coordinates.y, + providerId: providerId, + tileTypeId: tileTypeId, + ); + if (cancelled.isCompleted) throw const TileLoadCancelledException(); + return await decodeBytes(Uint8List.fromList(localBytes)); + } on TileLoadCancelledException { + rethrow; + } catch (_) { + // Local miss — fall through to disk cache + } + + // 2. Check disk cache (tiles cached from previous online sessions). + if (cancelled.isCompleted) throw const TileLoadCancelledException(); + final cachedTile = await _getCachedTile(); + if (cachedTile != null) { + return await decodeBytes(cachedTile.bytes); + } + + // 3. Both miss — throw so flutter_map marks the tile as failed. + throw const TileNotAvailableOfflineException(); } @override @@ -263,9 +454,11 @@ class DeflockOfflineTileImageProvider return other is DeflockOfflineTileImageProvider && other.coordinates == coordinates && other.providerId == providerId && - other.tileTypeId == tileTypeId; + other.tileTypeId == tileTypeId && + other.isOfflineOnly == isOfflineOnly; } @override - int get hashCode => Object.hash(coordinates, providerId, tileTypeId); + int get hashCode => + Object.hash(coordinates, providerId, tileTypeId, isOfflineOnly); } diff --git a/lib/services/map_data_submodules/nodes_from_osm_api.dart b/lib/services/map_data_submodules/nodes_from_osm_api.dart index 342abf6a..0c21dada 100644 --- a/lib/services/map_data_submodules/nodes_from_osm_api.dart +++ b/lib/services/map_data_submodules/nodes_from_osm_api.dart @@ -1,4 +1,5 @@ import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; import 'package:latlong2/latlong.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:xml/xml.dart'; @@ -7,6 +8,7 @@ import '../../models/node_profile.dart'; import '../../models/osm_node.dart'; import '../../app_state.dart'; import '../http_client.dart'; +import '../service_policy.dart'; /// Fetches surveillance nodes from the direct OSM API using bbox query. /// This is a fallback for when Overpass is not available (e.g., sandbox mode). @@ -58,28 +60,36 @@ Future> _fetchFromOsmApi({ try { debugPrint('[fetchOsmApiNodes] Querying OSM API for nodes in bbox...'); debugPrint('[fetchOsmApiNodes] URL: $url'); - - final response = await _client.get(Uri.parse(url)); - + + // Enforce max 2 concurrent download threads per OSM API usage policy + await ServiceRateLimiter.acquire(ServiceType.osmEditingApi); + + final http.Response response; + try { + response = await _client.get(Uri.parse(url)); + } finally { + ServiceRateLimiter.release(ServiceType.osmEditingApi); + } + if (response.statusCode != 200) { debugPrint('[fetchOsmApiNodes] OSM API error: ${response.statusCode} - ${response.body}'); throw Exception('OSM API error: ${response.statusCode} - ${response.body}'); } - + // Parse XML response final document = XmlDocument.parse(response.body); final nodes = _parseOsmApiResponseWithConstraints(document, profiles, maxResults); - + if (nodes.isNotEmpty) { debugPrint('[fetchOsmApiNodes] Retrieved ${nodes.length} matching surveillance nodes'); } - + // Don't report success here - let the top level handle it return nodes; - + } catch (e) { debugPrint('[fetchOsmApiNodes] Exception: $e'); - + // Don't report status here - let the top level handle it rethrow; // Re-throw to let caller handle } diff --git a/lib/services/map_data_submodules/tiles_from_local.dart b/lib/services/map_data_submodules/tiles_from_local.dart index 003d9a09..5113134d 100644 --- a/lib/services/map_data_submodules/tiles_from_local.dart +++ b/lib/services/map_data_submodules/tiles_from_local.dart @@ -1,7 +1,11 @@ import 'dart:io'; +import 'dart:math'; + +import 'package:flutter_map/flutter_map.dart' show LatLngBounds; +import 'package:flutter/foundation.dart' show visibleForTesting; + import '../offline_area_service.dart'; import '../offline_areas/offline_area_models.dart'; -import '../offline_areas/offline_tile_utils.dart'; import '../../app_state.dart'; /// Fetch a tile from the newest offline area that matches the given provider, or throw if not found. @@ -19,7 +23,7 @@ Future> fetchLocalTile({ final appState = AppState.instance; final currentProviderId = providerId ?? appState.selectedTileProvider?.id; final currentTileTypeId = tileTypeId ?? appState.selectedTileType?.id; - + final offlineService = OfflineAreaService(); await offlineService.ensureInitialized(); final areas = offlineService.offlineAreas; @@ -28,20 +32,21 @@ Future> fetchLocalTile({ for (final area in areas) { if (area.status != OfflineAreaStatus.complete) continue; if (z < area.minZoom || z > area.maxZoom) continue; - + // Only consider areas that match the current provider/type if (area.tileProviderId != currentProviderId || area.tileTypeId != currentTileTypeId) continue; - // Get tile coverage for area at this zoom only - final coveredTiles = computeTileList(area.bounds, z, z); - final hasTile = coveredTiles.any((tile) => tile[0] == z && tile[1] == x && tile[2] == y); - if (hasTile) { - final tilePath = _tilePath(area.directory, z, x, y); - final file = File(tilePath); - if (await file.exists()) { - final stat = await file.stat(); - candidates.add(_AreaTileMatch(area: area, file: file, modified: stat.modified)); - } + // O(1) bounds check instead of enumerating all tiles at this zoom level + if (!tileInBounds(area.bounds, z, x, y)) continue; + + final tilePath = _tilePath(area.directory, z, x, y); + final file = File(tilePath); + try { + final stat = await file.stat(); + if (stat.type == FileSystemEntityType.notFound) continue; + candidates.add(_AreaTileMatch(area: area, file: file, modified: stat.modified)); + } on FileSystemException { + continue; } } if (candidates.isEmpty) { @@ -51,6 +56,34 @@ Future> fetchLocalTile({ return await candidates.first.file.readAsBytes(); } +/// O(1) check whether tile (z, x, y) falls within the given lat/lng bounds. +/// +/// Uses the same Mercator projection math as [latLonToTile] in +/// offline_tile_utils.dart, but only computes the bounding tile range +/// instead of enumerating every tile at that zoom level. +/// +/// Note: Y axis is inverted in tile coordinates — north = lower Y. +@visibleForTesting +bool tileInBounds(LatLngBounds bounds, int z, int x, int y) { + final n = pow(2.0, z); + final west = bounds.west; + final east = bounds.east; + final north = bounds.north; + final south = bounds.south; + + final minX = ((west + 180.0) / 360.0 * n).floor(); + final maxX = ((east + 180.0) / 360.0 * n).floor(); + // North → lower Y (Mercator projection inverts latitude) + final minY = ((1.0 - log(tan(north * pi / 180.0) + + 1.0 / cos(north * pi / 180.0)) / + pi) / 2.0 * n).floor(); + final maxY = ((1.0 - log(tan(south * pi / 180.0) + + 1.0 / cos(south * pi / 180.0)) / + pi) / 2.0 * n).floor(); + + return x >= minX && x <= maxX && y >= minY && y <= maxY; +} + String _tilePath(String areaDir, int z, int x, int y) => '$areaDir/tiles/$z/$x/$y.png'; diff --git a/lib/services/offline_area_service.dart b/lib/services/offline_area_service.dart index 93008256..d458d760 100644 --- a/lib/services/offline_area_service.dart +++ b/lib/services/offline_area_service.dart @@ -33,14 +33,37 @@ class OfflineAreaService { if (!_initialized) { return false; // No offline areas loaded yet } - - return _areas.any((area) => + + return _areas.any((area) => area.status == OfflineAreaStatus.complete && area.tileProviderId == providerId && area.tileTypeId == tileTypeId ); } + + /// Like [hasOfflineAreasForProvider] but also checks that at least one area + /// covers the given [zoom] level. Used by [DeflockTileProvider] to skip the + /// offline-first path for tiles that will never be found locally. + bool hasOfflineAreasForProviderAtZoom(String providerId, String tileTypeId, int zoom) { + if (!_initialized) return false; + return _areas.any((area) => + area.status == OfflineAreaStatus.complete && + area.tileProviderId == providerId && + area.tileTypeId == tileTypeId && + zoom >= area.minZoom && + zoom <= area.maxZoom + ); + } + /// Reset service state and inject areas for unit tests. + @visibleForTesting + void setAreasForTesting(List areas) { + _areas + ..clear() + ..addAll(areas); + _initialized = true; + } + /// Cancel all active downloads (used when enabling offline mode) Future cancelActiveDownloads() async { final activeAreas = _areas.where((area) => area.status == OfflineAreaStatus.downloading).toList(); @@ -213,7 +236,7 @@ class OfflineAreaService { area = OfflineArea( id: id, name: name ?? area?.name ?? '', - bounds: bounds, + bounds: normalizeBounds(bounds), minZoom: minZoom, maxZoom: maxZoom, directory: directory, diff --git a/lib/services/offline_areas/offline_area_models.dart b/lib/services/offline_areas/offline_area_models.dart index 61906e74..38285c7d 100644 --- a/lib/services/offline_areas/offline_area_models.dart +++ b/lib/services/offline_areas/offline_area_models.dart @@ -1,6 +1,7 @@ import 'package:latlong2/latlong.dart'; import 'package:flutter_map/flutter_map.dart' show LatLngBounds; import '../../models/osm_node.dart'; +import 'offline_tile_utils.dart' show normalizeBounds; /// Status of an offline area enum OfflineAreaStatus { downloading, complete, error, cancelled } @@ -71,10 +72,10 @@ class OfflineArea { }; static OfflineArea fromJson(Map json) { - final bounds = LatLngBounds( + final bounds = normalizeBounds(LatLngBounds( LatLng(json['bounds']['sw']['lat'], json['bounds']['sw']['lng']), LatLng(json['bounds']['ne']['lat'], json['bounds']['ne']['lng']), - ); + )); return OfflineArea( id: json['id'], name: json['name'] ?? '', diff --git a/lib/services/offline_areas/offline_tile_utils.dart b/lib/services/offline_areas/offline_tile_utils.dart index b3da9773..7b283f47 100644 --- a/lib/services/offline_areas/offline_tile_utils.dart +++ b/lib/services/offline_areas/offline_tile_utils.dart @@ -4,14 +4,15 @@ import 'package:flutter_map/flutter_map.dart' show LatLngBounds; /// Utility for tile calculations and lat/lon conversions for OSM offline logic -Set> computeTileList(LatLngBounds bounds, int zMin, int zMax) { - Set> tiles = {}; +/// Normalize bounds so south ≤ north, west ≤ east, and degenerate (near-zero) +/// spans are expanded by epsilon. Call this before storing bounds so that +/// `tileInBounds` and [computeTileList] see consistent corner ordering. +LatLngBounds normalizeBounds(LatLngBounds bounds) { const double epsilon = 1e-7; - double latMin = min(bounds.southWest.latitude, bounds.northEast.latitude); - double latMax = max(bounds.southWest.latitude, bounds.northEast.latitude); - double lonMin = min(bounds.southWest.longitude, bounds.northEast.longitude); - double lonMax = max(bounds.southWest.longitude, bounds.northEast.longitude); - // Expand degenerate/flat areas a hair + var latMin = min(bounds.southWest.latitude, bounds.northEast.latitude); + var latMax = max(bounds.southWest.latitude, bounds.northEast.latitude); + var lonMin = min(bounds.southWest.longitude, bounds.northEast.longitude); + var lonMax = max(bounds.southWest.longitude, bounds.northEast.longitude); if ((latMax - latMin).abs() < epsilon) { latMin -= epsilon; latMax += epsilon; @@ -20,6 +21,16 @@ Set> computeTileList(LatLngBounds bounds, int zMin, int zMax) { lonMin -= epsilon; lonMax += epsilon; } + return LatLngBounds(LatLng(latMin, lonMin), LatLng(latMax, lonMax)); +} + +Set> computeTileList(LatLngBounds bounds, int zMin, int zMax) { + Set> tiles = {}; + final normalized = normalizeBounds(bounds); + final double latMin = normalized.south; + final double latMax = normalized.north; + final double lonMin = normalized.west; + final double lonMax = normalized.east; for (int z = zMin; z <= zMax; z++) { final n = pow(2, z).toInt(); final minTileRaw = latLonToTileRaw(latMin, lonMin, z); diff --git a/lib/services/provider_tile_cache_manager.dart b/lib/services/provider_tile_cache_manager.dart new file mode 100644 index 00000000..54e99d3b --- /dev/null +++ b/lib/services/provider_tile_cache_manager.dart @@ -0,0 +1,103 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import 'provider_tile_cache_store.dart'; +import 'service_policy.dart'; + +/// Factory and registry for per-provider [ProviderTileCacheStore] instances. +/// +/// Creates cache stores under `{appCacheDir}/tile_cache/{providerId}/{tileTypeId}/`. +/// Call [init] once at startup (e.g., from TileLayerManager.initialize) to +/// resolve the platform cache directory. After init, [getOrCreate] is +/// synchronous — the cache store lazily creates its directory on first write. +class ProviderTileCacheManager { + static final Map _stores = {}; + static String? _baseCacheDir; + + /// Resolve the platform cache directory. Call once at startup. + static Future init() async { + if (_baseCacheDir != null) return; + final cacheDir = await getApplicationCacheDirectory(); + _baseCacheDir = p.join(cacheDir.path, 'tile_cache'); + } + + /// Whether the manager has been initialized. + static bool get isInitialized => _baseCacheDir != null; + + /// Get or create a cache store for a specific provider/tile type combination. + /// + /// Synchronous after [init] has been called. The cache store lazily creates + /// its directory on first write. + static ProviderTileCacheStore getOrCreate({ + required String providerId, + required String tileTypeId, + required ServicePolicy policy, + int? maxCacheBytes, + }) { + assert(_baseCacheDir != null, + 'ProviderTileCacheManager.init() must be called before getOrCreate()'); + + final key = '$providerId/$tileTypeId'; + if (_stores.containsKey(key)) return _stores[key]!; + + final cacheDir = p.join(_baseCacheDir!, providerId, tileTypeId); + + final store = ProviderTileCacheStore( + cacheDirectory: cacheDir, + maxCacheBytes: maxCacheBytes ?? 500 * 1024 * 1024, + overrideFreshAge: policy.minCacheTtl, + ); + + _stores[key] = store; + return store; + } + + /// Delete a specific provider's cache directory and remove the store. + static Future deleteCache(String providerId, String tileTypeId) async { + final key = '$providerId/$tileTypeId'; + final store = _stores.remove(key); + if (store != null) { + await store.clear(); + } else if (_baseCacheDir != null) { + final cacheDir = Directory(p.join(_baseCacheDir!, providerId, tileTypeId)); + if (await cacheDir.exists()) { + await cacheDir.delete(recursive: true); + } + } + } + + /// Get estimated cache sizes for all active stores. + /// + /// Returns a map of `providerId/tileTypeId` → size in bytes. + static Future> getCacheSizes() async { + final sizes = {}; + for (final entry in _stores.entries) { + sizes[entry.key] = await entry.value.estimatedSizeBytes; + } + return sizes; + } + + /// Remove a store from the registry (e.g., when a provider is disposed). + static void unregister(String providerId, String tileTypeId) { + _stores.remove('$providerId/$tileTypeId'); + } + + /// Clear all stores and reset the registry (for testing). + @visibleForTesting + static Future resetAll() async { + for (final store in _stores.values) { + await store.clear(); + } + _stores.clear(); + _baseCacheDir = null; + } + + /// Set the base cache directory directly (for testing). + @visibleForTesting + static void setBaseCacheDir(String dir) { + _baseCacheDir = dir; + } +} diff --git a/lib/services/provider_tile_cache_store.dart b/lib/services/provider_tile_cache_store.dart new file mode 100644 index 00000000..bf23e151 --- /dev/null +++ b/lib/services/provider_tile_cache_store.dart @@ -0,0 +1,313 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:path/path.dart' as p; +import 'package:uuid/uuid.dart'; + +/// Per-provider tile cache implementing flutter_map's [MapCachingProvider]. +/// +/// Each instance manages an isolated cache directory with: +/// - Deterministic UUID v5 key generation from tile URLs +/// - Optional TTL override from [ServicePolicy.minCacheTtl] +/// - Configurable max cache size with oldest-modified eviction +/// +/// Files are stored as `{key}.tile` (image bytes) and `{key}.meta` (JSON +/// metadata containing staleAt, lastModified, etag). +class ProviderTileCacheStore implements MapCachingProvider { + final String cacheDirectory; + final int maxCacheBytes; + final Duration? overrideFreshAge; + + static const _uuid = Uuid(); + + /// Running estimate of cache size in bytes. Initialized lazily on first + /// [putTile] call to avoid blocking construction. + int? _estimatedSize; + + /// Throttle: don't re-scan more than once per minute. + DateTime? _lastPruneCheck; + + /// One-shot latch for lazy directory creation (safe under concurrent calls). + Completer? _directoryReady; + + /// Guard against concurrent eviction runs. + bool _isEvicting = false; + + ProviderTileCacheStore({ + required this.cacheDirectory, + this.maxCacheBytes = 500 * 1024 * 1024, // 500 MB default + this.overrideFreshAge, + }); + + @override + bool get isSupported => true; + + @override + Future getTile(String url) async { + final key = _keyFor(url); + final tileFile = File(p.join(cacheDirectory, '$key.tile')); + final metaFile = File(p.join(cacheDirectory, '$key.meta')); + + try { + final bytes = await tileFile.readAsBytes(); + final metaJson = json.decode(await metaFile.readAsString()) + as Map; + + final metadata = CachedMapTileMetadata( + staleAt: DateTime.fromMillisecondsSinceEpoch( + metaJson['staleAt'] as int, + isUtc: true, + ), + lastModified: metaJson['lastModified'] != null + ? DateTime.fromMillisecondsSinceEpoch( + metaJson['lastModified'] as int, + isUtc: true, + ) + : null, + etag: metaJson['etag'] as String?, + ); + + return (bytes: bytes, metadata: metadata); + } on PathNotFoundException { + return null; + } catch (e) { + throw CachedMapTileReadFailure( + url: url, + description: 'Failed to read cached tile', + originalError: e, + ); + } + } + + @override + Future putTile({ + required String url, + required CachedMapTileMetadata metadata, + Uint8List? bytes, + }) async { + await _ensureDirectory(); + + final key = _keyFor(url); + final tileFile = File(p.join(cacheDirectory, '$key.tile')); + final metaFile = File(p.join(cacheDirectory, '$key.meta')); + + // Apply minimum TTL override if configured (e.g., OSM 7-day minimum). + // Use the later of server-provided staleAt and our minimum to avoid + // accidentally shortening a longer server-provided freshness lifetime. + final effectiveMetadata = overrideFreshAge != null + ? (() { + final overrideStaleAt = DateTime.timestamp().add(overrideFreshAge!); + final staleAt = metadata.staleAt.isAfter(overrideStaleAt) + ? metadata.staleAt + : overrideStaleAt; + return CachedMapTileMetadata( + staleAt: staleAt, + lastModified: metadata.lastModified, + etag: metadata.etag, + ); + })() + : metadata; + + final metaJson = json.encode({ + 'staleAt': effectiveMetadata.staleAt.millisecondsSinceEpoch, + 'lastModified': + effectiveMetadata.lastModified?.millisecondsSinceEpoch, + 'etag': effectiveMetadata.etag, + }); + + // Write .tile before .meta: if we crash between the two writes, the + // read path's both-must-exist check sees a miss rather than an orphan .meta. + if (bytes != null) { + await tileFile.writeAsBytes(bytes); + } + await metaFile.writeAsString(metaJson); + + // Reset size estimate so it resyncs from disk on next check. + // This avoids drift from overwrites where the old size isn't subtracted. + _estimatedSize = null; + + // Schedule lazy size check + _scheduleEvictionCheck(); + } + + /// Ensure the cache directory exists (lazy creation on first write). + /// + /// Uses a Completer latch so concurrent callers share a single create(). + /// Safe under Dart's single-threaded event loop: the null check and + /// assignment happen in the same synchronous block with no `await` + /// between them, so no other microtask can interleave. + Future _ensureDirectory() { + if (_directoryReady == null) { + final completer = Completer(); + _directoryReady = completer; + Directory(cacheDirectory).create(recursive: true).then( + (_) => completer.complete(), + onError: (Object error, StackTrace stackTrace) { + // Reset latch on error so later calls can retry directory creation. + if (identical(_directoryReady, completer)) { + _directoryReady = null; + } + completer.completeError(error, stackTrace); + }, + ); + } + return _directoryReady!.future; + } + + /// Generate a cache key from URL using UUID v5 (same as flutter_map built-in). + static String _keyFor(String url) => _uuid.v5(Namespace.url.value, url); + + /// Estimate total cache size (lazy, first call scans directory). + Future _getEstimatedSize() async { + if (_estimatedSize != null) return _estimatedSize!; + + final dir = Directory(cacheDirectory); + if (!await dir.exists()) { + _estimatedSize = 0; + return 0; + } + + var total = 0; + await for (final entity in dir.list()) { + if (entity is File) { + total += await entity.length(); + } + } + _estimatedSize = total; + return total; + } + + /// Schedule eviction if we haven't checked recently. + void _scheduleEvictionCheck() { + final now = DateTime.now(); + if (_lastPruneCheck != null && + now.difference(_lastPruneCheck!) < const Duration(minutes: 1)) { + return; + } + _lastPruneCheck = now; + + // Fire-and-forget: eviction is best-effort background work. + // _estimatedSize may be momentarily stale between eviction start and + // completion, but this is acceptable — the guard only needs to be + // approximately correct to prevent unbounded growth, and the throttle + // ensures we re-check within a minute. + // ignore: discarded_futures + _evictIfNeeded(); + } + + /// Evict oldest-modified tiles if cache exceeds size limit. + /// + /// Sorts by file mtime (oldest first), not by last access — true LRU would + /// require touching files on every [getTile] read, adding I/O on the hot + /// path. In practice write-recency tracks usage well because tiles are + /// immutable and flutter_map holds visible tiles in memory. + /// + /// Guarded by [_isEvicting] to prevent concurrent runs from corrupting + /// [_estimatedSize]. + Future _evictIfNeeded() async { + if (_isEvicting) return; + _isEvicting = true; + try { + final currentSize = await _getEstimatedSize(); + if (currentSize <= maxCacheBytes) return; + + final dir = Directory(cacheDirectory); + if (!await dir.exists()) return; + + // Collect all files, separating .tile and .meta for eviction + orphan cleanup. + final tileFiles = []; + final metaFiles = {}; + await for (final entity in dir.list()) { + if (entity is File) { + if (entity.path.endsWith('.tile')) { + tileFiles.add(entity); + } else if (entity.path.endsWith('.meta')) { + metaFiles.add(p.basenameWithoutExtension(entity.path)); + } + } + } + + if (tileFiles.isEmpty) return; + + // Sort by modification time, oldest first + final stats = await Future.wait( + tileFiles.map((f) async => (file: f, stat: await f.stat())), + ); + stats.sort((a, b) => a.stat.modified.compareTo(b.stat.modified)); + + var freedBytes = 0; + final targetSize = (maxCacheBytes * 0.8).toInt(); // Free down to 80% + final evictedKeys = {}; + + for (final entry in stats) { + if (currentSize - freedBytes <= targetSize) break; + + final key = p.basenameWithoutExtension(entry.file.path); + final metaFile = File(p.join(cacheDirectory, '$key.meta')); + + try { + await entry.file.delete(); + freedBytes += entry.stat.size; + evictedKeys.add(key); + if (await metaFile.exists()) { + final metaStat = await metaFile.stat(); + await metaFile.delete(); + freedBytes += metaStat.size; + } + } catch (e) { + debugPrint('[ProviderTileCacheStore] Failed to evict $key: $e'); + } + } + + // Clean up orphan .meta files (no matching .tile file). + // Exclude keys we just evicted — their .tile is gone so they're orphans. + final remainingTileKeys = tileFiles + .map((f) => p.basenameWithoutExtension(f.path)) + .toSet() + ..removeAll(evictedKeys); + for (final metaKey in metaFiles) { + if (!remainingTileKeys.contains(metaKey)) { + try { + final orphan = File(p.join(cacheDirectory, '$metaKey.meta')); + final orphanStat = await orphan.stat(); + await orphan.delete(); + freedBytes += orphanStat.size; + } catch (_) { + // Best-effort cleanup + } + } + } + + _estimatedSize = currentSize - freedBytes; + debugPrint( + '[ProviderTileCacheStore] Evicted ${freedBytes ~/ 1024}KB ' + 'from $cacheDirectory', + ); + } catch (e) { + debugPrint('[ProviderTileCacheStore] Eviction error: $e'); + } finally { + _isEvicting = false; + } + } + + /// Delete all cached tiles in this store's directory. + Future clear() async { + final dir = Directory(cacheDirectory); + if (await dir.exists()) { + await dir.delete(recursive: true); + } + _estimatedSize = null; + _directoryReady = null; // Allow lazy re-creation + } + + /// Get the current estimated cache size in bytes. + Future get estimatedSizeBytes => _getEstimatedSize(); + + /// Force an eviction check, bypassing the throttle. + /// Only exposed for testing — production code uses [_scheduleEvictionCheck]. + @visibleForTesting + Future forceEviction() => _evictIfNeeded(); +} diff --git a/lib/services/search_service.dart b/lib/services/search_service.dart index 8ba0e40e..64597670 100644 --- a/lib/services/search_service.dart +++ b/lib/services/search_service.dart @@ -5,13 +5,31 @@ import 'package:latlong2/latlong.dart'; import '../models/search_result.dart'; import 'http_client.dart'; +import 'service_policy.dart'; + +/// Cached search result with expiry. +class _CachedResult { + final List results; + final DateTime cachedAt; + + _CachedResult(this.results) : cachedAt = DateTime.now(); + + bool get isExpired => + DateTime.now().difference(cachedAt) > const Duration(minutes: 5); +} class SearchService { static const String _baseUrl = 'https://nominatim.openstreetmap.org'; static const int _maxResults = 5; static const Duration _timeout = Duration(seconds: 10); final _client = UserAgentClient(); - + + /// Client-side result cache, keyed by normalized query + viewbox. + /// Required by Nominatim usage policy. Static so all SearchService + /// instances share the cache and don't generate redundant requests. + static final Map _resultCache = {}; + + /// Search for places using Nominatim geocoding service Future> search(String query, {LatLngBounds? viewbox}) async { if (query.trim().isEmpty) { @@ -27,23 +45,23 @@ class SearchService { // Otherwise, use Nominatim API return await _searchNominatim(query.trim(), viewbox: viewbox); } - + /// Try to parse various coordinate formats SearchResult? _tryParseCoordinates(String query) { // Remove common separators and normalize final normalized = query.replaceAll(RegExp(r'[,;]'), ' ').trim(); final parts = normalized.split(RegExp(r'\s+')); - + if (parts.length != 2) return null; - + final lat = double.tryParse(parts[0]); final lon = double.tryParse(parts[1]); - + if (lat == null || lon == null) return null; - + // Basic validation for Earth coordinates if (lat < -90 || lat > 90 || lon < -180 || lon > 180) return null; - + return SearchResult( displayName: 'Coordinates: ${lat.toStringAsFixed(6)}, ${lon.toStringAsFixed(6)}', coordinates: LatLng(lat, lon), @@ -51,17 +69,17 @@ class SearchService { type: 'point', ); } - - /// Search using Nominatim API - Future> _searchNominatim(String query, {LatLngBounds? viewbox}) async { - final params = { - 'q': query, - 'format': 'json', - 'limit': _maxResults.toString(), - 'addressdetails': '1', - 'extratags': '1', - }; + /// Search using Nominatim API with rate limiting and result caching. + /// + /// Nominatim usage policy requires: + /// - Max 1 request per second + /// - Client-side result caching + /// - No auto-complete / typeahead + Future> _searchNominatim(String query, {LatLngBounds? viewbox}) async { + // Normalize the viewbox first so both the cache key and the request + // params use the same effective values (rounded + min-span expanded). + String? viewboxParam; if (viewbox != null) { double round1(double v) => (v * 10).round() / 10; var west = round1(viewbox.west); @@ -80,31 +98,83 @@ class SearchService { north = mid + 0.25; } - params['viewbox'] = '$west,$north,$east,$south'; + viewboxParam = '$west,$north,$east,$south'; + } + + final cacheKey = _buildCacheKey(query, viewboxParam); + + // Check cache first (Nominatim policy requires client-side caching) + final cached = _resultCache[cacheKey]; + if (cached != null && !cached.isExpired) { + debugPrint('[SearchService] Cache hit for "$query"'); + return cached.results; + } + + final params = { + 'q': query, + 'format': 'json', + 'limit': _maxResults.toString(), + 'addressdetails': '1', + 'extratags': '1', + }; + + if (viewboxParam != null) { + params['viewbox'] = viewboxParam; } final uri = Uri.parse('$_baseUrl/search').replace(queryParameters: params); - + debugPrint('[SearchService] Searching Nominatim: $uri'); - + + // Rate limit: max 1 request/sec per Nominatim policy + await ServiceRateLimiter.acquire(ServiceType.nominatim); try { final response = await _client.get(uri).timeout(_timeout); - + if (response.statusCode != 200) { throw Exception('HTTP ${response.statusCode}: ${response.reasonPhrase}'); } - + final List jsonResults = json.decode(response.body); final results = jsonResults .map((json) => SearchResult.fromNominatim(json as Map)) .toList(); - + + // Cache the results + _resultCache[cacheKey] = _CachedResult(results); + _pruneCache(); + debugPrint('[SearchService] Found ${results.length} results'); return results; - - } catch (e) { + } catch (e, stackTrace) { debugPrint('[SearchService] Search failed: $e'); - throw Exception('Search failed: $e'); + Error.throwWithStackTrace(e, stackTrace); + } finally { + ServiceRateLimiter.release(ServiceType.nominatim); + } + } + + /// Build a cache key from the query and the already-normalized viewbox string. + /// + /// The viewbox should be the same `west,north,east,south` string sent to + /// Nominatim (after rounding and min-span expansion) so that requests with + /// different raw bounds but the same effective viewbox share a cache entry. + String _buildCacheKey(String query, String? viewboxParam) { + final normalizedQuery = query.trim().toLowerCase(); + if (viewboxParam == null) return normalizedQuery; + return '$normalizedQuery|$viewboxParam'; + } + + /// Remove expired entries and limit cache size. + void _pruneCache() { + _resultCache.removeWhere((_, cached) => cached.isExpired); + // Limit cache to 50 entries to prevent unbounded growth + if (_resultCache.length > 50) { + final sortedKeys = _resultCache.keys.toList() + ..sort((a, b) => _resultCache[a]!.cachedAt.compareTo(_resultCache[b]!.cachedAt)); + for (final key in sortedKeys.take(_resultCache.length - 50)) { + _resultCache.remove(key); + } } } -} \ No newline at end of file +} diff --git a/lib/services/service_policy.dart b/lib/services/service_policy.dart new file mode 100644 index 00000000..e8990a37 --- /dev/null +++ b/lib/services/service_policy.dart @@ -0,0 +1,400 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +/// Identifies the type of external service being accessed. +/// Used by [ServicePolicyResolver] to determine the correct compliance policy. +enum ServiceType { + // OSMF official services + osmEditingApi, // api.openstreetmap.org — editing & data queries + osmTileServer, // tile.openstreetmap.org — raster tiles + nominatim, // nominatim.openstreetmap.org — geocoding + overpass, // overpass-api.de — read-only data queries + tagInfo, // taginfo.openstreetmap.org — tag metadata + + // Third-party tile services + bingTiles, // *.tiles.virtualearth.net + mapboxTiles, // api.mapbox.com + + // Everything else + custom, // user's own infrastructure / unknown +} + +/// Defines the compliance rules for a specific service. +/// +/// Each policy captures the rate limits, caching requirements, offline +/// permissions, and attribution obligations mandated by the service operator. +/// When the app talks to official OSMF infrastructure the strict policies +/// apply; when the user configures self-hosted endpoints, [ServicePolicy.custom] +/// provides permissive defaults. +class ServicePolicy { + /// Max concurrent HTTP connections to this service. + /// A value of 0 means "managed elsewhere" (e.g., by flutter_map or PR #114). + final int maxConcurrentRequests; + + /// Minimum interval between consecutive requests. Null means no rate limit. + final Duration? minRequestInterval; + + /// Whether this endpoint permits offline/bulk downloading of tiles. + final bool allowsOfflineDownload; + + /// Whether the client must cache responses (e.g., Nominatim policy). + final bool requiresClientCaching; + + /// Minimum cache TTL to enforce regardless of server headers. + /// Null means "use server-provided max-age as-is". + final Duration? minCacheTtl; + + /// License/attribution URL to display in the attribution dialog. + /// Null means no special attribution link is needed. + final String? attributionUrl; + + const ServicePolicy({ + this.maxConcurrentRequests = 8, + this.minRequestInterval, + this.allowsOfflineDownload = true, + this.requiresClientCaching = false, + this.minCacheTtl, + this.attributionUrl, + }); + + /// OSM editing API (api.openstreetmap.org) + /// Policy: max 2 concurrent download threads. + /// https://operations.osmfoundation.org/policies/api/ + const ServicePolicy.osmEditingApi() + : maxConcurrentRequests = 2, + minRequestInterval = null, + allowsOfflineDownload = true, // n/a for API + requiresClientCaching = false, + minCacheTtl = null, + attributionUrl = null; + + /// OSM tile server (tile.openstreetmap.org) + /// Policy: no offline/bulk downloading, min 7-day cache, must honor cache headers. + /// Concurrency managed by flutter_map's NetworkTileProvider. + /// https://operations.osmfoundation.org/policies/tiles/ + const ServicePolicy.osmTileServer() + : maxConcurrentRequests = 0, // managed by flutter_map + minRequestInterval = null, + allowsOfflineDownload = false, + requiresClientCaching = true, + minCacheTtl = const Duration(days: 7), + attributionUrl = 'https://www.openstreetmap.org/copyright'; + + /// Nominatim geocoding (nominatim.openstreetmap.org) + /// Policy: max 1 req/sec, single machine only, results must be cached. + /// https://operations.osmfoundation.org/policies/nominatim/ + const ServicePolicy.nominatim() + : maxConcurrentRequests = 1, + minRequestInterval = const Duration(seconds: 1), + allowsOfflineDownload = true, // n/a for geocoding + requiresClientCaching = true, + minCacheTtl = null, + attributionUrl = 'https://www.openstreetmap.org/copyright'; + + /// Overpass API (overpass-api.de) + /// Concurrency and rate limiting managed by PR #114's _AsyncSemaphore. + const ServicePolicy.overpass() + : maxConcurrentRequests = 0, // managed by NodeDataManager + minRequestInterval = null, // managed by NodeDataManager + allowsOfflineDownload = true, // n/a for data queries + requiresClientCaching = false, + minCacheTtl = null, + attributionUrl = null; + + /// TagInfo API (taginfo.openstreetmap.org) + const ServicePolicy.tagInfo() + : maxConcurrentRequests = 2, + minRequestInterval = null, + allowsOfflineDownload = true, // n/a + requiresClientCaching = true, // already cached in NSIService + minCacheTtl = null, + attributionUrl = null; + + /// Bing Maps tiles (*.tiles.virtualearth.net) + const ServicePolicy.bingTiles() + : maxConcurrentRequests = 0, // managed by flutter_map + minRequestInterval = null, + allowsOfflineDownload = true, // check Bing ToS separately + requiresClientCaching = false, + minCacheTtl = null, + attributionUrl = null; + + /// Mapbox tiles (api.mapbox.com) + const ServicePolicy.mapboxTiles() + : maxConcurrentRequests = 0, // managed by flutter_map + minRequestInterval = null, + allowsOfflineDownload = true, // permitted with valid token + requiresClientCaching = false, + minCacheTtl = null, + attributionUrl = null; + + /// Custom/self-hosted service — permissive defaults. + const ServicePolicy.custom({ + int maxConcurrent = 8, + bool allowsOffline = true, + Duration? minInterval, + String? attribution, + }) : maxConcurrentRequests = maxConcurrent, + minRequestInterval = minInterval, + allowsOfflineDownload = allowsOffline, + requiresClientCaching = false, + minCacheTtl = null, + attributionUrl = attribution; + + @override + String toString() => 'ServicePolicy(' + 'maxConcurrent: $maxConcurrentRequests, ' + 'minInterval: $minRequestInterval, ' + 'offlineDownload: $allowsOfflineDownload, ' + 'clientCaching: $requiresClientCaching, ' + 'minCacheTtl: $minCacheTtl, ' + 'attributionUrl: $attributionUrl)'; +} + +/// Resolves URLs and tile providers to their applicable [ServicePolicy]. +/// +/// Built-in patterns cover all OSMF official services and common third-party +/// tile providers. Custom overrides can be registered for self-hosted endpoints +/// via [registerCustomPolicy]. +class ServicePolicyResolver { + /// Host → ServiceType mapping for known services. + static final Map _hostPatterns = { + 'api.openstreetmap.org': ServiceType.osmEditingApi, + 'api06.dev.openstreetmap.org': ServiceType.osmEditingApi, + 'master.apis.dev.openstreetmap.org': ServiceType.osmEditingApi, + 'tile.openstreetmap.org': ServiceType.osmTileServer, + 'nominatim.openstreetmap.org': ServiceType.nominatim, + 'overpass-api.de': ServiceType.overpass, + 'taginfo.openstreetmap.org': ServiceType.tagInfo, + 'tiles.virtualearth.net': ServiceType.bingTiles, + 'api.mapbox.com': ServiceType.mapboxTiles, + }; + + /// ServiceType → policy mapping. + static final Map _policies = { + ServiceType.osmEditingApi: const ServicePolicy.osmEditingApi(), + ServiceType.osmTileServer: const ServicePolicy.osmTileServer(), + ServiceType.nominatim: const ServicePolicy.nominatim(), + ServiceType.overpass: const ServicePolicy.overpass(), + ServiceType.tagInfo: const ServicePolicy.tagInfo(), + ServiceType.bingTiles: const ServicePolicy.bingTiles(), + ServiceType.mapboxTiles: const ServicePolicy.mapboxTiles(), + ServiceType.custom: const ServicePolicy(), + }; + + /// Custom host overrides registered at runtime (for self-hosted services). + static final Map _customOverrides = {}; + + /// Resolve a URL to its applicable [ServicePolicy]. + /// + /// Checks custom overrides first, then built-in host patterns. Falls back + /// to [ServicePolicy.custom] for unrecognized hosts. + static ServicePolicy resolve(String url) { + final host = _extractHost(url); + if (host == null) return const ServicePolicy(); + + // Check custom overrides first (exact or subdomain matching) + for (final entry in _customOverrides.entries) { + if (host == entry.key || host.endsWith('.${entry.key}')) { + return entry.value; + } + } + + // Check built-in patterns (support subdomain matching) + for (final entry in _hostPatterns.entries) { + if (host == entry.key || host.endsWith('.${entry.key}')) { + return _policies[entry.value] ?? const ServicePolicy(); + } + } + + return const ServicePolicy(); + } + + /// Resolve a URL to its [ServiceType]. + /// + /// Returns [ServiceType.custom] for unrecognized hosts. + static ServiceType resolveType(String url) { + final host = _extractHost(url); + if (host == null) return ServiceType.custom; + + // Check custom overrides first — a registered custom policy means + // the host is treated as ServiceType.custom with custom rules. + for (final entry in _customOverrides.entries) { + if (host == entry.key || host.endsWith('.${entry.key}')) { + return ServiceType.custom; + } + } + + for (final entry in _hostPatterns.entries) { + if (host == entry.key || host.endsWith('.${entry.key}')) { + return entry.value; + } + } + + return ServiceType.custom; + } + + /// Look up the [ServicePolicy] for a known [ServiceType]. + static ServicePolicy resolveByType(ServiceType type) => + _policies[type] ?? const ServicePolicy(); + + /// Register a custom policy override for a host pattern. + /// + /// Use this to configure self-hosted services: + /// ```dart + /// ServicePolicyResolver.registerCustomPolicy( + /// 'tiles.myserver.com', + /// ServicePolicy.custom(allowsOffline: true, maxConcurrent: 20), + /// ); + /// ``` + static void registerCustomPolicy(String hostPattern, ServicePolicy policy) { + _customOverrides[hostPattern] = policy; + } + + /// Remove a custom policy override. + static void removeCustomPolicy(String hostPattern) { + _customOverrides.remove(hostPattern); + } + + /// Clear all custom policy overrides (useful for testing). + static void clearCustomPolicies() { + _customOverrides.clear(); + } + + /// Extract the host from a URL or URL template. + static String? _extractHost(String url) { + // Handle URL templates like 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' + // and subdomain templates like 'https://ecn.t{0_3}.tiles.virtualearth.net/...' + try { + // Strip template variables from subdomain part for parsing + final cleaned = url + .replaceAll(RegExp(r'\{0_3\}'), '0') + .replaceAll(RegExp(r'\{1_4\}'), '1') + .replaceAll(RegExp(r'\{quadkey\}'), 'quadkey') + .replaceAll(RegExp(r'\{z\}'), '0') + .replaceAll(RegExp(r'\{x\}'), '0') + .replaceAll(RegExp(r'\{y\}'), '0') + .replaceAll(RegExp(r'\{api_key\}'), 'key'); + return Uri.parse(cleaned).host.toLowerCase(); + } catch (_) { + return null; + } + } +} + +/// Reusable per-service rate limiter and concurrency controller. +/// +/// Enforces the rate limits and concurrency constraints defined in each +/// service's [ServicePolicy]. Call [acquire] before making a request and +/// [release] after the request completes. +/// +/// Only manages services whose policies have [ServicePolicy.maxConcurrentRequests] > 0 +/// and/or [ServicePolicy.minRequestInterval] set. Services managed elsewhere +/// (flutter_map, PR #114) are passed through without blocking. +class ServiceRateLimiter { + /// Injectable clock for testing. Defaults to [DateTime.now]. + /// + /// Override with a deterministic clock (e.g. from `FakeAsync`) so tests + /// don't rely on wall-clock time and stay fast and stable under CI load. + @visibleForTesting + static DateTime Function() clock = DateTime.now; + + /// Per-service timestamps of the last acquired request slot / request start + /// (used for rate limiting in [acquire], not updated on completion). + static final Map _lastRequestTime = {}; + + /// Per-service concurrency semaphores. + static final Map _semaphores = {}; + + /// Acquire a slot: wait for rate limit compliance, then take a connection slot. + /// + /// Blocks if: + /// 1. The minimum interval between requests hasn't elapsed yet, or + /// 2. All concurrent connection slots are in use. + static Future acquire(ServiceType service) async { + final policy = ServicePolicyResolver.resolveByType(service); + + // Concurrency: acquire semaphore slot first, so only one caller at a + // time proceeds to the rate-limit check. This prevents concurrent + // callers from bypassing the min interval when _lastRequestTime is + // still null or stale. + _Semaphore? semaphore; + if (policy.maxConcurrentRequests > 0) { + semaphore = _semaphores.putIfAbsent( + service, + () => _Semaphore(policy.maxConcurrentRequests), + ); + await semaphore.acquire(); + } + + try { + // Rate limit: wait if we sent a request too recently + if (policy.minRequestInterval != null) { + final lastTime = _lastRequestTime[service]; + if (lastTime != null) { + final elapsed = clock().difference(lastTime); + final remaining = policy.minRequestInterval! - elapsed; + if (remaining > Duration.zero) { + debugPrint('[ServiceRateLimiter] Throttling $service for ${remaining.inMilliseconds}ms'); + await Future.delayed(remaining); + } + } + } + + // Record request time + _lastRequestTime[service] = clock(); + } catch (_) { + // Release the semaphore slot if the rate-limit delay fails, + // to avoid permanently leaking a slot. + semaphore?.release(); + rethrow; + } + } + + /// Release a connection slot after request completes. + static void release(ServiceType service) { + _semaphores[service]?.release(); + } + + /// Reset all rate limiter state (for testing). + @visibleForTesting + static void reset() { + _lastRequestTime.clear(); + _semaphores.clear(); + clock = DateTime.now; + } +} + +/// Simple async counting semaphore for concurrency limiting. +class _Semaphore { + final int _maxCount; + int _currentCount = 0; + final List> _waiters = []; + + _Semaphore(this._maxCount); + + Future acquire() async { + if (_currentCount < _maxCount) { + _currentCount++; + return; + } + final completer = Completer(); + _waiters.add(completer); + await completer.future; + } + + void release() { + if (_waiters.isNotEmpty) { + final next = _waiters.removeAt(0); + next.complete(); + } else if (_currentCount > 0) { + _currentCount--; + } else { + throw StateError( + 'Semaphore.release() called more times than acquire(); ' + 'currentCount is already zero.', + ); + } + } +} diff --git a/lib/widgets/download_area_dialog.dart b/lib/widgets/download_area_dialog.dart index 0adbdb28..cdda1d01 100644 --- a/lib/widgets/download_area_dialog.dart +++ b/lib/widgets/download_area_dialog.dart @@ -262,16 +262,80 @@ class _DownloadAreaDialogState extends State { ElevatedButton( onPressed: isOfflineMode ? null : () async { try { + // Get current tile provider info + final appState = context.read(); + final selectedProvider = appState.selectedTileProvider; + final selectedTileType = appState.selectedTileType; + + // Check if the tile provider allows offline downloads + if (selectedTileType != null && !selectedTileType.allowsOfflineDownload) { + if (!context.mounted) return; + // Capture navigator before popping, since context is + // deactivated after Navigator.pop. + final navigator = Navigator.of(context); + navigator.pop(); + showDialog( + context: navigator.context, + builder: (context) => AlertDialog( + title: Row( + children: [ + const Icon(Icons.block, color: Colors.orange), + const SizedBox(width: 10), + Text(locService.t('download.title')), + ], + ), + content: Text( + locService.t( + 'download.offlineNotPermitted', + params: [ + selectedProvider?.name ?? locService.t('download.currentTileProvider'), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(locService.t('actions.ok')), + ), + ], + ), + ); + return; + } + + // Guard: provider and tile type must be non-null for a + // useful offline area (fetchLocalTile requires exact match). + if (selectedProvider == null || selectedTileType == null) { + if (!context.mounted) return; + final navigator = Navigator.of(context); + navigator.pop(); + showDialog( + context: navigator.context, + builder: (context) => AlertDialog( + title: Row( + children: [ + const Icon(Icons.error, color: Colors.red), + const SizedBox(width: 10), + Text(locService.t('download.title')), + ], + ), + content: Text(locService.t('download.noTileProviderSelected')), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(locService.t('actions.ok')), + ), + ], + ), + ); + return; + } + final id = DateTime.now().toIso8601String().replaceAll(':', '-'); final appDocDir = await OfflineAreaService().getOfflineAreaDir(); if (!context.mounted) return; final dir = "${appDocDir.path}/$id"; - // Get current tile provider info - final appState = context.read(); - final selectedProvider = appState.selectedTileProvider; - final selectedTileType = appState.selectedTileType; - // Fire and forget: don't await download, so dialog closes immediately // ignore: unawaited_futures OfflineAreaService().downloadArea( @@ -282,10 +346,10 @@ class _DownloadAreaDialogState extends State { directory: dir, onProgress: (progress) {}, onComplete: (status) {}, - tileProviderId: selectedProvider?.id, - tileProviderName: selectedProvider?.name, - tileTypeId: selectedTileType?.id, - tileTypeName: selectedTileType?.name, + tileProviderId: selectedProvider.id, + tileProviderName: selectedProvider.name, + tileTypeId: selectedTileType.id, + tileTypeName: selectedTileType.name, ); Navigator.pop(context); showDialog( diff --git a/lib/widgets/map/map_overlays.dart b/lib/widgets/map/map_overlays.dart index 0d9772f9..5e952bb0 100644 --- a/lib/widgets/map/map_overlays.dart +++ b/lib/widgets/map/map_overlays.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_map_animations/flutter_map_animations.dart'; import 'package:provider/provider.dart'; +import 'package:url_launcher/url_launcher.dart'; import '../../app_state.dart'; import '../../dev_config.dart'; @@ -26,16 +27,63 @@ class MapOverlays extends StatelessWidget { this.onSearchPressed, }); - /// Show full attribution text in a dialog + /// Show full attribution text in a dialog with license link. void _showAttributionDialog(BuildContext context, String attribution) { final locService = LocalizationService.instance; + + // Get the license URL from the current tile provider's service policy + final appState = AppState.instance; + final tileType = appState.selectedTileType; + final attributionUrl = tileType?.servicePolicy.attributionUrl; + showDialog( context: context, builder: (context) => AlertDialog( title: Text(locService.t('mapTiles.attribution')), - content: SelectableText( - attribution, - style: const TextStyle(fontSize: 14), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SelectableText( + attribution, + style: const TextStyle(fontSize: 14), + ), + if (attributionUrl != null) ...[ + const SizedBox(height: 12), + Semantics( + link: true, + label: locService.t('mapTiles.openLicense', params: [attributionUrl]), + child: InkWell( + onTap: () async { + try { + final uri = Uri.parse(attributionUrl); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } else if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(locService.t('mapTiles.couldNotOpenLink'))), + ); + } + } catch (_) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(locService.t('mapTiles.couldNotOpenLink'))), + ); + } + } + }, + child: Text( + attributionUrl, + style: TextStyle( + fontSize: 13, + color: Theme.of(context).colorScheme.primary, + decoration: TextDecoration.underline, + ), + ), + ), + ), + ], + ], ), actions: [ TextButton( @@ -125,23 +173,30 @@ class MapOverlays extends StatelessWidget { Positioned( bottom: bottomPositionFromButtonBar(kAttributionSpacingAboveButtonBar, safeArea.bottom), left: leftPositionWithSafeArea(10, safeArea), - child: GestureDetector( - onTap: () => _showAttributionDialog(context, attribution!), - child: Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.9), + child: Semantics( + button: true, + label: LocalizationService.instance.t('mapTiles.mapAttribution', params: [attribution!]), + child: Material( + color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(4), + child: InkWell( borderRadius: BorderRadius.circular(4), - ), - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), - constraints: const BoxConstraints(maxWidth: 250), - child: Text( - attribution!, - style: TextStyle( - fontSize: 11, - color: Theme.of(context).colorScheme.onSurface, + onTap: () => _showAttributionDialog(context, attribution!), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 250), + child: Text( + attribution!, + style: TextStyle( + fontSize: 11, + color: Theme.of(context).colorScheme.onSurface, + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ), ), - overflow: TextOverflow.ellipsis, - maxLines: 1, ), ), ), diff --git a/lib/widgets/map/tile_layer_manager.dart b/lib/widgets/map/tile_layer_manager.dart index 75acc72b..fcf74d94 100644 --- a/lib/widgets/map/tile_layer_manager.dart +++ b/lib/widgets/map/tile_layer_manager.dart @@ -1,68 +1,124 @@ +import 'dart:async'; +import 'dart:math'; + import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import '../../models/tile_provider.dart' as models; import '../../services/deflock_tile_provider.dart'; +import '../../services/provider_tile_cache_manager.dart'; -/// Manages tile layer creation, caching, and provider switching. -/// Uses DeFlock's custom tile provider for clean integration. +/// Manages tile layer creation with per-provider caching and provider switching. +/// +/// Each tile provider/type combination gets its own [DeflockTileProvider] +/// instance with isolated caching (separate cache directory, configurable size +/// limit, and policy-driven TTL enforcement). Providers are created lazily on +/// first use and cached for instant switching. class TileLayerManager { - DeflockTileProvider? _tileProvider; + final Map _providers = {}; int _mapRebuildKey = 0; + String? _lastProviderId; String? _lastTileTypeId; bool? _lastOfflineMode; - /// Get the current map rebuild key for cache busting + /// Stream that triggers flutter_map to drop all tiles and reload. + /// Fired after a debounced delay when tile errors are detected. + final StreamController _resetController = + StreamController.broadcast(); + + /// Debounce timer for scheduling a tile reset after errors. + Timer? _retryTimer; + + /// Current retry delay — starts at [_minRetryDelay] and doubles on each + /// retry cycle (capped at [_maxRetryDelay]). Resets to [_minRetryDelay] + /// when a tile loads successfully. + Duration _retryDelay = const Duration(seconds: 2); + + static const _minRetryDelay = Duration(seconds: 2); + static const _maxRetryDelay = Duration(seconds: 60); + + /// Get the current map rebuild key for cache busting. int get mapRebuildKey => _mapRebuildKey; - /// Initialize the tile layer manager + /// Current retry delay (exposed for testing). + @visibleForTesting + Duration get retryDelay => _retryDelay; + + /// Stream of reset events (exposed for testing). + @visibleForTesting + Stream get resetStream => _resetController.stream; + + /// Initialize the tile layer manager. + /// + /// [ProviderTileCacheManager.init] is called in main() before any widgets + /// build, so this is a no-op retained for API compatibility. void initialize() { - // Don't create tile provider here - create it fresh for each build + // Cache directory is already resolved in main(). } - /// Dispose of resources + /// Dispose of all provider resources. + /// + /// Synchronous to match Flutter's [State.dispose] contract. Calls + /// [DeflockTileProvider.shutdown] to permanently close each provider's HTTP + /// client. (We don't call provider.dispose() here — flutter_map already + /// called it when the TileLayer widget was removed, and it's safe to call + /// again but unnecessary.) void dispose() { - _tileProvider?.dispose(); + _retryTimer?.cancel(); + _resetController.close(); + for (final provider in _providers.values) { + provider.shutdown(); + } + _providers.clear(); } /// Check if cache should be cleared and increment rebuild key if needed. /// Returns true if cache was cleared (map should be rebuilt). bool checkAndClearCacheIfNeeded({ + required String? currentProviderId, required String? currentTileTypeId, required bool currentOfflineMode, }) { bool shouldClear = false; String? reason; - if ((_lastTileTypeId != null && _lastTileTypeId != currentTileTypeId)) { + if (_lastProviderId != currentProviderId) { + reason = 'provider ($currentProviderId)'; + shouldClear = true; + } else if (_lastTileTypeId != currentTileTypeId) { reason = 'tile type ($currentTileTypeId)'; shouldClear = true; - } else if ((_lastOfflineMode != null && _lastOfflineMode != currentOfflineMode)) { + } else if (_lastOfflineMode != currentOfflineMode) { reason = 'offline mode ($currentOfflineMode)'; shouldClear = true; } if (shouldClear) { - // Force map rebuild with new key to bust flutter_map cache + // Force map rebuild with new key to bust flutter_map cache. + // We don't dispose providers here — they're reusable across switches. _mapRebuildKey++; - // Dispose old provider before creating a fresh one (closes HTTP client) - _tileProvider?.dispose(); - _tileProvider = null; + // Reset backoff so the new provider starts with a clean slate. + // Cancel any pending retry timer — it belongs to the old provider's errors. + _retryDelay = _minRetryDelay; + _retryTimer?.cancel(); debugPrint('[TileLayerManager] *** CACHE CLEAR *** $reason changed - rebuilding map $_mapRebuildKey'); } + _lastProviderId = currentProviderId; _lastTileTypeId = currentTileTypeId; _lastOfflineMode = currentOfflineMode; return shouldClear; } - /// Clear the tile request queue (call after cache clear) + /// Clear the tile request queue (call after cache clear). + /// + /// In the old architecture this incremented [_mapRebuildKey] a second time + /// to force a rebuild after the provider was disposed and recreated. With + /// per-provider caching, [checkAndClearCacheIfNeeded] already increments the + /// key, so this is now a no-op. Kept for API compatibility with map_view. void clearTileQueue() { - // With NetworkTileProvider, clearing is handled by FlutterMap's internal cache - // We just need to increment the rebuild key to bust the cache - _mapRebuildKey++; - debugPrint('[TileLayerManager] Cache cleared - rebuilding map $_mapRebuildKey'); + // No-op: checkAndClearCacheIfNeeded() already incremented _mapRebuildKey. } /// Clear tile queue immediately (for zoom changes, etc.) @@ -70,19 +126,85 @@ class TileLayerManager { // No immediate clearing needed — NetworkTileProvider aborts obsolete requests } - /// Clear only tiles that are no longer visible in the current bounds + /// Clear only tiles that are no longer visible in the current bounds. void clearStaleRequests({required LatLngBounds currentBounds}) { // No selective clearing needed — NetworkTileProvider aborts obsolete requests } + /// Called by flutter_map when a tile fails to load. Schedules a debounced + /// reset so that all failed tiles get retried after the burst of errors + /// settles down. Uses exponential backoff: 2s → 4s → 8s → … → 60s cap. + /// + /// Skips retry for [TileLoadCancelledException] (tile scrolled off screen) + /// and [TileNotAvailableOfflineException] (no cached data, retrying won't + /// help without network). + @visibleForTesting + void onTileLoadError( + TileImage tile, + Object error, + StackTrace? stackTrace, + ) { + // Cancelled tiles are already gone — no retry needed. + if (error is TileLoadCancelledException) return; + + // Offline misses won't resolve by retrying — tile isn't cached. + if (error is TileNotAvailableOfflineException) return; + + debugPrint( + '[TileLayerManager] Tile error at ' + '${tile.coordinates.z}/${tile.coordinates.x}/${tile.coordinates.y}, ' + 'scheduling retry in ${_retryDelay.inSeconds}s', + ); + scheduleRetry(); + } + + /// Schedule a debounced tile reset with exponential backoff. + /// + /// Cancels any pending retry timer and starts a new one at the current + /// [_retryDelay]. After the timer fires, [_retryDelay] doubles (capped + /// at [_maxRetryDelay]). + @visibleForTesting + void scheduleRetry() { + _retryTimer?.cancel(); + _retryTimer = Timer(_retryDelay, () { + if (!_resetController.isClosed) { + debugPrint('[TileLayerManager] Firing tile reset to retry failed tiles'); + _resetController.add(null); + } + // Back off for next failure cycle + _retryDelay = Duration( + milliseconds: min( + _retryDelay.inMilliseconds * 2, + _maxRetryDelay.inMilliseconds, + ), + ); + }); + } + + /// Reset backoff to minimum delay. Called when a tile loads successfully + /// via the offline-first path, indicating connectivity has been restored. + /// + /// Note: the common path (`NetworkTileImageProvider`) does not call this, + /// so backoff resets only when the offline-first path succeeds over the + /// network. In practice this is fine — the common path's `RetryClient` + /// handles its own retries, and the reset stream only retries tiles that + /// flutter_map has already marked as `loadError`. + void onTileLoadSuccess() { + _retryDelay = _minRetryDelay; + } + /// Build tile layer widget with current provider and type. - /// Uses DeFlock's custom tile provider for clean integration with our offline/online system. + /// + /// Gets or creates a [DeflockTileProvider] for the given provider/type + /// combination, each with its own isolated cache. Widget buildTileLayer({ required models.TileProvider? selectedProvider, required models.TileType? selectedTileType, }) { - // Create a fresh tile provider instance if we don't have one or cache was cleared - _tileProvider ??= DeflockTileProvider(); + final tileProvider = _getOrCreateProvider( + selectedProvider: selectedProvider, + selectedTileType: selectedTileType, + ); // Use the actual urlTemplate from the selected tile type. Our getTileUrl() // override handles the real URL generation; flutter_map uses urlTemplate @@ -94,7 +216,58 @@ class TileLayerManager { urlTemplate: urlTemplate, userAgentPackageName: 'me.deflock.deflockapp', maxZoom: selectedTileType?.maxZoom.toDouble() ?? 18.0, - tileProvider: _tileProvider!, + tileProvider: tileProvider, + // Wire the reset stream so failed tiles get retried after a delay. + reset: _resetController.stream, + errorTileCallback: onTileLoadError, + // Clean up error tiles when they scroll off screen. + evictErrorTileStrategy: EvictErrorTileStrategy.notVisible, ); } + + /// Get or create a [DeflockTileProvider] for the given provider/type. + DeflockTileProvider _getOrCreateProvider({ + required models.TileProvider? selectedProvider, + required models.TileType? selectedTileType, + }) { + if (selectedProvider == null || selectedTileType == null) { + // No provider configured — return a fallback with default config. + return _providers.putIfAbsent( + '_fallback', + () => DeflockTileProvider( + providerId: 'unknown', + tileType: models.TileType( + id: 'unknown', + name: 'Unknown', + urlTemplate: 'https://unknown.invalid/tiles/{z}/{x}/{y}', + attribution: '', + ), + ), + ); + } + + final key = '${selectedProvider.id}/${selectedTileType.id}'; + return _providers.putIfAbsent(key, () { + final cachingProvider = ProviderTileCacheManager.isInitialized + ? ProviderTileCacheManager.getOrCreate( + providerId: selectedProvider.id, + tileTypeId: selectedTileType.id, + policy: selectedTileType.servicePolicy, + ) + : null; + + debugPrint( + '[TileLayerManager] Creating provider for $key ' + '(cache: ${cachingProvider != null ? "enabled" : "disabled"})', + ); + + return DeflockTileProvider( + providerId: selectedProvider.id, + tileType: selectedTileType, + apiKey: selectedProvider.apiKey, + cachingProvider: cachingProvider, + onNetworkSuccess: onTileLoadSuccess, + ); + }); + } } diff --git a/lib/widgets/map_view.dart b/lib/widgets/map_view.dart index 96ef2b3d..1c817a46 100644 --- a/lib/widgets/map_view.dart +++ b/lib/widgets/map_view.dart @@ -284,17 +284,12 @@ class MapViewState extends State { onProfilesChanged: _refreshNodesFromProvider, ); - // Check if tile type OR offline mode changed and clear cache if needed - final cacheCleared = _tileManager.checkAndClearCacheIfNeeded( + // Check if provider, tile type, or offline mode changed and clear cache if needed + _tileManager.checkAndClearCacheIfNeeded( + currentProviderId: appState.selectedTileProvider?.id, currentTileTypeId: appState.selectedTileType?.id, currentOfflineMode: appState.offlineMode, ); - - if (cacheCleared) { - WidgetsBinding.instance.addPostFrameCallback((_) { - _tileManager.clearTileQueue(); - }); - } // Seed add‑mode target once, after first controller center is available. if (session != null && session.target == null) { @@ -396,7 +391,7 @@ class MapViewState extends State { if (_activePointers > 0) _activePointers--; }, child: FlutterMap( - key: ValueKey('map_${appState.offlineMode}_${appState.selectedTileType?.id ?? 'none'}_${_tileManager.mapRebuildKey}'), + key: ValueKey('map_${appState.selectedTileProvider?.id ?? 'none'}_${appState.selectedTileType?.id ?? 'none'}_${appState.offlineMode}_${_tileManager.mapRebuildKey}'), mapController: _controller.mapController, options: MapOptions( initialCenter: _gpsController.currentLocation ?? _positionManager.initialLocation ?? LatLng(37.7749, -122.4194), diff --git a/pubspec.lock b/pubspec.lock index 76982e6e..b61873af 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -178,7 +178,7 @@ packages: source: hosted version: "0.2.3" fake_async: - dependency: transitive + dependency: "direct dev" description: name: fake_async sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" diff --git a/pubspec.yaml b/pubspec.yaml index 03aa82d1..bb96369c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -43,6 +43,7 @@ dev_dependencies: flutter_test: sdk: flutter mocktail: ^1.0.4 + fake_async: ^1.3.0 flutter_launcher_icons: ^0.14.4 flutter_lints: ^6.0.0 flutter_native_splash: ^2.4.6 diff --git a/test/services/deflock_tile_provider_test.dart b/test/services/deflock_tile_provider_test.dart index ee4cd36b..140bd0e6 100644 --- a/test/services/deflock_tile_provider_test.dart +++ b/test/services/deflock_tile_provider_test.dart @@ -1,46 +1,57 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter/painting.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; import 'package:mocktail/mocktail.dart'; import 'package:deflockapp/app_state.dart'; import 'package:deflockapp/models/tile_provider.dart' as models; import 'package:deflockapp/services/deflock_tile_provider.dart'; +import 'package:deflockapp/services/provider_tile_cache_store.dart'; class MockAppState extends Mock implements AppState {} +class MockMapCachingProvider extends Mock implements MapCachingProvider {} void main() { late DeflockTileProvider provider; late MockAppState mockAppState; + final osmTileType = models.TileType( + id: 'osm_street', + name: 'Street Map', + urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + attribution: '© OpenStreetMap', + maxZoom: 19, + ); + + final mapboxTileType = models.TileType( + id: 'mapbox_satellite', + name: 'Satellite', + urlTemplate: + 'https://api.mapbox.com/v4/mapbox.satellite/{z}/{x}/{y}@2x.jpg90?access_token={api_key}', + attribution: '© Mapbox', + ); + setUp(() { mockAppState = MockAppState(); AppState.instance = mockAppState; - // Default stubs: online, OSM provider selected, no offline areas + // Default stubs: online, no offline areas when(() => mockAppState.offlineMode).thenReturn(false); - when(() => mockAppState.selectedTileProvider).thenReturn( - const models.TileProvider( - id: 'openstreetmap', - name: 'OpenStreetMap', - tileTypes: [], - ), - ); - when(() => mockAppState.selectedTileType).thenReturn( - const models.TileType( - id: 'osm_street', - name: 'Street Map', - urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', - attribution: '© OpenStreetMap', - maxZoom: 19, - ), - ); - provider = DeflockTileProvider(); + provider = DeflockTileProvider( + providerId: 'openstreetmap', + tileType: osmTileType, + ); }); tearDown(() async { - await provider.dispose(); + provider.shutdown(); AppState.instance = MockAppState(); }); @@ -49,7 +60,7 @@ void main() { expect(provider.supportsCancelLoading, isTrue); }); - test('getTileUrl() delegates to TileType.getTileUrl()', () { + test('getTileUrl() uses frozen tileType config', () { const coords = TileCoordinates(1, 2, 3); final options = TileLayer(urlTemplate: 'ignored/{z}/{x}/{y}'); @@ -58,23 +69,12 @@ void main() { expect(url, equals('https://tile.openstreetmap.org/3/1/2.png')); }); - test('getTileUrl() includes API key when present', () { - when(() => mockAppState.selectedTileProvider).thenReturn( - const models.TileProvider( - id: 'mapbox', - name: 'Mapbox', - apiKey: 'test_key_123', - tileTypes: [], - ), - ); - when(() => mockAppState.selectedTileType).thenReturn( - const models.TileType( - id: 'mapbox_satellite', - name: 'Satellite', - urlTemplate: - 'https://api.mapbox.com/v4/mapbox.satellite/{z}/{x}/{y}@2x.jpg90?access_token={api_key}', - attribution: '© Mapbox', - ), + test('getTileUrl() includes API key when present', () async { + provider.shutdown(); + provider = DeflockTileProvider( + providerId: 'mapbox', + tileType: mapboxTileType, + apiKey: 'test_key_123', ); const coords = TileCoordinates(1, 2, 10); @@ -86,19 +86,6 @@ void main() { expect(url, contains('/10/1/2@2x')); }); - test('getTileUrl() falls back to super when no provider selected', () { - when(() => mockAppState.selectedTileProvider).thenReturn(null); - when(() => mockAppState.selectedTileType).thenReturn(null); - - const coords = TileCoordinates(1, 2, 3); - final options = TileLayer(urlTemplate: 'https://example.com/{z}/{x}/{y}'); - - final url = provider.getTileUrl(coords, options); - - // Super implementation uses the urlTemplate from TileLayer options - expect(url, equals('https://example.com/3/1/2')); - }); - test('routes to network path when no offline areas exist', () { // offlineMode = false, OfflineAreaService not initialized → no offline areas const coords = TileCoordinates(5, 10, 12); @@ -136,10 +123,19 @@ void main() { expect(offlineProvider.providerId, equals('openstreetmap')); expect(offlineProvider.tileTypeId, equals('osm_street')); }); + + test('frozen config is independent of AppState', () { + // Provider was created with OSM config — changing AppState should not affect it + const coords = TileCoordinates(1, 2, 3); + final options = TileLayer(urlTemplate: 'ignored/{z}/{x}/{y}'); + + final url = provider.getTileUrl(coords, options); + expect(url, equals('https://tile.openstreetmap.org/3/1/2.png')); + }); }); group('DeflockOfflineTileImageProvider', () { - test('equal for same coordinates and provider/type', () { + test('equal for same coordinates, provider/type, and offlineOnly', () { const coords = TileCoordinates(1, 2, 3); final options = TileLayer(urlTemplate: 'test/{z}/{x}/{y}'); final cancel = Future.value(); @@ -161,7 +157,7 @@ void main() { httpClient: http.Client(), headers: const {}, cancelLoading: cancel, - isOfflineOnly: true, // different — but not in == + isOfflineOnly: false, providerId: 'prov_a', tileTypeId: 'type_1', tileUrl: 'https://other.com/3/1/2', // different — but not in == @@ -171,6 +167,37 @@ void main() { expect(a.hashCode, equals(b.hashCode)); }); + test('not equal for different isOfflineOnly', () { + const coords = TileCoordinates(1, 2, 3); + final options = TileLayer(urlTemplate: 'test/{z}/{x}/{y}'); + final cancel = Future.value(); + + final online = DeflockOfflineTileImageProvider( + coordinates: coords, + options: options, + httpClient: http.Client(), + headers: const {}, + cancelLoading: cancel, + isOfflineOnly: false, + providerId: 'prov_a', + tileTypeId: 'type_1', + tileUrl: 'url', + ); + final offline = DeflockOfflineTileImageProvider( + coordinates: coords, + options: options, + httpClient: http.Client(), + headers: const {}, + cancelLoading: cancel, + isOfflineOnly: true, + providerId: 'prov_a', + tileTypeId: 'type_1', + tileUrl: 'url', + ); + + expect(online, isNot(equals(offline))); + }); + test('not equal for different coordinates', () { const coords1 = TileCoordinates(1, 2, 3); const coords2 = TileCoordinates(1, 2, 4); @@ -247,5 +274,298 @@ void main() { expect(base, isNot(equals(diffType))); expect(base.hashCode, isNot(equals(diffType.hashCode))); }); + + test('equality ignores cachingProvider and onNetworkSuccess', () { + const coords = TileCoordinates(1, 2, 3); + final options = TileLayer(urlTemplate: 'test/{z}/{x}/{y}'); + final cancel = Future.value(); + + final withCaching = DeflockOfflineTileImageProvider( + coordinates: coords, + options: options, + httpClient: http.Client(), + headers: const {}, + cancelLoading: cancel, + isOfflineOnly: false, + providerId: 'prov_a', + tileTypeId: 'type_1', + tileUrl: 'url', + cachingProvider: MockMapCachingProvider(), + onNetworkSuccess: () {}, + ); + final withoutCaching = DeflockOfflineTileImageProvider( + coordinates: coords, + options: options, + httpClient: http.Client(), + headers: const {}, + cancelLoading: cancel, + isOfflineOnly: false, + providerId: 'prov_a', + tileTypeId: 'type_1', + tileUrl: 'url', + ); + + expect(withCaching, equals(withoutCaching)); + expect(withCaching.hashCode, equals(withoutCaching.hashCode)); + }); + }); + + group('DeflockTileProvider caching integration', () { + test('passes cachingProvider through to offline path', () { + when(() => mockAppState.offlineMode).thenReturn(true); + + final mockCaching = MockMapCachingProvider(); + var successCalled = false; + + final cachingProvider = DeflockTileProvider( + providerId: 'openstreetmap', + tileType: osmTileType, + cachingProvider: mockCaching, + onNetworkSuccess: () => successCalled = true, + ); + + const coords = TileCoordinates(5, 10, 12); + final options = TileLayer(urlTemplate: 'test/{z}/{x}/{y}'); + final cancelLoading = Future.value(); + + final imageProvider = cachingProvider.getImageWithCancelLoadingSupport( + coords, + options, + cancelLoading, + ); + + expect(imageProvider, isA()); + final offlineProvider = imageProvider as DeflockOfflineTileImageProvider; + expect(offlineProvider.cachingProvider, same(mockCaching)); + expect(offlineProvider.onNetworkSuccess, isNotNull); + + // Invoke the callback to verify it's wired correctly + offlineProvider.onNetworkSuccess!(); + expect(successCalled, isTrue); + + cachingProvider.shutdown(); + }); + + test('offline provider has null caching when not provided', () { + when(() => mockAppState.offlineMode).thenReturn(true); + + const coords = TileCoordinates(5, 10, 12); + final options = TileLayer(urlTemplate: 'test/{z}/{x}/{y}'); + final cancelLoading = Future.value(); + + final imageProvider = provider.getImageWithCancelLoadingSupport( + coords, + options, + cancelLoading, + ); + + expect(imageProvider, isA()); + final offlineProvider = imageProvider as DeflockOfflineTileImageProvider; + expect(offlineProvider.cachingProvider, isNull); + expect(offlineProvider.onNetworkSuccess, isNull); + }); + }); + + group('DeflockOfflineTileImageProvider caching helpers', () { + late Directory tempDir; + late ProviderTileCacheStore cacheStore; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('tile_cache_test_'); + cacheStore = ProviderTileCacheStore(cacheDirectory: tempDir.path); + }); + + tearDown(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + test('disk cache integration: putTile then getTile round-trip', () async { + const url = 'https://tile.example.com/3/1/2.png'; + final bytes = Uint8List.fromList([1, 2, 3, 4, 5]); + final metadata = CachedMapTileMetadata( + staleAt: DateTime.timestamp().add(const Duration(hours: 1)), + lastModified: DateTime.utc(2026, 2, 20), + etag: '"tile-etag"', + ); + + // Write to cache + await cacheStore.putTile(url: url, metadata: metadata, bytes: bytes); + + // Read back + final cached = await cacheStore.getTile(url); + expect(cached, isNotNull); + expect(cached!.bytes, equals(bytes)); + expect(cached.metadata.etag, equals('"tile-etag"')); + expect(cached.metadata.isStale, isFalse); + }); + + test('disk cache: stale tiles are detectable', () async { + const url = 'https://tile.example.com/stale.png'; + final bytes = Uint8List.fromList([1, 2, 3]); + final metadata = CachedMapTileMetadata( + staleAt: DateTime.timestamp().subtract(const Duration(hours: 1)), + lastModified: null, + etag: null, + ); + + await cacheStore.putTile(url: url, metadata: metadata, bytes: bytes); + + final cached = await cacheStore.getTile(url); + expect(cached, isNotNull); + expect(cached!.metadata.isStale, isTrue); + // Bytes are still available even when stale (for conditional revalidation) + expect(cached.bytes, equals(bytes)); + }); + + test('disk cache: metadata-only update preserves bytes', () async { + const url = 'https://tile.example.com/revalidated.png'; + final bytes = Uint8List.fromList([10, 20, 30]); + + // Initial write with bytes + await cacheStore.putTile( + url: url, + metadata: CachedMapTileMetadata( + staleAt: DateTime.timestamp().subtract(const Duration(hours: 1)), + lastModified: null, + etag: '"v1"', + ), + bytes: bytes, + ); + + // Metadata-only update (simulating 304 Not Modified revalidation) + await cacheStore.putTile( + url: url, + metadata: CachedMapTileMetadata( + staleAt: DateTime.timestamp().add(const Duration(hours: 1)), + lastModified: null, + etag: '"v2"', + ), + // No bytes — metadata only + ); + + final cached = await cacheStore.getTile(url); + expect(cached, isNotNull); + expect(cached!.bytes, equals(bytes)); // original bytes preserved + expect(cached.metadata.etag, equals('"v2"')); // metadata updated + expect(cached.metadata.isStale, isFalse); // now fresh + }); + }); + + group('DeflockOfflineTileImageProvider load error paths', () { + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + /// Load the tile via [loadImage] and return the first error from the + /// image stream. The decode callback should never be reached on error + /// paths, so we throw if it is. + Future loadAndExpectError( + DeflockOfflineTileImageProvider provider) { + final completer = Completer(); + final stream = provider.loadImage( + provider, + (buffer, {getTargetSize}) async => + throw StateError('decode should not be called'), + ); + stream.addListener(ImageStreamListener( + (_, _) { + if (!completer.isCompleted) { + completer + .completeError(StateError('expected error but got image')); + } + }, + onError: (error, _) { + if (!completer.isCompleted) completer.complete(error); + }, + )); + return completer.future; + } + + test('offline both-miss throws TileNotAvailableOfflineException', + () async { + // No offline areas, no cache → both miss. + final error = await loadAndExpectError( + DeflockOfflineTileImageProvider( + coordinates: const TileCoordinates(1, 2, 3), + options: TileLayer(urlTemplate: 'test/{z}/{x}/{y}'), + httpClient: http.Client(), + headers: const {}, + cancelLoading: Completer().future, // never cancels + isOfflineOnly: true, + providerId: 'nonexistent', + tileTypeId: 'nonexistent', + tileUrl: 'https://example.com/3/1/2.png', + ), + ); + + expect(error, isA()); + }); + + test('cancelled offline tile throws TileLoadCancelledException', + () async { + // cancelLoading already resolved → _loadAsync catch block detects + // cancellation and throws TileLoadCancelledException instead of + // the underlying TileNotAvailableOfflineException. + final error = await loadAndExpectError( + DeflockOfflineTileImageProvider( + coordinates: const TileCoordinates(1, 2, 3), + options: TileLayer(urlTemplate: 'test/{z}/{x}/{y}'), + httpClient: http.Client(), + headers: const {}, + cancelLoading: Future.value(), // already cancelled + isOfflineOnly: true, + providerId: 'nonexistent', + tileTypeId: 'nonexistent', + tileUrl: 'https://example.com/3/1/2.png', + ), + ); + + expect(error, isA()); + }); + + test('online cancel before network throws TileLoadCancelledException', + () async { + // Online mode: cache miss, local miss, then cancelled check fires + // before reaching the network fetch. + final error = await loadAndExpectError( + DeflockOfflineTileImageProvider( + coordinates: const TileCoordinates(1, 2, 3), + options: TileLayer(urlTemplate: 'test/{z}/{x}/{y}'), + httpClient: http.Client(), + headers: const {}, + cancelLoading: Future.value(), // already cancelled + isOfflineOnly: false, + providerId: 'nonexistent', + tileTypeId: 'nonexistent', + tileUrl: 'https://example.com/3/1/2.png', + ), + ); + + expect(error, isA()); + }); + + test('network error throws HttpException', () async { + // Online mode: cache miss, local miss, not cancelled, network + // returns 500 → HttpException with tile coordinates and status. + final error = await loadAndExpectError( + DeflockOfflineTileImageProvider( + coordinates: const TileCoordinates(4, 5, 6), + options: TileLayer(urlTemplate: 'test/{z}/{x}/{y}'), + httpClient: MockClient((_) async => http.Response('', 500)), + headers: const {}, + cancelLoading: Completer().future, // never cancels + isOfflineOnly: false, + providerId: 'nonexistent', + tileTypeId: 'nonexistent', + tileUrl: 'https://example.com/6/4/5.png', + ), + ); + + expect(error, isA()); + expect((error as HttpException).message, contains('6/4/5')); + expect(error.message, contains('500')); + }); }); } diff --git a/test/services/offline_area_service_test.dart b/test/services/offline_area_service_test.dart new file mode 100644 index 00000000..54f58e3a --- /dev/null +++ b/test/services/offline_area_service_test.dart @@ -0,0 +1,93 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:flutter_map/flutter_map.dart' show LatLngBounds; + +import 'package:deflockapp/services/offline_area_service.dart'; +import 'package:deflockapp/services/offline_areas/offline_area_models.dart'; + +OfflineArea _makeArea({ + String providerId = 'osm', + String tileTypeId = 'standard', + int minZoom = 5, + int maxZoom = 12, + OfflineAreaStatus status = OfflineAreaStatus.complete, +}) { + return OfflineArea( + id: 'test-$providerId-$tileTypeId-$minZoom-$maxZoom', + bounds: LatLngBounds(const LatLng(0, 0), const LatLng(1, 1)), + minZoom: minZoom, + maxZoom: maxZoom, + directory: '/tmp/test-area', + status: status, + tileProviderId: providerId, + tileTypeId: tileTypeId, + ); +} + +void main() { + final service = OfflineAreaService(); + + setUp(() { + service.setAreasForTesting([]); + }); + + group('hasOfflineAreasForProviderAtZoom', () { + test('returns true for zoom within range', () { + service.setAreasForTesting([_makeArea(minZoom: 5, maxZoom: 12)]); + + expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 5), isTrue); + expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 8), isTrue); + expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 12), isTrue); + }); + + test('returns false for zoom outside range', () { + service.setAreasForTesting([_makeArea(minZoom: 5, maxZoom: 12)]); + + expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 4), isFalse); + expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 13), isFalse); + expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 14), isFalse); + }); + + test('returns false for wrong provider', () { + service.setAreasForTesting([_makeArea(providerId: 'osm')]); + + expect(service.hasOfflineAreasForProviderAtZoom('other', 'standard', 8), isFalse); + }); + + test('returns false for wrong tile type', () { + service.setAreasForTesting([_makeArea(tileTypeId: 'standard')]); + + expect(service.hasOfflineAreasForProviderAtZoom('osm', 'satellite', 8), isFalse); + }); + + test('returns false for non-complete areas', () { + service.setAreasForTesting([ + _makeArea(status: OfflineAreaStatus.downloading), + _makeArea(status: OfflineAreaStatus.error), + ]); + + expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 8), isFalse); + }); + + test('returns false when initialized with no areas', () { + service.setAreasForTesting([]); + expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 8), isFalse); + }); + + test('matches when any area covers the zoom level', () { + service.setAreasForTesting([ + _makeArea(minZoom: 5, maxZoom: 8), + _makeArea(minZoom: 10, maxZoom: 14), + ]); + + // In first area's range + expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 6), isTrue); + // In gap between areas + expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 9), isFalse); + // In second area's range + expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 13), isTrue); + // Beyond both areas + expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 15), isFalse); + }); + }); +} diff --git a/test/services/provider_tile_cache_store_test.dart b/test/services/provider_tile_cache_store_test.dart new file mode 100644 index 00000000..c1906f48 --- /dev/null +++ b/test/services/provider_tile_cache_store_test.dart @@ -0,0 +1,509 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; + +import 'package:deflockapp/services/provider_tile_cache_store.dart'; +import 'package:deflockapp/services/provider_tile_cache_manager.dart'; +import 'package:deflockapp/services/service_policy.dart'; + +void main() { + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('tile_cache_test_'); + }); + + tearDown(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + await ProviderTileCacheManager.resetAll(); + }); + + group('ProviderTileCacheStore', () { + late ProviderTileCacheStore store; + + setUp(() { + store = ProviderTileCacheStore( + cacheDirectory: tempDir.path, + ); + }); + + test('isSupported is true', () { + expect(store.isSupported, isTrue); + }); + + test('getTile returns null for uncached URL', () async { + final result = await store.getTile('https://tile.example.com/1/2/3.png'); + expect(result, isNull); + }); + + test('putTile and getTile round-trip', () async { + const url = 'https://tile.example.com/1/2/3.png'; + final bytes = Uint8List.fromList([1, 2, 3, 4, 5]); + final staleAt = DateTime.utc(2026, 3, 1); + final metadata = CachedMapTileMetadata( + staleAt: staleAt, + lastModified: DateTime.utc(2026, 2, 20), + etag: '"abc123"', + ); + + await store.putTile(url: url, metadata: metadata, bytes: bytes); + + final cached = await store.getTile(url); + expect(cached, isNotNull); + expect(cached!.bytes, equals(bytes)); + expect( + cached.metadata.staleAt.millisecondsSinceEpoch, + equals(staleAt.millisecondsSinceEpoch), + ); + expect(cached.metadata.etag, equals('"abc123"')); + expect(cached.metadata.lastModified, isNotNull); + }); + + test('putTile without bytes updates metadata only', () async { + const url = 'https://tile.example.com/1/2/3.png'; + final bytes = Uint8List.fromList([1, 2, 3]); + final metadata1 = CachedMapTileMetadata( + staleAt: DateTime.utc(2026, 3, 1), + lastModified: null, + etag: '"v1"', + ); + + // Write with bytes first + await store.putTile(url: url, metadata: metadata1, bytes: bytes); + + // Update metadata only + final metadata2 = CachedMapTileMetadata( + staleAt: DateTime.utc(2026, 4, 1), + lastModified: null, + etag: '"v2"', + ); + await store.putTile(url: url, metadata: metadata2); + + final cached = await store.getTile(url); + expect(cached, isNotNull); + expect(cached!.bytes, equals(bytes)); // bytes unchanged + expect(cached.metadata.etag, equals('"v2"')); // metadata updated + }); + + test('handles null lastModified and etag', () async { + const url = 'https://tile.example.com/simple.png'; + final bytes = Uint8List.fromList([10, 20, 30]); + final metadata = CachedMapTileMetadata( + staleAt: DateTime.utc(2026, 3, 1), + lastModified: null, + etag: null, + ); + + await store.putTile(url: url, metadata: metadata, bytes: bytes); + + final cached = await store.getTile(url); + expect(cached, isNotNull); + expect(cached!.metadata.lastModified, isNull); + expect(cached.metadata.etag, isNull); + }); + + test('creates cache directory lazily on first putTile', () async { + final subDir = p.join(tempDir.path, 'lazy', 'nested'); + final lazyStore = ProviderTileCacheStore(cacheDirectory: subDir); + + // Directory should not exist yet + expect(await Directory(subDir).exists(), isFalse); + + await lazyStore.putTile( + url: 'https://example.com/tile.png', + metadata: CachedMapTileMetadata( + staleAt: DateTime.utc(2026, 3, 1), + lastModified: null, + etag: null, + ), + bytes: Uint8List.fromList([1]), + ); + + // Directory should now exist + expect(await Directory(subDir).exists(), isTrue); + }); + + test('clear deletes all cached tiles', () async { + // Write some tiles + for (var i = 0; i < 5; i++) { + await store.putTile( + url: 'https://example.com/$i.png', + metadata: CachedMapTileMetadata( + staleAt: DateTime.utc(2026, 3, 1), + lastModified: null, + etag: null, + ), + bytes: Uint8List.fromList([i]), + ); + } + + // Verify tiles exist + expect(await store.getTile('https://example.com/0.png'), isNotNull); + + // Clear + await store.clear(); + + // Directory should be gone + expect(await Directory(tempDir.path).exists(), isFalse); + + // getTile should return null (directory gone) + expect(await store.getTile('https://example.com/0.png'), isNull); + }); + }); + + group('ProviderTileCacheStore TTL override', () { + test('overrideFreshAge bumps staleAt forward', () async { + final store = ProviderTileCacheStore( + cacheDirectory: tempDir.path, + overrideFreshAge: const Duration(days: 7), + ); + + const url = 'https://tile.example.com/osm.png'; + // Server says stale in 1 hour, but policy requires 7 days + final serverMetadata = CachedMapTileMetadata( + staleAt: DateTime.timestamp().add(const Duration(hours: 1)), + lastModified: null, + etag: null, + ); + + await store.putTile( + url: url, + metadata: serverMetadata, + bytes: Uint8List.fromList([1, 2, 3]), + ); + + final cached = await store.getTile(url); + expect(cached, isNotNull); + + // staleAt should be ~7 days from now, not 1 hour + final expectedMin = DateTime.timestamp().add(const Duration(days: 6)); + expect(cached!.metadata.staleAt.isAfter(expectedMin), isTrue); + }); + + test('without overrideFreshAge, server staleAt is preserved', () async { + final store = ProviderTileCacheStore( + cacheDirectory: tempDir.path, + // No overrideFreshAge + ); + + const url = 'https://tile.example.com/bing.png'; + final serverStaleAt = DateTime.utc(2026, 3, 15, 12, 0); + final serverMetadata = CachedMapTileMetadata( + staleAt: serverStaleAt, + lastModified: null, + etag: null, + ); + + await store.putTile( + url: url, + metadata: serverMetadata, + bytes: Uint8List.fromList([1, 2, 3]), + ); + + final cached = await store.getTile(url); + expect(cached, isNotNull); + expect( + cached!.metadata.staleAt.millisecondsSinceEpoch, + equals(serverStaleAt.millisecondsSinceEpoch), + ); + }); + }); + + group('ProviderTileCacheStore isolation', () { + test('separate directories do not interfere', () async { + final dirA = p.join(tempDir.path, 'provider_a', 'type_1'); + final dirB = p.join(tempDir.path, 'provider_b', 'type_1'); + + final storeA = ProviderTileCacheStore(cacheDirectory: dirA); + final storeB = ProviderTileCacheStore(cacheDirectory: dirB); + + const url = 'https://tile.example.com/shared-url.png'; + final metadata = CachedMapTileMetadata( + staleAt: DateTime.utc(2026, 3, 1), + lastModified: null, + etag: null, + ); + + await storeA.putTile( + url: url, + metadata: metadata, + bytes: Uint8List.fromList([1, 1, 1]), + ); + await storeB.putTile( + url: url, + metadata: metadata, + bytes: Uint8List.fromList([2, 2, 2]), + ); + + final cachedA = await storeA.getTile(url); + final cachedB = await storeB.getTile(url); + + expect(cachedA!.bytes, equals(Uint8List.fromList([1, 1, 1]))); + expect(cachedB!.bytes, equals(Uint8List.fromList([2, 2, 2]))); + }); + }); + + group('ProviderTileCacheManager', () { + test('getOrCreate returns same instance for same key', () { + ProviderTileCacheManager.setBaseCacheDir(tempDir.path); + + final storeA = ProviderTileCacheManager.getOrCreate( + providerId: 'osm', + tileTypeId: 'street', + policy: const ServicePolicy(), + ); + final storeB = ProviderTileCacheManager.getOrCreate( + providerId: 'osm', + tileTypeId: 'street', + policy: const ServicePolicy(), + ); + + expect(identical(storeA, storeB), isTrue); + }); + + test('getOrCreate returns different instances for different keys', () { + ProviderTileCacheManager.setBaseCacheDir(tempDir.path); + + final storeA = ProviderTileCacheManager.getOrCreate( + providerId: 'osm', + tileTypeId: 'street', + policy: const ServicePolicy(), + ); + final storeB = ProviderTileCacheManager.getOrCreate( + providerId: 'bing', + tileTypeId: 'satellite', + policy: const ServicePolicy(), + ); + + expect(identical(storeA, storeB), isFalse); + }); + + test('passes overrideFreshAge from policy.minCacheTtl', () { + ProviderTileCacheManager.setBaseCacheDir(tempDir.path); + + final store = ProviderTileCacheManager.getOrCreate( + providerId: 'osm', + tileTypeId: 'street', + policy: const ServicePolicy.osmTileServer(), + ); + + expect(store.overrideFreshAge, equals(const Duration(days: 7))); + }); + + test('custom maxCacheBytes is applied', () { + ProviderTileCacheManager.setBaseCacheDir(tempDir.path); + + final store = ProviderTileCacheManager.getOrCreate( + providerId: 'big', + tileTypeId: 'tiles', + policy: const ServicePolicy(), + maxCacheBytes: 1024 * 1024 * 1024, // 1 GB + ); + + expect(store.maxCacheBytes, equals(1024 * 1024 * 1024)); + }); + + test('resetAll clears all stores from registry', () async { + ProviderTileCacheManager.setBaseCacheDir(tempDir.path); + + final storeBefore = ProviderTileCacheManager.getOrCreate( + providerId: 'osm', + tileTypeId: 'street', + policy: const ServicePolicy(), + ); + ProviderTileCacheManager.getOrCreate( + providerId: 'bing', + tileTypeId: 'satellite', + policy: const ServicePolicy(), + ); + + await ProviderTileCacheManager.resetAll(); + + // After reset, must set base dir again before creating stores + ProviderTileCacheManager.setBaseCacheDir(tempDir.path); + final storeAfter = ProviderTileCacheManager.getOrCreate( + providerId: 'osm', + tileTypeId: 'street', + policy: const ServicePolicy(), + ); + // New instance should be created (not the old cached one) + expect(identical(storeBefore, storeAfter), isFalse); + }); + + test('unregister removes store from registry', () { + ProviderTileCacheManager.setBaseCacheDir(tempDir.path); + + final store1 = ProviderTileCacheManager.getOrCreate( + providerId: 'osm', + tileTypeId: 'street', + policy: const ServicePolicy(), + ); + + ProviderTileCacheManager.unregister('osm', 'street'); + + // Should create a new instance after unregistering + final store2 = ProviderTileCacheManager.getOrCreate( + providerId: 'osm', + tileTypeId: 'street', + policy: const ServicePolicy(), + ); + + expect(identical(store1, store2), isFalse); + }); + }); + + group('ProviderTileCacheStore eviction', () { + /// Helper: populate cache with [count] tiles, each [bytesPerTile] bytes. + /// Uses small delays between writes so modification times are + /// distinguishable for oldest-modified ordering. + Future fillCache( + ProviderTileCacheStore store, { + required int count, + required int bytesPerTile, + String prefix = '', + }) async { + final bytes = Uint8List.fromList(List.filled(bytesPerTile, 42)); + final metadata = CachedMapTileMetadata( + staleAt: DateTime.utc(2026, 3, 1), + lastModified: null, + etag: null, + ); + for (var i = 0; i < count; i++) { + await store.putTile( + url: 'https://tile.example.com/$prefix$i.png', + metadata: metadata, + bytes: bytes, + ); + // Small delay so modification times are distinguishable for eviction order + await Future.delayed(const Duration(milliseconds: 10)); + } + } + + test('eviction reduces cache when exceeding maxCacheBytes', () async { + final store = ProviderTileCacheStore( + cacheDirectory: tempDir.path, + maxCacheBytes: 500, + ); + + // Write tiles that exceed the limit + await fillCache(store, count: 10, bytesPerTile: 100); + + // Explicitly trigger eviction (bypasses throttle) + await store.forceEviction(); + + final sizeAfter = await store.estimatedSizeBytes; + expect(sizeAfter, lessThanOrEqualTo(500), + reason: 'Eviction should reduce cache to at or below limit'); + }); + + test('eviction targets 80% of maxCacheBytes', () async { + final store = ProviderTileCacheStore( + cacheDirectory: tempDir.path, + maxCacheBytes: 1000, + ); + + await fillCache(store, count: 10, bytesPerTile: 200); + await store.forceEviction(); + + final sizeAfter = await store.estimatedSizeBytes; + // Target is 80% of 1000 = 800 bytes + expect(sizeAfter, lessThanOrEqualTo(800), + reason: 'Eviction should target 80% of maxCacheBytes'); + }); + + test('oldest-modified tiles are evicted first', () async { + final store = ProviderTileCacheStore( + cacheDirectory: tempDir.path, + maxCacheBytes: 500, + ); + + // Write old tiles first (these should be evicted) + await fillCache(store, count: 5, bytesPerTile: 100, prefix: 'old_'); + + // Write newer tiles (these should survive) + await fillCache(store, count: 5, bytesPerTile: 100, prefix: 'new_'); + + await store.forceEviction(); + + // Newest tile should still be present + final newestTile = await store.getTile('https://tile.example.com/new_4.png'); + expect(newestTile, isNotNull, + reason: 'Newest tiles should survive eviction'); + + // Oldest tile should have been evicted + final oldestTile = await store.getTile('https://tile.example.com/old_0.png'); + expect(oldestTile, isNull, + reason: 'Oldest tiles should be evicted first'); + }); + + test('orphan .meta files are cleaned up during eviction', () async { + final store = ProviderTileCacheStore( + cacheDirectory: tempDir.path, + maxCacheBytes: 500, + ); + + // Write a tile to create the directory + await fillCache(store, count: 1, bytesPerTile: 50); + + // Manually create an orphan .meta file (no matching .tile) + final orphanMetaFile = File(p.join(tempDir.path, 'orphan_key.meta')); + await orphanMetaFile.writeAsString('{"staleAt":0}'); + expect(await orphanMetaFile.exists(), isTrue); + + // Write enough tiles to exceed the limit, then force eviction + await fillCache(store, count: 10, bytesPerTile: 100, prefix: 'trigger_'); + await store.forceEviction(); + + // The orphan .meta file should have been cleaned up + expect(await orphanMetaFile.exists(), isFalse, + reason: 'Orphan .meta file should be cleaned up during eviction'); + }); + + test('evicted tiles have their .meta files removed too', () async { + final store = ProviderTileCacheStore( + cacheDirectory: tempDir.path, + maxCacheBytes: 300, + ); + + await fillCache(store, count: 10, bytesPerTile: 100); + await store.forceEviction(); + + // After eviction, count remaining .tile and .meta files + final dir = Directory(tempDir.path); + final files = await dir.list().toList(); + final tileFiles = files + .whereType() + .where((f) => f.path.endsWith('.tile')) + .length; + final metaFiles = files + .whereType() + .where((f) => f.path.endsWith('.meta')) + .length; + + // Every remaining .tile should have a matching .meta (1:1) + expect(metaFiles, equals(tileFiles), + reason: '.meta count should match .tile count after eviction'); + }); + + test('no eviction when cache is under limit', () async { + final store = ProviderTileCacheStore( + cacheDirectory: tempDir.path, + maxCacheBytes: 100000, // 100KB — way more than we'll write + ); + + await fillCache(store, count: 3, bytesPerTile: 50); + final sizeBefore = await store.estimatedSizeBytes; + + await store.forceEviction(); + final sizeAfter = await store.estimatedSizeBytes; + + expect(sizeAfter, equals(sizeBefore), + reason: 'No eviction needed when under limit'); + }); + }); +} diff --git a/test/services/service_policy_test.dart b/test/services/service_policy_test.dart new file mode 100644 index 00000000..59a1287b --- /dev/null +++ b/test/services/service_policy_test.dart @@ -0,0 +1,426 @@ +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:deflockapp/services/service_policy.dart'; + +void main() { + group('ServicePolicyResolver', () { + setUp(() { + ServicePolicyResolver.clearCustomPolicies(); + }); + + group('resolveType', () { + test('resolves OSM editing API from production URL', () { + expect( + ServicePolicyResolver.resolveType('https://api.openstreetmap.org/api/0.6/map?bbox=1,2,3,4'), + ServiceType.osmEditingApi, + ); + }); + + test('resolves OSM editing API from sandbox URL', () { + expect( + ServicePolicyResolver.resolveType('https://api06.dev.openstreetmap.org/api/0.6/map?bbox=1,2,3,4'), + ServiceType.osmEditingApi, + ); + }); + + test('resolves OSM editing API from dev URL', () { + expect( + ServicePolicyResolver.resolveType('https://master.apis.dev.openstreetmap.org/api/0.6/user/details'), + ServiceType.osmEditingApi, + ); + }); + + test('resolves OSM tile server from tile URL', () { + expect( + ServicePolicyResolver.resolveType('https://tile.openstreetmap.org/12/1234/5678.png'), + ServiceType.osmTileServer, + ); + }); + + test('resolves Nominatim from geocoding URL', () { + expect( + ServicePolicyResolver.resolveType('https://nominatim.openstreetmap.org/search?q=London'), + ServiceType.nominatim, + ); + }); + + test('resolves Overpass API', () { + expect( + ServicePolicyResolver.resolveType('https://overpass-api.de/api/interpreter'), + ServiceType.overpass, + ); + }); + + test('resolves TagInfo', () { + expect( + ServicePolicyResolver.resolveType('https://taginfo.openstreetmap.org/api/4/key/values'), + ServiceType.tagInfo, + ); + }); + + test('resolves Bing tiles from virtualearth URL', () { + expect( + ServicePolicyResolver.resolveType('https://ecn.t0.tiles.virtualearth.net/tiles/a12345.jpeg'), + ServiceType.bingTiles, + ); + }); + + test('resolves Mapbox tiles', () { + expect( + ServicePolicyResolver.resolveType('https://api.mapbox.com/v4/mapbox.satellite/12/1234/5678@2x.jpg90'), + ServiceType.mapboxTiles, + ); + }); + + test('returns custom for unknown host', () { + expect( + ServicePolicyResolver.resolveType('https://tiles.myserver.com/12/1234/5678.png'), + ServiceType.custom, + ); + }); + + test('returns custom for empty string', () { + expect( + ServicePolicyResolver.resolveType(''), + ServiceType.custom, + ); + }); + + test('returns custom for malformed URL', () { + expect( + ServicePolicyResolver.resolveType('not-a-url'), + ServiceType.custom, + ); + }); + }); + + group('resolve', () { + test('OSM tile server policy disallows offline download', () { + final policy = ServicePolicyResolver.resolve( + 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + ); + expect(policy.allowsOfflineDownload, false); + }); + + test('OSM tile server policy requires 7-day min cache TTL', () { + final policy = ServicePolicyResolver.resolve( + 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + ); + expect(policy.minCacheTtl, const Duration(days: 7)); + }); + + test('OSM tile server has attribution URL', () { + final policy = ServicePolicyResolver.resolve( + 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + ); + expect(policy.attributionUrl, 'https://www.openstreetmap.org/copyright'); + }); + + test('Nominatim policy enforces 1-second rate limit', () { + final policy = ServicePolicyResolver.resolve( + 'https://nominatim.openstreetmap.org/search?q=test', + ); + expect(policy.minRequestInterval, const Duration(seconds: 1)); + }); + + test('Nominatim policy requires client caching', () { + final policy = ServicePolicyResolver.resolve( + 'https://nominatim.openstreetmap.org/search?q=test', + ); + expect(policy.requiresClientCaching, true); + }); + + test('Nominatim has attribution URL', () { + final policy = ServicePolicyResolver.resolve( + 'https://nominatim.openstreetmap.org/search?q=test', + ); + expect(policy.attributionUrl, 'https://www.openstreetmap.org/copyright'); + }); + + test('OSM editing API allows max 2 concurrent requests', () { + final policy = ServicePolicyResolver.resolve( + 'https://api.openstreetmap.org/api/0.6/map?bbox=1,2,3,4', + ); + expect(policy.maxConcurrentRequests, 2); + }); + + test('Bing tiles allow offline download', () { + final policy = ServicePolicyResolver.resolve( + 'https://ecn.t0.tiles.virtualearth.net/tiles/a{quadkey}.jpeg?g=1&n=z', + ); + expect(policy.allowsOfflineDownload, true); + }); + + test('Mapbox tiles allow offline download', () { + final policy = ServicePolicyResolver.resolve( + 'https://api.mapbox.com/v4/mapbox.satellite/{z}/{x}/{y}@2x.jpg90', + ); + expect(policy.allowsOfflineDownload, true); + }); + + test('custom/unknown host gets permissive defaults', () { + final policy = ServicePolicyResolver.resolve( + 'https://tiles.myserver.com/{z}/{x}/{y}.png', + ); + expect(policy.allowsOfflineDownload, true); + expect(policy.minRequestInterval, isNull); + expect(policy.requiresClientCaching, false); + expect(policy.attributionUrl, isNull); + }); + }); + + group('resolve with URL templates', () { + test('handles {z}/{x}/{y} template variables', () { + final policy = ServicePolicyResolver.resolve( + 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + ); + expect(policy.allowsOfflineDownload, false); + }); + + test('handles {quadkey} template variable', () { + final policy = ServicePolicyResolver.resolve( + 'https://ecn.t{0_3}.tiles.virtualearth.net/tiles/a{quadkey}.jpeg?g=1', + ); + expect(policy.allowsOfflineDownload, true); + }); + + test('handles {0_3} subdomain template', () { + final type = ServicePolicyResolver.resolveType( + 'https://ecn.t{0_3}.tiles.virtualearth.net/tiles/a{quadkey}.jpeg', + ); + expect(type, ServiceType.bingTiles); + }); + + test('handles {api_key} template variable', () { + final type = ServicePolicyResolver.resolveType( + 'https://api.mapbox.com/v4/mapbox.satellite/{z}/{x}/{y}@2x.jpg90?access_token={api_key}', + ); + expect(type, ServiceType.mapboxTiles); + }); + }); + + group('custom policy overrides', () { + test('custom override takes precedence over built-in', () { + ServicePolicyResolver.registerCustomPolicy( + 'overpass-api.de', + const ServicePolicy.custom(maxConcurrent: 20, allowsOffline: true), + ); + + final policy = ServicePolicyResolver.resolve( + 'https://overpass-api.de/api/interpreter', + ); + expect(policy.maxConcurrentRequests, 20); + }); + + test('custom policy for self-hosted tiles allows offline', () { + ServicePolicyResolver.registerCustomPolicy( + 'tiles.myserver.com', + const ServicePolicy.custom(allowsOffline: true, maxConcurrent: 16), + ); + + final policy = ServicePolicyResolver.resolve( + 'https://tiles.myserver.com/{z}/{x}/{y}.png', + ); + expect(policy.allowsOfflineDownload, true); + expect(policy.maxConcurrentRequests, 16); + }); + + test('removing custom override restores built-in policy', () { + ServicePolicyResolver.registerCustomPolicy( + 'overpass-api.de', + const ServicePolicy.custom(maxConcurrent: 20), + ); + expect( + ServicePolicyResolver.resolve('https://overpass-api.de/api/interpreter').maxConcurrentRequests, + 20, + ); + + ServicePolicyResolver.removeCustomPolicy('overpass-api.de'); + // Should fall back to built-in Overpass policy (maxConcurrent: 0 = managed elsewhere) + expect( + ServicePolicyResolver.resolve('https://overpass-api.de/api/interpreter').maxConcurrentRequests, + 0, + ); + }); + + test('clearCustomPolicies removes all overrides', () { + ServicePolicyResolver.registerCustomPolicy('a.com', const ServicePolicy.custom(maxConcurrent: 1)); + ServicePolicyResolver.registerCustomPolicy('b.com', const ServicePolicy.custom(maxConcurrent: 2)); + + ServicePolicyResolver.clearCustomPolicies(); + + // Both should now return custom (default) policy + expect( + ServicePolicyResolver.resolve('https://a.com/test').maxConcurrentRequests, + 8, // default custom maxConcurrent + ); + }); + }); + }); + + group('ServiceRateLimiter', () { + setUp(() { + ServiceRateLimiter.reset(); + }); + + test('acquire and release work for editing API (2 concurrent)', () async { + // Should be able to acquire 2 slots without blocking + await ServiceRateLimiter.acquire(ServiceType.osmEditingApi); + await ServiceRateLimiter.acquire(ServiceType.osmEditingApi); + + // Release both + ServiceRateLimiter.release(ServiceType.osmEditingApi); + ServiceRateLimiter.release(ServiceType.osmEditingApi); + }); + + test('third acquire blocks until a slot is released', () async { + // Fill both slots (osmEditingApi maxConcurrentRequests = 2) + await ServiceRateLimiter.acquire(ServiceType.osmEditingApi); + await ServiceRateLimiter.acquire(ServiceType.osmEditingApi); + + // Third acquire should block + var thirdCompleted = false; + final thirdFuture = ServiceRateLimiter.acquire(ServiceType.osmEditingApi).then((_) { + thirdCompleted = true; + }); + + // Give microtasks a chance to run — third should still be blocked + await Future.delayed(Duration.zero); + expect(thirdCompleted, false); + + // Release one slot — third should now complete + ServiceRateLimiter.release(ServiceType.osmEditingApi); + await thirdFuture; + expect(thirdCompleted, true); + + // Clean up + ServiceRateLimiter.release(ServiceType.osmEditingApi); + ServiceRateLimiter.release(ServiceType.osmEditingApi); + }); + + test('Nominatim rate limiting delays rapid requests', () { + fakeAsync((async) { + ServiceRateLimiter.clock = () => async.getClock(DateTime(2026)).now(); + + var acquireCount = 0; + + // First request should be immediate + ServiceRateLimiter.acquire(ServiceType.nominatim).then((_) { + acquireCount++; + ServiceRateLimiter.release(ServiceType.nominatim); + }); + async.flushMicrotasks(); + expect(acquireCount, 1); + + // Second request should be delayed by ~1 second + ServiceRateLimiter.acquire(ServiceType.nominatim).then((_) { + acquireCount++; + ServiceRateLimiter.release(ServiceType.nominatim); + }); + async.flushMicrotasks(); + expect(acquireCount, 1, reason: 'second acquire should be blocked'); + + // Advance past the 1-second rate limit + async.elapse(const Duration(seconds: 1)); + expect(acquireCount, 2, reason: 'second acquire should have completed'); + }); + }); + + test('services with no rate limit pass through immediately', () { + fakeAsync((async) { + ServiceRateLimiter.clock = () => async.getClock(DateTime(2026)).now(); + + var acquireCount = 0; + + // Overpass has maxConcurrentRequests: 0, so acquire should not apply + // any artificial rate limiting delays. + ServiceRateLimiter.acquire(ServiceType.overpass).then((_) { + acquireCount++; + ServiceRateLimiter.release(ServiceType.overpass); + }); + async.flushMicrotasks(); + expect(acquireCount, 1); + + ServiceRateLimiter.acquire(ServiceType.overpass).then((_) { + acquireCount++; + ServiceRateLimiter.release(ServiceType.overpass); + }); + async.flushMicrotasks(); + expect(acquireCount, 2); + }); + }); + + test('Nominatim enforces min interval under concurrent callers', () { + fakeAsync((async) { + ServiceRateLimiter.clock = () => async.getClock(DateTime(2026)).now(); + + var completedCount = 0; + + // Start two concurrent callers; only one should run at a time and + // the minRequestInterval of ~1s should still be enforced. + ServiceRateLimiter.acquire(ServiceType.nominatim).then((_) { + completedCount++; + ServiceRateLimiter.release(ServiceType.nominatim); + }); + ServiceRateLimiter.acquire(ServiceType.nominatim).then((_) { + completedCount++; + ServiceRateLimiter.release(ServiceType.nominatim); + }); + + async.flushMicrotasks(); + expect(completedCount, 1, reason: 'only first caller should complete immediately'); + + // Advance past the 1-second rate limit + async.elapse(const Duration(seconds: 1)); + expect(completedCount, 2, reason: 'second caller should complete after interval'); + }); + }); + }); + + group('ServicePolicy', () { + test('osmTileServer policy has correct values', () { + const policy = ServicePolicy.osmTileServer(); + expect(policy.allowsOfflineDownload, false); + expect(policy.minCacheTtl, const Duration(days: 7)); + expect(policy.requiresClientCaching, true); + expect(policy.attributionUrl, 'https://www.openstreetmap.org/copyright'); + expect(policy.maxConcurrentRequests, 0); // managed by flutter_map + }); + + test('nominatim policy has correct values', () { + const policy = ServicePolicy.nominatim(); + expect(policy.minRequestInterval, const Duration(seconds: 1)); + expect(policy.maxConcurrentRequests, 1); + expect(policy.requiresClientCaching, true); + expect(policy.attributionUrl, 'https://www.openstreetmap.org/copyright'); + }); + + test('osmEditingApi policy has correct values', () { + const policy = ServicePolicy.osmEditingApi(); + expect(policy.maxConcurrentRequests, 2); + expect(policy.minRequestInterval, isNull); + }); + + test('custom policy uses permissive defaults', () { + const policy = ServicePolicy(); + expect(policy.maxConcurrentRequests, 8); + expect(policy.allowsOfflineDownload, true); + expect(policy.minRequestInterval, isNull); + expect(policy.requiresClientCaching, false); + expect(policy.minCacheTtl, isNull); + expect(policy.attributionUrl, isNull); + }); + + test('custom policy accepts overrides', () { + const policy = ServicePolicy.custom( + maxConcurrent: 20, + allowsOffline: false, + attribution: 'https://example.com/license', + ); + expect(policy.maxConcurrentRequests, 20); + expect(policy.allowsOfflineDownload, false); + expect(policy.attributionUrl, 'https://example.com/license'); + }); + }); +} diff --git a/test/services/tiles_from_local_test.dart b/test/services/tiles_from_local_test.dart new file mode 100644 index 00000000..767647f3 --- /dev/null +++ b/test/services/tiles_from_local_test.dart @@ -0,0 +1,227 @@ +import 'dart:math'; + +import 'package:flutter_map/flutter_map.dart' show LatLngBounds; +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; + +import 'package:deflockapp/services/map_data_submodules/tiles_from_local.dart'; +import 'package:deflockapp/services/offline_areas/offline_tile_utils.dart'; + +void main() { + group('normalizeBounds', () { + test('swapped corners are normalized', () { + // NE as first arg, SW as second (swapped) + final swapped = LatLngBounds( + const LatLng(52.0, 1.0), // NE corner passed as SW + const LatLng(51.0, -1.0), // SW corner passed as NE + ); + final normalized = normalizeBounds(swapped); + expect(normalized.south, closeTo(51.0, 1e-6)); + expect(normalized.north, closeTo(52.0, 1e-6)); + expect(normalized.west, closeTo(-1.0, 1e-6)); + expect(normalized.east, closeTo(1.0, 1e-6)); + }); + + test('degenerate (zero-width) bounds are expanded', () { + final point = LatLngBounds( + const LatLng(51.5, -0.1), + const LatLng(51.5, -0.1), + ); + final normalized = normalizeBounds(point); + expect(normalized.south, lessThan(51.5)); + expect(normalized.north, greaterThan(51.5)); + expect(normalized.west, lessThan(-0.1)); + expect(normalized.east, greaterThan(-0.1)); + }); + + test('already-normalized bounds are unchanged', () { + final normal = LatLngBounds( + const LatLng(40.0, -10.0), + const LatLng(60.0, 30.0), + ); + final normalized = normalizeBounds(normal); + expect(normalized.south, closeTo(40.0, 1e-6)); + expect(normalized.north, closeTo(60.0, 1e-6)); + expect(normalized.west, closeTo(-10.0, 1e-6)); + expect(normalized.east, closeTo(30.0, 1e-6)); + }); + }); + + group('tileInBounds', () { + /// Helper: compute expected tile range for [bounds] at [z] using the same + /// Mercator projection math and return whether (x, y) is within range. + bool referenceTileInBounds( + LatLngBounds bounds, int z, int x, int y) { + final n = pow(2.0, z); + final minX = ((bounds.west + 180.0) / 360.0 * n).floor(); + final maxX = ((bounds.east + 180.0) / 360.0 * n).floor(); + final minY = ((1.0 - + log(tan(bounds.north * pi / 180.0) + + 1.0 / cos(bounds.north * pi / 180.0)) / + pi) / + 2.0 * + n) + .floor(); + final maxY = ((1.0 - + log(tan(bounds.south * pi / 180.0) + + 1.0 / cos(bounds.south * pi / 180.0)) / + pi) / + 2.0 * + n) + .floor(); + return x >= minX && x <= maxX && y >= minY && y <= maxY; + } + + test('zoom 0: single tile covers the whole world', () { + final world = LatLngBounds( + const LatLng(-85, -180), + const LatLng(85, 180), + ); + expect(tileInBounds(world, 0, 0, 0), isTrue); + }); + + test('zoom 1: London area covers NW and NE quadrants', () { + // Bounds straddling the prime meridian in the northern hemisphere + final londonArea = LatLngBounds( + const LatLng(51.0, -1.0), + const LatLng(52.0, 1.0), + ); + + // NW quadrant (x=0, y=0) — should be in bounds + expect(tileInBounds(londonArea, 1, 0, 0), isTrue); + // NE quadrant (x=1, y=0) — should be in bounds + expect(tileInBounds(londonArea, 1, 1, 0), isTrue); + // SW quadrant (x=0, y=1) — southern hemisphere, out of bounds + expect(tileInBounds(londonArea, 1, 0, 1), isFalse); + // SE quadrant (x=1, y=1) — southern hemisphere, out of bounds + expect(tileInBounds(londonArea, 1, 1, 1), isFalse); + }); + + test('zoom 2: London area covers specific tiles', () { + final londonArea = LatLngBounds( + const LatLng(51.0, -1.0), + const LatLng(52.0, 1.0), + ); + + // Expected: X 1-2, Y 1 + expect(tileInBounds(londonArea, 2, 1, 1), isTrue); + expect(tileInBounds(londonArea, 2, 2, 1), isTrue); + // Outside X range + expect(tileInBounds(londonArea, 2, 0, 1), isFalse); + expect(tileInBounds(londonArea, 2, 3, 1), isFalse); + // Outside Y range + expect(tileInBounds(londonArea, 2, 1, 0), isFalse); + expect(tileInBounds(londonArea, 2, 1, 2), isFalse); + }); + + test('southern hemisphere: Sydney area', () { + final sydneyArea = LatLngBounds( + const LatLng(-34.0, 151.0), + const LatLng(-33.5, 151.5), + ); + + // At zoom 1, Sydney is in the SE quadrant (x=1, y=1) + expect(tileInBounds(sydneyArea, 1, 1, 1), isTrue); + expect(tileInBounds(sydneyArea, 1, 0, 0), isFalse); + expect(tileInBounds(sydneyArea, 1, 0, 1), isFalse); + expect(tileInBounds(sydneyArea, 1, 1, 0), isFalse); + }); + + test('western hemisphere: NYC area at zoom 4', () { + final nycArea = LatLngBounds( + const LatLng(40.5, -74.5), + const LatLng(41.0, -73.5), + ); + + // At zoom 4 (16x16), NYC should be around x=4-5, y=6 + // x = floor((-74.5+180)/360 * 16) = floor(105.5/360*16) = floor(4.69) = 4 + // x = floor((-73.5+180)/360 * 16) = floor(106.5/360*16) = floor(4.73) = 4 + // So x range is just 4 + expect(tileInBounds(nycArea, 4, 4, 6), isTrue); + expect(tileInBounds(nycArea, 4, 5, 6), isFalse); + expect(tileInBounds(nycArea, 4, 3, 6), isFalse); + }); + + test('higher zoom: smaller area at zoom 10', () { + // Small area around central London + final centralLondon = LatLngBounds( + const LatLng(51.49, -0.13), + const LatLng(51.52, -0.08), + ); + + // Compute expected tile range at zoom 10 using reference + const z = 10; + final n = pow(2.0, z); + final expectedMinX = + ((-0.13 + 180.0) / 360.0 * n).floor(); + final expectedMaxX = + ((-0.08 + 180.0) / 360.0 * n).floor(); + + // Tiles inside the computed range should be in bounds + for (var x = expectedMinX; x <= expectedMaxX; x++) { + expect( + referenceTileInBounds(centralLondon, z, x, 340), + equals(tileInBounds(centralLondon, z, x, 340)), + reason: 'Mismatch at tile ($x, 340, $z)', + ); + } + + // Tiles outside X range should not be in bounds + expect(tileInBounds(centralLondon, z, expectedMinX - 1, 340), isFalse); + expect(tileInBounds(centralLondon, z, expectedMaxX + 1, 340), isFalse); + }); + + test('tile exactly at boundary is included', () { + // Bounds whose edges align exactly with tile boundaries at zoom 1 + // At zoom 1: x=0 covers lon -180 to 0, x=1 covers lon 0 to 180 + final halfWorld = LatLngBounds( + const LatLng(0.0, 0.0), + const LatLng(60.0, 180.0), + ); + + // Tile (1, 0, 1) should be in bounds (NE quadrant) + expect(tileInBounds(halfWorld, 1, 1, 0), isTrue); + }); + + test('anti-meridian: bounds crossing 180° longitude', () { + // Bounds from eastern Russia (170°E) to Alaska (170°W = -170°) + // After normalization, west=170 east=-170 which is swapped — + // normalizeBounds will swap to west=-170 east=170, which covers + // nearly the whole world. This is the expected behavior since + // LatLngBounds doesn't support anti-meridian wrapping. + final antiMeridian = normalizeBounds(LatLngBounds( + const LatLng(50.0, 170.0), + const LatLng(70.0, -170.0), + )); + + // After normalization, west=-170 east=170 (covers most longitudes) + // At zoom 2, tiles 0-3 along X axis + // Since the normalized bounds cover lon -170 to 170 (340° of 360°), + // almost all tiles should be in bounds + expect(tileInBounds(antiMeridian, 2, 0, 0), isTrue); + expect(tileInBounds(antiMeridian, 2, 1, 0), isTrue); + expect(tileInBounds(antiMeridian, 2, 2, 0), isTrue); + expect(tileInBounds(antiMeridian, 2, 3, 0), isTrue); + }); + + test('exhaustive check at zoom 3 matches reference', () { + final bounds = LatLngBounds( + const LatLng(40.0, -10.0), + const LatLng(60.0, 30.0), + ); + + // Check all 64 tiles at zoom 3 against reference implementation + const z = 3; + final tilesPerSide = pow(2, z).toInt(); + for (var x = 0; x < tilesPerSide; x++) { + for (var y = 0; y < tilesPerSide; y++) { + expect( + tileInBounds(bounds, z, x, y), + equals(referenceTileInBounds(bounds, z, x, y)), + reason: 'Mismatch at tile ($x, $y, $z)', + ); + } + } + }); + }); +} diff --git a/test/widgets/map/tile_layer_manager_test.dart b/test/widgets/map/tile_layer_manager_test.dart new file mode 100644 index 00000000..43a80b2a --- /dev/null +++ b/test/widgets/map/tile_layer_manager_test.dart @@ -0,0 +1,487 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import 'package:deflockapp/services/deflock_tile_provider.dart'; +import 'package:deflockapp/widgets/map/tile_layer_manager.dart'; + +class MockTileImage extends Mock implements TileImage {} + +void main() { + group('TileLayerManager exponential backoff', () { + test('initial retry delay is 2 seconds', () { + final manager = TileLayerManager(); + expect(manager.retryDelay, equals(const Duration(seconds: 2))); + manager.dispose(); + }); + + test('scheduleRetry fires reset stream after delay', () { + FakeAsync().run((async) { + final manager = TileLayerManager(); + final resets = []; + manager.resetStream.listen((_) => resets.add(null)); + + manager.scheduleRetry(); + + expect(resets, isEmpty); + async.elapse(const Duration(seconds: 1)); + expect(resets, isEmpty); + async.elapse(const Duration(seconds: 1)); + expect(resets, hasLength(1)); + + manager.dispose(); + }); + }); + + test('delay doubles after each retry fires', () { + FakeAsync().run((async) { + final manager = TileLayerManager(); + manager.resetStream.listen((_) {}); + + // First retry: 2s + manager.scheduleRetry(); + async.elapse(const Duration(seconds: 2)); + expect(manager.retryDelay, equals(const Duration(seconds: 4))); + + // Second retry: 4s + manager.scheduleRetry(); + async.elapse(const Duration(seconds: 4)); + expect(manager.retryDelay, equals(const Duration(seconds: 8))); + + // Third retry: 8s + manager.scheduleRetry(); + async.elapse(const Duration(seconds: 8)); + expect(manager.retryDelay, equals(const Duration(seconds: 16))); + + manager.dispose(); + }); + }); + + test('delay caps at 60 seconds', () { + FakeAsync().run((async) { + final manager = TileLayerManager(); + manager.resetStream.listen((_) {}); + + // Drive through cycles: 2 → 4 → 8 → 16 → 32 → 60 → 60 + var currentDelay = manager.retryDelay; + while (currentDelay < const Duration(seconds: 60)) { + manager.scheduleRetry(); + async.elapse(currentDelay); + currentDelay = manager.retryDelay; + } + + // Should be capped at 60s + expect(manager.retryDelay, equals(const Duration(seconds: 60))); + + // Another cycle stays at 60s + manager.scheduleRetry(); + async.elapse(const Duration(seconds: 60)); + expect(manager.retryDelay, equals(const Duration(seconds: 60))); + + manager.dispose(); + }); + }); + + test('onTileLoadSuccess resets delay to minimum', () { + FakeAsync().run((async) { + final manager = TileLayerManager(); + manager.resetStream.listen((_) {}); + + // Drive up the delay + manager.scheduleRetry(); + async.elapse(const Duration(seconds: 2)); + expect(manager.retryDelay, equals(const Duration(seconds: 4))); + + manager.scheduleRetry(); + async.elapse(const Duration(seconds: 4)); + expect(manager.retryDelay, equals(const Duration(seconds: 8))); + + // Reset on success + manager.onTileLoadSuccess(); + expect(manager.retryDelay, equals(const Duration(seconds: 2))); + + manager.dispose(); + }); + }); + + test('rapid errors debounce: only last timer fires', () { + FakeAsync().run((async) { + final manager = TileLayerManager(); + final resets = []; + manager.resetStream.listen((_) => resets.add(null)); + + // Fire 3 errors in quick succession (each cancels the previous timer) + manager.scheduleRetry(); + async.elapse(const Duration(milliseconds: 500)); + manager.scheduleRetry(); + async.elapse(const Duration(milliseconds: 500)); + manager.scheduleRetry(); + + // 1s elapsed total since first error, but last timer started 0ms ago + // Need to wait 2s from *last* scheduleRetry call + async.elapse(const Duration(seconds: 1)); + expect(resets, isEmpty, reason: 'Timer should not fire yet'); + async.elapse(const Duration(seconds: 1)); + expect(resets, hasLength(1), reason: 'Only one reset should fire'); + + manager.dispose(); + }); + }); + + test('delay stays at minimum if no retries have fired', () { + final manager = TileLayerManager(); + // Just calling onTileLoadSuccess without any errors + manager.onTileLoadSuccess(); + expect(manager.retryDelay, equals(const Duration(seconds: 2))); + manager.dispose(); + }); + + test('backoff progression: 2 → 4 → 8 → 16 → 32 → 60 → 60', () { + FakeAsync().run((async) { + final manager = TileLayerManager(); + manager.resetStream.listen((_) {}); + + final expectedDelays = [ + const Duration(seconds: 2), + const Duration(seconds: 4), + const Duration(seconds: 8), + const Duration(seconds: 16), + const Duration(seconds: 32), + const Duration(seconds: 60), + const Duration(seconds: 60), // capped + ]; + + for (var i = 0; i < expectedDelays.length; i++) { + expect(manager.retryDelay, equals(expectedDelays[i]), + reason: 'Step $i'); + manager.scheduleRetry(); + async.elapse(expectedDelays[i]); + } + + manager.dispose(); + }); + }); + + test('dispose cancels pending retry timer', () { + FakeAsync().run((async) { + final manager = TileLayerManager(); + final resets = []; + late StreamSubscription sub; + sub = manager.resetStream.listen((_) => resets.add(null)); + + manager.scheduleRetry(); + // Dispose before timer fires + sub.cancel(); + manager.dispose(); + + async.elapse(const Duration(seconds: 10)); + expect(resets, isEmpty, reason: 'Timer should be cancelled by dispose'); + }); + }); + }); + + group('TileLayerManager checkAndClearCacheIfNeeded', () { + late TileLayerManager manager; + + setUp(() { + manager = TileLayerManager(); + }); + + tearDown(() { + manager.dispose(); + }); + + test('first call triggers clear (initial null differs from provided values)', () { + final result = manager.checkAndClearCacheIfNeeded( + currentProviderId: 'osm', + currentTileTypeId: 'street', + currentOfflineMode: false, + ); + // First call: internal state is (null, null, false) → (osm, street, false) + // provider null→osm triggers clear. Harmless: no tiles to clear yet. + expect(result, isTrue); + }); + + test('same values on second call returns false', () { + manager.checkAndClearCacheIfNeeded( + currentProviderId: 'osm', + currentTileTypeId: 'street', + currentOfflineMode: false, + ); + final result = manager.checkAndClearCacheIfNeeded( + currentProviderId: 'osm', + currentTileTypeId: 'street', + currentOfflineMode: false, + ); + expect(result, isFalse); + }); + + test('different provider triggers cache clear', () { + manager.checkAndClearCacheIfNeeded( + currentProviderId: 'osm', + currentTileTypeId: 'street', + currentOfflineMode: false, + ); + final result = manager.checkAndClearCacheIfNeeded( + currentProviderId: 'bing', + currentTileTypeId: 'street', + currentOfflineMode: false, + ); + expect(result, isTrue); + }); + + test('different tile type triggers cache clear', () { + manager.checkAndClearCacheIfNeeded( + currentProviderId: 'osm', + currentTileTypeId: 'street', + currentOfflineMode: false, + ); + final result = manager.checkAndClearCacheIfNeeded( + currentProviderId: 'osm', + currentTileTypeId: 'satellite', + currentOfflineMode: false, + ); + expect(result, isTrue); + }); + + test('different offline mode triggers cache clear', () { + manager.checkAndClearCacheIfNeeded( + currentProviderId: 'osm', + currentTileTypeId: 'street', + currentOfflineMode: false, + ); + final result = manager.checkAndClearCacheIfNeeded( + currentProviderId: 'osm', + currentTileTypeId: 'street', + currentOfflineMode: true, + ); + expect(result, isTrue); + }); + + test('cache clear increments mapRebuildKey', () { + final initialKey = manager.mapRebuildKey; + manager.checkAndClearCacheIfNeeded( + currentProviderId: 'osm', + currentTileTypeId: 'street', + currentOfflineMode: false, + ); + // First call increments (null → osm) + expect(manager.mapRebuildKey, equals(initialKey + 1)); + + manager.checkAndClearCacheIfNeeded( + currentProviderId: 'osm', + currentTileTypeId: 'satellite', + currentOfflineMode: false, + ); + // Type change should increment again + expect(manager.mapRebuildKey, equals(initialKey + 2)); + }); + + test('no cache clear does not increment mapRebuildKey', () { + manager.checkAndClearCacheIfNeeded( + currentProviderId: 'osm', + currentTileTypeId: 'street', + currentOfflineMode: false, + ); + final keyAfterFirst = manager.mapRebuildKey; + + manager.checkAndClearCacheIfNeeded( + currentProviderId: 'osm', + currentTileTypeId: 'street', + currentOfflineMode: false, + ); + expect(manager.mapRebuildKey, equals(keyAfterFirst)); + }); + + test('null to non-null transition triggers clear', () { + manager.checkAndClearCacheIfNeeded( + currentProviderId: null, + currentTileTypeId: null, + currentOfflineMode: false, + ); + final result = manager.checkAndClearCacheIfNeeded( + currentProviderId: 'osm', + currentTileTypeId: 'street', + currentOfflineMode: false, + ); + // null → osm is a change — triggers clear so stale tiles are flushed + expect(result, isTrue); + }); + + test('non-null to null to non-null triggers clear both times', () { + manager.checkAndClearCacheIfNeeded( + currentProviderId: 'osm', + currentTileTypeId: 'street', + currentOfflineMode: false, + ); + + // Provider goes null (e.g., during reload) + expect( + manager.checkAndClearCacheIfNeeded( + currentProviderId: null, + currentTileTypeId: null, + currentOfflineMode: false, + ), + isTrue, + ); + + // Provider returns — should still trigger clear + expect( + manager.checkAndClearCacheIfNeeded( + currentProviderId: 'bing', + currentTileTypeId: 'street', + currentOfflineMode: false, + ), + isTrue, + ); + }); + + test('switching back and forth triggers clear each time', () { + manager.checkAndClearCacheIfNeeded( + currentProviderId: 'osm', + currentTileTypeId: 'street', + currentOfflineMode: false, + ); + + expect( + manager.checkAndClearCacheIfNeeded( + currentProviderId: 'osm', + currentTileTypeId: 'satellite', + currentOfflineMode: false, + ), + isTrue, + ); + + expect( + manager.checkAndClearCacheIfNeeded( + currentProviderId: 'osm', + currentTileTypeId: 'street', + currentOfflineMode: false, + ), + isTrue, + ); + }); + + test('switching providers with same tile type triggers clear', () { + manager.checkAndClearCacheIfNeeded( + currentProviderId: 'osm', + currentTileTypeId: 'standard', + currentOfflineMode: false, + ); + + final result = manager.checkAndClearCacheIfNeeded( + currentProviderId: 'bing', + currentTileTypeId: 'standard', + currentOfflineMode: false, + ); + expect(result, isTrue); + }); + + test('provider switch resets retry delay and cancels pending timer', () { + FakeAsync().run((async) { + final resets = []; + manager.resetStream.listen((_) => resets.add(null)); + + // Escalate backoff: 2s → 4s → 8s + manager.scheduleRetry(); + async.elapse(const Duration(seconds: 2)); + manager.scheduleRetry(); + async.elapse(const Duration(seconds: 4)); + expect(manager.retryDelay, equals(const Duration(seconds: 8))); + + // Start another retry timer (hasn't fired yet) + manager.scheduleRetry(); + + // Switch provider — should reset delay and cancel pending timer + manager.checkAndClearCacheIfNeeded( + currentProviderId: 'osm', + currentTileTypeId: 'street', + currentOfflineMode: false, + ); + manager.checkAndClearCacheIfNeeded( + currentProviderId: 'bing', + currentTileTypeId: 'street', + currentOfflineMode: false, + ); + + expect(manager.retryDelay, equals(const Duration(seconds: 2))); + + // The pending 8s timer should have been cancelled + final resetsBefore = resets.length; + async.elapse(const Duration(seconds: 10)); + expect(resets.length, equals(resetsBefore), + reason: 'Old retry timer should be cancelled on provider switch'); + }); + }); + }); + + group('TileLayerManager error-type filtering', () { + late TileLayerManager manager; + late MockTileImage mockTile; + + setUp(() { + manager = TileLayerManager(); + mockTile = MockTileImage(); + when(() => mockTile.coordinates) + .thenReturn(const TileCoordinates(1, 2, 3)); + }); + + tearDown(() { + manager.dispose(); + }); + + test('skips retry for TileLoadCancelledException', () { + FakeAsync().run((async) { + final resets = []; + manager.resetStream.listen((_) => resets.add(null)); + + manager.onTileLoadError( + mockTile, + const TileLoadCancelledException(), + null, + ); + + // Even after waiting well past the retry delay, no reset should fire. + async.elapse(const Duration(seconds: 10)); + expect(resets, isEmpty); + }); + }); + + test('skips retry for TileNotAvailableOfflineException', () { + FakeAsync().run((async) { + final resets = []; + manager.resetStream.listen((_) => resets.add(null)); + + manager.onTileLoadError( + mockTile, + const TileNotAvailableOfflineException(), + null, + ); + + async.elapse(const Duration(seconds: 10)); + expect(resets, isEmpty); + }); + }); + + test('schedules retry for other errors (e.g. HttpException)', () { + FakeAsync().run((async) { + final resets = []; + manager.resetStream.listen((_) => resets.add(null)); + + manager.onTileLoadError( + mockTile, + const HttpException('tile fetch failed'), + null, + ); + + // Should fire after the initial 2s retry delay. + async.elapse(const Duration(seconds: 2)); + expect(resets, hasLength(1)); + }); + }); + }); +} From f3f40f36ef866b4de5489d1de1235af333ec27ba Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Wed, 4 Mar 2026 09:47:42 -0700 Subject: [PATCH 15/18] Allow OSM offline downloads, disable button for restricted providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow offline area downloads for OSM tile server. Move the "downloads not permitted" check from inside the download dialog to the download button itself — the button is now disabled (greyed out) when the current tile type doesn't support offline downloads. Co-Authored-By: Claude Opus 4.6 --- lib/screens/home_screen.dart | 60 ++++++++++++++------------ lib/services/service_policy.dart | 4 +- lib/widgets/download_area_dialog.dart | 36 ---------------- test/services/service_policy_test.dart | 8 ++-- 4 files changed, 38 insertions(+), 70 deletions(-) diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index ff9702e6..acc3e03a 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -578,37 +578,41 @@ class _HomeScreenState extends State with TickerProviderStateMixin { flex: 3, // 30% for secondary action child: AnimatedBuilder( animation: LocalizationService.instance, - builder: (context, child) => FittedBox( - fit: BoxFit.scaleDown, - child: ElevatedButton.icon( - icon: Icon(Icons.download_for_offline), - label: Text(LocalizationService.instance.download), - onPressed: () { - // Check minimum zoom level before opening download dialog - final currentZoom = _mapController.mapController.camera.zoom; - if (currentZoom < kMinZoomForOfflineDownload) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - LocalizationService.instance.t('download.areaTooBigMessage', - params: [kMinZoomForOfflineDownload.toString()]) + builder: (context, child) { + final appState = context.watch(); + final canDownload = appState.selectedTileType?.allowsOfflineDownload ?? false; + return FittedBox( + fit: BoxFit.scaleDown, + child: ElevatedButton.icon( + icon: Icon(Icons.download_for_offline), + label: Text(LocalizationService.instance.download), + onPressed: canDownload ? () { + // Check minimum zoom level before opening download dialog + final currentZoom = _mapController.mapController.camera.zoom; + if (currentZoom < kMinZoomForOfflineDownload) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + LocalizationService.instance.t('download.areaTooBigMessage', + params: [kMinZoomForOfflineDownload.toString()]) + ), ), - ), + ); + return; + } + + showDialog( + context: context, + builder: (ctx) => DownloadAreaDialog(controller: _mapController.mapController), ); - return; - } - - showDialog( - context: context, - builder: (ctx) => DownloadAreaDialog(controller: _mapController.mapController), - ); - }, - style: ElevatedButton.styleFrom( - minimumSize: Size(0, 48), - textStyle: TextStyle(fontSize: 16), + } : null, + style: ElevatedButton.styleFrom( + minimumSize: Size(0, 48), + textStyle: TextStyle(fontSize: 16), + ), ), - ), - ), + ); + }, ), ), ], diff --git a/lib/services/service_policy.dart b/lib/services/service_policy.dart index e8990a37..078e7a74 100644 --- a/lib/services/service_policy.dart +++ b/lib/services/service_policy.dart @@ -70,13 +70,13 @@ class ServicePolicy { attributionUrl = null; /// OSM tile server (tile.openstreetmap.org) - /// Policy: no offline/bulk downloading, min 7-day cache, must honor cache headers. + /// Policy: min 7-day cache, must honor cache headers. /// Concurrency managed by flutter_map's NetworkTileProvider. /// https://operations.osmfoundation.org/policies/tiles/ const ServicePolicy.osmTileServer() : maxConcurrentRequests = 0, // managed by flutter_map minRequestInterval = null, - allowsOfflineDownload = false, + allowsOfflineDownload = true, requiresClientCaching = true, minCacheTtl = const Duration(days: 7), attributionUrl = 'https://www.openstreetmap.org/copyright'; diff --git a/lib/widgets/download_area_dialog.dart b/lib/widgets/download_area_dialog.dart index cdda1d01..a370aaf4 100644 --- a/lib/widgets/download_area_dialog.dart +++ b/lib/widgets/download_area_dialog.dart @@ -267,42 +267,6 @@ class _DownloadAreaDialogState extends State { final selectedProvider = appState.selectedTileProvider; final selectedTileType = appState.selectedTileType; - // Check if the tile provider allows offline downloads - if (selectedTileType != null && !selectedTileType.allowsOfflineDownload) { - if (!context.mounted) return; - // Capture navigator before popping, since context is - // deactivated after Navigator.pop. - final navigator = Navigator.of(context); - navigator.pop(); - showDialog( - context: navigator.context, - builder: (context) => AlertDialog( - title: Row( - children: [ - const Icon(Icons.block, color: Colors.orange), - const SizedBox(width: 10), - Text(locService.t('download.title')), - ], - ), - content: Text( - locService.t( - 'download.offlineNotPermitted', - params: [ - selectedProvider?.name ?? locService.t('download.currentTileProvider'), - ], - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text(locService.t('actions.ok')), - ), - ], - ), - ); - return; - } - // Guard: provider and tile type must be non-null for a // useful offline area (fetchLocalTile requires exact match). if (selectedProvider == null || selectedTileType == null) { diff --git a/test/services/service_policy_test.dart b/test/services/service_policy_test.dart index 59a1287b..bfe31e41 100644 --- a/test/services/service_policy_test.dart +++ b/test/services/service_policy_test.dart @@ -96,11 +96,11 @@ void main() { }); group('resolve', () { - test('OSM tile server policy disallows offline download', () { + test('OSM tile server policy allows offline download', () { final policy = ServicePolicyResolver.resolve( 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', ); - expect(policy.allowsOfflineDownload, false); + expect(policy.allowsOfflineDownload, true); }); test('OSM tile server policy requires 7-day min cache TTL', () { @@ -175,7 +175,7 @@ void main() { final policy = ServicePolicyResolver.resolve( 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', ); - expect(policy.allowsOfflineDownload, false); + expect(policy.allowsOfflineDownload, true); }); test('handles {quadkey} template variable', () { @@ -381,7 +381,7 @@ void main() { group('ServicePolicy', () { test('osmTileServer policy has correct values', () { const policy = ServicePolicy.osmTileServer(); - expect(policy.allowsOfflineDownload, false); + expect(policy.allowsOfflineDownload, true); expect(policy.minCacheTtl, const Duration(days: 7)); expect(policy.requiresClientCaching, true); expect(policy.attributionUrl, 'https://www.openstreetmap.org/copyright'); From 91e517705608bca5efabdce314111c7cfc98a72a Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Wed, 4 Mar 2026 10:12:49 -0700 Subject: [PATCH 16/18] Detect config drift in cached tile providers and replace stale instances When a user edits a tile type's URL template, max zoom, or API key without changing IDs, the cached DeflockTileProvider would keep the old frozen config. Now _getOrCreateProvider() computes a config fingerprint and replaces the provider when drift is detected. Co-Authored-By: Claude Opus 4.6 --- lib/services/deflock_tile_provider.dart | 7 + lib/services/provider_tile_cache_manager.dart | 7 +- lib/services/provider_tile_cache_store.dart | 8 +- lib/services/service_policy.dart | 10 +- lib/widgets/map/tile_layer_manager.dart | 31 +++++ .../provider_tile_cache_store_test.dart | 16 ++- test/widgets/map/tile_layer_manager_test.dart | 131 ++++++++++++++++++ 7 files changed, 197 insertions(+), 13 deletions(-) diff --git a/lib/services/deflock_tile_provider.dart b/lib/services/deflock_tile_provider.dart index 9ab86cb3..7b1d6ce7 100644 --- a/lib/services/deflock_tile_provider.dart +++ b/lib/services/deflock_tile_provider.dart @@ -52,6 +52,10 @@ class DeflockTileProvider extends NetworkTileProvider { final models.TileType tileType; final String? apiKey; + /// Opaque fingerprint of the config this provider was created with. + /// Used by [TileLayerManager] to detect config drift after edits. + final String configFingerprint; + /// Caching provider for the offline-first path. The same instance is passed /// to super for the common path — we keep a reference here so we can also /// use it in [DeflockOfflineTileImageProvider]. @@ -69,6 +73,7 @@ class DeflockTileProvider extends NetworkTileProvider { this.apiKey, MapCachingProvider? cachingProvider, this.onNetworkSuccess, + this.configFingerprint = '', }) : _sharedHttpClient = httpClient, _cachingProvider = cachingProvider, super( @@ -87,6 +92,7 @@ class DeflockTileProvider extends NetworkTileProvider { String? apiKey, MapCachingProvider? cachingProvider, VoidCallback? onNetworkSuccess, + String configFingerprint = '', }) { final client = UserAgentClient(RetryClient(Client())); return DeflockTileProvider._( @@ -96,6 +102,7 @@ class DeflockTileProvider extends NetworkTileProvider { apiKey: apiKey, cachingProvider: cachingProvider, onNetworkSuccess: onNetworkSuccess, + configFingerprint: configFingerprint, ); } diff --git a/lib/services/provider_tile_cache_manager.dart b/lib/services/provider_tile_cache_manager.dart index 54e99d3b..cdbce718 100644 --- a/lib/services/provider_tile_cache_manager.dart +++ b/lib/services/provider_tile_cache_manager.dart @@ -37,8 +37,11 @@ class ProviderTileCacheManager { required ServicePolicy policy, int? maxCacheBytes, }) { - assert(_baseCacheDir != null, - 'ProviderTileCacheManager.init() must be called before getOrCreate()'); + if (_baseCacheDir == null) { + throw StateError( + 'ProviderTileCacheManager.init() must be called before getOrCreate()', + ); + } final key = '$providerId/$tileTypeId'; if (_stores.containsKey(key)) return _stores[key]!; diff --git a/lib/services/provider_tile_cache_store.dart b/lib/services/provider_tile_cache_store.dart index bf23e151..192a13a8 100644 --- a/lib/services/provider_tile_cache_store.dart +++ b/lib/services/provider_tile_cache_store.dart @@ -47,7 +47,7 @@ class ProviderTileCacheStore implements MapCachingProvider { @override Future getTile(String url) async { - final key = _keyFor(url); + final key = keyFor(url); final tileFile = File(p.join(cacheDirectory, '$key.tile')); final metaFile = File(p.join(cacheDirectory, '$key.meta')); @@ -90,7 +90,7 @@ class ProviderTileCacheStore implements MapCachingProvider { }) async { await _ensureDirectory(); - final key = _keyFor(url); + final key = keyFor(url); final tileFile = File(p.join(cacheDirectory, '$key.tile')); final metaFile = File(p.join(cacheDirectory, '$key.meta')); @@ -158,7 +158,8 @@ class ProviderTileCacheStore implements MapCachingProvider { } /// Generate a cache key from URL using UUID v5 (same as flutter_map built-in). - static String _keyFor(String url) => _uuid.v5(Namespace.url.value, url); + @visibleForTesting + static String keyFor(String url) => _uuid.v5(Namespace.url.value, url); /// Estimate total cache size (lazy, first call scans directory). Future _getEstimatedSize() async { @@ -301,6 +302,7 @@ class ProviderTileCacheStore implements MapCachingProvider { } _estimatedSize = null; _directoryReady = null; // Allow lazy re-creation + _lastPruneCheck = null; // Reset throttle so next write can trigger eviction } /// Get the current estimated cache size in bytes. diff --git a/lib/services/service_policy.dart b/lib/services/service_policy.dart index 078e7a74..acf8a246 100644 --- a/lib/services/service_policy.dart +++ b/lib/services/service_policy.dart @@ -315,10 +315,12 @@ class ServiceRateLimiter { static Future acquire(ServiceType service) async { final policy = ServicePolicyResolver.resolveByType(service); - // Concurrency: acquire semaphore slot first, so only one caller at a - // time proceeds to the rate-limit check. This prevents concurrent - // callers from bypassing the min interval when _lastRequestTime is - // still null or stale. + // Concurrency: acquire a semaphore slot first so that at most + // [policy.maxConcurrentRequests] callers proceed concurrently. + // The min-interval check below is only race-free when + // maxConcurrentRequests == 1 (currently only Nominatim). For services + // with higher concurrency the interval is approximate, which is + // acceptable — their policies don't specify a min interval. _Semaphore? semaphore; if (policy.maxConcurrentRequests > 0) { semaphore = _semaphores.putIfAbsent( diff --git a/lib/widgets/map/tile_layer_manager.dart b/lib/widgets/map/tile_layer_manager.dart index fcf74d94..78cda5e2 100644 --- a/lib/widgets/map/tile_layer_manager.dart +++ b/lib/widgets/map/tile_layer_manager.dart @@ -225,7 +225,24 @@ class TileLayerManager { ); } + /// Build a config fingerprint for drift detection. + /// + /// If any of these fields change (e.g. user edits the URL template or + /// rotates an API key) the cached [DeflockTileProvider] must be replaced. + static String _configFingerprint( + models.TileProvider provider, + models.TileType tileType, + ) => + '${provider.id}/${tileType.id}' + '|${tileType.urlTemplate}' + '|${tileType.maxZoom}' + '|${provider.apiKey ?? ''}'; + /// Get or create a [DeflockTileProvider] for the given provider/type. + /// + /// Providers are cached by `providerId/tileTypeId`. If the effective config + /// (URL template, max zoom, API key) has changed since the provider was + /// created, the stale instance is shut down and replaced. DeflockTileProvider _getOrCreateProvider({ required models.TileProvider? selectedProvider, required models.TileType? selectedTileType, @@ -247,6 +264,19 @@ class TileLayerManager { } final key = '${selectedProvider.id}/${selectedTileType.id}'; + final fingerprint = _configFingerprint(selectedProvider, selectedTileType); + + // Check for config drift: if the provider exists but its config has + // changed, shut down the stale instance so a fresh one is created below. + final existing = _providers[key]; + if (existing != null && existing.configFingerprint != fingerprint) { + debugPrint( + '[TileLayerManager] Config changed for $key — replacing provider', + ); + existing.shutdown(); + _providers.remove(key); + } + return _providers.putIfAbsent(key, () { final cachingProvider = ProviderTileCacheManager.isInitialized ? ProviderTileCacheManager.getOrCreate( @@ -267,6 +297,7 @@ class TileLayerManager { apiKey: selectedProvider.apiKey, cachingProvider: cachingProvider, onNetworkSuccess: onTileLoadSuccess, + configFingerprint: fingerprint, ); }); } diff --git a/test/services/provider_tile_cache_store_test.dart b/test/services/provider_tile_cache_store_test.dart index c1906f48..e0f974d7 100644 --- a/test/services/provider_tile_cache_store_test.dart +++ b/test/services/provider_tile_cache_store_test.dart @@ -359,8 +359,8 @@ void main() { group('ProviderTileCacheStore eviction', () { /// Helper: populate cache with [count] tiles, each [bytesPerTile] bytes. - /// Uses small delays between writes so modification times are - /// distinguishable for oldest-modified ordering. + /// Sets deterministic modification times (1 second apart) so eviction + /// ordering is stable across platforms without relying on wall-clock delays. Future fillCache( ProviderTileCacheStore store, { required int count, @@ -373,14 +373,22 @@ void main() { lastModified: null, etag: null, ); + final baseTime = DateTime.utc(2026, 1, 1); for (var i = 0; i < count; i++) { await store.putTile( url: 'https://tile.example.com/$prefix$i.png', metadata: metadata, bytes: bytes, ); - // Small delay so modification times are distinguishable for eviction order - await Future.delayed(const Duration(milliseconds: 10)); + // Set deterministic mtime so eviction order is stable across platforms. + final key = ProviderTileCacheStore.keyFor( + 'https://tile.example.com/$prefix$i.png', + ); + final tileFile = File(p.join(store.cacheDirectory, '$key.tile')); + final metaFile = File(p.join(store.cacheDirectory, '$key.meta')); + final mtime = baseTime.add(Duration(seconds: i)); + await tileFile.setLastModified(mtime); + await metaFile.setLastModified(mtime); } } diff --git a/test/widgets/map/tile_layer_manager_test.dart b/test/widgets/map/tile_layer_manager_test.dart index 43a80b2a..3a9183ba 100644 --- a/test/widgets/map/tile_layer_manager_test.dart +++ b/test/widgets/map/tile_layer_manager_test.dart @@ -6,6 +6,7 @@ import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:deflockapp/models/tile_provider.dart' as models; import 'package:deflockapp/services/deflock_tile_provider.dart'; import 'package:deflockapp/widgets/map/tile_layer_manager.dart'; @@ -419,6 +420,136 @@ void main() { }); }); + group('TileLayerManager config drift detection', () { + late TileLayerManager manager; + + setUp(() { + manager = TileLayerManager(); + }); + + tearDown(() { + manager.dispose(); + }); + + models.TileProvider makeProvider({String? apiKey}) => models.TileProvider( + id: 'test_provider', + name: 'Test', + apiKey: apiKey, + tileTypes: [], + ); + + models.TileType makeTileType({ + String urlTemplate = 'https://example.com/{z}/{x}/{y}.png', + int maxZoom = 18, + }) => + models.TileType( + id: 'test_tile', + name: 'Test', + urlTemplate: urlTemplate, + attribution: 'Test', + maxZoom: maxZoom, + ); + + test('returns same provider for identical config', () { + final provider = makeProvider(); + final tileType = makeTileType(); + + final layer1 = manager.buildTileLayer( + selectedProvider: provider, + selectedTileType: tileType, + ) as TileLayer; + + final layer2 = manager.buildTileLayer( + selectedProvider: provider, + selectedTileType: tileType, + ) as TileLayer; + + expect( + identical(layer1.tileProvider, layer2.tileProvider), + isTrue, + reason: 'Same config should return the cached provider instance', + ); + }); + + test('replaces provider when urlTemplate changes', () { + final provider = makeProvider(); + final tileTypeV1 = makeTileType( + urlTemplate: 'https://old.example.com/{z}/{x}/{y}.png', + ); + final tileTypeV2 = makeTileType( + urlTemplate: 'https://new.example.com/{z}/{x}/{y}.png', + ); + + final layer1 = manager.buildTileLayer( + selectedProvider: provider, + selectedTileType: tileTypeV1, + ) as TileLayer; + + final layer2 = manager.buildTileLayer( + selectedProvider: provider, + selectedTileType: tileTypeV2, + ) as TileLayer; + + expect( + identical(layer1.tileProvider, layer2.tileProvider), + isFalse, + reason: 'Changed urlTemplate should create a new provider', + ); + expect( + (layer2.tileProvider as DeflockTileProvider).tileType.urlTemplate, + 'https://new.example.com/{z}/{x}/{y}.png', + ); + }); + + test('replaces provider when apiKey changes', () { + final providerV1 = makeProvider(apiKey: 'old_key'); + final providerV2 = makeProvider(apiKey: 'new_key'); + final tileType = makeTileType(); + + final layer1 = manager.buildTileLayer( + selectedProvider: providerV1, + selectedTileType: tileType, + ) as TileLayer; + + final layer2 = manager.buildTileLayer( + selectedProvider: providerV2, + selectedTileType: tileType, + ) as TileLayer; + + expect( + identical(layer1.tileProvider, layer2.tileProvider), + isFalse, + reason: 'Changed apiKey should create a new provider', + ); + expect( + (layer2.tileProvider as DeflockTileProvider).apiKey, + 'new_key', + ); + }); + + test('replaces provider when maxZoom changes', () { + final provider = makeProvider(); + final tileTypeV1 = makeTileType(maxZoom: 18); + final tileTypeV2 = makeTileType(maxZoom: 20); + + final layer1 = manager.buildTileLayer( + selectedProvider: provider, + selectedTileType: tileTypeV1, + ) as TileLayer; + + final layer2 = manager.buildTileLayer( + selectedProvider: provider, + selectedTileType: tileTypeV2, + ) as TileLayer; + + expect( + identical(layer1.tileProvider, layer2.tileProvider), + isFalse, + reason: 'Changed maxZoom should create a new provider', + ); + }); + }); + group('TileLayerManager error-type filtering', () { late TileLayerManager manager; late MockTileImage mockTile; From de65cecc6a801afabc6757991b7d9456f3158bed Mon Sep 17 00:00:00 2001 From: stopflock Date: Sat, 7 Mar 2026 16:51:38 -0600 Subject: [PATCH 17/18] bump ver --- assets/changelog.json | 5 +++++ pubspec.yaml | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/assets/changelog.json b/assets/changelog.json index 4b9b9be1..cddd8d0d 100644 --- a/assets/changelog.json +++ b/assets/changelog.json @@ -1,4 +1,9 @@ { + "2.9.0": { + "content": [ + "• Caching, tile retries, offline areas, now working properly. Map imagery should load correctly." + ] + }, "2.8.1": { "content": [ "• Fixed bug where the \"existing tags\" profile would incorrectly add default FOV ranges during submission", diff --git a/pubspec.yaml b/pubspec.yaml index bb96369c..6269a4b4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: deflockapp description: Map public surveillance infrastructure with OpenStreetMap publish_to: "none" -version: 2.8.1+50 # The thing after the + is the version code, incremented with each release +version: 2.9.0+51 # The thing after the + is the version code, incremented with each release environment: sdk: ">=3.10.3 <4.0.0" # Resolved dependency floor (Dart 3.10.3 = Flutter 3.38+) From f719e5cb949439dfbff7d7c80ebd023b21cefb60 Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Sun, 8 Mar 2026 23:58:40 -0600 Subject: [PATCH 18/18] Add Gatling load tests for overpass.deflock.org Validate the performance of our self-hosted Overpass API endpoint before switching app users to it. The simulation replays realistic queries matching the app's actual request format, walking through zoom levels 15 (city blocks) to 10 (metro region) across 6 US cities with high surveillance camera density. Includes: - Gatling 3.15 / Scala 2.13 / Gradle 9.4 project in load-tests/ - Single-user zoom progression scenario with p99/error assertions - Manual-trigger GitHub Actions workflow with HTML report artifact - Dev container for JDK 21 + Scala development environment - Thorough documentation for contributors unfamiliar with the stack Co-Authored-By: Claude Opus 4.6 --- .github/workflows/load-test.yml | 44 ++++ .gitignore | 2 +- load-tests/.devcontainer/Dockerfile | 17 ++ load-tests/.devcontainer/devcontainer.json | 28 ++ load-tests/.gitignore | 5 + load-tests/README.md | 140 ++++++++++ load-tests/build.gradle.kts | 20 ++ load-tests/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 48966 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + load-tests/gradlew | 248 ++++++++++++++++++ load-tests/gradlew.bat | 93 +++++++ load-tests/settings.gradle.kts | 2 + load-tests/src/gatling/resources/gatling.conf | 19 ++ .../src/gatling/resources/logback-test.xml | 23 ++ .../scala/deflock/OverpassRequests.scala | 100 +++++++ .../scala/deflock/OverpassSimulation.scala | 77 ++++++ .../src/gatling/scala/deflock/TestData.scala | 104 ++++++++ 17 files changed, 928 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/load-test.yml create mode 100644 load-tests/.devcontainer/Dockerfile create mode 100644 load-tests/.devcontainer/devcontainer.json create mode 100644 load-tests/.gitignore create mode 100644 load-tests/README.md create mode 100644 load-tests/build.gradle.kts create mode 100644 load-tests/gradle/wrapper/gradle-wrapper.jar create mode 100644 load-tests/gradle/wrapper/gradle-wrapper.properties create mode 100755 load-tests/gradlew create mode 100644 load-tests/gradlew.bat create mode 100644 load-tests/settings.gradle.kts create mode 100644 load-tests/src/gatling/resources/gatling.conf create mode 100644 load-tests/src/gatling/resources/logback-test.xml create mode 100644 load-tests/src/gatling/scala/deflock/OverpassRequests.scala create mode 100644 load-tests/src/gatling/scala/deflock/OverpassSimulation.scala create mode 100644 load-tests/src/gatling/scala/deflock/TestData.scala diff --git a/.github/workflows/load-test.yml b/.github/workflows/load-test.yml new file mode 100644 index 00000000..3ca2474e --- /dev/null +++ b/.github/workflows/load-test.yml @@ -0,0 +1,44 @@ +# Manual-trigger workflow to run Gatling load tests against overpass.deflock.org. +# +# Trigger from the Actions tab → "Load Test" → "Run workflow". +# The HTML report is uploaded as a downloadable artifact (retained 30 days). +# +# Only one load test can run at a time (concurrency group prevents overlapping +# runs from skewing results or overwhelming the Overpass instance). + +name: Load Test + +on: + workflow_dispatch: + +concurrency: + group: load-test + cancel-in-progress: true + +jobs: + load-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + + # Caches Gradle wrapper and dependencies between runs + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Run Gatling load test + working-directory: load-tests + run: ./gradlew gatlingRun + + - name: Upload Gatling report + if: always() + uses: actions/upload-artifact@v4 + with: + name: gatling-report + path: load-tests/build/reports/gatling/ + retention-days: 30 diff --git a/.gitignore b/.gitignore index db2e32aa..5417a608 100644 --- a/.gitignore +++ b/.gitignore @@ -103,4 +103,4 @@ build_keys.conf linux/ macos/ web/ -windows/ \ No newline at end of file +windows/ diff --git a/load-tests/.devcontainer/Dockerfile b/load-tests/.devcontainer/Dockerfile new file mode 100644 index 00000000..fcff07a6 --- /dev/null +++ b/load-tests/.devcontainer/Dockerfile @@ -0,0 +1,17 @@ +# Dev container image for running Gatling load tests. +# +# Based on Microsoft's Java 21 dev container, which includes: +# - JDK 21 (Eclipse Temurin) +# - Gradle (via the wrapper in the project) +# - Standard dev tools (git, curl, etc.) +# +# We add Coursier (the Scala package manager) for Scala tooling support +# in VS Code via the Metals extension. + +FROM mcr.microsoft.com/devcontainers/java:21 + +# Install Coursier for Scala tooling (used by the Metals VS Code extension). +# Note: the Scala compiler itself is managed by Gradle via the Gatling plugin, +# so we only need Coursier for IDE support. +RUN curl -fL https://github.com/coursier/coursier/releases/latest/download/cs-x86_64-pc-linux.gz | gzip -d > /usr/local/bin/cs \ + && chmod +x /usr/local/bin/cs diff --git a/load-tests/.devcontainer/devcontainer.json b/load-tests/.devcontainer/devcontainer.json new file mode 100644 index 00000000..f9f5d2f4 --- /dev/null +++ b/load-tests/.devcontainer/devcontainer.json @@ -0,0 +1,28 @@ +// Dev container for the Gatling load test project. +// +// This provides a ready-to-go JVM + Scala environment without needing +// to install anything locally. Open the load-tests/ folder in VS Code +// and select "Dev Containers: Reopen in Container". +// +// Includes: +// - JDK 21 (Temurin) +// - Scala via Coursier +// - VS Code extensions: Scala Metals (IDE support) + Gradle (build tasks) +{ + "name": "Deflock Load Tests", + "build": { + "dockerfile": "Dockerfile" + }, + "workspaceFolder": "/workspaces/deflock-app/load-tests", + "customizations": { + "vscode": { + "extensions": [ + "scalameta.metals", + "vscjava.vscode-gradle" + ] + } + }, + // Pre-download all Gradle + Gatling dependencies so the first + // `./gradlew gatlingRun` is fast. + "postCreateCommand": "./gradlew dependencies" +} diff --git a/load-tests/.gitignore b/load-tests/.gitignore new file mode 100644 index 00000000..686141c9 --- /dev/null +++ b/load-tests/.gitignore @@ -0,0 +1,5 @@ +# Gradle build outputs (includes Gatling HTML reports in build/reports/gatling/) +build/ + +# Gradle cache +.gradle/ diff --git a/load-tests/README.md b/load-tests/README.md new file mode 100644 index 00000000..43175cf4 --- /dev/null +++ b/load-tests/README.md @@ -0,0 +1,140 @@ +# Deflock Load Tests + +Gatling load tests for validating [`overpass.deflock.org`](https://overpass.deflock.org) performance before rolling it out as the primary Overpass API endpoint for all Deflock app users. + +## What is this? + +The Deflock app fetches surveillance camera data from the [Overpass API](https://wiki.openstreetmap.org/wiki/Overpass_API) every time a user pans or zooms the map. We've deployed our own Overpass instance at `overpass.deflock.org` to reduce dependence on the public endpoint. These load tests validate that our instance can handle realistic traffic patterns before we switch users over to it. + +The tests use [Gatling](https://gatling.io), an open-source load testing framework. Gatling simulates virtual users sending HTTP requests and produces detailed HTML reports with latency percentiles, error rates, and throughput metrics. + +## Quick start + +### Prerequisites + +- **JDK 21+** — install via [SDKMAN](https://sdkman.io) (`sdk install java 21-tem`) or your package manager +- **No other tools needed** — Gradle and Scala are handled automatically by the included wrapper and build config + +Or use the included [dev container](#dev-container) to skip local setup entirely. + +### Run the tests + +```bash +cd load-tests +./gradlew gatlingRun +``` + +This takes about 10-15 seconds (6 sequential requests with 500ms pauses between them). When finished, Gatling prints a `file://` URL to the HTML report — open it in your browser. + +The report lands in `build/reports/gatling//index.html`. + +### Run via GitHub Actions + +1. Go to the **Actions** tab in GitHub +2. Select the **"Load Test"** workflow +3. Click **"Run workflow"** +4. When complete, download the **gatling-report** artifact (retained for 30 days) + +## How the test works + +### The simulation + +A single virtual user walks through map zoom levels from **z15** (a few city blocks) down to **z10** (a metro region). At each zoom level, it picks a random US city and sends the same Overpass API query that the Deflock app sends when a user views that area on the map. + +This zoom progression reveals how response time scales with viewport size — larger viewports contain more surveillance nodes, producing bigger API responses. + +### Test data + +Six US cities were chosen for high surveillance camera density in their downtown areas: + +| City | Center coordinates | Landmark | +|---|---|---| +| Denver | 39.75, -105.00 | 16th St Mall / Union Station | +| Los Angeles | 34.05, -118.25 | Pershing Square, DTLA | +| San Francisco | 37.79, -122.40 | Financial District | +| New York | 40.75, -73.98 | Midtown / 42nd & 6th Ave | +| Boston | 42.36, -71.06 | Downtown Crossing | +| Chicago | 41.88, -87.63 | State & Madison, The Loop | + +### Zoom levels and viewport sizes + +Each zoom level corresponds to a different viewport size on a typical mobile phone screen (~400x800px, portrait): + +| Zoom | Area covered | Lat x Lng span | +|---|---|---| +| 15 | A few city blocks (~1.5 x 3 km) | 0.026 x 0.017 deg | +| 14 | A neighborhood (~3 x 6 km) | 0.053 x 0.034 deg | +| 13 | A district (~6 x 12 km) | 0.105 x 0.069 deg | +| 12 | A mid-size city (~12 x 23 km) | 0.210 x 0.140 deg | +| 11 | A large city (~23 x 47 km) | 0.420 x 0.270 deg | +| 10 | A metro region (~47 x 93 km) | 0.840 x 0.550 deg | + +## Interpreting the report + +The Gatling HTML report includes several views. Here's what to look for: + +### Key metrics + +- **p50 (median) latency** — what a typical user experiences +- **p95 latency** — should be under 10s for a good user experience +- **p99 latency** — should be under 30s (the assertion threshold) +- **Error rate** — should be 0% under single-user load + +### Report sections + +- **Response time distribution** — histogram showing how many requests fell into each latency bucket +- **Response time percentiles over time** — trend lines for p50/p75/p95/p99 throughout the test +- **Requests per second** — throughput over the test duration +- **Individual request details** — click any request name (e.g., "Overpass z15 - Denver") to see its specific metrics + +### What "good" looks like + +From our baseline runs, typical single-user performance is: + +| Zoom | Expected latency | +|---|---| +| z15 (blocks) | ~400-600ms | +| z13-z14 (neighborhood) | ~600-1000ms | +| z10-z11 (city/metro) | ~1000-1600ms | + +## Planned scenarios + +| Scenario | Description | Status | +|---|---|---| +| Single-user zoom progression | Baseline latency at each zoom level | Current | +| Concurrent users | Ramp up multiple users to find capacity limits | Planned | +| Stress test | Push beyond expected capacity to find breaking points | Planned | + +## Project structure + +``` +load-tests/ +├── .devcontainer/ # VS Code dev container (JDK 21 + Scala) +│ ├── devcontainer.json +│ └── Dockerfile +├── build.gradle.kts # Build config (Gatling + Scala plugins) +├── settings.gradle.kts # Gradle project name +├── gradlew / gradlew.bat # Gradle wrapper (no global install needed) +├── gradle/wrapper/ # Gradle wrapper jar + config +├── src/gatling/ +│ ├── scala/deflock/ +│ │ ├── OverpassSimulation.scala # The simulation (test scenario) +│ │ ├── OverpassRequests.scala # HTTP request definitions +│ │ └── TestData.scala # City coordinates + zoom feeders +│ └── resources/ +│ ├── gatling.conf # Gatling charting config +│ └── logback-test.xml # Log level config (WARN by default) +└── build/reports/gatling/ # Generated HTML reports (.gitignored) +``` + +## Dev container + +If you don't want to install JDK locally, the included dev container provides a ready-to-go environment: + +1. Open the `load-tests/` folder in VS Code +2. Install the [Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) +3. Press `Ctrl+Shift+P` → "Dev Containers: Reopen in Container" +4. Wait for the container to build (first time takes a few minutes) +5. Open a terminal and run `./gradlew gatlingRun` + +The container includes JDK 21, Scala (via [Coursier](https://get-coursier.io)), and VS Code extensions for Scala (Metals) and Gradle. diff --git a/load-tests/build.gradle.kts b/load-tests/build.gradle.kts new file mode 100644 index 00000000..da02f1d8 --- /dev/null +++ b/load-tests/build.gradle.kts @@ -0,0 +1,20 @@ +// Gatling load test build configuration. +// +// Gatling (https://gatling.io) is a load testing framework that simulates +// virtual users sending HTTP requests and produces HTML performance reports. +// +// The `scala` plugin compiles our Scala simulation files. +// The `io.gatling.gradle` plugin adds the `gatlingRun` task and manages +// Gatling + Scala library dependencies automatically. +// +// Run tests: ./gradlew gatlingRun +// Reports: build/reports/gatling/ + +plugins { + scala + id("io.gatling.gradle") version "3.15.0" +} + +repositories { + mavenCentral() +} diff --git a/load-tests/gradle/wrapper/gradle-wrapper.jar b/load-tests/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..d997cfc60f4cff0e7451d19d49a82fa986695d07 GIT binary patch literal 48966 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

`Lem|V$A>eR0uN8Y&sdjtu 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

1bvi{DS7E@q1{$_8->K{_OWv2 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 literal 0 HcmV?d00001 diff --git a/load-tests/gradle/wrapper/gradle-wrapper.properties b/load-tests/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..dbc3ce4a --- /dev/null +++ b/load-tests/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.0-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/load-tests/gradlew b/load-tests/gradlew new file mode 100755 index 00000000..d06d3135 --- /dev/null +++ b/load-tests/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/b5fe9efed6cae7b9f2fbdb2d380fb69af16bb752/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/load-tests/gradlew.bat b/load-tests/gradlew.bat new file mode 100644 index 00000000..bd8a8c05 --- /dev/null +++ b/load-tests/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/load-tests/settings.gradle.kts b/load-tests/settings.gradle.kts new file mode 100644 index 00000000..7bb784cb --- /dev/null +++ b/load-tests/settings.gradle.kts @@ -0,0 +1,2 @@ +// Gradle project name — only affects build output naming, not functionality. +rootProject.name = "deflock-load-tests" diff --git a/load-tests/src/gatling/resources/gatling.conf b/load-tests/src/gatling/resources/gatling.conf new file mode 100644 index 00000000..a35bec22 --- /dev/null +++ b/load-tests/src/gatling/resources/gatling.conf @@ -0,0 +1,19 @@ +// Gatling configuration. +// See https://docs.gatling.io/reference/script/core/configuration/ for all options. +// +// The Gradle plugin handles most settings (like report output directory). +// This file only overrides charting thresholds for the HTML report. + +gatling { + charting { + // These thresholds define the color bands in the response time + // distribution chart in the HTML report: + // Green: < lowerBound (under 1 second — great) + // Yellow: lowerBound to higherBound (1-5 seconds — acceptable) + // Red: > higherBound (over 5 seconds — needs attention) + indicators { + lowerBound = 1000 + higherBound = 5000 + } + } +} diff --git a/load-tests/src/gatling/resources/logback-test.xml b/load-tests/src/gatling/resources/logback-test.xml new file mode 100644 index 00000000..4eade1fe --- /dev/null +++ b/load-tests/src/gatling/resources/logback-test.xml @@ -0,0 +1,23 @@ + + + + + + %d{HH:mm:ss.SSS} [%-5level] %logger{36} - %msg%n + + + + + + + diff --git a/load-tests/src/gatling/scala/deflock/OverpassRequests.scala b/load-tests/src/gatling/scala/deflock/OverpassRequests.scala new file mode 100644 index 00000000..d4db538e --- /dev/null +++ b/load-tests/src/gatling/scala/deflock/OverpassRequests.scala @@ -0,0 +1,100 @@ +package deflock + +import io.gatling.core.Predef._ +import io.gatling.http.Predef._ + +import scala.concurrent.duration._ + +/** + * Reusable Overpass API request definitions for Gatling simulations. + * + * The request format here must match what the Deflock app actually sends. + * See lib/services/overpass_service.dart for the app's implementation. + * + * Key design decisions: + * - POST to /api/interpreter with form-encoded body (not GET with query params) + * - Query includes both surveillance nodes and their parent ways/relations + * - Timeout matches the app's ResiliencePolicy.httpTimeout (45s server + 5s client margin) + */ +object OverpassRequests { + + // --- Timeouts --- + // The Overpass QL query tells the server to abort after this many seconds. + // This matches kOverpassQueryTimeout in the app (lib/dev_config.dart). + val serverTimeoutSeconds = 45 + + // The HTTP client timeout is slightly longer than the server timeout so that + // we always receive the server's own timeout error response (a 200 with a + // "remark" field) rather than the client aborting the connection first. + val clientTimeout = (serverTimeoutSeconds + 5).seconds + + // --- Overpass tag filters --- + // These match the app's default enabled NodeProfiles. Each filter becomes + // a separate `node[...]` clause in the Overpass QL query, and the results + // are unioned together. To test different profiles, add/remove filters here. + // + // See: lib/models/node_profile.dart for the full list of app profiles. + val tagFilters: Seq[String] = Seq( + """["man_made"="surveillance"]""", + """["camera:type"="fixed"]""" + ) + + // --- Feeder session keys --- + // These constants are the variable names injected into each virtual user's + // session by the feeders in TestData. Using constants here (instead of raw + // strings) prevents typos that would silently break at runtime. + val CityName = "cityName" + val ZoomLevel = "zoomLevel" + val QueryBody = "queryBody" + + /** + * Build an Overpass QL query string for the given bounding box. + * + * The query structure matches OverpassService._buildQuery() in the app: + * 1. Fetch nodes matching any of the tag filters within the bbox + * 2. Fetch parent ways and relations for those nodes (out skel) + * + * Overpass bbox format is (south, west, north, east) — note this is + * different from many mapping libraries that use (west, south, east, north). + * + * @return A complete Overpass QL query string ready to POST + */ + def buildQuery(south: Double, west: Double, north: Double, east: Double): String = { + val nodeClauses = tagFilters.map { tags => + s" node$tags($south,$west,$north,$east);" + }.mkString("\n") + + s"""[out:json][timeout:$serverTimeoutSeconds]; + |( + |$nodeClauses + |); + |out body; + |( + | way(bn); + | rel(bn); + |); + |out skel;""".stripMargin + } + + /** + * The HTTP request definition that Gatling will execute. + * + * Uses Gatling's #{...} Expression Language syntax to inject session + * variables at request time. These variables are populated by the feeders + * in TestData — see feederForZoom(). + * + * The request name (e.g., "Overpass z15 - Denver") appears in the Gatling + * HTML report, making it easy to compare performance across zoom levels + * and cities. + * + * Checks: + * - HTTP 200 status (Overpass returns 200 even for empty results) + * - Response body contains an "elements" array (valid Overpass JSON) + */ + val overpassRequest = http("Overpass z#{zoomLevel} - #{cityName}") + .post("/api/interpreter") + .formParam("data", "#{queryBody}") + .requestTimeout(clientTimeout) + .check(status.is(200)) + .check(jsonPath("$.elements").exists) +} diff --git a/load-tests/src/gatling/scala/deflock/OverpassSimulation.scala b/load-tests/src/gatling/scala/deflock/OverpassSimulation.scala new file mode 100644 index 00000000..eafdc571 --- /dev/null +++ b/load-tests/src/gatling/scala/deflock/OverpassSimulation.scala @@ -0,0 +1,77 @@ +package deflock + +import io.gatling.core.Predef._ +import io.gatling.http.Predef._ + +import scala.concurrent.duration._ + +/** + * Gatling simulation for load-testing the Deflock Overpass API endpoint. + * + * This simulation validates the performance of overpass.deflock.org before + * it becomes the primary endpoint for all Deflock app users. It replays + * realistic queries matching the app's actual request format. + * + * == How it works == + * + * A single virtual user walks through zoom levels from tightest (z15, a few + * city blocks) to widest (z10, a metro region). At each zoom level, it picks + * a random US city, builds a bounding box around that city's downtown, and + * sends the same Overpass query the app would send. + * + * This progression reveals how response time scales with viewport size — + * larger viewports return more surveillance nodes, producing bigger responses. + * + * == Running == + * + * {{{ + * cd load-tests + * ./gradlew gatlingRun + * }}} + * + * The HTML report will be in build/reports/gatling/ — open index.html. + * + * == Future scenarios (planned) == + * + * - Concurrent users: ramp up multiple virtual users to find capacity limits + * - Stress test: push beyond expected capacity to find breaking points + */ +class OverpassSimulation extends Simulation { + + // Target our self-hosted Overpass instance (not the public OSMF one). + // The User-Agent identifies load test traffic in server logs. + val httpProtocol = http + .baseUrl("https://overpass.deflock.org") + .userAgentHeader("DeFlock/LoadTest (+https://deflock.org)") + .acceptHeader("application/json") + + // Walk through zoom levels from tightest (z15) to widest (z10). + // At each level, a random city is selected and queried, with a 500ms + // pause between requests (matching the app's debounce interval). + // + // The .reduce(_.exec(_)) chains the zoom-level steps together into a + // single sequential scenario — Gatling's DSL builds an immutable chain + // of actions, and reduce folds them left-to-right into one chain. + val baselineScenario = scenario("Single-user zoom progression") + .exec( + TestData.zoomViewports.map { viewport => + feed(TestData.feederForZoom(viewport)) + .exec(OverpassRequests.overpassRequest) + .pause(500.milliseconds) + }.reduce(_.exec(_)) + ) + + // --- Test setup --- + // atOnceUsers(1): inject exactly 1 virtual user immediately (no ramp-up). + // This is a baseline test — we want clean, isolated measurements before + // adding concurrency in future scenarios. + setUp( + baselineScenario.inject(atOnceUsers(1)) + ).protocols(httpProtocol) + .assertions( + // p99 response time under 30 seconds (generous for Overpass) + global.responseTime.percentile(99).lt(30000), + // Less than 5% of requests should fail + global.failedRequests.percent.lt(5.0) + ) +} diff --git a/load-tests/src/gatling/scala/deflock/TestData.scala b/load-tests/src/gatling/scala/deflock/TestData.scala new file mode 100644 index 00000000..52f7a8dc --- /dev/null +++ b/load-tests/src/gatling/scala/deflock/TestData.scala @@ -0,0 +1,104 @@ +package deflock + +import io.gatling.core.Predef._ + +/** + * Center coordinates for a city's downtown area. + * + * These are the starting points for building map viewport bounding boxes. + * Each coordinate was verified against map data and chosen for its high + * density of surveillance infrastructure (cameras, ALPR, etc.), which + * produces realistic Overpass API response sizes. + * + * @param name Human-readable city name (appears in Gatling report labels) + * @param lat Latitude of the downtown center point + * @param lng Longitude of the downtown center point + */ +case class CityCenter(name: String, lat: Double, lng: Double) + +/** + * The dimensions of a map viewport at a given zoom level. + * + * These represent what a user sees on their phone screen at each zoom level. + * Larger viewports (lower zoom) fetch more data from the Overpass API, so + * we use these to measure how response time scales with area. + * + * @param zoom OSM/Slippy map zoom level (10 = metro region, 15 = a few blocks) + * @param latSpan Height of the viewport in degrees of latitude + * @param lngSpan Width of the viewport in degrees of longitude + */ +case class ZoomViewport(zoom: Int, latSpan: Double, lngSpan: Double) + +object TestData { + + /** + * US cities with verified downtown coordinates targeting high-surveillance areas. + * + * Each city was chosen because its downtown has significant camera density, + * producing realistic query results. The coordinates point to specific + * well-known locations in each city's central business district. + */ + val cities: Seq[CityCenter] = Seq( + CityCenter("Denver", 39.7478, -104.9995), // 16th St Mall / Union Station + CityCenter("Los Angeles", 34.0483, -118.2530), // Pershing Square, DTLA + CityCenter("San Francisco", 37.7946, -122.3999), // Financial District / Market & Montgomery + CityCenter("New York", 40.7549, -73.9840), // Midtown / 42nd & 6th Ave + CityCenter("Boston", 42.3567, -71.0588), // Downtown Crossing + CityCenter("Chicago", 41.8783, -87.6258) // State & Madison, The Loop + ) + + /** + * Map viewport sizes for zoom levels 10 through 15. + * + * Calculated for a ~400x800px mobile screen (portrait orientation) at ~40 deg N + * latitude using standard OSM/Slippy map tile math (Mercator projection, + * 256px tiles). Each zoom level doubles the tile count, halving the viewport span. + * + * | Zoom | Approx area covered | Example | + * |------|-----------------------|------------------------------| + * | 15 | ~1.5 x 3 km | A few city blocks | + * | 14 | ~3 x 6 km | A neighborhood | + * | 13 | ~6 x 12 km | A district | + * | 12 | ~12 x 23 km | A mid-size city | + * | 11 | ~23 x 47 km | A large city extent | + * | 10 | ~47 x 93 km | A metro region | + * + * Ordered from tightest to widest so the simulation can walk through them + * and show the performance impact of increasing viewport size. + */ + val zoomViewports: Seq[ZoomViewport] = Seq( + ZoomViewport(15, 0.026, 0.017), + ZoomViewport(14, 0.053, 0.034), + ZoomViewport(13, 0.105, 0.069), + ZoomViewport(12, 0.210, 0.140), + ZoomViewport(11, 0.420, 0.270), + ZoomViewport(10, 0.840, 0.550) + ) + + /** + * Create a Gatling feeder that picks a random city for a given zoom level. + * + * A "feeder" in Gatling is a data source that injects variables into the + * virtual user's session before each request. This one pre-computes the + * Overpass query body so it's built once at startup, not on every request. + * + * The bounding box is computed by centering the viewport on the city's + * downtown coordinates: south/north = lat +/- half the latSpan, etc. + * + * @param viewport The zoom level and its corresponding viewport dimensions + * @return A Gatling feeder that randomly selects a city and provides session + * variables: cityName, zoomLevel, and the pre-built queryBody + */ + def feederForZoom(viewport: ZoomViewport) = cities.map { city => + val south = city.lat - viewport.latSpan / 2 + val north = city.lat + viewport.latSpan / 2 + val west = city.lng - viewport.lngSpan / 2 + val east = city.lng + viewport.lngSpan / 2 + + Map( + OverpassRequests.CityName -> city.name, + OverpassRequests.ZoomLevel -> viewport.zoom, + OverpassRequests.QueryBody -> OverpassRequests.buildQuery(south, west, north, east) + ) + }.toIndexedSeq.random // .random makes Gatling pick a random city each iteration +}