fix(startup): defer crash reports until the reporter exists
Reporting the failure inline was wrong for the phase that matters most. The gate opens preferences before SentryFlutter.init, so a corrupt or unreadable store — the likeliest cause of #1732 — was captured by a NoOpHub and silently discarded, which is exactly the telemetry gap the previous commit claimed to close. Initialising the reporter earlier is not an option either: `_beforeSend` reads the crash-reporting opt-out from settings, so events raised before settings load would bypass a user's choice. Every failure is now persisted first and flushed once the reporter is up with settings loaded, which in practice is the user's own retry seconds later in the same process. Records carry a `reported` flag so a send happens exactly once, and a failed send leaves the flag clear so the next launch tries again. The flush reads without consuming, so the record still reaches Settings > Logs. Also routes the tvOS recovery marker through the tolerant read: `reconcile()` runs inside `AppDatabase.open`, a fatal gate step, so a wrong-typed marker vetoed startup outright on a first-class TV target despite the new default-instead-of-veto behaviour. An unreadable marker tells us nothing, which is the same position as an absent one.
This commit is contained in:
@@ -80,6 +80,7 @@ class StartupFailureRecord {
|
||||
required this.appVersion,
|
||||
required this.platform,
|
||||
this.repairable = false,
|
||||
this.reported = false,
|
||||
}) : message = LogRedactionManager.redact(message),
|
||||
stackTrace = stackTrace == null ? null : LogRedactionManager.redact(stackTrace);
|
||||
|
||||
@@ -144,6 +145,26 @@ class StartupFailureRecord {
|
||||
/// Whether the gate can offer an in-app repair for this failure.
|
||||
final bool repairable;
|
||||
|
||||
/// Whether this record has already reached the crash reporter.
|
||||
///
|
||||
/// The earliest gate phases run before crash reporting is initialised, so a
|
||||
/// failure there is captured by a no-op hub and silently discarded. Records
|
||||
/// are therefore always persisted first and sent once the reporter is up —
|
||||
/// on the in-app retry, or on the next launch (#1732).
|
||||
final bool reported;
|
||||
|
||||
StartupFailureRecord copyWith({bool? reported}) => StartupFailureRecord(
|
||||
phase: phase,
|
||||
errorType: errorType,
|
||||
message: message,
|
||||
stackTrace: stackTrace,
|
||||
timestamp: timestamp,
|
||||
appVersion: appVersion,
|
||||
platform: platform,
|
||||
repairable: repairable,
|
||||
reported: reported ?? this.reported,
|
||||
);
|
||||
|
||||
String get phaseId => phase?.id ?? 'unknown';
|
||||
|
||||
/// One-line summary for the failure screen and the log.
|
||||
@@ -177,6 +198,7 @@ class StartupFailureRecord {
|
||||
'appVersion': appVersion,
|
||||
'platform': platform,
|
||||
'repairable': repairable,
|
||||
'reported': reported,
|
||||
};
|
||||
|
||||
static StartupFailureRecord? fromJson(Map<String, Object?> json) {
|
||||
@@ -193,6 +215,7 @@ class StartupFailureRecord {
|
||||
appVersion: json['appVersion'] as String? ?? 'unknown',
|
||||
platform: json['platform'] as String? ?? 'unknown',
|
||||
repairable: json['repairable'] as bool? ?? false,
|
||||
reported: json['reported'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -243,6 +266,37 @@ abstract final class StartupDiagnosticsStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads a persisted record without consuming it.
|
||||
///
|
||||
/// Used by the crash-report flush, which has to run before the record is
|
||||
/// consumed for display and must not remove it if the send fails.
|
||||
static Future<StartupFailureRecord?> peekPersisted() async {
|
||||
try {
|
||||
final file = await _file();
|
||||
if (file == null || !await file.exists()) return null;
|
||||
final decoded = jsonDecode(await file.readAsString());
|
||||
if (decoded is! Map) return null;
|
||||
return StartupFailureRecord.fromJson(decoded.cast<String, Object?>());
|
||||
} catch (error, stackTrace) {
|
||||
appLogger.d('Could not peek at the startup failure record', error: error, stackTrace: stackTrace);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Rewrites the persisted record as already reported, so a later launch does
|
||||
/// not send it a second time. It stays on disk for Settings > Logs.
|
||||
static Future<void> markReported(StartupFailureRecord failure) async {
|
||||
final updated = failure.copyWith(reported: true);
|
||||
if (_pending != null) _pending = updated;
|
||||
try {
|
||||
final file = await _file();
|
||||
if (file == null || !await file.exists()) return;
|
||||
await file.writeAsString(jsonEncode(updated.toJson()), flush: true);
|
||||
} catch (error, stackTrace) {
|
||||
appLogger.d('Could not mark the startup failure record as reported', error: error, stackTrace: stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads and deletes a record written by an earlier launch.
|
||||
///
|
||||
/// Deleting on read stops one stale failure from following the user forever;
|
||||
|
||||
Reference in New Issue
Block a user