diff --git a/lib/src/code_push.dart b/lib/src/code_push.dart index f0d9dac..f6fd62b 100644 --- a/lib/src/code_push.dart +++ b/lib/src/code_push.dart @@ -176,6 +176,65 @@ abstract final class CodePush { static String? _loadedPatchId; static String? _loadedPatchHash; + /// Once-per-process latch for the incompatible-reload telemetry — + /// repeated init() calls re-enter the reload and must not re-POST a + /// DELIVERED report. Cleared again on transport failure so devices + /// that boot offline retry on a later re-init. + static bool _reportedIncompatibleReload = false; + + /// Fires the incompatible-reload stranding report. Latched per + /// process on DELIVERY: set optimistically (no duplicate in-flight + /// posts), cleared again if the POST never completed an HTTP round + /// trip (device offline at boot) so a later re-init retries. One + /// delivered report per process is exactly sufficient — an engine + /// ABI cannot change within a running process. + static Future _reportIncompatibleReload({ + required String serverUrl, + required String appId, + required String? patchId, + required String? storedAbi, + required String? liveAbi, + }) async { + if (_reportedIncompatibleReload) return; + _reportedIncompatibleReload = true; + final delivered = await _reportIncompatibleBaseline( + serverUrl: serverUrl, + appId: appId, + patchId: patchId, + kind: 'incompatible_reload', + reason: 'Installed patch was built for a different engine ' + 'ABI; reload skipped, baseline running', + expectedFingerprint: storedAbi, + actualFingerprint: liveAbi, + ); + if (!delivered) _reportedIncompatibleReload = false; + } + + /// Test-only wrapper over [_reportIncompatibleReload] — the + /// production trigger is the private method (reload path); this seam + /// exists so the payload and latch semantics are assertable on host. + @visibleForTesting + static Future debugReportIncompatibleReload({ + required String serverUrl, + required String appId, + required String? patchId, + required String? storedAbi, + required String? liveAbi, + }) => + _reportIncompatibleReload( + serverUrl: serverUrl, + appId: appId, + patchId: patchId, + storedAbi: storedAbi, + liveAbi: liveAbi, + ); + + /// Test-only: clears the incompatible-reload latch. + @visibleForTesting + static void debugResetIncompatibleReloadLatch() { + _reportedIncompatibleReload = false; + } + /// Initializes automatic code push update checking with crash protection. /// /// Call this once in your app's startup. It will: @@ -849,11 +908,12 @@ abstract final class CodePush { required String reason, required String? expectedFingerprint, required String? actualFingerprint, + String kind = 'incompatible_baseline', }) async { try { final payload = { 'app_id': appId, - 'kind': 'incompatible_baseline', + 'kind': kind, 'reason': reason, 'platform': _platform, if (patchId != null) 'patch_id': patchId, @@ -1866,6 +1926,19 @@ abstract final class CodePush { _iosResetBootCounter(patchDir); status.value = 'Installed patch was built for a different ' 'engine — waiting for a compatible update'; + // Fleet observability: without this, engine-changed devices + // sit on baseline invisibly. Fire-and-forget so the boot path + // never waits on telemetry; delivery/latch semantics live in + // the helper. + unawaited( + _reportIncompatibleReload( + serverUrl: serverUrl, + appId: appId, + patchId: patchId, + storedAbi: info?['engine_abi']?.toString(), + liveAbi: liveAbi, + ), + ); return; case IosReloadGateDecision.dropCorrupt: // Corruption, NOT a bad patch — delete without quarantining diff --git a/test/ios_reload_telemetry_test.dart b/test/ios_reload_telemetry_test.dart new file mode 100644 index 0000000..058aa70 --- /dev/null +++ b/test/ios_reload_telemetry_test.dart @@ -0,0 +1,81 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutterplaza_code_push/flutterplaza_code_push.dart'; + +/// The incompatible-reload stranding report: correct payload, latched +/// per process on DELIVERY (a completed HTTP round trip), and retried +/// when the device was offline at boot. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + // The test binding stubs HttpClient to a 400-only fake; these tests + // need the real loopback stack. + HttpOverrides.global = null; + + late HttpServer server; + late List> posts; + + setUp(() async { + CodePush.debugResetIncompatibleReloadLatch(); + posts = []; + server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + server.listen((HttpRequest req) async { + posts.add( + jsonDecode(await utf8.decoder.bind(req).join()) // + as Map, + ); + req.response.statusCode = HttpStatus.ok; + await req.response.close(); + }); + }); + + tearDown(() async { + await server.close(force: true); + CodePush.debugResetIncompatibleReloadLatch(); + }); + + Future fire({String? url}) => CodePush.debugReportIncompatibleReload( + serverUrl: url ?? 'http://127.0.0.1:${server.port}', + appId: 'test-app', + patchId: 'p1', + storedAbi: 'flutter-3.41.2', + liveAbi: 'flutter-3.41.6', + ); + + test('posts kind incompatible_reload with stored vs live ABI', () async { + await fire(); + + expect(posts, hasLength(1)); + final p = posts.single; + expect(p['kind'], 'incompatible_reload'); + expect(p['app_id'], 'test-app'); + expect(p['patch_id'], 'p1'); + expect(p['expected_engine_fingerprint'], 'flutter-3.41.2'); + expect(p['actual_engine_fingerprint'], 'flutter-3.41.6'); + }); + + test('a delivered report latches — re-init does not re-POST', () async { + await fire(); + await fire(); + + expect(posts, hasLength(1), + reason: 'one delivered report per process is sufficient'); + }); + + test('transport failure clears the latch so a later re-init retries', + () async { + // A port with no listener — connection refused, no round trip. + final deadServer = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final deadPort = deadServer.port; + await deadServer.close(force: true); + + await fire(url: 'http://127.0.0.1:$deadPort'); + expect(posts, isEmpty); + + // Device back online (the real server): the retry must fire. + await fire(); + expect(posts, hasLength(1), + reason: 'an offline boot must not permanently lose the report'); + }); +}