feat: implement self-signed certificate support in API and UI

- Added support for self-signed TLS certificates in the ApiService, allowing configuration based on server settings.
- Introduced a toggle in the ServerConnectionPage to enable or disable trusting self-signed certificates.
- Updated localization files to include new strings for self-signed certificate settings in multiple languages.
- Enhanced the OptimizedStorageService to manage trusted servers based on user preferences for self-signed certificates.
- Improved error handling and logging throughout the affected services to ensure clarity and maintainability.
This commit is contained in:
cogwheel0
2025-10-09 01:49:56 +05:30
parent 10658d076a
commit 259fe3f9f0
27 changed files with 428 additions and 37 deletions

View File

@@ -224,8 +224,7 @@ flutter pub run build_runner build --delete-conflicting-outputs
If you protect OpenWebUI with SSO or a reverse proxy (Authlia, Authentik, If you protect OpenWebUI with SSO or a reverse proxy (Authlia, Authentik,
etc.), whitelist these path prefixes so Conduit can complete login, sync, and etc.), whitelist these path prefixes so Conduit can complete login, sync, and
streaming flows. Paths are relative to your server base URL; replace tokens like streaming flows. Paths are relative to your server base URL.
`{chatId}` with actual identifiers.
- `/health` - `/health`
- `/api/*` - `/api/*`

View File

@@ -1,4 +1,6 @@
PODS: PODS:
- connectivity_plus (0.0.1):
- Flutter
- DKImagePickerController/Core (4.3.9): - DKImagePickerController/Core (4.3.9):
- DKImagePickerController/ImageDataManager - DKImagePickerController/ImageDataManager
- DKImagePickerController/Resource - DKImagePickerController/Resource
@@ -82,6 +84,7 @@ PODS:
- FlutterMacOS - FlutterMacOS
DEPENDENCIES: DEPENDENCIES:
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- file_picker (from `.symlinks/plugins/file_picker/ios`) - file_picker (from `.symlinks/plugins/file_picker/ios`)
- Flutter (from `Flutter`) - Flutter (from `Flutter`)
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
@@ -110,6 +113,8 @@ SPEC REPOS:
- SwiftyGif - SwiftyGif
EXTERNAL SOURCES: EXTERNAL SOURCES:
connectivity_plus:
:path: ".symlinks/plugins/connectivity_plus/ios"
file_picker: file_picker:
:path: ".symlinks/plugins/file_picker/ios" :path: ".symlinks/plugins/file_picker/ios"
Flutter: Flutter:
@@ -150,6 +155,7 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/webview_flutter_wkwebview/darwin" :path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
SPEC CHECKSUMS: SPEC CHECKSUMS:
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be

View File

@@ -13,6 +13,9 @@ sealed class ServerConfig with _$ServerConfig {
@Default({}) Map<String, String> customHeaders, @Default({}) Map<String, String> customHeaders,
DateTime? lastConnected, DateTime? lastConnected,
@Default(false) bool isActive, @Default(false) bool isActive,
/// Whether to trust self-signed TLS certificates for this server.
@Default(false) bool allowSelfSignedCertificates,
}) = _ServerConfig; }) = _ServerConfig;
factory ServerConfig.fromJson(Map<String, dynamic> json) => factory ServerConfig.fromJson(Map<String, dynamic> json) =>

View File

@@ -1,8 +1,9 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:dio/io.dart';
import 'package:flutter/foundation.dart';
// import 'package:http_parser/http_parser.dart'; // import 'package:http_parser/http_parser.dart';
// Removed legacy websocket/socket.io imports // Removed legacy websocket/socket.io imports
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
@@ -62,6 +63,8 @@ class ApiService {
: null, : null,
), ),
) { ) {
_configureSelfSignedSupport();
// Use API key from server config if provided and no explicit auth token // Use API key from server config if provided and no explicit auth token
final effectiveAuthToken = authToken ?? serverConfig.apiKey; final effectiveAuthToken = authToken ?? serverConfig.apiKey;
@@ -125,6 +128,55 @@ class ApiService {
} }
} }
void _configureSelfSignedSupport() {
if (kIsWeb || !serverConfig.allowSelfSignedCertificates) {
return;
}
final baseUri = _parseBaseUri(serverConfig.url);
if (baseUri == null) {
return;
}
final adapter = _dio.httpClientAdapter;
if (adapter is! IOHttpClientAdapter) {
return;
}
adapter.createHttpClient = () {
final client = HttpClient();
final host = baseUri.host.toLowerCase();
final port = baseUri.hasPort ? baseUri.port : null;
client.badCertificateCallback =
(X509Certificate cert, String requestHost, int requestPort) {
if (requestHost.toLowerCase() != host) {
return false;
}
if (port == null) {
return true;
}
return requestPort == port;
};
return client;
};
}
Uri? _parseBaseUri(String baseUrl) {
final trimmed = baseUrl.trim();
if (trimmed.isEmpty) {
return null;
}
Uri? parsed = Uri.tryParse(trimmed);
if (parsed == null) {
return null;
}
if (!parsed.hasScheme) {
parsed =
Uri.tryParse('https://$trimmed') ?? Uri.tryParse('http://$trimmed');
}
return parsed;
}
// Health check // Health check
Future<bool> checkHealth() async { Future<bool> checkHealth() async {
try { try {

View File

@@ -217,7 +217,11 @@ class BackgroundStreamingHandler {
DebugLogger.stream( DebugLogger.stream(
'saveStreamStatesForRecovery called', 'saveStreamStatesForRecovery called',
scope: 'background', scope: 'background',
data: {'streamIds': streamIds, 'reason': reason, 'statesCount': _streamStates.length}, data: {
'streamIds': streamIds,
'reason': reason,
'statesCount': _streamStates.length,
},
); );
final statesToSave = streamIds final statesToSave = streamIds

View File

@@ -9,6 +9,7 @@ import '../persistence/hive_boxes.dart';
import '../persistence/persistence_keys.dart'; import '../persistence/persistence_keys.dart';
import '../utils/debug_logger.dart'; import '../utils/debug_logger.dart';
import 'secure_credential_storage.dart'; import 'secure_credential_storage.dart';
import 'self_signed_certificate_manager.dart';
/// Optimized storage service backed by Hive for non-sensitive data and /// Optimized storage service backed by Hive for non-sensitive data and
/// FlutterSecureStorage for credentials. /// FlutterSecureStorage for credentials.
@@ -196,6 +197,9 @@ class OptimizedStorageService {
await _secureCredentialStorage.saveServerConfigs(jsonString); await _secureCredentialStorage.saveServerConfigs(jsonString);
_cache['server_config_count'] = configs.length; _cache['server_config_count'] = configs.length;
_cacheTimestamps['server_config_count'] = DateTime.now(); _cacheTimestamps['server_config_count'] = DateTime.now();
SelfSignedCertificateManager.instance
..ensureInitialized()
..updateTrustedServers(configs);
DebugLogger.log( DebugLogger.log(
'Server configs saved (${configs.length} entries)', 'Server configs saved (${configs.length} entries)',
scope: 'storage/optimized', scope: 'storage/optimized',
@@ -215,6 +219,9 @@ class OptimizedStorageService {
if (jsonString == null || jsonString.isEmpty) { if (jsonString == null || jsonString.isEmpty) {
_cache['server_config_count'] = 0; _cache['server_config_count'] = 0;
_cacheTimestamps['server_config_count'] = DateTime.now(); _cacheTimestamps['server_config_count'] = DateTime.now();
SelfSignedCertificateManager.instance
..ensureInitialized()
..clearTrustedServers();
return const []; return const [];
} }
@@ -224,12 +231,16 @@ class OptimizedStorageService {
.toList(); .toList();
_cache['server_config_count'] = configs.length; _cache['server_config_count'] = configs.length;
_cacheTimestamps['server_config_count'] = DateTime.now(); _cacheTimestamps['server_config_count'] = DateTime.now();
SelfSignedCertificateManager.instance
..ensureInitialized()
..updateTrustedServers(configs);
return configs; return configs;
} catch (error) { } catch (error) {
DebugLogger.log( DebugLogger.log(
'Failed to retrieve server configs: $error', 'Failed to retrieve server configs: $error',
scope: 'storage/optimized', scope: 'storage/optimized',
); );
SelfSignedCertificateManager.instance.clearTrustedServers();
return const []; return const [];
} }
} }

View File

@@ -234,7 +234,9 @@ class PersistentStreamingService with WidgetsBindingObserver {
void unregisterStream(String streamId, {bool saveForRecovery = false}) { void unregisterStream(String streamId, {bool saveForRecovery = false}) {
// If app is in background and stream is unregistering, it might be due to // If app is in background and stream is unregistering, it might be due to
// network interruption - save state for recovery instead of just dropping it // network interruption - save state for recovery instead of just dropping it
if (_isInBackground && !saveForRecovery && _streamMetadata.containsKey(streamId)) { if (_isInBackground &&
!saveForRecovery &&
_streamMetadata.containsKey(streamId)) {
DebugLogger.stream( DebugLogger.stream(
'PersistentStreamingService: Stream $streamId interrupted in background, saving for recovery', 'PersistentStreamingService: Stream $streamId interrupted in background, saving for recovery',
); );

View File

@@ -0,0 +1,23 @@
import '../models/server_config.dart';
import 'self_signed_certificate_manager_io.dart'
if (dart.library.html) 'self_signed_certificate_manager_stub.dart'
as platform;
/// Coordinates opt-in trust for self-signed TLS certificates.
///
/// On IO platforms we install an [HttpOverrides] that whitelists the servers
/// flagged in [ServerConfig.allowSelfSignedCertificates]. On web platforms the
/// helpers are no-ops because browsers manage TLS validation themselves.
class SelfSignedCertificateManager {
const SelfSignedCertificateManager._();
static const SelfSignedCertificateManager instance =
SelfSignedCertificateManager._();
void ensureInitialized() => platform.ensureInitialized();
void updateTrustedServers(Iterable<ServerConfig> configs) =>
platform.updateTrustedServers(configs);
void clearTrustedServers() => platform.clearTrustedServers();
}

View File

@@ -0,0 +1,117 @@
import 'dart:io';
import '../models/server_config.dart';
final _IoSelfSignedCertificateManager _manager =
_IoSelfSignedCertificateManager();
void ensureInitialized() => _manager.ensureInitialized();
void updateTrustedServers(Iterable<ServerConfig> configs) =>
_manager.updateTrustedServers(configs);
void clearTrustedServers() => _manager.clearTrustedServers();
class _IoSelfSignedCertificateManager {
_IoSelfSignedCertificateManager();
_ConduitHttpOverrides? _overrides;
void ensureInitialized() {
if (_overrides != null) return;
final overrides = _ConduitHttpOverrides();
HttpOverrides.global = overrides;
_overrides = overrides;
}
void updateTrustedServers(Iterable<ServerConfig> configs) {
ensureInitialized();
_overrides?.updateTrustedServers(configs);
}
void clearTrustedServers() {
_overrides?.clearTrustedServers();
}
}
class _ConduitHttpOverrides extends HttpOverrides {
final Set<_TrustedEndpoint> _trustedEndpoints = {};
void updateTrustedServers(Iterable<ServerConfig> configs) {
_trustedEndpoints
..clear()
..addAll(
configs
.where((config) => config.allowSelfSignedCertificates)
.map((config) => _TrustedEndpoint.fromUrl(config.url))
.whereType<_TrustedEndpoint>(),
);
}
void clearTrustedServers() {
_trustedEndpoints.clear();
}
bool _shouldTrust(String host, int port) {
for (final endpoint in _trustedEndpoints) {
if (endpoint.matches(host, port)) {
return true;
}
}
return false;
}
@override
HttpClient createHttpClient(SecurityContext? context) {
final client = super.createHttpClient(context);
client.badCertificateCallback =
(X509Certificate cert, String host, int port) =>
_shouldTrust(host, port);
return client;
}
}
class _TrustedEndpoint {
const _TrustedEndpoint({required this.host, this.port});
final String host;
final int? port;
static _TrustedEndpoint? fromUrl(String url) {
final uri = _normalizeUrl(url);
if (uri == null || uri.host.isEmpty) {
return null;
}
final normalizedHost = uri.host.toLowerCase();
final normalizedPort = uri.hasPort ? uri.port : null;
return _TrustedEndpoint(host: normalizedHost, port: normalizedPort);
}
static Uri? _normalizeUrl(String value) {
if (value.trim().isEmpty) {
return null;
}
Uri? parsed = Uri.tryParse(value.trim());
if (parsed == null) {
return null;
}
if (!parsed.hasScheme) {
parsed =
Uri.tryParse('https://${value.trim()}') ??
Uri.tryParse('http://${value.trim()}');
}
return parsed;
}
bool matches(String otherHost, int otherPort) {
final normalizedHost = otherHost.toLowerCase();
if (normalizedHost != host) {
return false;
}
if (port == null) {
return true;
}
return port == otherPort;
}
}

View File

@@ -0,0 +1,7 @@
import '../models/server_config.dart';
void ensureInitialized() {}
void updateTrustedServers(Iterable<ServerConfig> configs) {}
void clearTrustedServers() {}

View File

@@ -107,16 +107,22 @@ ActiveSocketStream attachUnifiedChunkedStreaming({
persistentController.add(data); persistentController.add(data);
}, },
onDone: () async { onDone: () async {
DebugLogger.stream('Source stream onDone fired, hasReceivedData=$hasReceivedData'); DebugLogger.stream(
'Source stream onDone fired, hasReceivedData=$hasReceivedData',
);
// If stream closes immediately without data, it's likely due to backgrounding/network drop // If stream closes immediately without data, it's likely due to backgrounding/network drop
// Not a natural completion // Not a natural completion
if (!hasReceivedData) { if (!hasReceivedData) {
DebugLogger.stream('Stream closed without data - likely interrupted, not completing'); DebugLogger.stream(
'Stream closed without data - likely interrupted, not completing',
);
// Check if app is backgrounding - if so, finish streaming with whatever we have // Check if app is backgrounding - if so, finish streaming with whatever we have
await Future.delayed(const Duration(milliseconds: 300)); await Future.delayed(const Duration(milliseconds: 300));
if (persistentService.isInBackground) { if (persistentService.isInBackground) {
DebugLogger.stream('App backgrounding during stream - finishing with current content'); DebugLogger.stream(
'App backgrounding during stream - finishing with current content',
);
finishStreaming(); finishStreaming();
} }
// Don't close the controller to prevent cascading completion handlers // Don't close the controller to prevent cascading completion handlers
@@ -127,14 +133,20 @@ ActiveSocketStream attachUnifiedChunkedStreaming({
await Future.delayed(const Duration(milliseconds: 500)); await Future.delayed(const Duration(milliseconds: 500));
final isInBg = persistentService.isInBackground; final isInBg = persistentService.isInBackground;
DebugLogger.stream('Stream onDone check: streamId=$streamId, isInBackground=$isInBg'); DebugLogger.stream(
'Stream onDone check: streamId=$streamId, isInBackground=$isInBg',
);
// Check if we're in background before closing // Check if we're in background before closing
if (!isInBg) { if (!isInBg) {
DebugLogger.stream('Closing stream controller for $streamId (foreground completion)'); DebugLogger.stream(
'Closing stream controller for $streamId (foreground completion)',
);
persistentController.close(); persistentController.close();
} else { } else {
DebugLogger.stream('Source stream completed in background for $streamId - keeping open for recovery'); DebugLogger.stream(
'Source stream completed in background for $streamId - keeping open for recovery',
);
// Finish streaming to save the content we have // Finish streaming to save the content we have
finishStreaming(); finishStreaming();
} }
@@ -417,7 +429,8 @@ ActiveSocketStream attachUnifiedChunkedStreaming({
if (name is String && name.isNotEmpty) { if (name is String && name.isNotEmpty) {
final msgs = getMessages(); final msgs = getMessages();
// Quick string check before expensive regex // Quick string check before expensive regex
final exists = (msgs.isNotEmpty) && final exists =
(msgs.isNotEmpty) &&
msgs.last.content.contains('name="$name"'); msgs.last.content.contains('name="$name"');
if (!exists) { if (!exists) {
final status = final status =
@@ -519,7 +532,8 @@ ActiveSocketStream attachUnifiedChunkedStreaming({
if (name is String && name.isNotEmpty) { if (name is String && name.isNotEmpty) {
final msgs = getMessages(); final msgs = getMessages();
// Quick string check before expensive regex // Quick string check before expensive regex
final exists = (msgs.isNotEmpty) && final exists =
(msgs.isNotEmpty) &&
msgs.last.content.contains('name="$name"'); msgs.last.content.contains('name="$name"');
if (!exists) { if (!exists) {
final status = final status =
@@ -549,7 +563,8 @@ ActiveSocketStream attachUnifiedChunkedStreaming({
if (name is String && name.isNotEmpty) { if (name is String && name.isNotEmpty) {
final msgs = getMessages(); final msgs = getMessages();
// Quick string check before expensive regex // Quick string check before expensive regex
final exists = (msgs.isNotEmpty) && final exists =
(msgs.isNotEmpty) &&
msgs.last.content.contains('name="$name"'); msgs.last.content.contains('name="$name"');
if (!exists) { if (!exists) {
final status = final status =

View File

@@ -36,6 +36,7 @@ class _ServerConnectionPageState extends ConsumerState<ServerConnectionPage> {
String? _connectionError; String? _connectionError;
bool _isConnecting = false; bool _isConnecting = false;
bool _showAdvancedSettings = false; bool _showAdvancedSettings = false;
bool _allowSelfSignedCertificates = false;
@override @override
void initState() { void initState() {
@@ -45,9 +46,11 @@ class _ServerConnectionPageState extends ConsumerState<ServerConnectionPage> {
Future<void> _prefillFromState() async { Future<void> _prefillFromState() async {
final activeServer = await ref.read(activeServerProvider.future); final activeServer = await ref.read(activeServerProvider.future);
if (activeServer != null) { if (!mounted || activeServer == null) return;
setState(() {
_urlController.text = activeServer.url; _urlController.text = activeServer.url;
} _allowSelfSignedCertificates = activeServer.allowSelfSignedCertificates;
});
} }
@override @override
@@ -75,6 +78,7 @@ class _ServerConnectionPageState extends ConsumerState<ServerConnectionPage> {
url: url, url: url,
customHeaders: Map<String, String>.from(_customHeaders), customHeaders: Map<String, String>.from(_customHeaders),
isActive: true, isActive: true,
allowSelfSignedCertificates: _allowSelfSignedCertificates,
); );
final api = ApiService(serverConfig: tempConfig); final api = ApiService(serverConfig: tempConfig);
@@ -536,6 +540,69 @@ class _ServerConnectionPageState extends ConsumerState<ServerConnectionPage> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Container(
width: double.infinity,
padding: const EdgeInsets.all(Spacing.md),
margin: const EdgeInsets.only(bottom: Spacing.md),
decoration: BoxDecoration(
color: context.conduitTheme.surfaceContainer.withValues(
alpha: 0.3,
),
borderRadius: BorderRadius.circular(AppBorderRadius.small),
border: Border.all(
color: context.conduitTheme.dividerColor.withValues(alpha: 0.4),
width: BorderWidth.thin,
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Platform.isIOS
? CupertinoIcons.lock_shield
: Icons.verified_user,
color: context.conduitTheme.iconSecondary,
size: IconSize.small,
),
const SizedBox(width: Spacing.sm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppLocalizations.of(
context,
)!.allowSelfSignedCertificates,
style: context.conduitTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w600,
color: context.conduitTheme.textPrimary,
),
),
const SizedBox(height: Spacing.xs),
Text(
AppLocalizations.of(
context,
)!.allowSelfSignedCertificatesDescription,
style: context.conduitTheme.bodySmall?.copyWith(
color: context.conduitTheme.textSecondary,
),
),
],
),
),
const SizedBox(width: Spacing.sm),
Switch.adaptive(
value: _allowSelfSignedCertificates,
onChanged: (value) {
setState(() {
_allowSelfSignedCertificates = value;
});
},
activeTrackColor: context.conduitTheme.buttonPrimary,
),
],
),
),
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [

View File

@@ -17,8 +17,7 @@ class VoiceCallNotificationService {
// Notification IDs and channels // Notification IDs and channels
static const String _channelId = 'voice_call_channel'; static const String _channelId = 'voice_call_channel';
static const String _channelName = 'Voice Call'; static const String _channelName = 'Voice Call';
static const String _channelDescription = static const String _channelDescription = 'Ongoing voice call notifications';
'Ongoing voice call notifications';
static const int _notificationId = 2001; static const int _notificationId = 2001;
// Action IDs // Action IDs
@@ -32,7 +31,9 @@ class VoiceCallNotificationService {
Future<void> initialize() async { Future<void> initialize() async {
if (_initialized) return; if (_initialized) return;
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher'); const androidSettings = AndroidInitializationSettings(
'@mipmap/ic_launcher',
);
const iosSettings = DarwinInitializationSettings( const iosSettings = DarwinInitializationSettings(
requestAlertPermission: false, requestAlertPermission: false,
requestBadgePermission: false, requestBadgePermission: false,
@@ -72,7 +73,8 @@ class VoiceCallNotificationService {
await _notifications await _notifications
.resolvePlatformSpecificImplementation< .resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>() AndroidFlutterLocalNotificationsPlugin
>()
?.createNotificationChannel(androidChannel); ?.createNotificationChannel(androidChannel);
} }
@@ -190,8 +192,10 @@ class VoiceCallNotificationService {
/// Check if notifications are enabled /// Check if notifications are enabled
Future<bool> areNotificationsEnabled() async { Future<bool> areNotificationsEnabled() async {
if (Platform.isAndroid) { if (Platform.isAndroid) {
final androidImpl = _notifications.resolvePlatformSpecificImplementation< final androidImpl = _notifications
AndroidFlutterLocalNotificationsPlugin>(); .resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>();
return await androidImpl?.areNotificationsEnabled() ?? false; return await androidImpl?.areNotificationsEnabled() ?? false;
} else if (Platform.isIOS) { } else if (Platform.isIOS) {
// iOS doesn't have a direct check, assume enabled if initialized // iOS doesn't have a direct check, assume enabled if initialized
@@ -203,13 +207,17 @@ class VoiceCallNotificationService {
/// Request notification permissions /// Request notification permissions
Future<bool> requestPermissions() async { Future<bool> requestPermissions() async {
if (Platform.isAndroid) { if (Platform.isAndroid) {
final androidImpl = _notifications.resolvePlatformSpecificImplementation< final androidImpl = _notifications
AndroidFlutterLocalNotificationsPlugin>(); .resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>();
final granted = await androidImpl?.requestNotificationsPermission(); final granted = await androidImpl?.requestNotificationsPermission();
return granted ?? false; return granted ?? false;
} else if (Platform.isIOS) { } else if (Platform.isIOS) {
final iosImpl = _notifications final iosImpl = _notifications
.resolvePlatformSpecificImplementation<IOSFlutterLocalNotificationsPlugin>(); .resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin
>();
final granted = await iosImpl?.requestPermissions( final granted = await iosImpl?.requestPermissions(
alert: true, alert: true,
badge: false, badge: false,

View File

@@ -148,9 +148,8 @@ class _VoiceCallPageState extends ConsumerState<VoiceCallPage>
icon: const Icon(CupertinoIcons.xmark), icon: const Icon(CupertinoIcons.xmark),
onPressed: () async { onPressed: () async {
await _service?.stopCall(); await _service?.stopCall();
if (mounted) { if (!context.mounted) return;
Navigator.of(context).pop(); Navigator.of(context).pop();
}
}, },
), ),
), ),

View File

@@ -1480,9 +1480,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
), ),
child: Center( child: Center(
child: Icon( child: Icon(
Platform.isIOS Platform.isIOS ? CupertinoIcons.waveform : Icons.graphic_eq,
? CupertinoIcons.waveform
: Icons.graphic_eq,
size: IconSize.large, size: IconSize.large,
color: enabledVoiceCall color: enabledVoiceCall
? context.conduitTheme.buttonPrimaryText ? context.conduitTheme.buttonPrimaryText

View File

@@ -259,6 +259,14 @@
"advancedSettings": "Erweiterte Einstellungen", "advancedSettings": "Erweiterte Einstellungen",
"customHeaders": "Benutzerdefinierte Header", "customHeaders": "Benutzerdefinierte Header",
"customHeadersDescription": "Füge benutzerdefinierte HTTP-Header für Authentifizierung, API-Schlüssel oder spezielle Serveranforderungen hinzu.", "customHeadersDescription": "Füge benutzerdefinierte HTTP-Header für Authentifizierung, API-Schlüssel oder spezielle Serveranforderungen hinzu.",
"allowSelfSignedCertificates": "Selbstsignierten Zertifikaten vertrauen",
"@allowSelfSignedCertificates": {
"description": "Schalterbeschriftung zum Zulassen selbstsignierter TLS-Zertifikate für den konfigurierten Server."
},
"allowSelfSignedCertificatesDescription": "Akzeptiere das TLS-Zertifikat dieses Servers auch dann, wenn es selbstsigniert ist. Aktiviere diese Option nur für Server, denen du vertraust.",
"@allowSelfSignedCertificatesDescription": {
"description": "Hilfetext, der die Risiken beim Aktivieren des Schalters für selbstsignierte Zertifikate erklärt."
},
"headerNameEmpty": "Header-Name darf nicht leer sein", "headerNameEmpty": "Header-Name darf nicht leer sein",
"headerNameTooLong": "Header-Name zu lang (max. 64 Zeichen)", "headerNameTooLong": "Header-Name zu lang (max. 64 Zeichen)",
"headerNameInvalidChars": "Ungültiger Header-Name. Verwende nur Buchstaben, Zahlen und diese Zeichen: !#$&-^_`|~", "headerNameInvalidChars": "Ungültiger Header-Name. Verwende nur Buchstaben, Zahlen und diese Zeichen: !#$&-^_`|~",

View File

@@ -550,6 +550,14 @@
"@customHeaders": {"description": "Section title for adding custom HTTP headers."}, "@customHeaders": {"description": "Section title for adding custom HTTP headers."},
"customHeadersDescription": "Add custom HTTP headers for authentication, API keys, or special server requirements.", "customHeadersDescription": "Add custom HTTP headers for authentication, API keys, or special server requirements.",
"@customHeadersDescription": {"description": "Helper text explaining use-cases for custom headers."}, "@customHeadersDescription": {"description": "Helper text explaining use-cases for custom headers."},
"allowSelfSignedCertificates": "Trust self-signed certificates",
"@allowSelfSignedCertificates": {
"description": "Toggle label that allows trusting self-signed TLS certificates for the configured server."
},
"allowSelfSignedCertificatesDescription": "Accept this server's TLS certificate even if it is self-signed. Enable only for servers you trust.",
"@allowSelfSignedCertificatesDescription": {
"description": "Helper text clarifying the risks of enabling the self-signed certificate toggle."
},
"headerNameEmpty": "Header name cannot be empty", "headerNameEmpty": "Header name cannot be empty",
"@headerNameEmpty": {"description": "Validation message for empty header name."}, "@headerNameEmpty": {"description": "Validation message for empty header name."},
"headerNameTooLong": "Header name too long (max 64 characters)", "headerNameTooLong": "Header name too long (max 64 characters)",

View File

@@ -259,6 +259,14 @@
"advancedSettings": "Paramètres avancés", "advancedSettings": "Paramètres avancés",
"customHeaders": "En-têtes personnalisés", "customHeaders": "En-têtes personnalisés",
"customHeadersDescription": "Ajoutez des en-têtes HTTP personnalisés pour l'authentification, les clés API ou des exigences spécifiques du serveur.", "customHeadersDescription": "Ajoutez des en-têtes HTTP personnalisés pour l'authentification, les clés API ou des exigences spécifiques du serveur.",
"allowSelfSignedCertificates": "Faire confiance aux certificats auto-signés",
"@allowSelfSignedCertificates": {
"description": "Libellé du commutateur permettant d'autoriser les certificats TLS auto-signés pour le serveur configuré."
},
"allowSelfSignedCertificatesDescription": "Acceptez le certificat TLS de ce serveur même s'il est auto-signé. Activez cette option uniquement pour les serveurs auxquels vous faites confiance.",
"@allowSelfSignedCertificatesDescription": {
"description": "Texte d'aide expliquant les risques liés à l'activation de l'option de certificat auto-signé."
},
"headerNameEmpty": "Le nom de l'en-tête ne peut pas être vide", "headerNameEmpty": "Le nom de l'en-tête ne peut pas être vide",
"headerNameTooLong": "Nom d'en-tête trop long (max 64 caractères)", "headerNameTooLong": "Nom d'en-tête trop long (max 64 caractères)",
"headerNameInvalidChars": "Nom d'en-tête invalide. Utilisez uniquement des lettres, des chiffres et ces symboles : !#$&-^_`|~", "headerNameInvalidChars": "Nom d'en-tête invalide. Utilisez uniquement des lettres, des chiffres et ces symboles : !#$&-^_`|~",

View File

@@ -259,6 +259,14 @@
"advancedSettings": "Impostazioni avanzate", "advancedSettings": "Impostazioni avanzate",
"customHeaders": "Header personalizzati", "customHeaders": "Header personalizzati",
"customHeadersDescription": "Aggiungi header HTTP personalizzati per autenticazione, chiavi API o requisiti speciali del server.", "customHeadersDescription": "Aggiungi header HTTP personalizzati per autenticazione, chiavi API o requisiti speciali del server.",
"allowSelfSignedCertificates": "Considera attendibili i certificati autofirmati",
"@allowSelfSignedCertificates": {
"description": "Etichetta dell'interruttore che consente i certificati TLS autofirmati per il server configurato."
},
"allowSelfSignedCertificatesDescription": "Accetta il certificato TLS di questo server anche se è autofirmato. Attiva questa opzione solo per server di cui ti fidi.",
"@allowSelfSignedCertificatesDescription": {
"description": "Testo di supporto che chiarisce i rischi dell'attivazione dell'opzione per certificati autofirmati."
},
"headerNameEmpty": "Il nome header non può essere vuoto", "headerNameEmpty": "Il nome header non può essere vuoto",
"headerNameTooLong": "Nome header troppo lungo (max 64 caratteri)", "headerNameTooLong": "Nome header troppo lungo (max 64 caratteri)",
"headerNameInvalidChars": "Nome header non valido. Usa solo lettere, numeri e questi simboli: !#$&-^_`|~", "headerNameInvalidChars": "Nome header non valido. Usa solo lettere, numeri e questi simboli: !#$&-^_`|~",

View File

@@ -1482,6 +1482,18 @@ abstract class AppLocalizations {
/// **'Add custom HTTP headers for authentication, API keys, or special server requirements.'** /// **'Add custom HTTP headers for authentication, API keys, or special server requirements.'**
String get customHeadersDescription; String get customHeadersDescription;
/// Toggle label that allows trusting self-signed TLS certificates for the configured server.
///
/// In en, this message translates to:
/// **'Trust self-signed certificates'**
String get allowSelfSignedCertificates;
/// Helper text clarifying the risks of enabling the self-signed certificate toggle.
///
/// In en, this message translates to:
/// **'Accept this server\'s TLS certificate even if it is self-signed. Enable only for servers you trust.'**
String get allowSelfSignedCertificatesDescription;
/// Validation message for empty header name. /// Validation message for empty header name.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View File

@@ -760,6 +760,14 @@ class AppLocalizationsDe extends AppLocalizations {
String get customHeadersDescription => String get customHeadersDescription =>
'Füge benutzerdefinierte HTTP-Header für Authentifizierung, API-Schlüssel oder spezielle Serveranforderungen hinzu.'; 'Füge benutzerdefinierte HTTP-Header für Authentifizierung, API-Schlüssel oder spezielle Serveranforderungen hinzu.';
@override
String get allowSelfSignedCertificates =>
'Selbstsignierten Zertifikaten vertrauen';
@override
String get allowSelfSignedCertificatesDescription =>
'Akzeptiere das TLS-Zertifikat dieses Servers auch dann, wenn es selbstsigniert ist. Aktiviere diese Option nur für Server, denen du vertraust.';
@override @override
String get headerNameEmpty => 'Header-Name darf nicht leer sein'; String get headerNameEmpty => 'Header-Name darf nicht leer sein';

View File

@@ -754,6 +754,13 @@ class AppLocalizationsEn extends AppLocalizations {
String get customHeadersDescription => String get customHeadersDescription =>
'Add custom HTTP headers for authentication, API keys, or special server requirements.'; 'Add custom HTTP headers for authentication, API keys, or special server requirements.';
@override
String get allowSelfSignedCertificates => 'Trust self-signed certificates';
@override
String get allowSelfSignedCertificatesDescription =>
'Accept this server\'s TLS certificate even if it is self-signed. Enable only for servers you trust.';
@override @override
String get headerNameEmpty => 'Header name cannot be empty'; String get headerNameEmpty => 'Header name cannot be empty';

View File

@@ -765,6 +765,14 @@ class AppLocalizationsFr extends AppLocalizations {
String get customHeadersDescription => String get customHeadersDescription =>
'Ajoutez des en-têtes HTTP personnalisés pour l\'authentification, les clés API ou des exigences spécifiques du serveur.'; 'Ajoutez des en-têtes HTTP personnalisés pour l\'authentification, les clés API ou des exigences spécifiques du serveur.';
@override
String get allowSelfSignedCertificates =>
'Faire confiance aux certificats auto-signés';
@override
String get allowSelfSignedCertificatesDescription =>
'Acceptez le certificat TLS de ce serveur même s\'il est auto-signé. Activez cette option uniquement pour les serveurs auxquels vous faites confiance.';
@override @override
String get headerNameEmpty => 'Le nom de l\'en-tête ne peut pas être vide'; String get headerNameEmpty => 'Le nom de l\'en-tête ne peut pas être vide';

View File

@@ -756,6 +756,14 @@ class AppLocalizationsIt extends AppLocalizations {
String get customHeadersDescription => String get customHeadersDescription =>
'Aggiungi header HTTP personalizzati per autenticazione, chiavi API o requisiti speciali del server.'; 'Aggiungi header HTTP personalizzati per autenticazione, chiavi API o requisiti speciali del server.';
@override
String get allowSelfSignedCertificates =>
'Considera attendibili i certificati autofirmati';
@override
String get allowSelfSignedCertificatesDescription =>
'Accetta il certificato TLS di questo server anche se è autofirmato. Attiva questa opzione solo per server di cui ti fidi.';
@override @override
String get headerNameEmpty => 'Il nome header non può essere vuoto'; String get headerNameEmpty => 'Il nome header non può essere vuoto';

View File

@@ -11,6 +11,7 @@ import 'core/providers/app_providers.dart';
import 'core/persistence/hive_bootstrap.dart'; import 'core/persistence/hive_bootstrap.dart';
import 'core/persistence/persistence_migrator.dart'; import 'core/persistence/persistence_migrator.dart';
import 'core/persistence/persistence_providers.dart'; import 'core/persistence/persistence_providers.dart';
import 'core/services/self_signed_certificate_manager.dart';
import 'core/router/app_router.dart'; import 'core/router/app_router.dart';
import 'shared/widgets/offline_indicator.dart'; import 'shared/widgets/offline_indicator.dart';
import 'features/auth/providers/unified_auth_providers.dart'; import 'features/auth/providers/unified_auth_providers.dart';
@@ -29,6 +30,8 @@ void main() {
() async { () async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
SelfSignedCertificateManager.instance.ensureInitialized();
// Global error handlers // Global error handlers
FlutterError.onError = (FlutterErrorDetails details) { FlutterError.onError = (FlutterErrorDetails details) {
DebugLogger.error( DebugLogger.error(

View File

@@ -727,7 +727,9 @@ class _DetailsBlockSyntax extends md.BlockSyntax {
} }
// Check for summary tag // Check for summary tag
final summaryMatch = RegExp(r'^<summary>(.*?)<\/summary>$').firstMatch(line); final summaryMatch = RegExp(
r'^<summary>(.*?)<\/summary>$',
).firstMatch(line);
if (summaryMatch != null) { if (summaryMatch != null) {
summary = summaryMatch.group(1) ?? ''; summary = summaryMatch.group(1) ?? '';
parser.advance(); parser.advance();

View File

@@ -13,10 +13,7 @@ class ConduitMarkdownPreprocessor {
r'^[ \t]+```([^\n`]*)\s*$', r'^[ \t]+```([^\n`]*)\s*$',
multiLine: true, multiLine: true,
); );
static final _dedentCloseRegex = RegExp( static final _dedentCloseRegex = RegExp(r'^[ \t]+```\s*$', multiLine: true);
r'^[ \t]+```\s*$',
multiLine: true,
);
static final _inlineClosingRegex = RegExp(r'([^\r\n`])```(?=\s*(?:\r?\n|$))'); static final _inlineClosingRegex = RegExp(r'([^\r\n`])```(?=\s*(?:\r?\n|$))');
static final _labelThenDashRegex = RegExp( static final _labelThenDashRegex = RegExp(
r'^(\*\*[^\n*]+\*\*.*)\n(\s*-{3,}\s*$)', r'^(\*\*[^\n*]+\*\*.*)\n(\s*-{3,}\s*$)',
@@ -45,7 +42,10 @@ class ConduitMarkdownPreprocessor {
// Dedent opening fences to avoid partial code-block detection when the // Dedent opening fences to avoid partial code-block detection when the
// model indents fences by accident. // model indents fences by accident.
output = output.replaceAllMapped(_dedentOpenRegex, (match) => '```${match[1]}'); output = output.replaceAllMapped(
_dedentOpenRegex,
(match) => '```${match[1]}',
);
// Dedent closing fences for the same reason as the opening fences. // Dedent closing fences for the same reason as the opening fences.
output = output.replaceAllMapped(_dedentCloseRegex, (_) => '```'); output = output.replaceAllMapped(_dedentCloseRegex, (_) => '```');