fix: survive MissingPluginException on startup, fix crash report URL (#146)

Two fixes:

1. notification_service.dart: initNotifications() now catches
   MissingPluginException (and any other init failure) so the app no
   longer crashes when flutter_local_notifications is unavailable on
   some Android devices.  _initialized tracks success; showNewMailNotification
   skips the plugin call when it never initialised.

2. crash_screen.dart: "Report Issue on Codeberg" no longer puts the full
   report in the URL query string.  Long stack traces exceeded browser
   URL-length limits and caused "create issue failed".  The URL now
   carries only the pre-filled title; the user copies the full report
   via "Copy to Clipboard" and pastes it in the issue body.

Tests added:
- test/unit/notification_service_test.dart: verifies initNotifications()
  completes without throwing when the plugin channel is unavailable.
- test/widget/crash_screen_test.dart: verifies the Codeberg URL contains
  the title but no &body= parameter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas SharedInbox
2026-05-22 13:01:34 +02:00
co-authored by Claude Sonnet 4.6
parent ea52e89934
commit f7d021c62a
4 changed files with 57 additions and 15 deletions
+19 -10
View File
@@ -1,26 +1,35 @@
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
const _kChannelId = 'new_mail';
const _kChannelName = 'New mail';
final _plugin = FlutterLocalNotificationsPlugin();
bool _initialized = false;
Future<void> initNotifications() async {
const android = AndroidInitializationSettings('@mipmap/ic_launcher');
await _plugin.initialize(
const InitializationSettings(android: android),
onDidReceiveNotificationResponse: (_) {},
);
await _plugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>()
?.requestNotificationsPermission();
try {
const android = AndroidInitializationSettings('@mipmap/ic_launcher');
await _plugin.initialize(
const InitializationSettings(android: android),
onDidReceiveNotificationResponse: (_) {},
);
await _plugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>()
?.requestNotificationsPermission();
_initialized = true;
} on MissingPluginException {
// Plugin not registered on this device; notifications silently disabled.
} catch (_) {
// Unexpected initialization failure; notifications silently disabled.
}
}
Future<void> showNewMailNotification(String accountEmail) async {
if (!Platform.isAndroid) return;
if (!Platform.isAndroid || !_initialized) return;
await _plugin.show(
accountEmail.hashCode & 0x7FFFFFFF,
'New mail',
+5 -3
View File
@@ -112,13 +112,15 @@ class CrashScreen extends StatelessWidget {
const SizedBox(height: 16),
OutlinedButton.icon(
onPressed: () async {
final report = await _buildReport();
// URL carries only the title to avoid exceeding browser
// URL-length limits — long stack traces caused "create
// issue failed" (#146). Use "Copy to Clipboard" first to
// get the full report, then paste it in the issue body.
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',
'https://codeberg.org/guettli/sharedinbox/issues/new?title=$title',
);
try {
final launched = await launchUrl(
+26
View File
@@ -0,0 +1,26 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/core/services/notification_service.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
// Regression test for https://codeberg.org/guettli/sharedinbox/issues/146:
// On some Android devices the flutter_local_notifications plugin channel is
// absent at startup, throwing MissingPluginException (or a similar error).
// initNotifications() must absorb the failure and let the app continue.
test(
'initNotifications completes without throwing when plugin is unavailable',
() async {
// In the unit-test environment the native plugin is not registered, so
// _plugin.initialize() throws. The fix catches it and keeps _initialized
// false. This test fails before the fix (exception propagates) and passes
// after it (exception is swallowed).
await expectLater(initNotifications(), completes);
});
test('showNewMailNotification completes without throwing', () async {
// Platform.isAndroid is false in tests, so this returns early without
// touching the plugin. Ensures the guard path is exercised.
await expectLater(showNewMailNotification('test@example.com'), completes);
});
}
+7 -2
View File
@@ -59,6 +59,10 @@ void main() {
await tester.tap(find.text('Report Issue on Codeberg'));
await tester.pumpAndSettle();
// Regression for #146: URL must contain only the title, NOT the full
// report body. Long stack traces caused "create issue failed" by
// exceeding browser URL-length limits. The report is copied to clipboard
// so the user can paste it into the issue body.
expect(
mock.launchedUrl,
contains('https://codeberg.org/guettli/sharedinbox/issues/new'),
@@ -67,8 +71,9 @@ void main() {
mock.launchedUrl,
contains('title=Crash%3A%20TestException%3A%20something%20broke'),
);
expect(mock.launchedUrl, contains('App%20Version%3A%201.0.0%2B42'));
expect(mock.launchedUrl, contains('TestException%3A%20something%20broke'));
expect(mock.launchedUrl, isNot(contains('&body=')));
expect(mock.launchedUrl, isNot(contains('App%20Version')));
expect(mock.launchedUrl, isNot(contains('Stack%20Trace')));
});
testWidgets(