feat: add secure URL launcher utility

Add UrlLauncher utility class with security validation to prevent
malicious URL schemes (javascript:, file:, data:). Only allows
http and https schemes for external links.

Features:
- URL scheme validation with whitelist approach
- User-friendly error messages via SnackBar
- External browser launch mode for better security
- Case-insensitive scheme checking

Security: Prevents XSS and local file access attacks

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

Changed files
+78
lib
+78
lib/utils/url_launcher.dart
···
+
import 'package:flutter/foundation.dart';
+
import 'package:flutter/material.dart';
+
import 'package:url_launcher/url_launcher.dart';
+
+
/// Utility class for safely launching external URLs
+
///
+
/// Provides security validation and error handling for opening URLs
+
/// in external browsers or applications.
+
class UrlLauncher {
+
UrlLauncher._(); // Private constructor to prevent instantiation
+
+
/// Allowed URL schemes for security
+
static const _allowedSchemes = ['http', 'https'];
+
+
/// Launches an external URL with security validation
+
///
+
/// Returns true if the URL was successfully launched, false otherwise.
+
///
+
/// Security:
+
/// - Only allows http and https schemes
+
/// - Blocks potentially malicious schemes (javascript:, file:, etc.)
+
/// - Opens in external browser for user control
+
///
+
/// If [context] is provided and mounted, shows a user-friendly error message
+
/// when the URL cannot be opened.
+
static Future<bool> launchExternalUrl(
+
String url, {
+
BuildContext? context,
+
}) async {
+
try {
+
final uri = Uri.parse(url);
+
+
// Validate URL scheme for security
+
if (!_allowedSchemes.contains(uri.scheme.toLowerCase())) {
+
if (kDebugMode) {
+
debugPrint('Blocked non-http(s) URL scheme: ${uri.scheme}');
+
}
+
_showErrorIfPossible(context, 'Invalid link format');
+
return false;
+
}
+
+
// Check if URL can be launched
+
if (await canLaunchUrl(uri)) {
+
return await launchUrl(uri, mode: LaunchMode.externalApplication);
+
}
+
+
if (kDebugMode) {
+
debugPrint('Could not launch URL: $url');
+
}
+
// ignore: use_build_context_synchronously
+
_showErrorIfPossible(context, 'Could not open link');
+
return false;
+
} on FormatException catch (e) {
+
if (kDebugMode) {
+
debugPrint('Invalid URL format: $url - $e');
+
}
+
// ignore: use_build_context_synchronously
+
_showErrorIfPossible(context, 'Invalid link format');
+
return false;
+
} on Exception catch (e) {
+
if (kDebugMode) {
+
debugPrint('Error launching URL: $url - $e');
+
}
+
// ignore: use_build_context_synchronously
+
_showErrorIfPossible(context, 'Could not open link');
+
return false;
+
}
+
}
+
+
/// Shows an error snackbar if context is available and mounted
+
static void _showErrorIfPossible(BuildContext? context, String message) {
+
if (context != null && context.mounted) {
+
ScaffoldMessenger.of(
+
context,
+
).showSnackBar(SnackBar(content: Text(message)));
+
}
+
}
+
}