@tduncan / Cortana / commits / 07188eb

fix(sidebar): prevent crash when GitHub URL cannot be launched #71

- Replace bare throw Exception with try/catch + SnackBar in GitHubRepoCard._launchUrl - Use LaunchMode.externalApplication so iOS opens URLs in Safari instead of SFSafariViewController (which can fail on iPadOS under certain conditions) - Store repo URL as static final Uri to avoid repeated parsing - Harden _openPrivacyPolicy in settings and _openHuggingFace in model manager with matching try/catch guards - Add plugin_platform_interface and url_launcher_platform_interface as dev deps for testability - Add widget tests for GitHubRepoCard and regression group in issue_crash_fixes_test.dart

abdulmominsakib committed Aug 28, 2026 at 18:29 UTC 07188ebbce7116a3518b38431e2633d5db0644d6
7 files changed +169 -22
lib/features/on_device/views/model_manager_screen.dart
+23 -11
@@ -944,18 +944,30 @@ class _HuggingFaceGgufImportDialogState
944
945 Future<void> _openHuggingFace(BuildContext context) async {
946 final messenger = ScaffoldMessenger.of(context);
947 - final launched = await launchUrl(
948 - _huggingFaceModelsUri,
949 - mode: LaunchMode.externalApplication,
950 - );
951 - if (!launched && context.mounted) {
952 - messenger.showSnackBar(
953 - SnackBar(
954 - content: Text(
955 - AppLocalizations.of(context)!.could_not_open_huggingface,
956 - ),
957 - ),
947 + try {
948 + final launched = await launchUrl(
949 + _huggingFaceModelsUri,
950 + mode: LaunchMode.externalApplication,
951 );
952 + if (!launched && context.mounted) {
953 + messenger.showSnackBar(
954 + SnackBar(
955 + content: Text(
956 + AppLocalizations.of(context)!.could_not_open_huggingface,
957 + ),
958 + ),
959 + );
960 + }
961 + } catch (_) {
962 + if (context.mounted) {
963 + messenger.showSnackBar(
964 + SnackBar(
965 + content: Text(
966 + AppLocalizations.of(context)!.could_not_open_huggingface,
967 + ),
968 + ),
969 + );
970 + }
971 }
972 }
973
lib/features/settings/views/settings_screen.dart
+33 -2
@@ -2526,6 +2526,38 @@ class _PrivacyPolicyLink extends StatelessWidget {
2526 'https://momin.pro/privacy-policy-localmind/',
2527 );
2528
2529 + Future<void> _openPrivacyPolicy(BuildContext context) async {
2530 + final messenger = ScaffoldMessenger.maybeOf(context);
2531 + final l10n = AppLocalizations.of(context);
2532 + try {
2533 + final ok = await launchUrl(
2534 + _privacyUrl,
2535 + mode: LaunchMode.externalApplication,
2536 + );
2537 + if (!ok && messenger != null) {
2538 + messenger.showSnackBar(
2539 + SnackBar(
2540 + content: Text(
2541 + l10n?.failed_to_open_url(_privacyUrl.toString()) ??
2542 + 'Failed to open URL: $_privacyUrl',
2543 + ),
2544 + ),
2545 + );
2546 + }
2547 + } catch (e) {
2548 + if (messenger != null) {
2549 + messenger.showSnackBar(
2550 + SnackBar(
2551 + content: Text(
2552 + l10n?.failed_to_open_url(e.toString()) ??
2553 + 'Failed to open URL: $e',
2554 + ),
2555 + ),
2556 + );
2557 + }
2558 + }
2559 + }
2560 +
2561 @override
2562 Widget build(BuildContext context) {
2563 final l10n = AppLocalizations.of(context)!;
@@ -2534,8 +2566,7 @@ class _PrivacyPolicyLink extends StatelessWidget {
2566
2567 return Center(
2568 child: InkWell(
2537 - onTap: () =>
2538 - launchUrl(_privacyUrl, mode: LaunchMode.externalApplication),
2569 + onTap: () => _openPrivacyPolicy(context),
2570 borderRadius: BorderRadius.circular(8),
2571 child: Padding(
2572 padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
lib/features/sidebar/components/github_repo_card.dart
+31 -6
@@ -8,12 +8,37 @@ import '../../../l10n/app_localizations.dart';
8 class GitHubRepoCard extends StatelessWidget {
9 const GitHubRepoCard({super.key});
10
11 - final String repoUrl = 'https://github.com/abdulmominsakib/localmind';
11 + static final Uri _repoUri = Uri.parse(
12 + 'https://github.com/abdulmominsakib/localmind',
13 + );
14
13 - Future<void> _launchUrl() async {
14 - final Uri url = Uri.parse(repoUrl);
15 - if (!await launchUrl(url)) {
16 - throw Exception('Could not launch $url');
15 + Future<void> _launchUrl(BuildContext context) async {
16 + final messenger = ScaffoldMessenger.maybeOf(context);
17 + final l10n = AppLocalizations.of(context);
18 + try {
19 + final launched = await launchUrl(
20 + _repoUri,
21 + mode: LaunchMode.externalApplication,
22 + );
23 + if (!launched && messenger != null) {
24 + messenger.showSnackBar(
25 + SnackBar(
26 + content: Text(
27 + l10n?.could_not_open_github ?? 'Could not open GitHub.',
28 + ),
29 + ),
30 + );
31 + }
32 + } catch (_) {
33 + if (messenger != null) {
34 + messenger.showSnackBar(
35 + SnackBar(
36 + content: Text(
37 + l10n?.could_not_open_github ?? 'Could not open GitHub.',
38 + ),
39 + ),
40 + );
41 + }
42 }
43 }
44
@@ -33,7 +58,7 @@ class GitHubRepoCard extends StatelessWidget {
58 borderRadius: BorderRadius.circular(8),
59 ),
60 child: InkWell(
36 - onTap: _launchUrl,
61 + onTap: () => _launchUrl(context),
62 borderRadius: BorderRadius.circular(8),
63 child: Padding(
64 padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
pubspec.lock
+2 -2
@@ -1430,7 +1430,7 @@ packages:
1430 source: hosted
1431 version: "3.1.6"
1432 plugin_platform_interface:
1433 - dependency: transitive
1433 + dependency: "direct dev"
1434 description:
1435 name: plugin_platform_interface
1436 sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
@@ -2004,7 +2004,7 @@ packages:
2004 source: hosted
2005 version: "3.2.5"
2006 url_launcher_platform_interface:
2007 - dependency: transitive
2007 + dependency: "direct dev"
2008 description:
2009 name: url_launcher_platform_interface
2010 sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
pubspec.yaml
+2
@@ -93,6 +93,8 @@ dependencies:
93 dev_dependencies:
94 flutter_test:
95 sdk: flutter
96 + plugin_platform_interface: ^2.1.8
97 + url_launcher_platform_interface: ^2.3.2
98
99 # The "flutter_lints" package below contains a set of recommended lints to
100 # encourage good coding practices. The lint set provided by the package is
test/features/issue_crash_fixes_test.dart
+32 -1
@@ -1,6 +1,5 @@
1 import 'dart:async';
2 import 'dart:io';
3 -import 'dart:typed_data';
3
4 import 'package:dio/dio.dart';
5 import 'package:flutter/material.dart';
@@ -23,8 +22,11 @@ import 'package:localmind/features/servers/data/server_api_service.dart';
22 import 'package:localmind/features/servers/providers/server_providers.dart';
23 import 'package:localmind/features/onboarding/screens/onboarding_server_setup_screen.dart';
24 import 'package:localmind/features/servers/views/add_server_screen.dart';
25 +import 'package:flutter_localizations/flutter_localizations.dart';
26 import 'package:localmind/features/settings/data/models/app_settings.dart';
27 +import 'package:localmind/features/sidebar/components/github_repo_card.dart';
28 import 'package:localmind/l10n/app_localizations.dart';
29 +import 'package:flutter/services.dart';
30 import 'package:shadcn_ui/shadcn_ui.dart';
31 import 'package:shared_preferences/shared_preferences.dart';
32
@@ -760,6 +762,35 @@ void main() {
762 },
763 );
764 });
765 +
766 + group('Issue #71: GitHub URL Launcher Safe Failure Handling', () {
767 + testWidgets(
768 + 'GitHubRepoCard does not throw an unhandled exception when tapped',
769 + (tester) async {
770 + // In the test environment there is no native URL launcher. The
771 + // try/catch in _launchUrl must swallow any failure so the app
772 + // does not crash — that is the regression guard for issue #71.
773 + await tester.pumpWidget(
774 + const MaterialApp(
775 + localizationsDelegates: [
776 + AppLocalizations.delegate,
777 + GlobalMaterialLocalizations.delegate,
778 + GlobalWidgetsLocalizations.delegate,
779 + GlobalCupertinoLocalizations.delegate,
780 + ],
781 + supportedLocales: AppLocalizations.supportedLocales,
782 + home: Scaffold(body: Center(child: GitHubRepoCard())),
783 + ),
784 + );
785 + await tester.pumpAndSettle();
786 +
787 + await tester.tap(find.byType(GitHubRepoCard));
788 + await tester.pumpAndSettle();
789 +
790 + expect(tester.takeException(), isNull);
791 + },
792 + );
793 + });
794 }
795
796 class _MutableActiveServerNotifier extends ActiveServerNotifier {
test/features/sidebar/github_repo_card_test.dart new
+46
@@ -0,0 +1,46 @@
1 +import 'package:flutter/material.dart';
2 +import 'package:flutter_localizations/flutter_localizations.dart';
3 +import 'package:flutter_test/flutter_test.dart';
4 +import 'package:localmind/features/sidebar/components/github_repo_card.dart';
5 +import 'package:localmind/l10n/app_localizations.dart';
6 +
7 +void main() {
8 + TestWidgetsFlutterBinding.ensureInitialized();
9 +
10 + Widget createWidgetUnderTest() {
11 + return const MaterialApp(
12 + localizationsDelegates: [
13 + AppLocalizations.delegate,
14 + GlobalMaterialLocalizations.delegate,
15 + GlobalWidgetsLocalizations.delegate,
16 + GlobalCupertinoLocalizations.delegate,
17 + ],
18 + supportedLocales: AppLocalizations.supportedLocales,
19 + home: Scaffold(body: Center(child: GitHubRepoCard())),
20 + );
21 + }
22 +
23 + testWidgets('GitHubRepoCard renders title and star prompt', (tester) async {
24 + await tester.pumpWidget(createWidgetUnderTest());
25 + await tester.pumpAndSettle();
26 +
27 + expect(find.text('Open Source'), findsOneWidget);
28 + expect(find.text('Star on GitHub'), findsOneWidget);
29 + });
30 +
31 + testWidgets(
32 + 'GitHubRepoCard does not throw an unhandled exception when tapped',
33 + (tester) async {
34 + // In the test environment there is no native URL launcher. The try/catch
35 + // in _launchUrl must swallow any failure so the app does not crash.
36 + await tester.pumpWidget(createWidgetUnderTest());
37 + await tester.pumpAndSettle();
38 +
39 + await tester.tap(find.byType(GitHubRepoCard));
40 + await tester.pumpAndSettle();
41 +
42 + // The core regression: no unhandled exception must reach the framework.
43 + expect(tester.takeException(), isNull);
44 + },
45 + );
46 +}