fix: resolve startup crash and CrashScreen button crashes (#127)

Two bugs caused the crash-at-startup report:

1. CrashScreen used the widget's build context (above its own MaterialApp)
   for ScaffoldMessenger.of() in button callbacks. When the screen is the
   root widget — the runApp() path after a startup crash — there is no
   ScaffoldMessenger above it, so both 'Copy to Clipboard' and 'Report Issue
   on Codeberg' crashed with a null check error. Fix: wrap Scaffold.body in
   Builder to obtain a context that is a descendant of the Scaffold.

2. path_provider_android 2.2.21 updated to Pigeon 26, which causes a
   channel-error on startup for some Android devices. Pin to <2.2.21
   (resolves to 2.2.20, which uses the stable pre-Pigeon-26 implementation).
   Additionally, make initDatabasePath() catch PlatformException so a
   channel error at the very start of main() no longer hard-crashes the app;
   _openConnection()'s lazy fallback retries after runApp() completes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas SharedInbox
2026-05-22 11:16:09 +02:00
co-authored by Claude Sonnet 4.6
parent c4e7042430
commit 23cbe4611c
5 changed files with 139 additions and 90 deletions
+10 -2
View File
@@ -3,6 +3,7 @@ import 'dart:io';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:flutter/services.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
@@ -578,9 +579,16 @@ String? _dbPath;
/// Call after WidgetsFlutterBinding.ensureInitialized() so that the
/// path_provider plugin channel is registered before the first DB access.
/// On some Android versions the Pigeon channel is not ready at the very
/// start of main(); if it fails, _openConnection() retries lazily.
Future<void> initDatabasePath() async {
final dir = await getApplicationSupportDirectory();
_dbPath = p.join(dir.path, 'sharedinbox.db');
try {
final dir = await getApplicationSupportDirectory();
_dbPath = p.join(dir.path, 'sharedinbox.db');
} on PlatformException {
// Channel not yet established; LazyDatabase will resolve the path
// on first access, after runApp() completes initialization.
}
}
LazyDatabase _openConnection() {
+87 -82
View File
@@ -37,39 +37,22 @@ class CrashScreen extends StatelessWidget {
title: const Text('Something went wrong'),
backgroundColor: Theme.of(context).colorScheme.errorContainer,
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Icon(Icons.error_outline, color: Colors.red, size: 64),
const SizedBox(height: 16),
Text(
'sharedinbox.de encountered an unexpected error and needs to be restarted.',
style: Theme.of(context).textTheme.titleMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
const Text(
'Error Details:',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: BorderRadius.circular(8),
),
child: Text(
exception.toString(),
style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
),
),
if (stackTrace != null) ...[
body: Builder(
builder: (ctx) => SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Icon(Icons.error_outline, color: Colors.red, size: 64),
const SizedBox(height: 16),
Text(
'sharedinbox.de encountered an unexpected error and needs to be restarted.',
style: Theme.of(ctx).textTheme.titleMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
const Text(
'Stack Trace:',
'Error Details:',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
@@ -80,70 +63,92 @@ class CrashScreen extends StatelessWidget {
borderRadius: BorderRadius.circular(8),
),
child: Text(
stackTrace.toString(),
exception.toString(),
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 10,
fontSize: 12,
),
),
),
],
const SizedBox(height: 24),
FilledButton.icon(
onPressed: () async {
final data = await _buildReport();
await Clipboard.setData(ClipboardData(text: data));
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
duration: Duration(seconds: 5),
content: Text('Copied to clipboard'),
if (stackTrace != null) ...[
const SizedBox(height: 16),
const Text(
'Stack Trace:',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: BorderRadius.circular(8),
),
child: Text(
stackTrace.toString(),
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 10,
),
);
}
},
icon: const Icon(Icons.copy),
label: const Text('Copy to Clipboard'),
),
const SizedBox(height: 16),
OutlinedButton.icon(
onPressed: () async {
final report = await _buildReport();
final title = Uri.encodeComponent(
'Crash: ${exception.toString().split('\n').first}',
);
final body = Uri.encodeComponent(report);
final url = Uri.parse(
'https://codeberg.org/guettli/sharedinbox/issues/new?title=$title&body=$body',
);
try {
final launched = await launchUrl(
url,
mode: LaunchMode.externalApplication,
);
if (!launched && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
),
),
],
const SizedBox(height: 24),
FilledButton.icon(
onPressed: () async {
final data = await _buildReport();
await Clipboard.setData(ClipboardData(text: data));
if (ctx.mounted) {
ScaffoldMessenger.of(ctx).showSnackBar(
const SnackBar(
duration: Duration(seconds: 5),
content: Text('Could not open browser.'),
content: Text('Copied to clipboard'),
),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
duration: const Duration(seconds: 5),
content: Text('Error: $e'),
),
},
icon: const Icon(Icons.copy),
label: const Text('Copy to Clipboard'),
),
const SizedBox(height: 16),
OutlinedButton.icon(
onPressed: () async {
final report = await _buildReport();
final title = Uri.encodeComponent(
'Crash: ${exception.toString().split('\n').first}',
);
final body = Uri.encodeComponent(report);
final url = Uri.parse(
'https://codeberg.org/guettli/sharedinbox/issues/new?title=$title&body=$body',
);
try {
final launched = await launchUrl(
url,
mode: LaunchMode.externalApplication,
);
if (!launched && ctx.mounted) {
ScaffoldMessenger.of(ctx).showSnackBar(
const SnackBar(
duration: Duration(seconds: 5),
content: Text('Could not open browser.'),
),
);
}
} catch (e) {
if (ctx.mounted) {
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(
duration: const Duration(seconds: 5),
content: Text('Error: $e'),
),
);
}
}
}
},
icon: const Icon(Icons.bug_report),
label: const Text('Report Issue on Codeberg'),
),
],
},
icon: const Icon(Icons.bug_report),
label: const Text('Report Issue on Codeberg'),
),
],
),
),
),
),
+2 -2
View File
@@ -731,10 +731,10 @@ packages:
dependency: "direct overridden"
description:
name: path_provider_android
sha256: "149441ca6e4f38193b2e004c0ca6376a3d11f51fa5a77552d8bd4d2b0c0912ba"
sha256: e122c5ea805bb6773bb12ce667611265980940145be920cd09a4b0ec0285cb16
url: "https://pub.dev"
source: hosted
version: "2.2.23"
version: "2.2.20"
path_provider_foundation:
dependency: transitive
description:
+5 -4
View File
@@ -84,7 +84,8 @@ flutter:
- assets/
dependency_overrides:
# path_provider_android 2.3+ uses package:jni which crashes on startup
# (SIGSEGV in libdartjni.so FindClassUnchecked — JNI env not ready when
# the Dart VM first calls into it). Pin to 2.2.x which uses Pigeon instead.
path_provider_android: ">=2.2.0 <2.3.0"
# path_provider_android 2.2.21 updated to Pigeon 26, which causes a
# channel-error on startup on some Android devices. 2.3+ uses package:jni
# (SIGSEGV in libdartjni.so FindClassUnchecked). Pin to 2.2.20 which uses
# stable Pigeon and is known to work reliably.
path_provider_android: ">=2.2.0 <2.2.21"
+35
View File
@@ -70,4 +70,39 @@ void main() {
expect(mock.launchedUrl, contains('App%20Version%3A%201.0.0%2B42'));
expect(mock.launchedUrl, contains('TestException%3A%20something%20broke'));
});
testWidgets(
'CrashScreen used as root widget — buttons work without ScaffoldMessenger crash',
(tester) async {
// Regression test for: ScaffoldMessenger.of(context) null-crash when
// CrashScreen is the root widget (runApp path after startup crash).
tester.view.physicalSize = const Size(800, 1200);
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
final mock = MockUrlLauncher();
UrlLauncherPlatform.instance = mock;
const exception = 'TestException: startup crash';
final stackTrace = StackTrace.current;
// Pump CrashScreen directly as the root — no parent MaterialApp.
await tester.pumpWidget(
CrashScreen(exception: exception, stackTrace: stackTrace),
);
expect(find.textContaining('TestException'), findsOneWidget);
// Tapping 'Report Issue on Codeberg' must not crash. Previously
// ScaffoldMessenger.of(context) threw because context was above the
// MaterialApp that CrashScreen itself creates.
await tester.tap(find.text('Report Issue on Codeberg'));
await tester.pumpAndSettle();
expect(
mock.launchedUrl,
contains('https://codeberg.org/guettli/sharedinbox/issues/new'),
);
},
);
}