Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 74 additions & 1 deletion lib/src/code_push.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> _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<void> 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:
Expand Down Expand Up @@ -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 = <String, dynamic>{
'app_id': appId,
'kind': 'incompatible_baseline',
'kind': kind,
'reason': reason,
'platform': _platform,
if (patchId != null) 'patch_id': patchId,
Expand Down Expand Up @@ -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
Expand Down
81 changes: 81 additions & 0 deletions test/ios_reload_telemetry_test.dart
Original file line number Diff line number Diff line change
@@ -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<Map<String, dynamic>> 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<String, dynamic>,
);
req.response.statusCode = HttpStatus.ok;
await req.response.close();
});
});

tearDown(() async {
await server.close(force: true);
CodePush.debugResetIncompatibleReloadLatch();
});

Future<void> 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');
});
}
Loading