@tduncan / Cortana / commits / fbb7da4

feat: implement end-to-end encrypted S3 cloud synchronization with associated services and UI

abdulmominsakib committed Jul 11, 2026 at 23:16 UTC fbb7da4b3fddfcd68c5138b09052d6068042b1df
52 files changed +4858 -110
README.md
+1 -1
@@ -104,7 +104,7 @@ LocalMind is rapidly evolving. Here is what's coming next:
104 - [x] **Export Options**: Export your chats to Markdown or PDF to share or archive.
105 - [ ] **Quick Shortcuts**: OS-level widgets and shortcuts for launching new chats instantly.
106 - [ ] **Voice-to-Voice Mode**: Engage in real-time, low-latency spoken conversations with local models.
107 -- [ ] **S3 Cloud Sync**: End-to-end encrypted cloud sync for settings, custom personas, and chats in your own S3 server.
107 +- [x] **S3 Cloud Sync**: End-to-end encrypted cloud sync for settings, custom personas, and chats in your own S3 server.
108
109 ## Getting Started
110
docs/s3-cloud-sync.md new
+31
@@ -0,0 +1,31 @@
1 +# S3 Cloud Sync
2 +
3 +LocalMind encrypts sync state and attachments on the device before uploading
4 +them. The passphrase is never uploaded. The bucket operator can still observe
5 +object sizes, access times, and the configured object prefix.
6 +
7 +The configured credentials need `GetObject`, `PutObject`, and `DeleteObject`
8 +for the selected prefix. A minimal AWS policy is:
9 +
10 +```json
11 +{
12 + "Version": "2012-10-17",
13 + "Statement": [
14 + {
15 + "Effect": "Allow",
16 + "Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
17 + "Resource": "arn:aws:s3:::YOUR_BUCKET/YOUR_PREFIX/*"
18 + }
19 + ]
20 +}
21 +```
22 +
23 +Use path-style addressing for MinIO and providers that do not support bucket
24 +subdomains. HTTPS is recommended. HTTP is available only as an explicit opt-in
25 +for trusted local-network servers. The server must return ETags and support
26 +conditional `If-Match` and `If-None-Match` writes.
27 +
28 +Sync runs while LocalMind is in the foreground: on launch, on resume, and
29 +shortly after local data changes. Disconnecting clears credentials and key
30 +material from the device but does not delete remote objects. There is no
31 +passphrase recovery; use a new prefix if the passphrase is lost.
lib/app.dart
+6
@@ -30,6 +30,7 @@ import 'features/servers/views/server_list_screen.dart';
30 import 'features/tts/views/tts_model_manager_screen.dart';
31 import 'features/saved_messages/views/saved_messages_screen.dart';
32 import 'features/settings/views/settings_screen.dart';
33 +import 'features/cloud_sync/views/cloud_sync_screen.dart';
34 import 'features/lm_studio_catalog/views/lm_studio_model_browser_screen.dart';
35 import 'features/sidebar/sidebar_drawer.dart';
36 import 'features/sidebar/sidebar_widget.dart';
@@ -138,6 +139,11 @@ final routerProvider = Provider<GoRouter>((ref) {
139 pageBuilder: (context, state) =>
140 const NoTransitionPage(child: SettingsViews()),
141 ),
142 + GoRoute(
143 + path: AppRoutes.cloudSync,
144 + pageBuilder: (context, state) =>
145 + const MaterialPage(child: CloudSyncScreen()),
146 + ),
147 GoRoute(
148 path: AppRoutes.chatHistory,
149 pageBuilder: (context, state) =>
lib/bootstrap/bootstrap_host.dart
+6 -1
@@ -11,8 +11,10 @@ import '../core/providers/storage_providers.dart';
11 import '../core/storage/objectbox_store.dart';
12 import '../features/on_device/data/on_device_gemma_service.dart';
13 import '../core/models/enums.dart';
14 +import '../features/personas/providers/personas_providers.dart';
15 import '../features/servers/data/models/server.dart';
16 import '../features/servers/providers/server_providers.dart';
17 +import '../features/cloud_sync/views/cloud_sync_lifecycle_host.dart';
18 import '../core/logger/app_logger.dart';
19 import '../core/utils/locale_utils.dart';
20 import 'bootstrap_screen.dart';
@@ -49,6 +51,9 @@ class _BootstrapHostState extends State<BootstrapHost> {
51 final prefs = results[1] as SharedPreferences;
52 final database = results[2] as ObjectBoxStore;
53
54 + // Seed built-in personas once before any provider reads the database.
55 + PersonasNotifier.seedIfNeeded(database);
56 +
57 _updateStage(BootstrapStage.preparingApp, 'Preparing app...');
58
59 try {
@@ -132,7 +137,7 @@ class _BootstrapHostState extends State<BootstrapHost> {
137 if (_container != null) {
138 return UncontrolledProviderScope(
139 container: _container!,
135 - child: const App(),
140 + child: const CloudSyncLifecycleHost(child: App()),
141 );
142 }
143
lib/core/routes/app_routes.dart
+1
@@ -11,6 +11,7 @@ class AppRoutes {
11 static const String personas = '/personas';
12 static const String createPersona = '/personas/create';
13 static const String settings = '/settings';
14 + static const String cloudSync = '/settings/cloud-sync';
15 static const String models = '/models';
16 static const String mcpTools = '/mcp-tools';
17 static const String chatHistory = '/history';
lib/core/services/data_backup_service.dart
+236 -3
@@ -9,12 +9,53 @@ import '../models/enums.dart';
9 import '../storage/entities.dart';
10 import 'data_backup_import_helpers.dart';
11 import '../../features/chat/data/models/message.dart';
12 +import '../../features/chat/data/tools/tool_event.dart';
13 import '../../features/conversations/data/models/conversation.dart';
14 import '../../features/personas/data/models/persona.dart';
15 import '../../features/servers/data/models/server.dart';
16 import '../../objectbox.g.dart';
17
18 class DataBackupService {
19 + static const _synchronizedSettingKeys = {
20 + 'temperature',
21 + 'topP',
22 + 'maxTokens',
23 + 'contextLength',
24 + 'themeMode',
25 + 'fontSize',
26 + 'showSystemMessages',
27 + 'hapticFeedbackEnabled',
28 + 'sendOnEnter',
29 + 'showDataIndicator',
30 + 'autoGenerateTitle',
31 + 'streamingEnabled',
32 + 'defaultPersonaId',
33 + 'mcpEnabled',
34 + 'newChatMcpEnabled',
35 + 'codeThemeDark',
36 + 'codeThemeLight',
37 + 'preferredBackend',
38 + 'ttsEngine',
39 + 'ttsVoiceId',
40 + 'ttsSpeed',
41 + 'kittenTtsModelVariant',
42 + 'autoSpeakEnabled',
43 + 'ttsProcessMarkdown',
44 + 'ttsSkipSeconds',
45 + 'smartReplyEnabled',
46 + 'aiUserResponseEnabled',
47 + 'localeCode',
48 + 'unloadModelsBeforeLoad',
49 + 'tempChatKeyboardIncognito',
50 + 'resumeLastChat',
51 + 'imageCompressionEnabled',
52 + 'imageCompressionLevel',
53 + 'smartRepliesUsePersona',
54 + 'keepPersonaOnNewChat',
55 + 'roleSwapButtonEnabled',
56 + 'showSystemMessagesInChat',
57 + };
58 +
59 Map<String, dynamic> _messageToMap(Message message) => {
60 'id': message.id,
61 'conversationId': message.conversationId,
@@ -24,18 +65,25 @@ class DataBackupService {
65 'status': message.status.name,
66 'modelId': message.modelId,
67 'tokenCount': message.tokenCount,
68 + 'inputTokenCount': message.inputTokenCount,
69 + 'tokensPerSecond': message.tokensPerSecond,
70 + 'ttftMs': message.ttftMs,
71 + 'stopReason': message.stopReason,
72 'errorMessage': message.errorMessage,
73 'attachmentPaths': message.attachmentPaths,
74 'generationTimeMs': message.generationTimeMs,
75 'reasoningContent': message.reasoningContent,
76 + 'toolCalls': message.toolCalls?.map((e) => e.toMap()).toList(),
77 'toolCallId': message.toolCallId,
78 'isProcessing': message.isProcessing,
79 'toolSessionId': message.toolSessionId,
80 + 'toolEvents': message.toolEvents?.map((e) => e.toMap()).toList(),
81 'variantGroupId': message.variantGroupId,
82 'variantIndex': message.variantIndex,
83 'threadOrder': message.threadOrder,
84 'isActiveVariant': message.isActiveVariant,
85 'parentMessageId': message.parentMessageId,
86 + 'contentTokenCount': message.contentTokenCount,
87 };
88
89 Map<String, dynamic> _conversationToMap(Conversation domain) => {
@@ -201,6 +249,87 @@ class DataBackupService {
249 };
250 }
251
252 + Map<String, dynamic> exportCloudSync(Store store, String settingsJson) {
253 + final all = exportAll(store);
254 + final persistentConversations = (all['conversations'] as List)
255 + .where((item) => (item as Map)['isTemporary'] != true)
256 + .cast<Map<String, dynamic>>()
257 + .toList();
258 + final conversationIds = persistentConversations
259 + .map((item) => item['id'] as String)
260 + .toSet();
261 + final allSettings = Map<String, dynamic>.from(
262 + jsonDecode(settingsJson) as Map,
263 + );
264 + final settings = <String, dynamic>{
265 + for (final entry in allSettings.entries)
266 + if (_synchronizedSettingKeys.contains(entry.key))
267 + entry.key: entry.value,
268 + };
269 + return {
270 + 'version': 1,
271 + 'type': 'cloudSync',
272 + 'settings': settings,
273 + 'conversations': persistentConversations,
274 + 'messages': (all['messages'] as List)
275 + .where(
276 + (item) => conversationIds.contains((item as Map)['conversationId']),
277 + )
278 + .toList(),
279 + 'personas': all['personas'],
280 + 'savedMessages': all['savedMessages'],
281 + 'savedMessageFolders': all['savedMessageFolders'],
282 + 'conversationFolders': all['conversationFolders'],
283 + };
284 + }
285 +
286 + Future<void> importCloudSync(
287 + Store store,
288 + Map<String, dynamic> payload,
289 + ) async {
290 + _validateCloudPayload(payload);
291 + await importFromJson(store, jsonEncode(payload), replaceCloudData: true);
292 + }
293 +
294 + void _validateCloudPayload(Map<String, dynamic> payload) {
295 + const collections = {
296 + 'conversations',
297 + 'messages',
298 + 'personas',
299 + 'savedMessages',
300 + 'savedMessageFolders',
301 + 'conversationFolders',
302 + };
303 + if (payload['version'] != 1 ||
304 + payload['type'] != 'cloudSync' ||
305 + payload['settings'] is! Map ||
306 + !(payload['settings'] as Map).keys.every(
307 + (key) => key is String && _synchronizedSettingKeys.contains(key),
308 + )) {
309 + throw const FormatException('Invalid cloud sync payload');
310 + }
311 + for (final collection in collections) {
312 + final records = payload[collection];
313 + if (records is! List ||
314 + !records.every(
315 + (record) =>
316 + record is Map &&
317 + record['id'] is String &&
318 + (record['id'] as String).isNotEmpty,
319 + )) {
320 + throw FormatException('Invalid cloud sync collection: $collection');
321 + }
322 + }
323 + if ((payload['conversations'] as List).any(
324 + (record) => (record as Map)['isTemporary'] == true,
325 + ) ||
326 + (payload['personas'] as List).any(
327 + (record) => (record as Map)['isBuiltIn'] == true,
328 + )) {
329 + throw const FormatException('Cloud payload contains excluded records');
330 + }
331 + }
332 +
333 Map<String, dynamic> exportConversations(Store store) {
334 final all = exportAll(store);
335 return {
@@ -377,7 +506,11 @@ class DataBackupService {
506 ).convert(exportAll(store, prefs: prefs));
507 }
508
380 - Future<void> importFromJson(Store store, String jsonString) async {
509 + Future<void> importFromJson(
510 + Store store,
511 + String jsonString, {
512 + bool replaceCloudData = false,
513 + }) async {
514 final decoded = jsonDecode(jsonString);
515 if (decoded is! Map<String, dynamic>) {
516 throw FormatException('Invalid backup format');
@@ -392,7 +525,7 @@ class DataBackupService {
525 final attachmentsDir = p.normalize(p.join(appDir.path, 'attachments'));
526
527 await store.runInTransactionAsync(TxMode.write, (store, data) {
395 - final (decoded, attachmentsDir) = data;
528 + final (decoded, attachmentsDir, replaceCloudData) = data;
529 final convBox = store.box<ConversationEntity>();
530 final messageBox = store.box<MessageEntity>();
531 final personaBox = store.box<PersonaEntity>();
@@ -642,6 +775,10 @@ class DataBackupService {
775 ),
776 modelId: item['modelId'] as String?,
777 tokenCount: item['tokenCount'] as int?,
778 + inputTokenCount: item['inputTokenCount'] as int?,
779 + tokensPerSecond: (item['tokensPerSecond'] as num?)?.toDouble(),
780 + ttftMs: item['ttftMs'] as int?,
781 + stopReason: item['stopReason'] as String?,
782 errorMessage: item['errorMessage'] as String?,
783 attachmentPaths: _sanitizedAttachmentPaths(
784 item['attachmentPaths'],
@@ -649,14 +786,32 @@ class DataBackupService {
786 ),
787 generationTimeMs: item['generationTimeMs'] as int?,
788 reasoningContent: item['reasoningContent'] as String?,
789 + toolCalls: item['toolCalls'] is List
790 + ? (item['toolCalls'] as List)
791 + .whereType<Map>()
792 + .map(
793 + (e) =>
794 + ToolCallData.fromMap(Map<String, dynamic>.from(e)),
795 + )
796 + .toList()
797 + : null,
798 toolCallId: item['toolCallId'] as String?,
799 isProcessing: item['isProcessing'] as bool? ?? false,
800 toolSessionId: item['toolSessionId'] as String?,
801 + toolEvents: item['toolEvents'] is List
802 + ? (item['toolEvents'] as List)
803 + .whereType<Map>()
804 + .map(
805 + (e) => ToolEvent.fromMap(Map<String, dynamic>.from(e)),
806 + )
807 + .toList()
808 + : null,
809 variantGroupId: item['variantGroupId'] as String?,
810 variantIndex: item['variantIndex'] as int? ?? 0,
811 threadOrder: item['threadOrder'] as int? ?? 0,
812 isActiveVariant: item['isActiveVariant'] as bool? ?? true,
813 parentMessageId: item['parentMessageId'] as String?,
814 + contentTokenCount: item['contentTokenCount'] as int?,
815 );
816
817 final entity = MessageEntity.fromDomain(message)
@@ -670,7 +825,85 @@ class DataBackupService {
825 messageBox.put(entity);
826 }
827 }
673 - }, (decoded, attachmentsDir));
828 +
829 + if (replaceCloudData) {
830 + Set<String> ids(dynamic records) => records is List
831 + ? records
832 + .whereType<Map>()
833 + .map((item) => item['id'])
834 + .whereType<String>()
835 + .toSet()
836 + : <String>{};
837 +
838 + void removeMissing<T>(
839 + Box<T> box,
840 + Set<String> expected,
841 + String Function(T) idOf,
842 + int Function(T) internalIdOf,
843 + ) {
844 + box.removeMany(
845 + box
846 + .getAll()
847 + .where((item) => !expected.contains(idOf(item)))
848 + .map(internalIdOf)
849 + .toList(),
850 + );
851 + }
852 +
853 + final conversationIds = ids(decoded['conversations']);
854 + final messageIds = ids(decoded['messages']);
855 + final personaIds = ids(decoded['personas']);
856 + final temporaryConversationIds = convBox
857 + .getAll()
858 + .where((item) => item.isTemporary)
859 + .map((item) => item.id)
860 + .toSet();
861 + convBox.removeMany(
862 + convBox
863 + .getAll()
864 + .where((item) => !item.isTemporary)
865 + .where((item) => !conversationIds.contains(item.id))
866 + .map((item) => item.internalId)
867 + .toList(),
868 + );
869 + messageBox.removeMany(
870 + messageBox
871 + .getAll()
872 + .where((item) => !messageIds.contains(item.id))
873 + .where(
874 + (item) =>
875 + !temporaryConversationIds.contains(item.conversationUid),
876 + )
877 + .map((item) => item.internalId)
878 + .toList(),
879 + );
880 + personaBox.removeMany(
881 + personaBox
882 + .getAll()
883 + .where((item) => !item.isBuiltIn && !personaIds.contains(item.id))
884 + .map((item) => item.internalId)
885 + .toList(),
886 + );
887 + removeMissing<SavedMessageEntity>(
888 + savedBox,
889 + ids(decoded['savedMessages']),
890 + (item) => item.id,
891 + (item) => item.internalId,
892 + );
893 + removeMissing<SavedMessageFolderEntity>(
894 + savedFolderBox,
895 + ids(decoded['savedMessageFolders']),
896 + (item) => item.id,
897 + (item) => item.internalId,
898 + );
899 + removeMissing<ConversationFolderEntity>(
900 + conversationFolderBox,
901 + ids(decoded['conversationFolders']),
902 + (item) => item.id,
903 + (item) => item.internalId,
904 + );
905 + }
906 + }, (decoded, attachmentsDir, replaceCloudData));
907 }
908
909 List<String>? _sanitizedAttachmentPaths(dynamic raw, String attachmentsDir) {
lib/features/cloud_sync/data/models/cloud_sync_models.dart new
+359
@@ -0,0 +1,359 @@
1 +import 'dart:convert';
2 +
3 +enum CloudSyncPhase { disabled, ready, syncing, synced, error, locked }
4 +
5 +enum CloudSyncFailureKind {
6 + validation,
7 + credentials,
8 + passphrase,
9 + connectivity,
10 + incompatibleServer,
11 + corruptedData,
12 + conflict,
13 + cancelled,
14 + unknown,
15 +}
16 +
17 +class CloudSyncFailure implements Exception {
18 + const CloudSyncFailure(this.kind, this.message, {this.statusCode});
19 +
20 + final CloudSyncFailureKind kind;
21 + final String message;
22 + final int? statusCode;
23 +
24 + @override
25 + String toString() => message;
26 +}
27 +
28 +class S3SyncConfig {
29 + const S3SyncConfig({
30 + required this.endpoint,
31 + required this.bucket,
32 + this.region = 'us-east-1',
33 + this.prefix = 'localmind',
34 + this.pathStyle = true,
35 + this.allowInsecureHttp = false,
36 + this.enabled = false,
37 + });
38 +
39 + final String endpoint;
40 + final String bucket;
41 + final String region;
42 + final String prefix;
43 + final bool pathStyle;
44 + final bool allowInsecureHttp;
45 + final bool enabled;
46 +
47 + Uri get endpointUri => Uri.parse(endpoint);
48 +
49 + String? validate() {
50 + final uri = Uri.tryParse(endpoint.trim());
51 + if (uri == null || !uri.hasScheme || uri.host.isEmpty) {
52 + return 'Enter a valid S3 endpoint URL.';
53 + }
54 + if (uri.scheme != 'https' && uri.scheme != 'http') {
55 + return 'The endpoint must use HTTPS or HTTP.';
56 + }
57 + if (uri.scheme == 'http' && !allowInsecureHttp) {
58 + return 'Confirm insecure HTTP before continuing.';
59 + }
60 + if (bucket.trim().isEmpty || region.trim().isEmpty) {
61 + return 'Bucket and region are required.';
62 + }
63 + return null;
64 + }
65 +
66 + S3SyncConfig copyWith({
67 + String? endpoint,
68 + String? bucket,
69 + String? region,
70 + String? prefix,
71 + bool? pathStyle,
72 + bool? allowInsecureHttp,
73 + bool? enabled,
74 + }) {
75 + return S3SyncConfig(
76 + endpoint: endpoint ?? this.endpoint,
77 + bucket: bucket ?? this.bucket,
78 + region: region ?? this.region,
79 + prefix: prefix ?? this.prefix,
80 + pathStyle: pathStyle ?? this.pathStyle,
81 + allowInsecureHttp: allowInsecureHttp ?? this.allowInsecureHttp,
82 + enabled: enabled ?? this.enabled,
83 + );
84 + }
85 +
86 + factory S3SyncConfig.fromJson(Map<String, dynamic> json) => S3SyncConfig(
87 + endpoint: json['endpoint'] as String? ?? '',
88 + bucket: json['bucket'] as String? ?? '',
89 + region: json['region'] as String? ?? 'us-east-1',
90 + prefix: json['prefix'] as String? ?? 'localmind',
91 + pathStyle: json['pathStyle'] as bool? ?? true,
92 + allowInsecureHttp: json['allowInsecureHttp'] as bool? ?? false,
93 + enabled: json['enabled'] as bool? ?? false,
94 + );
95 +
96 + Map<String, dynamic> toJson() => {
97 + 'endpoint': endpoint,
98 + 'bucket': bucket,
99 + 'region': region,
100 + 'prefix': prefix,
101 + 'pathStyle': pathStyle,
102 + 'allowInsecureHttp': allowInsecureHttp,
103 + 'enabled': enabled,
104 + };
105 +}
106 +
107 +class CloudSyncCredentials {
108 + const CloudSyncCredentials({
109 + required this.accessKeyId,
110 + required this.secretAccessKey,
111 + this.sessionToken,
112 + });
113 +
114 + final String accessKeyId;
115 + final String secretAccessKey;
116 + final String? sessionToken;
117 +
118 + bool get isValid =>
119 + accessKeyId.trim().isNotEmpty && secretAccessKey.trim().isNotEmpty;
120 +}
121 +
122 +class CloudSyncStatus {
123 + const CloudSyncStatus({
124 + this.phase = CloudSyncPhase.disabled,
125 + this.lastSyncedAt,
126 + this.message,
127 + this.conflictCount = 0,
128 + this.warnings = const [],
129 + });
130 +
131 + final CloudSyncPhase phase;
132 + final DateTime? lastSyncedAt;
133 + final String? message;
134 + final int conflictCount;
135 + final List<String> warnings;
136 +
137 + CloudSyncStatus copyWith({
138 + CloudSyncPhase? phase,
139 + DateTime? lastSyncedAt,
140 + String? message,
141 + bool clearMessage = false,
142 + int? conflictCount,
143 + List<String>? warnings,
144 + }) => CloudSyncStatus(
145 + phase: phase ?? this.phase,
146 + lastSyncedAt: lastSyncedAt ?? this.lastSyncedAt,
147 + message: clearMessage ? null : (message ?? this.message),
148 + conflictCount: conflictCount ?? this.conflictCount,
149 + warnings: warnings ?? this.warnings,
150 + );
151 +}
152 +
153 +class CloudSyncSnapshot {
154 + const CloudSyncSnapshot({
155 + required this.deviceId,
156 + required this.revision,
157 + required this.updatedAt,
158 + required this.payload,
159 + this.tombstones = const {},
160 + });
161 +
162 + final String deviceId;
163 + final int revision;
164 + final DateTime updatedAt;
165 + final Map<String, dynamic> payload;
166 + final Map<String, dynamic> tombstones;
167 +
168 + factory CloudSyncSnapshot.fromJson(Map<String, dynamic> json) {
169 + if (json['schema'] != 1 ||
170 + json['payload'] is! Map ||
171 + json['deviceId'] is! String ||
172 + (json['deviceId'] as String).length < 8 ||
173 + json['revision'] is! int ||
174 + (json['revision'] as int) < 0 ||
175 + json['updatedAt'] is! String) {
176 + throw const FormatException('Unsupported cloud sync snapshot');
177 + }
178 + final payload = Map<String, dynamic>.from(json['payload'] as Map);
179 + _validatePayload(payload);
180 + final tombstones = json['tombstones'] is Map
181 + ? Map<String, dynamic>.from(json['tombstones'] as Map)
182 + : <String, dynamic>{};
183 + for (final entry in tombstones.entries) {
184 + if (!_collections.contains(entry.key) ||
185 + entry.value is! List ||
186 + !(entry.value as List).every((value) => value is String)) {
187 + throw const FormatException('Invalid cloud sync tombstones');
188 + }
189 + }
190 + return CloudSyncSnapshot(
191 + deviceId: json['deviceId'] as String,
192 + revision: json['revision'] as int,
193 + updatedAt: DateTime.parse(json['updatedAt'] as String),
194 + payload: payload,
195 + tombstones: tombstones,
196 + );
197 + }
198 +
199 + static const _collections = {
200 + 'conversations',
201 + 'messages',
202 + 'personas',
203 + 'savedMessages',
204 + 'savedMessageFolders',
205 + 'conversationFolders',
206 + };
207 +
208 + static const _settingKeys = {
209 + 'temperature',
210 + 'topP',
211 + 'maxTokens',
212 + 'contextLength',
213 + 'themeMode',
214 + 'fontSize',
215 + 'showSystemMessages',
216 + 'hapticFeedbackEnabled',
217 + 'sendOnEnter',
218 + 'showDataIndicator',
219 + 'autoGenerateTitle',
220 + 'streamingEnabled',
221 + 'defaultPersonaId',
222 + 'mcpEnabled',
223 + 'newChatMcpEnabled',
224 + 'codeThemeDark',
225 + 'codeThemeLight',
226 + 'preferredBackend',
227 + 'ttsEngine',
228 + 'ttsVoiceId',
229 + 'ttsSpeed',
230 + 'kittenTtsModelVariant',
231 + 'autoSpeakEnabled',
232 + 'ttsProcessMarkdown',
233 + 'ttsSkipSeconds',
234 + 'smartReplyEnabled',
235 + 'aiUserResponseEnabled',
236 + 'localeCode',
237 + 'unloadModelsBeforeLoad',
238 + 'tempChatKeyboardIncognito',
239 + 'resumeLastChat',
240 + 'imageCompressionEnabled',
241 + 'imageCompressionLevel',
242 + 'smartRepliesUsePersona',
243 + 'keepPersonaOnNewChat',
244 + 'roleSwapButtonEnabled',
245 + 'showSystemMessagesInChat',
246 + };
247 +
248 + static void _validatePayload(Map<String, dynamic> payload) {
249 + if (payload['version'] != 1 ||
250 + payload['type'] != 'cloudSync' ||
251 + payload['settings'] is! Map ||
252 + !(payload['settings'] as Map).keys.every(
253 + (key) => key is String && _settingKeys.contains(key),
254 + )) {
255 + throw const FormatException('Invalid cloud sync payload');
256 + }
257 + for (final collection in _collections) {
258 + final records = payload[collection];
259 + if (records is! List ||
260 + !records.every(
261 + (record) =>
262 + record is Map &&
263 + record['id'] is String &&
264 + (record['id'] as String).isNotEmpty,
265 + )) {
266 + throw FormatException('Invalid cloud sync collection: $collection');
267 + }
268 + final ids = records
269 + .map((record) => (record as Map)['id'] as String)
270 + .toList();
271 + if (ids.toSet().length != ids.length) {
272 + throw FormatException('Duplicate IDs in cloud collection: $collection');
273 + }
274 + }
275 + final conversations = {
276 + for (final record in payload['conversations'] as List)
277 + (record as Map)['id'] as String,
278 + };
279 + if ((payload['conversations'] as List).any(
280 + (record) => (record as Map)['isTemporary'] == true,
281 + ) ||
282 + (payload['personas'] as List).any(
283 + (record) => (record as Map)['isBuiltIn'] == true,
284 + )) {
285 + throw const FormatException('Cloud payload contains excluded records');
286 + }
287 + bool validDate(dynamic value) =>
288 + value is String && DateTime.tryParse(value) != null;
289 + for (final raw in payload['conversations'] as List) {
290 + final record = raw as Map;
291 + if (!validDate(record['createdAt']) || !validDate(record['updatedAt'])) {
292 + throw const FormatException('Invalid cloud conversation');
293 + }
294 + }
295 + for (final raw in payload['personas'] as List) {
296 + final record = raw as Map;
297 + if (record['name'] is! String ||
298 + !validDate(record['createdAt']) ||
299 + !validDate(record['updatedAt'])) {
300 + throw const FormatException('Invalid cloud persona');
301 + }
302 + }
303 + for (final collection in const [
304 + 'savedMessageFolders',
305 + 'conversationFolders',
306 + ]) {
307 + for (final raw in payload[collection] as List) {
308 + final record = raw as Map;
309 + if (record['name'] is! String || !validDate(record['createdAt'])) {
310 + throw FormatException('Invalid cloud folder: $collection');
311 + }
312 + }
313 + }
314 + for (final raw in payload['savedMessages'] as List) {
315 + final record = raw as Map;
316 + if (record['sourceMessageId'] is! String ||
317 + record['conversationId'] is! String ||
318 + !validDate(record['savedAt'])) {
319 + throw const FormatException('Invalid cloud saved message');
320 + }
321 + }
322 + for (final raw in payload['messages'] as List) {
323 + final message = raw as Map;
324 + if (message['conversationId'] is! String ||
325 + (message['conversationId'] as String).isEmpty ||
326 + !conversations.contains(message['conversationId']) ||
327 + !validDate(message['createdAt']) ||
328 + (message['attachmentPaths'] != null &&
329 + (message['attachmentPaths'] is! List ||
330 + !(message['attachmentPaths'] as List).every(
331 + (path) =>
332 + path is String &&
333 + RegExp(
334 + r'^cloud://[A-Za-z0-9_-]+/[A-Za-z0-9_-]+$',
335 + ).hasMatch(path),
336 + )))) {
337 + throw const FormatException('Invalid cloud sync message');
338 + }
339 + }
340 + }
341 +
342 + Map<String, dynamic> toJson() => {
343 + 'schema': 1,
344 + 'deviceId': deviceId,
345 + 'revision': revision,
346 + 'updatedAt': updatedAt.toUtc().toIso8601String(),
347 + 'payload': payload,
348 + 'tombstones': tombstones,
349 + };
350 +
351 + String encode() => jsonEncode(toJson());
352 +}
353 +
354 +class CloudSyncRemoteState {
355 + const CloudSyncRemoteState({required this.bytes, this.etag});
356 +
357 + final List<int> bytes;
358 + final String? etag;
359 +}
lib/features/cloud_sync/data/repositories/cloud_sync_local_repository.dart new
+144
@@ -0,0 +1,144 @@
1 +import 'dart:convert';
2 +import 'dart:io';
3 +import 'dart:math';
4 +
5 +import 'package:flutter_secure_storage/flutter_secure_storage.dart';
6 +import 'package:path/path.dart' as p;
7 +import 'package:path_provider/path_provider.dart';
8 +import 'package:shared_preferences/shared_preferences.dart';
9 +
10 +import '../models/cloud_sync_models.dart';
11 +
12 +class CloudSyncLocalRepository {
13 + CloudSyncLocalRepository(
14 + this._preferences, {
15 + FlutterSecureStorage secureStorage = const FlutterSecureStorage(),
16 + }) : _secureStorage = secureStorage; // ignore: prefer_initializing_formals
17 +
18 + static const _configKey = 'cloudSync.config.v1';
19 + static const _deviceKey = 'cloudSync.deviceId.v1';
20 + static const _journalKey = 'cloudSync.journal.v1';
21 + static const _lastSyncKey = 'cloudSync.lastSyncedAt.v1';
22 + static const _accessKey = 'cloudSync.accessKeyId.v1';
23 + static const _secretKey = 'cloudSync.secretAccessKey.v1';
24 + static const _sessionKey = 'cloudSync.sessionToken.v1';
25 + static const _masterKey = 'cloudSync.masterKey.v1';
26 + static const _saltKey = 'cloudSync.salt.v1';
27 +
28 + final SharedPreferences _preferences;
29 + final FlutterSecureStorage _secureStorage;
30 +
31 + S3SyncConfig? loadConfig() {
32 + final raw = _preferences.getString(_configKey);
33 + if (raw == null) return null;
34 + try {
35 + return S3SyncConfig.fromJson(jsonDecode(raw) as Map<String, dynamic>);
36 + } catch (_) {
37 + return null;
38 + }
39 + }
40 +
41 + Future<void> saveConfig(S3SyncConfig config) =>
42 + _preferences.setString(_configKey, jsonEncode(config.toJson()));
43 +
44 + Future<CloudSyncCredentials?> loadCredentials() async {
45 + final accessKey = await _secureStorage.read(key: _accessKey);
46 + final secret = await _secureStorage.read(key: _secretKey);
47 + if (accessKey == null || secret == null) return null;
48 + return CloudSyncCredentials(
49 + accessKeyId: accessKey,
50 + secretAccessKey: secret,
51 + sessionToken: await _secureStorage.read(key: _sessionKey),
52 + );
53 + }
54 +
55 + Future<void> saveSecrets({
56 + required CloudSyncCredentials credentials,
57 + required List<int> masterKey,
58 + required List<int> salt,
59 + }) async {
60 + await Future.wait([
61 + _secureStorage.write(key: _accessKey, value: credentials.accessKeyId),
62 + _secureStorage.write(key: _secretKey, value: credentials.secretAccessKey),
63 + if (credentials.sessionToken?.isNotEmpty == true)
64 + _secureStorage.write(key: _sessionKey, value: credentials.sessionToken)
65 + else
66 + _secureStorage.delete(key: _sessionKey),
67 + _secureStorage.write(key: _masterKey, value: base64Encode(masterKey)),
68 + _secureStorage.write(key: _saltKey, value: base64Encode(salt)),
69 + ]);
70 + }
71 +
72 + Future<List<int>?> loadMasterKey() async {
73 + final value = await _secureStorage.read(key: _masterKey);
74 + return value == null ? null : base64Decode(value);
75 + }
76 +
77 + Future<List<int>?> loadSalt() async {
78 + final value = await _secureStorage.read(key: _saltKey);
79 + return value == null ? null : base64Decode(value);
80 + }
81 +
82 + String getOrCreateDeviceId() {
83 + final existing = _preferences.getString(_deviceKey);
84 + if (existing != null) return existing;
85 + final random = Random.secure();
86 + final id = List<int>.generate(
87 + 16,
88 + (_) => random.nextInt(256),
89 + ).map((byte) => byte.toRadixString(16).padLeft(2, '0')).join();
90 + _preferences.setString(_deviceKey, id);
91 + return id;
92 + }
93 +
94 + Future<File> _journalFile() async {
95 + final support = await getApplicationSupportDirectory();
96 + final directory = Directory(p.join(support.path, 'cloud-sync'));
97 + await directory.create(recursive: true);
98 + return File(p.join(directory.path, 'journal-v1.json'));
99 + }
100 +
101 + Future<Map<String, dynamic>> loadJournal() async {
102 + final file = await _journalFile();
103 + String? raw;
104 + if (await file.exists()) {
105 + raw = await file.readAsString();
106 + } else {
107 + raw = _preferences.getString(_journalKey);
108 + }
109 + if (raw == null) return <String, dynamic>{};
110 + try {
111 + return Map<String, dynamic>.from(jsonDecode(raw) as Map);
112 + } catch (_) {
113 + return <String, dynamic>{};
114 + }
115 + }
116 +
117 + DateTime? loadLastSync() =>
118 + DateTime.tryParse(_preferences.getString(_lastSyncKey) ?? '');
119 +
120 + Future<void> saveJournal(Map<String, dynamic> journal) async {
121 + final file = await _journalFile();
122 + final temporary = File('${file.path}.tmp');
123 + await temporary.writeAsString(jsonEncode(journal), flush: true);
124 + await temporary.rename(file.path);
125 + await _preferences.remove(_journalKey);
126 + final lastSync = journal['lastSyncedAt'] as String?;
127 + if (lastSync != null) await _preferences.setString(_lastSyncKey, lastSync);
128 + }
129 +
130 + Future<void> disconnect() async {
131 + await _preferences.remove(_configKey);
132 + await _preferences.remove(_journalKey);
133 + await _preferences.remove(_lastSyncKey);
134 + final journal = await _journalFile();
135 + if (await journal.exists()) await journal.delete();
136 + await Future.wait([
137 + _secureStorage.delete(key: _accessKey),
138 + _secureStorage.delete(key: _secretKey),
139 + _secureStorage.delete(key: _sessionKey),
140 + _secureStorage.delete(key: _masterKey),
141 + _secureStorage.delete(key: _saltKey),
142 + ]);
143 + }
144 +}
lib/features/cloud_sync/data/repositories/s3_cloud_sync_repository.dart new
+230
@@ -0,0 +1,230 @@
1 +import 'dart:convert';
2 +import 'dart:math';
3 +
4 +import 'package:aws_common/aws_common.dart';
5 +import 'package:aws_signature_v4/aws_signature_v4.dart';
6 +
7 +import '../models/cloud_sync_models.dart';
8 +
9 +class S3CloudSyncRepository {
10 + S3CloudSyncRepository({required this.config, required this.credentials})
11 + : _signer = AWSSigV4Signer(
12 + credentialsProvider: AWSCredentialsProvider(
13 + AWSCredentials(
14 + credentials.accessKeyId,
15 + credentials.secretAccessKey,
16 + credentials.sessionToken,
17 + ),
18 + ),
19 + );
20 +
21 + final S3SyncConfig config;
22 + final CloudSyncCredentials credentials;
23 + final AWSSigV4Signer _signer;
24 +
25 + final _scopeTimeout = const Duration(seconds: 30);
26 +
27 + String get stateKey => _key('v1/state.enc');
28 + String attachmentKey(String identifier) =>
29 + _key('v1/attachments/$identifier.enc');
30 +
31 + String _key(String suffix) {
32 + final prefix = config.prefix.trim().replaceAll(RegExp(r'^/+|/+$'), '');
33 + return prefix.isEmpty ? suffix : '$prefix/$suffix';
34 + }
35 +
36 + Uri _objectUri(String key) {
37 + final endpoint = config.endpointUri;
38 + final normalizedKey = key
39 + .split('/')
40 + .where((part) => part.isNotEmpty)
41 + .join('/');
42 + final basePath = endpoint.path.replaceAll(RegExp(r'/+$'), '');
43 + if (config.pathStyle) {
44 + return endpoint.replace(
45 + path: '$basePath/${config.bucket}/$normalizedKey',
46 + query: null,
47 + fragment: null,
48 + );
49 + }
50 + return endpoint.replace(
51 + host: '${config.bucket}.${endpoint.host}',
52 + path: '$basePath/$normalizedKey',
53 + query: null,
54 + fragment: null,
55 + );
56 + }
57 +
58 + Future<AWSHttpResponse> _send(
59 + AWSHttpRequest request, {
60 + Set<int> success = const {200},
61 + }) async {
62 + try {
63 + final signed = await _signer.sign(
64 + request,
65 + credentialScope: AWSCredentialScope(
66 + region: config.region,
67 + service: AWSService.s3,
68 + ),
69 + serviceConfiguration: S3ServiceConfiguration(),
70 + );
71 + final response = await signed.send().response.timeout(_scopeTimeout);
72 + final flattened = AWSHttpResponse(
73 + statusCode: response.statusCode,
74 + headers: response.headers,
75 + body: await Future<List<int>>.value(response.bodyBytes),
76 + );
77 + await response.close();
78 + if (!success.contains(flattened.statusCode)) {
79 + final kind = switch (flattened.statusCode) {
80 + 401 || 403 => CloudSyncFailureKind.credentials,
81 + 409 || 412 => CloudSyncFailureKind.conflict,
82 + _ => CloudSyncFailureKind.connectivity,
83 + };
84 + final body = flattened.bodyBytes.isEmpty
85 + ? ''
86 + : utf8.decode(flattened.bodyBytes, allowMalformed: true);
87 + throw CloudSyncFailure(
88 + kind,
89 + _friendlyError(flattened.statusCode, body),
90 + statusCode: flattened.statusCode,
91 + );
92 + }
93 + return flattened;
94 + } on CloudSyncFailure {
95 + rethrow;
96 + } catch (error) {
97 + throw CloudSyncFailure(
98 + CloudSyncFailureKind.connectivity,
99 + 'Could not reach the S3 server: $error',
100 + );
101 + }
102 + }
103 +
104 + String _friendlyError(int status, String body) {
105 + final code = RegExp(r'<Code>([^<]+)</Code>').firstMatch(body)?.group(1);
106 + return switch (status) {
107 + 401 || 403 => 'S3 rejected the credentials or bucket permissions.',
108 + 404 => 'The configured S3 bucket or object was not found.',
109 + 409 || 412 => 'The cloud data changed on another device.',
110 + _ => 'S3 request failed ($status${code == null ? '' : ', $code'}).',
111 + };
112 + }
113 +
114 + Future<CloudSyncRemoteState?> readState() => readObject(stateKey);
115 +
116 + Future<bool> objectExists(String key) async {
117 + final uri = _objectUri(key);
118 + final response = await _send(
119 + AWSHttpRequest.head(uri, headers: {AWSHeaders.host: uri.authority}),
120 + success: const {200, 404},
121 + );
122 + return response.statusCode == 200;
123 + }
124 +
125 + Future<CloudSyncRemoteState?> readObject(String key) async {
126 + final uri = _objectUri(key);
127 + final response = await _send(
128 + AWSHttpRequest.get(uri, headers: {AWSHeaders.host: uri.authority}),
129 + success: const {200, 404},
130 + );
131 + if (response.statusCode == 404) return null;
132 + return CloudSyncRemoteState(
133 + bytes: response.bodyBytes,
134 + etag: response.headers['etag'],
135 + );
136 + }
137 +
138 + Future<String?> writeState(
139 + List<int> bytes, {
140 + String? expectedEtag,
141 + bool createOnly = false,
142 + }) => writeObject(
143 + stateKey,
144 + bytes,
145 + expectedEtag: expectedEtag,
146 + createOnly: createOnly,
147 + );
148 +
149 + Future<String?> writeObject(
150 + String key,
151 + List<int> bytes, {
152 + String? expectedEtag,
153 + bool createOnly = false,
154 + }) async {
155 + final uri = _objectUri(key);
156 + final headers = <String, String>{
157 + AWSHeaders.host: uri.authority,
158 + AWSHeaders.contentType: 'application/octet-stream',
159 + AWSHeaders.contentLength: bytes.length.toString(),
160 + };
161 + if (expectedEtag != null) headers['if-match'] = expectedEtag;
162 + if (createOnly) headers['if-none-match'] = '*';
163 + final response = await _send(
164 + AWSHttpRequest.put(uri, body: bytes, headers: headers),
165 + );
166 + return response.headers['etag'];
167 + }
168 +
169 + Future<void> deleteObject(String key) async {
170 + final uri = _objectUri(key);
171 + await _send(
172 + AWSHttpRequest.delete(uri, headers: {AWSHeaders.host: uri.authority}),
173 + success: const {200, 204, 404},
174 + );
175 + }
176 +
177 + Future<void> testConnection() async {
178 + final validation = config.validate();
179 + if (validation != null) {
180 + throw CloudSyncFailure(CloudSyncFailureKind.validation, validation);
181 + }
182 + if (!credentials.isValid) {
183 + throw const CloudSyncFailure(
184 + CloudSyncFailureKind.validation,
185 + 'Access key and secret key are required.',
186 + );
187 + }
188 + final suffix = Random.secure().nextInt(1 << 32).toRadixString(16);
189 + final key = _key('v1/.connection-test-$suffix');
190 + try {
191 + final firstEtag = await writeObject(key, const [1], createOnly: true);
192 + final read = await readObject(key);
193 + if (read == null || read.bytes.length != 1 || read.bytes.single != 1) {
194 + throw const CloudSyncFailure(
195 + CloudSyncFailureKind.incompatibleServer,
196 + 'The S3 server did not return the test object correctly.',
197 + );
198 + }
199 + if (firstEtag == null) {
200 + throw const CloudSyncFailure(
201 + CloudSyncFailureKind.incompatibleServer,
202 + 'The S3 server does not expose ETags required for safe sync.',
203 + );
204 + }
205 + final secondEtag = await writeObject(key, const [
206 + 2,
207 + ], expectedEtag: firstEtag);
208 + if (secondEtag == null) {
209 + throw const CloudSyncFailure(
210 + CloudSyncFailureKind.incompatibleServer,
211 + 'The S3 server stopped returning ETags during the connection test.',
212 + );
213 + }
214 + try {
215 + await writeObject(key, const [3], expectedEtag: firstEtag);
216 + } on CloudSyncFailure catch (error) {
217 + if (error.kind == CloudSyncFailureKind.conflict) return;
218 + rethrow;
219 + }
220 + throw const CloudSyncFailure(
221 + CloudSyncFailureKind.incompatibleServer,
222 + 'The S3 server does not enforce conditional writes required for safe sync.',
223 + );
224 + } finally {
225 + try {
226 + await deleteObject(key);
227 + } catch (_) {}
228 + }
229 + }
230 +}
lib/features/cloud_sync/providers/cloud_sync_providers.dart new
+632
@@ -0,0 +1,632 @@
1 +import 'dart:async';
2 +import 'dart:convert';
3 +import 'dart:io';
4 +
5 +import 'package:flutter/widgets.dart';
6 +import 'package:flutter_riverpod/flutter_riverpod.dart';
7 +import 'package:path/path.dart' as p;
8 +import 'package:path_provider/path_provider.dart';
9 +
10 +import '../../../core/providers/app_providers.dart';
11 +import '../../../core/providers/storage_providers.dart';
12 +import '../../../core/services/data_backup_service.dart';
13 +import '../../conversations/providers/conversation_providers.dart';
14 +import '../../personas/providers/personas_providers.dart';
15 +import '../../saved_messages/providers/saved_message_providers.dart';
16 +import '../../settings/data/models/app_settings.dart';
17 +import '../data/models/cloud_sync_models.dart';
18 +import '../data/repositories/cloud_sync_local_repository.dart';
19 +import '../data/repositories/s3_cloud_sync_repository.dart';
20 +import '../services/cloud_sync_crypto_service.dart';
21 +import '../services/cloud_sync_merge_service.dart';
22 +import '../services/cloud_attachment_reference_resolver.dart';
23 +
24 +final cloudSyncLocalRepositoryProvider = Provider<CloudSyncLocalRepository>(
25 + (ref) => CloudSyncLocalRepository(ref.watch(sharedPreferencesProvider)),
26 +);
27 +
28 +final cloudSyncCryptoServiceProvider = Provider<CloudSyncCryptoService>(
29 + (ref) => CloudSyncCryptoService(),
30 +);
31 +
32 +final cloudSyncMergeServiceProvider = Provider<CloudSyncMergeService>(
33 + (ref) => CloudSyncMergeService(),
34 +);
35 +
36 +final cloudAttachmentReferenceResolverProvider =
37 + Provider<CloudAttachmentReferenceResolver>(
38 + (ref) => const CloudAttachmentReferenceResolver(),
39 + );
40 +
41 +final cloudSyncControllerProvider =
42 + NotifierProvider<CloudSyncController, CloudSyncStatus>(
43 + CloudSyncController.new,
44 + );
45 +
46 +class CloudSyncController extends Notifier<CloudSyncStatus> {
47 + Timer? _debounce;
48 + Timer? _retryTimer;
49 + bool _running = false;
50 + bool _pending = false;
51 + bool _applyingRemote = false;
52 + int _retryIndex = 0;
53 + int _operationGeneration = 0;
54 +
55 + bool get isApplyingRemote => _applyingRemote;
56 +
57 + CloudSyncLocalRepository get _local =>
58 + ref.read(cloudSyncLocalRepositoryProvider);
59 + CloudSyncCryptoService get _crypto =>
60 + ref.read(cloudSyncCryptoServiceProvider);
61 + CloudSyncMergeService get _merge => ref.read(cloudSyncMergeServiceProvider);
62 + CloudAttachmentReferenceResolver get _attachmentReferenceResolver =>
63 + ref.read(cloudAttachmentReferenceResolverProvider);
64 +
65 + @override
66 + CloudSyncStatus build() {
67 + ref.onDispose(() {
68 + _debounce?.cancel();
69 + _retryTimer?.cancel();
70 + });
71 + final config = _local.loadConfig();
72 + return CloudSyncStatus(
73 + phase: config?.enabled == true
74 + ? CloudSyncPhase.ready
75 + : CloudSyncPhase.disabled,
76 + lastSyncedAt: _local.loadLastSync(),
77 + );
78 + }
79 +
80 + S3SyncConfig? get config => _local.loadConfig();
81 +
82 + Future<void> testConnection(
83 + S3SyncConfig config,
84 + CloudSyncCredentials credentials,
85 + ) => S3CloudSyncRepository(
86 + config: config,
87 + credentials: credentials,
88 + ).testConnection();
89 +
90 + Future<void> configure({
91 + required S3SyncConfig config,
92 + required CloudSyncCredentials credentials,
93 + required String passphrase,
94 + }) async {
95 + final validation = config.validate();
96 + if (validation != null) {
97 + throw CloudSyncFailure(CloudSyncFailureKind.validation, validation);
98 + }
99 + final enabledConfig = config.copyWith(enabled: true);
100 + final remoteRepository = S3CloudSyncRepository(
101 + config: enabledConfig,
102 + credentials: credentials,
103 + );
104 + state = state.copyWith(
105 + phase: CloudSyncPhase.syncing,
106 + message: 'Testing S3 connection…',
107 + );
108 + try {
109 + await remoteRepository.testConnection();
110 + final remote = await remoteRepository.readState();
111 + final salt = remote == null
112 + ? _crypto.newSalt()
113 + : _crypto.extractSalt(remote.bytes);
114 + final masterKey = await _crypto.deriveMasterKey(passphrase, salt);
115 + if (remote != null) {
116 + await _crypto.decryptState(remote.bytes, masterKey);
117 + }
118 + await _local.saveConfig(enabledConfig);
119 + await _local.saveSecrets(
120 + credentials: credentials,
121 + masterKey: masterKey,
122 + salt: salt,
123 + );
124 + state = state.copyWith(
125 + phase: CloudSyncPhase.ready,
126 + message: 'Cloud sync enabled.',
127 + );
128 + await syncNow(rethrowErrors: true);
129 + } catch (error) {
130 + state = state.copyWith(
131 + phase: CloudSyncPhase.error,
132 + message: error.toString(),
133 + );
134 + rethrow;
135 + }
136 + }
137 +
138 + void scheduleSync({Duration delay = const Duration(seconds: 5)}) {
139 + if (_applyingRemote || config?.enabled != true) return;
140 + _debounce?.cancel();
141 + _debounce = Timer(delay, () => unawaited(syncNow()));
142 + }
143 +
144 + Future<void> syncNow({bool rethrowErrors = false}) async {
145 + if (_running) {
146 + _pending = true;
147 + return;
148 + }
149 + final config = _local.loadConfig();
150 + if (config == null || !config.enabled) return;
151 + final credentials = await _local.loadCredentials();
152 + final masterKey = await _local.loadMasterKey();
153 + final salt = await _local.loadSalt();
154 + if (credentials == null || masterKey == null || salt == null) {
155 + state = state.copyWith(
156 + phase: CloudSyncPhase.locked,
157 + message: 'Cloud sync must be unlocked again on this device.',
158 + );
159 + return;
160 + }
161 +
162 + final generation = _operationGeneration;
163 + _running = true;
164 + state = state.copyWith(
165 + phase: CloudSyncPhase.syncing,
166 + message: 'Syncing encrypted data…',
167 + warnings: const [],
168 + );
169 + try {
170 + final warnings = await _performSync(
171 + config,
172 + credentials,
173 + masterKey,
174 + salt,
175 + generation,
176 + );
177 + _ensureActive(generation);
178 + _retryIndex = 0;
179 + _retryTimer?.cancel();
180 + state = CloudSyncStatus(
181 + phase: CloudSyncPhase.synced,
182 + lastSyncedAt: DateTime.now(),
183 + message: 'Encrypted sync is up to date.',
184 + conflictCount: state.conflictCount,
185 + warnings: warnings,
186 + );
187 + } catch (error) {
188 + if (error is CloudSyncFailure &&
189 + error.kind == CloudSyncFailureKind.cancelled) {
190 + return;
191 + }
192 + state = state.copyWith(
193 + phase: CloudSyncPhase.error,
194 + message: error.toString(),
195 + );
196 + _scheduleRetry();
197 + if (rethrowErrors) rethrow;
198 + } finally {
199 + _running = false;
200 + if (_pending) {
201 + _pending = false;
202 + scheduleSync(delay: Duration.zero);
203 + }
204 + }
205 + }
206 +
207 + void _scheduleRetry() {
208 + const delays = [
209 + Duration(seconds: 5),
210 + Duration(seconds: 30),
211 + Duration(minutes: 2),
212 + ];
213 + if (_retryIndex >= delays.length || config?.enabled != true) return;
214 + _retryTimer?.cancel();
215 + _retryTimer = Timer(delays[_retryIndex++], () => unawaited(syncNow()));
216 + }
217 +
218 + Future<List<String>> _performSync(
219 + S3SyncConfig config,
220 + CloudSyncCredentials credentials,
221 + List<int> masterKey,
222 + List<int> salt,
223 + int generation,
224 + ) async {
225 + final repository = S3CloudSyncRepository(
226 + config: config,
227 + credentials: credentials,
228 + );
229 + final journal = await _local.loadJournal();
230 + final payload = DataBackupService().exportCloudSync(
231 + ref.read(databaseProvider).store,
232 + ref.read(settingsProvider).toJson(),
233 + );
234 + final warnings = <String>[];
235 + await _prepareAttachments(
236 + payload,
237 + repository,
238 + masterKey,
239 + salt,
240 + warnings,
241 + journal,
242 + generation,
243 + );
244 + _ensureActive(generation);
245 + final deviceId = _local.getOrCreateDeviceId();
246 + final revision = (journal['localRevision'] as int? ?? 0) + 1;
247 + final local = CloudSyncSnapshot(
248 + deviceId: deviceId,
249 + revision: revision,
250 + updatedAt: DateTime.now().toUtc(),
251 + payload: payload,
252 + tombstones: _buildTombstones(payload, journal),
253 + );
254 +
255 + for (var attempt = 0; attempt < 3; attempt++) {
256 + _ensureActive(generation);
257 + final remoteObject = await repository.readState();
258 + _ensureActive(generation);
259 + if (remoteObject != null && remoteObject.etag == null) {
260 + throw const CloudSyncFailure(
261 + CloudSyncFailureKind.incompatibleServer,
262 + 'The S3 state object is missing the ETag required for safe sync.',
263 + );
264 + }
265 + CloudSyncSnapshot? remote;
266 + if (remoteObject != null) {
267 + final clear = await _crypto.decryptState(remoteObject.bytes, masterKey);
268 + remote = CloudSyncSnapshot.fromJson(
269 + jsonDecode(utf8.decode(clear)) as Map<String, dynamic>,
270 + );
271 + }
272 + final result = await _merge.merge(
273 + local: local,
274 + remote: remote,
275 + journal: journal,
276 + );
277 + try {
278 + final remoteHash = remote == null
279 + ? null
280 + : await _merge.snapshotFingerprint(remote);
281 + final mergedStateHash = await _merge.snapshotFingerprint(
282 + result.snapshot,
283 + );
284 + final mergedPayloadHash = await _merge.fingerprint(
285 + result.snapshot.payload,
286 + );
287 + var etag = remoteObject?.etag;
288 + if (remoteHash != mergedStateHash || remote == null) {
289 + _ensureActive(generation);
290 + final encrypted = await _crypto.encryptState(
291 + utf8.encode(result.snapshot.encode()),
292 + masterKey: masterKey,
293 + salt: salt,
294 + );
295 + etag = await repository.writeState(
296 + encrypted,
297 + expectedEtag: remoteObject?.etag,
298 + createOnly: remoteObject == null,
299 + );
300 + _ensureActive(generation);
301 + }
302 + _ensureActive(generation);
303 + await _applySnapshot(
304 + result.snapshot,
305 + repository,
306 + masterKey,
307 + warnings,
308 + generation,
309 + );
310 + _ensureActive(generation);
311 + final now = DateTime.now().toUtc();
312 + await _local.saveJournal({
313 + 'localRevision': result.snapshot.revision,
314 + 'remoteRevision': result.snapshot.revision,
315 + 'remoteDeviceId': result.snapshot.deviceId,
316 + 'payloadHash': mergedPayloadHash,
317 + 'etag': etag,
318 + 'lastSyncedAt': now.toIso8601String(),
319 + 'ids': _snapshotIds(result.snapshot.payload),
320 + 'recordHashes': await _merge.recordHashes(result.snapshot.payload),
321 + 'tombstones': result.snapshot.tombstones,
322 + 'attachmentRefs': _attachmentRefs(result.snapshot.payload),
323 + });
324 + state = state.copyWith(conflictCount: result.conflictCount);
325 + return warnings;
326 + } on CloudSyncFailure catch (error) {
327 + if (error.kind != CloudSyncFailureKind.conflict || attempt == 2) {
328 + rethrow;
329 + }
330 + }
331 + }
332 + return warnings;
333 + }
334 +
335 + Map<String, dynamic> _buildTombstones(
336 + Map<String, dynamic> payload,
337 + Map<String, dynamic> journal,
338 + ) {
339 + final previous = journal['ids'] is Map
340 + ? Map<String, dynamic>.from(journal['ids'] as Map)
341 + : const <String, dynamic>{};
342 + final current = _snapshotIds(payload);
343 + final result = <String, dynamic>{
344 + if (journal['tombstones'] is Map)
345 + for (final entry in (journal['tombstones'] as Map).entries)
346 + entry.key.toString(): entry.value is List
347 + ? List<String>.from((entry.value as List).whereType<String>())
348 + : <String>[],
349 + };
350 + for (final collection in current.keys) {
351 + final oldIds = previous[collection] is List
352 + ? (previous[collection] as List).whereType<String>().toSet()
353 + : <String>{};
354 + final currentIds = (current[collection] as List)
355 + .whereType<String>()
356 + .toSet();
357 + final deleted = oldIds.difference(currentIds);
358 + if (deleted.isNotEmpty) {
359 + final cumulative =
360 + (result[collection] as List?)?.whereType<String>().toSet() ??
361 + <String>{};
362 + cumulative.addAll(deleted);
363 + result[collection] = cumulative.toList();
364 + }
365 + }
366 + return result;
367 + }
368 +
369 + Map<String, dynamic> _snapshotIds(Map<String, dynamic> payload) => {
370 + for (final collection in const [
371 + 'conversations',
372 + 'messages',
373 + 'personas',
374 + 'savedMessages',
375 + 'savedMessageFolders',
376 + 'conversationFolders',
377 + ])
378 + collection: payload[collection] is List
379 + ? (payload[collection] as List)
380 + .whereType<Map>()
381 + .map((item) => item['id'])
382 + .whereType<String>()
383 + .toList()
384 + : <String>[],
385 + };
386 +
387 + Map<String, dynamic> _attachmentRefs(Map<String, dynamic> payload) => {
388 + for (final message in (payload['messages'] as List).whereType<Map>())
389 + if (message['id'] is String && message['attachmentPaths'] is List)
390 + message['id'] as String: (message['attachmentPaths'] as List)
391 + .whereType<String>()
392 + .where((path) => path.startsWith('cloud://'))
393 + .toList(),
394 + };
395 +
396 + void _ensureActive(int generation) {
397 + if (generation != _operationGeneration) {
398 + throw const CloudSyncFailure(
399 + CloudSyncFailureKind.cancelled,
400 + 'Cloud sync was cancelled.',
401 + );
402 + }
403 + }
404 +
405 + Future<void> _prepareAttachments(
406 + Map<String, dynamic> payload,
407 + S3CloudSyncRepository repository,
408 + List<int> masterKey,
409 + List<int> salt,
410 + List<String> warnings,
411 + Map<String, dynamic> journal,
412 + int generation,
413 + ) async {
414 + final appDir = await getApplicationDocumentsDirectory();
415 + final attachmentsDir = p.normalize(p.join(appDir.path, 'attachments'));
416 + final messages = payload['messages'];
417 + if (messages is! List) return;
418 + for (final raw in messages.whereType<Map>()) {
419 + _ensureActive(generation);
420 + final paths = raw['attachmentPaths'];
421 + if (paths is! List) continue;
422 + final messageId = raw['id'] as String?;
423 + final previousRefs =
424 + messageId != null &&
425 + journal['attachmentRefs'] is Map &&
426 + (journal['attachmentRefs'] as Map)[messageId] is List
427 + ? ((journal['attachmentRefs'] as Map)[messageId] as List)
428 + .whereType<String>()
429 + .toList()
430 + : <String>[];
431 + final refs = <String>[];
432 + for (var index = 0; index < paths.length; index++) {
433 + final value = paths[index];
434 + if (value is! String) continue;
435 + if (value.startsWith('cloud://')) {
436 + refs.add(value);
437 + continue;
438 + }
439 + final normalized = p.normalize(value);
440 + if (!p.isWithin(attachmentsDir, normalized)) {
441 + _preserveUnavailableAttachment(
442 + refs,
443 + previousRefs,
444 + value,
445 + index,
446 + warnings,
447 + );
448 + continue;
449 + }
450 + final file = File(normalized);
451 + if (!await file.exists()) {
452 + _preserveUnavailableAttachment(
453 + refs,
454 + previousRefs,
455 + value,
456 + index,
457 + warnings,
458 + );
459 + continue;
460 + }
461 + final clear = await file.readAsBytes();
462 + final identifier = await _crypto.attachmentIdentifier(clear, masterKey);
463 + final key = repository.attachmentKey(identifier);
464 + if (!await repository.objectExists(key)) {
465 + _ensureActive(generation);
466 + final encrypted = await _crypto.encryptAttachment(
467 + clear,
468 + masterKey: masterKey,
469 + salt: salt,
470 + );
471 + try {
472 + await repository.writeObject(key, encrypted, createOnly: true);
473 + } on CloudSyncFailure catch (error) {
474 + if (error.kind != CloudSyncFailureKind.conflict) rethrow;
475 + }
476 + }
477 + final name = base64UrlEncode(
478 + utf8.encode(p.basename(normalized)),
479 + ).replaceAll('=', '');
480 + refs.add('cloud://$identifier/$name');
481 + }
482 + raw['attachmentPaths'] = refs;
483 + }
484 + }
485 +
486 + void _preserveUnavailableAttachment(
487 + List<String> refs,
488 + List<String> previousRefs,
489 + String localPath,
490 + int index,
491 + List<String> warnings,
492 + ) {
493 + final previous = _attachmentReferenceResolver.findPreviousReference(
494 + previousReferences: previousRefs,
495 + localPath: localPath,
496 + index: index,
497 + );
498 + if (previous == null) {
499 + throw const CloudSyncFailure(
500 + CloudSyncFailureKind.corruptedData,
501 + 'A local attachment is unavailable and has no prior cloud reference.',
502 + );
503 + }
504 + refs.add(previous);
505 + warnings.add('Preserved an unavailable attachment from cloud history.');
506 + }
507 +
508 + Future<void> _applySnapshot(
509 + CloudSyncSnapshot snapshot,
510 + S3CloudSyncRepository repository,
511 + List<int> masterKey,
512 + List<String> warnings,
513 + int generation,
514 + ) async {
515 + final payload = Map<String, dynamic>.from(snapshot.payload);
516 + await _materializeAttachments(payload, repository, masterKey);
517 + _ensureActive(generation);
518 + AppSettings? importedSettings;
519 + final remoteSettings = payload['settings'];
520 + if (remoteSettings is Map) {
521 + final current = ref.read(settingsProvider);
522 + final merged = <String, dynamic>{
523 + ...current.toMap(),
524 + ...Map<String, dynamic>.from(remoteSettings),
525 + 'huggingFaceToken': current.huggingFaceToken,
526 + 'defaultServerId': current.defaultServerId,
527 + 'hasCompletedOnboarding': current.hasCompletedOnboarding,
528 + 'hasAskedForNotifications': current.hasAskedForNotifications,
529 + };
530 + try {
531 + importedSettings = AppSettings.fromMap(merged);
532 + } catch (_) {
533 + throw const CloudSyncFailure(
534 + CloudSyncFailureKind.corruptedData,
535 + 'The encrypted cloud settings are invalid.',
536 + );
537 + }
538 + }
539 + _applyingRemote = true;
540 + try {
541 + await DataBackupService().importCloudSync(
542 + ref.read(databaseProvider).store,
543 + payload,
544 + );
545 + _ensureActive(generation);
546 + // Defer both the settings update and the async provider invalidations
547 + // until after the current build frame. If a subscriber widget (for
548 + // example SettingsViews) is rebuilt because settings changed, watching
549 + // the invalidated async providers inside the build can flush them and
550 + // cause Riverpod to call setState on UncontrolledProviderScope during
551 + // build, which throws.
552 + //
553 + // By moving the settings update into the post-frame callback alongside
554 + // the invalidations, every dependent rebuild happens on a subsequent
555 + // frame where the providers are already in their AsyncLoading state,
556 + // so reads no longer trigger an invalidation mid-build.
557 + WidgetsBinding.instance.addPostFrameCallback((_) async {
558 + if (!ref.mounted) return;
559 + if (importedSettings != null) {
560 + await ref
561 + .read(settingsProvider.notifier)
562 + .updateSettings(importedSettings);
563 + if (!ref.mounted) return;
564 + }
565 + ref.invalidate(conversationsProvider);
566 + ref.invalidate(personasNotifierProvider);
567 + ref.invalidate(savedMessagesProvider);
568 + ref.invalidate(savedMessageFoldersProvider);
569 + });
570 + } finally {
571 + _applyingRemote = false;
572 + }
573 + }
574 +
575 + Future<void> _materializeAttachments(
576 + Map<String, dynamic> payload,
577 + S3CloudSyncRepository repository,
578 + List<int> masterKey,
579 + ) async {
580 + final appDir = await getApplicationDocumentsDirectory();
581 + final dir = Directory(p.join(appDir.path, 'attachments', 'cloud-sync'));
582 + await dir.create(recursive: true);
583 + final messages = payload['messages'];
584 + if (messages is! List) return;
585 + for (final raw in messages.whereType<Map>()) {
586 + final paths = raw['attachmentPaths'];
587 + if (paths is! List) continue;
588 + final localPaths = <String>[];
589 + for (final value in paths.whereType<String>()) {
590 + final match = RegExp(
591 + r'^cloud://([A-Za-z0-9_-]+)/([A-Za-z0-9_-]+)$',
592 + ).firstMatch(value);
593 + if (match == null) continue;
594 + final identifier = match.group(1)!;
595 + final encodedName = match.group(2)!;
596 + final padded = encodedName.padRight(
597 + encodedName.length + ((4 - encodedName.length % 4) % 4),
598 + '=',
599 + );
600 + final decodedName = utf8.decode(base64Url.decode(padded));
601 + final safeName = p.basename(decodedName);
602 + final destination = File(p.join(dir.path, '$identifier-$safeName'));
603 + if (!await destination.exists()) {
604 + final remote = await repository.readObject(
605 + repository.attachmentKey(identifier),
606 + );
607 + if (remote == null) {
608 + throw const CloudSyncFailure(
609 + CloudSyncFailureKind.corruptedData,
610 + 'A required encrypted attachment is missing from S3.',
611 + );
612 + }
613 + final clear = await _crypto.decryptAttachment(
614 + remote.bytes,
615 + masterKey,
616 + );
617 + await destination.writeAsBytes(clear, flush: true);
618 + }
619 + localPaths.add(destination.path);
620 + }
621 + raw['attachmentPaths'] = localPaths;
622 + }
623 + }
624 +
625 + Future<void> disconnect() async {
626 + _operationGeneration++;
627 + _debounce?.cancel();
628 + _retryTimer?.cancel();
629 + await _local.disconnect();
630 + state = const CloudSyncStatus();
631 + }
632 +}
lib/features/cloud_sync/services/cloud_attachment_reference_resolver.dart new
+28
@@ -0,0 +1,28 @@
1 +import 'dart:convert';
2 +
3 +import 'package:path/path.dart' as p;
4 +
5 +class CloudAttachmentReferenceResolver {
6 + const CloudAttachmentReferenceResolver();
7 +
8 + String? findPreviousReference({
9 + required List<String> previousReferences,
10 + required String localPath,
11 + required int index,
12 + }) {
13 + final localName = p.basename(localPath);
14 + for (final candidate in previousReferences) {
15 + try {
16 + final encodedName = Uri.parse(candidate).pathSegments.last;
17 + final padded = encodedName.padRight(
18 + encodedName.length + ((4 - encodedName.length % 4) % 4),
19 + '=',
20 + );
21 + if (utf8.decode(base64Url.decode(padded)) == localName) {
22 + return candidate;
23 + }
24 + } catch (_) {}
25 + }
26 + return index < previousReferences.length ? previousReferences[index] : null;
27 + }
28 +}
lib/features/cloud_sync/services/cloud_sync_crypto_service.dart new
+171
@@ -0,0 +1,171 @@
1 +import 'dart:convert';
2 +import 'dart:io';
3 +
4 +import 'package:cryptography/cryptography.dart';
5 +import 'package:cryptography/helpers.dart';
6 +
7 +import '../data/models/cloud_sync_models.dart';
8 +
9 +class CloudSyncCryptoService {
10 + static const _magic = 'localmind-sync-v1';
11 + static const _memoryKiB = 32768;
12 + static const _iterations = 3;
13 + static const _parallelism = 2;
14 +
15 + final AesGcm _cipher = AesGcm.with256bits();
16 + final Hkdf _hkdf = Hkdf(hmac: Hmac.sha256(), outputLength: 32);
17 +
18 + List<int> newSalt() => randomBytes(16);
19 +
20 + Future<List<int>> deriveMasterKey(String passphrase, List<int> salt) async {
21 + if (passphrase.length < 8) {
22 + throw const CloudSyncFailure(
23 + CloudSyncFailureKind.validation,
24 + 'The encryption passphrase must be at least 8 characters.',
25 + );
26 + }
27 + final algorithm = Argon2id(
28 + memory: _memoryKiB,
29 + parallelism: _parallelism,
30 + iterations: _iterations,
31 + hashLength: 32,
32 + );
33 + final key = await algorithm.deriveKey(
34 + secretKey: SecretKey(utf8.encode(passphrase)),
35 + nonce: salt,
36 + );
37 + return key.extractBytes();
38 + }
39 +
40 + Future<SecretKey> _subkey(List<int> masterKey, String purpose) {
41 + return _hkdf.deriveKey(
42 + secretKey: SecretKey(masterKey),
43 + nonce: utf8.encode(_magic),
44 + info: utf8.encode(purpose),
45 + );
46 + }
47 +
48 + Future<List<int>> encryptState(
49 + List<int> clearText, {
50 + required List<int> masterKey,
51 + required List<int> salt,
52 + }) => _encrypt(clearText, masterKey: masterKey, salt: salt, kind: 'state');
53 +
54 + Future<List<int>> encryptAttachment(
55 + List<int> clearText, {
56 + required List<int> masterKey,
57 + required List<int> salt,
58 + }) =>
59 + _encrypt(clearText, masterKey: masterKey, salt: salt, kind: 'attachment');
60 +
61 + Future<List<int>> _encrypt(
62 + List<int> clearText, {
63 + required List<int> masterKey,
64 + required List<int> salt,
65 + required String kind,
66 + }) async {
67 + final header = <String, dynamic>{
68 + 'magic': _magic,
69 + 'version': 1,
70 + 'kind': kind,
71 + 'compression': 'gzip',
72 + 'salt': base64Encode(salt),
73 + 'argon2': {
74 + 'memory': _memoryKiB,
75 + 'iterations': _iterations,
76 + 'parallelism': _parallelism,
77 + },
78 + };
79 + final aad = utf8.encode(jsonEncode(header));
80 + final box = await _cipher.encrypt(
81 + gzip.encode(clearText),
82 + secretKey: await _subkey(masterKey, kind),
83 + aad: aad,
84 + );
85 + return utf8.encode(
86 + jsonEncode({
87 + ...header,
88 + 'nonce': base64Encode(box.nonce),
89 + 'ciphertext': base64Encode(box.cipherText),
90 + 'mac': base64Encode(box.mac.bytes),
91 + }),
92 + );
93 + }
94 +
95 + Future<List<int>> decryptState(List<int> envelope, List<int> masterKey) =>
96 + _decrypt(envelope, masterKey: masterKey, expectedKind: 'state');
97 +
98 + Future<List<int>> decryptAttachment(
99 + List<int> envelope,
100 + List<int> masterKey,
101 + ) => _decrypt(envelope, masterKey: masterKey, expectedKind: 'attachment');
102 +
103 + Future<List<int>> _decrypt(
104 + List<int> envelope, {
105 + required List<int> masterKey,
106 + required String expectedKind,
107 + }) async {
108 + try {
109 + final decoded = jsonDecode(utf8.decode(envelope));
110 + if (decoded is! Map ||
111 + decoded['magic'] != _magic ||
112 + decoded['version'] != 1 ||
113 + decoded['kind'] != expectedKind) {
114 + throw const FormatException('Unsupported encrypted payload');
115 + }
116 + final map = Map<String, dynamic>.from(decoded);
117 + final header = <String, dynamic>{
118 + 'magic': map['magic'],
119 + 'version': map['version'],
120 + 'kind': map['kind'],
121 + 'compression': map['compression'],
122 + 'salt': map['salt'],
123 + 'argon2': map['argon2'],
124 + };
125 + final box = SecretBox(
126 + base64Decode(map['ciphertext'] as String),
127 + nonce: base64Decode(map['nonce'] as String),
128 + mac: Mac(base64Decode(map['mac'] as String)),
129 + );
130 + final compressed = await _cipher.decrypt(
131 + box,
132 + secretKey: await _subkey(masterKey, expectedKind),
133 + aad: utf8.encode(jsonEncode(header)),
134 + );
135 + return gzip.decode(compressed);
136 + } on CloudSyncFailure {
137 + rethrow;
138 + } catch (_) {
139 + throw const CloudSyncFailure(
140 + CloudSyncFailureKind.passphrase,
141 + 'The passphrase is incorrect or the encrypted data was modified.',
142 + );
143 + }
144 + }
145 +
146 + List<int> extractSalt(List<int> envelope) {
147 + try {
148 + final decoded = jsonDecode(utf8.decode(envelope)) as Map<String, dynamic>;
149 + if (decoded['magic'] != _magic || decoded['version'] != 1) {
150 + throw const FormatException();
151 + }
152 + return base64Decode(decoded['salt'] as String);
153 + } catch (_) {
154 + throw const CloudSyncFailure(
155 + CloudSyncFailureKind.corruptedData,
156 + 'The remote sync data has an unsupported or corrupted header.',
157 + );
158 + }
159 + }
160 +
161 + Future<String> attachmentIdentifier(
162 + List<int> clearText,
163 + List<int> masterKey,
164 + ) async {
165 + final mac = await Hmac.sha256().calculateMac(
166 + clearText,
167 + secretKey: await _subkey(masterKey, 'attachment-id'),
168 + );
169 + return base64UrlEncode(mac.bytes).replaceAll('=', '');
170 + }
171 +}
lib/features/cloud_sync/services/cloud_sync_merge_service.dart new
+425
@@ -0,0 +1,425 @@
1 +import 'dart:convert';
2 +
3 +import 'package:cryptography/cryptography.dart';
4 +
5 +import '../data/models/cloud_sync_models.dart';
6 +
7 +class CloudSyncMergeResult {
8 + const CloudSyncMergeResult(this.snapshot, this.conflictCount);
9 +
10 + final CloudSyncSnapshot snapshot;
11 + final int conflictCount;
12 +}
13 +
14 +class CloudSyncMergeService {
15 + Future<String> fingerprint(Map<String, dynamic> payload) async {
16 + final canonical = _canonicalize(payload);
17 + final hash = await Sha256().hash(utf8.encode(jsonEncode(canonical)));
18 + return base64UrlEncode(hash.bytes).replaceAll('=', '');
19 + }
20 +
21 + Future<String> snapshotFingerprint(CloudSyncSnapshot snapshot) => _hashValue({
22 + 'payload': snapshot.payload,
23 + 'tombstones': snapshot.tombstones,
24 + });
25 +
26 + Future<Map<String, dynamic>> recordHashes(
27 + Map<String, dynamic> payload,
28 + ) async {
29 + final result = <String, dynamic>{};
30 + final messages = _groupByConversation(payload['messages']);
31 + for (final collection in const [
32 + 'personas',
33 + 'savedMessages',
34 + 'savedMessageFolders',
35 + 'conversationFolders',
36 + ]) {
37 + final hashes = <String, String>{};
38 + for (final item in _list(payload[collection])) {
39 + final id = item['id'] as String?;
40 + if (id != null) hashes[id] = await _hashValue(item);
41 + }
42 + result[collection] = hashes;
43 + }
44 + final conversationHashes = <String, String>{};
45 + for (final item in _list(payload['conversations'])) {
46 + final id = item['id'] as String?;
47 + if (id != null) {
48 + conversationHashes[id] = await _hashValue({
49 + 'conversation': item,
50 + 'messages': messages[id] ?? const [],
51 + });
52 + }
53 + }
54 + result['conversations'] = conversationHashes;
55 + return result;
56 + }
57 +
58 + Future<String> _hashValue(Map<String, dynamic> value) async {
59 + final hash = await Sha256().hash(
60 + utf8.encode(jsonEncode(_canonicalize(value))),
61 + );
62 + return base64UrlEncode(hash.bytes).replaceAll('=', '');
63 + }
64 +
65 + String? _baseHash(
66 + Map<String, dynamic> journal,
67 + String collection,
68 + String id,
69 + ) {
70 + final all = journal['recordHashes'];
71 + if (all is! Map || all[collection] is! Map) return null;
72 + return (all[collection] as Map)[id] as String?;
73 + }
74 +
75 + Map<String, dynamic> _canonicalize(Map<String, dynamic> payload) {
76 + dynamic normalize(dynamic value) {
77 + if (value is Map) {
78 + final keys = value.keys.map((e) => e.toString()).toList()..sort();
79 + return {for (final key in keys) key: normalize(value[key])};
80 + }
81 + if (value is List) {
82 + final normalized = value.map(normalize).toList();
83 + if (normalized.every((item) => item is Map && item['id'] is String)) {
84 + normalized.sort(
85 + (a, b) => (a['id'] as String).compareTo(b['id'] as String),
86 + );
87 + }
88 + return normalized;
89 + }
90 + return value;
91 + }
92 +
93 + return Map<String, dynamic>.from(normalize(payload) as Map);
94 + }
95 +
96 + Future<CloudSyncMergeResult> merge({
97 + required CloudSyncSnapshot local,
98 + required CloudSyncSnapshot? remote,
99 + required Map<String, dynamic> journal,
100 + }) async {
101 + if (remote == null) return CloudSyncMergeResult(local, 0);
102 +
103 + final localHash = await fingerprint(local.payload);
104 + final lastHash = journal['payloadHash'] as String?;
105 + final lastRemoteRevision = journal['remoteRevision'] as int?;
106 + final localChanged = lastHash == null || localHash != lastHash;
107 + final remoteChanged =
108 + lastRemoteRevision == null ||
109 + remote.revision != lastRemoteRevision ||
110 + remote.deviceId != journal['remoteDeviceId'];
111 +
112 + if (!localChanged) return CloudSyncMergeResult(remote, 0);
113 + if (!remoteChanged) return CloudSyncMergeResult(local, 0);
114 +
115 + final payload = Map<String, dynamic>.from(remote.payload);
116 + var conflicts = 0;
117 + payload['settings'] = lastHash == null
118 + ? remote.payload['settings']
119 + : local.updatedAt.isAfter(remote.updatedAt)
120 + ? local.payload['settings']
121 + : remote.payload['settings'];
122 +
123 + final personas = _mapById(remote.payload['personas']);
124 + for (final item in _list(local.payload['personas'])) {
125 + final id = item['id'] as String?;
126 + if (id == null) continue;
127 + final existing = personas[id];
128 + if (existing == null) {
129 + personas[id] = item;
130 + } else if (!_same(existing, item)) {
131 + final base = _baseHash(journal, 'personas', id);
132 + final localRecordHash = await _hashValue(item);
133 + final remoteRecordHash = await _hashValue(existing);
134 + if (base != null && localRecordHash == base) continue;
135 + if (base != null && remoteRecordHash == base) {
136 + personas[id] = item;
137 + continue;
138 + }
139 + conflicts++;
140 + final copy = Map<String, dynamic>.from(item);
141 + copy['id'] = _conflictId(id, local);
142 + copy['name'] = '${copy['name'] ?? 'Persona'} (Conflict)';
143 + personas[copy['id'] as String] = copy;
144 + }
145 + }
146 + payload['personas'] = personas.values.toList();
147 +
148 + final remoteConversations = _mapById(remote.payload['conversations']);
149 + final localConversations = _mapById(local.payload['conversations']);
150 + final remoteMessages = _groupByConversation(remote.payload['messages']);
151 + final localMessages = _groupByConversation(local.payload['messages']);
152 + final mergedConversations = Map<String, Map<String, dynamic>>.from(
153 + remoteConversations,
154 + );
155 + final mergedMessages = _mapById(remote.payload['messages']);
156 +
157 + for (final entry in localConversations.entries) {
158 + final remoteConversation = remoteConversations[entry.key];
159 + if (remoteConversation == null) {
160 + mergedConversations[entry.key] = entry.value;
161 + for (final message in localMessages[entry.key] ?? const []) {
162 + mergedMessages[message['id'] as String] = message;
163 + }
164 + continue;
165 + }
166 + final localThread = localMessages[entry.key] ?? const [];
167 + final remoteThread = remoteMessages[entry.key] ?? const [];
168 + final hasConflict =
169 + !_same(remoteConversation, entry.value) ||
170 + !_sameList(remoteThread, localThread);
171 + if (!hasConflict) continue;
172 +
173 + final base = _baseHash(journal, 'conversations', entry.key);
174 + final localRecordHash = await _hashValue({
175 + 'conversation': entry.value,
176 + 'messages': localThread,
177 + });
178 + final remoteRecordHash = await _hashValue({
179 + 'conversation': remoteConversation,
180 + 'messages': remoteThread,
181 + });
182 + if (base != null && localRecordHash == base) continue;
183 + if (base != null && remoteRecordHash == base) {
184 + mergedConversations[entry.key] = entry.value;
185 + for (final message in remoteThread) {
186 + mergedMessages.remove(message['id']);
187 + }
188 + for (final message in localThread) {
189 + mergedMessages[message['id'] as String] = message;
190 + }
191 + continue;
192 + }
193 +
194 + conflicts++;
195 + final newConversationId = _conflictId(entry.key, local);
196 + final idMap = <String, String>{};
197 + for (final message in localThread) {
198 + final oldId = message['id'] as String;
199 + idMap[oldId] = _conflictId(oldId, local);
200 + }
201 + final copy = Map<String, dynamic>.from(entry.value)
202 + ..['id'] = newConversationId
203 + ..['title'] = '${entry.value['title'] ?? 'Chat'} (Conflict)'
204 + ..['updatedAt'] = DateTime.now().toUtc().toIso8601String();
205 + mergedConversations[newConversationId] = copy;
206 + for (final message in localThread) {
207 + final cloned = Map<String, dynamic>.from(message)
208 + ..['id'] = idMap[message['id']]!
209 + ..['conversationId'] = newConversationId;
210 + final parent = message['parentMessageId'] as String?;
211 + if (parent != null) cloned['parentMessageId'] = idMap[parent];
212 + final variant = message['variantGroupId'] as String?;
213 + if (variant != null) {
214 + cloned['variantGroupId'] = _conflictId(variant, local);
215 + }
216 + mergedMessages[cloned['id'] as String] = cloned;
217 + }
218 + }
219 + payload['conversations'] = mergedConversations.values.toList();
220 + payload['messages'] = mergedMessages.values.toList();
221 +
222 + for (final collection in const [
223 + 'conversationFolders',
224 + 'savedMessageFolders',
225 + 'savedMessages',
226 + ]) {
227 + final records = _mapById(remote.payload[collection]);
228 + for (final item in _list(local.payload[collection])) {
229 + final id = item['id'] as String?;
230 + if (id == null) continue;
231 + final existing = records[id];
232 + if (existing == null) {
233 + records[id] = item;
234 + continue;
235 + }
236 + if (_same(existing, item)) continue;
237 + final base = _baseHash(journal, collection, id);
238 + if (base != null && await _hashValue(existing) == base) {
239 + records[id] = item;
240 + }
241 + }
242 + payload[collection] = records.values.toList();
243 + }
244 +
245 + Future<bool> changedFromBase(
246 + String collection,
247 + String id,
248 + Map<String, dynamic> record,
249 + ) async {
250 + final base = _baseHash(journal, collection, id);
251 + return base != null && await _hashValue(record) != base;
252 + }
253 +
254 + final mergedPersonaRecords = _mapById(payload['personas']);
255 + for (final pair in [(local, remote), (remote, local)]) {
256 + final deletingSnapshot = pair.$1;
257 + final editedSnapshot = pair.$2;
258 + final editedPersonas = _mapById(editedSnapshot.payload['personas']);
259 + for (final id in _tombstoneIds(deletingSnapshot, 'personas')) {
260 + final edited = editedPersonas[id];
261 + if (edited == null || !await changedFromBase('personas', id, edited)) {
262 + continue;
263 + }
264 + final copy = Map<String, dynamic>.from(edited);
265 + copy['id'] = _conflictId(id, editedSnapshot);
266 + copy['name'] = '${copy['name'] ?? 'Persona'} (Conflict)';
267 + mergedPersonaRecords[copy['id'] as String] = copy;
268 + conflicts++;
269 + }
270 + }
271 + payload['personas'] = mergedPersonaRecords.values.toList();
272 +
273 + final conflictConversations = _mapById(payload['conversations']);
274 + final conflictMessages = _mapById(payload['messages']);
275 + for (final pair in [(local, remote), (remote, local)]) {
276 + final deletingSnapshot = pair.$1;
277 + final editedSnapshot = pair.$2;
278 + final editedConversations = _mapById(
279 + editedSnapshot.payload['conversations'],
280 + );
281 + final editedMessages = _groupByConversation(
282 + editedSnapshot.payload['messages'],
283 + );
284 + for (final id in _tombstoneIds(deletingSnapshot, 'conversations')) {
285 + final edited = editedConversations[id];
286 + if (edited == null) continue;
287 + final thread = editedMessages[id] ?? const [];
288 + if (!await changedFromBase('conversations', id, {
289 + 'conversation': edited,
290 + 'messages': thread,
291 + })) {
292 + continue;
293 + }
294 + _cloneConversationBranch(
295 + conversation: edited,
296 + messages: thread,
297 + source: editedSnapshot,
298 + conversations: conflictConversations,
299 + mergedMessages: conflictMessages,
300 + );
301 + conflicts++;
302 + }
303 + }
304 + payload['conversations'] = conflictConversations.values.toList();
305 + payload['messages'] = conflictMessages.values.toList();
306 +
307 + final tombstones = <String, dynamic>{};
308 + for (final source in [remote.tombstones, local.tombstones]) {
309 + for (final entry in source.entries) {
310 + final values =
311 + (tombstones[entry.key] as List?)?.whereType<String>().toSet() ??
312 + <String>{};
313 + if (entry.value is List) {
314 + values.addAll((entry.value as List).whereType<String>());
315 + }
316 + tombstones[entry.key] = values.toList();
317 + }
318 + }
319 + _applyTombstones(payload, tombstones);
320 + return CloudSyncMergeResult(
321 + CloudSyncSnapshot(
322 + deviceId: local.deviceId,
323 + revision: remote.revision >= local.revision
324 + ? remote.revision + 1
325 + : local.revision,
326 + updatedAt: DateTime.now().toUtc(),
327 + payload: payload,
328 + tombstones: tombstones,
329 + ),
330 + conflicts,
331 + );
332 + }
333 +
334 + String _conflictId(String id, CloudSyncSnapshot local) =>
335 + '$id-conflict-${local.deviceId.substring(0, 8)}-${local.revision}';
336 +
337 + Set<String> _tombstoneIds(CloudSyncSnapshot snapshot, String collection) =>
338 + snapshot.tombstones[collection] is List
339 + ? (snapshot.tombstones[collection] as List).whereType<String>().toSet()
340 + : <String>{};
341 +
342 + void _cloneConversationBranch({
343 + required Map<String, dynamic> conversation,
344 + required List<Map<String, dynamic>> messages,
345 + required CloudSyncSnapshot source,
346 + required Map<String, Map<String, dynamic>> conversations,
347 + required Map<String, Map<String, dynamic>> mergedMessages,
348 + }) {
349 + final oldConversationId = conversation['id'] as String;
350 + final newConversationId = _conflictId(oldConversationId, source);
351 + final idMap = <String, String>{
352 + for (final message in messages)
353 + message['id'] as String: _conflictId(message['id'] as String, source),
354 + };
355 + conversations[newConversationId] = Map<String, dynamic>.from(conversation)
356 + ..['id'] = newConversationId
357 + ..['title'] = '${conversation['title'] ?? 'Chat'} (Conflict)'
358 + ..['updatedAt'] = DateTime.now().toUtc().toIso8601String();
359 + for (final message in messages) {
360 + final cloned = Map<String, dynamic>.from(message)
361 + ..['id'] = idMap[message['id']]!
362 + ..['conversationId'] = newConversationId;
363 + final parent = message['parentMessageId'] as String?;
364 + if (parent != null) cloned['parentMessageId'] = idMap[parent];
365 + final variant = message['variantGroupId'] as String?;
366 + if (variant != null) {
367 + cloned['variantGroupId'] = _conflictId(variant, source);
368 + }
369 + mergedMessages[cloned['id'] as String] = cloned;
370 + }
371 + }
372 +
373 + List<Map<String, dynamic>> _list(dynamic value) => value is List
374 + ? value.whereType<Map>().map(Map<String, dynamic>.from).toList()
375 + : <Map<String, dynamic>>[];
376 +
377 + Map<String, Map<String, dynamic>> _mapById(dynamic value) => {
378 + for (final item in _list(value))
379 + if (item['id'] is String) item['id'] as String: item,
380 + };
381 +
382 + Map<String, List<Map<String, dynamic>>> _groupByConversation(dynamic value) {
383 + final result = <String, List<Map<String, dynamic>>>{};
384 + for (final item in _list(value)) {
385 + final id = item['conversationId'] as String?;
386 + if (id != null) (result[id] ??= []).add(item);
387 + }
388 + for (final items in result.values) {
389 + items.sort((a, b) => '${a['id']}'.compareTo('${b['id']}'));
390 + }
391 + return result;
392 + }
393 +
394 + bool _same(Map<String, dynamic> a, Map<String, dynamic> b) =>
395 + jsonEncode(_canonicalize(a)) == jsonEncode(_canonicalize(b));
396 +
397 + bool _sameList(List<Map<String, dynamic>> a, List<Map<String, dynamic>> b) =>
398 + jsonEncode(a.map(_canonicalize).toList()) ==
399 + jsonEncode(b.map(_canonicalize).toList());
400 +
401 + void _applyTombstones(
402 + Map<String, dynamic> payload,
403 + Map<String, dynamic> tombstones,
404 + ) {
405 + for (final entry in tombstones.entries) {
406 + if (payload[entry.key] is! List || entry.value is! List) continue;
407 + final ids = (entry.value as List).whereType<String>().toSet();
408 + payload[entry.key] = (payload[entry.key] as List)
409 + .where((item) => item is! Map || !ids.contains(item['id']))
410 + .toList();
411 + }
412 + final deletedConversations = tombstones['conversations'] is List
413 + ? (tombstones['conversations'] as List).whereType<String>().toSet()
414 + : <String>{};
415 + if (deletedConversations.isNotEmpty && payload['messages'] is List) {
416 + payload['messages'] = (payload['messages'] as List)
417 + .where(
418 + (item) =>
419 + item is! Map ||
420 + !deletedConversations.contains(item['conversationId']),
421 + )
422 + .toList();
423 + }
424 + }
425 +}
lib/features/cloud_sync/views/cloud_sync_lifecycle_host.dart new
+78
@@ -0,0 +1,78 @@
1 +import 'dart:async';
2 +
3 +import 'package:flutter/material.dart';
4 +import 'package:flutter_riverpod/flutter_riverpod.dart';
5 +import 'package:objectbox/objectbox.dart';
6 +
7 +import '../../../core/providers/app_providers.dart';
8 +import '../../../core/providers/storage_providers.dart';
9 +import '../../../core/storage/entities.dart';
10 +import '../providers/cloud_sync_providers.dart';
11 +
12 +class CloudSyncLifecycleHost extends ConsumerStatefulWidget {
13 + const CloudSyncLifecycleHost({super.key, required this.child});
14 +
15 + final Widget child;
16 +
17 + @override
18 + ConsumerState<CloudSyncLifecycleHost> createState() =>
19 + _CloudSyncLifecycleHostState();
20 +}
21 +
22 +class _CloudSyncLifecycleHostState extends ConsumerState<CloudSyncLifecycleHost>
23 + with WidgetsBindingObserver {
24 + StreamSubscription<List<Type>>? _databaseSubscription;
25 + ProviderSubscription<dynamic>? _settingsSubscription;
26 +
27 + static const _syncedTypes = <Type>{
28 + ConversationEntity,
29 + MessageEntity,
30 + PersonaEntity,
31 + ConversationFolderEntity,
32 + SavedMessageEntity,
33 + SavedMessageFolderEntity,
34 + };
35 +
36 + @override
37 + void initState() {
38 + super.initState();
39 + WidgetsBinding.instance.addObserver(this);
40 + _databaseSubscription = ref
41 + .read(databaseProvider)
42 + .store
43 + .entityChanges
44 + .listen((types) {
45 + if (types.any(_syncedTypes.contains)) {
46 + ref.read(cloudSyncControllerProvider.notifier).scheduleSync();
47 + }
48 + });
49 + _settingsSubscription = ref.listenManual(settingsProvider, (_, next) {
50 + ref.read(cloudSyncControllerProvider.notifier).scheduleSync();
51 + });
52 + WidgetsBinding.instance.addPostFrameCallback((_) {
53 + ref
54 + .read(cloudSyncControllerProvider.notifier)
55 + .scheduleSync(delay: Duration.zero);
56 + });
57 + }
58 +
59 + @override
60 + void didChangeAppLifecycleState(AppLifecycleState state) {
61 + if (state == AppLifecycleState.resumed) {
62 + ref
63 + .read(cloudSyncControllerProvider.notifier)
64 + .scheduleSync(delay: Duration.zero);
65 + }
66 + }
67 +
68 + @override
69 + void dispose() {
70 + WidgetsBinding.instance.removeObserver(this);
71 + unawaited(_databaseSubscription?.cancel());
72 + _settingsSubscription?.close();
73 + super.dispose();
74 + }
75 +
76 + @override
77 + Widget build(BuildContext context) => widget.child;
78 +}
lib/features/cloud_sync/views/cloud_sync_screen.dart new
+301
@@ -0,0 +1,301 @@
1 +import 'package:flutter/material.dart';
2 +import 'package:flutter_riverpod/flutter_riverpod.dart';
3 +import 'package:go_router/go_router.dart';
4 +
5 +import '../../../core/routes/app_routes.dart';
6 +import '../../../l10n/app_localizations.dart';
7 +import '../data/models/cloud_sync_models.dart';
8 +import '../providers/cloud_sync_providers.dart';
9 +import 'components/cloud_sync_status_card.dart';
10 +
11 +class CloudSyncScreen extends ConsumerStatefulWidget {
12 + const CloudSyncScreen({super.key});
13 +
14 + @override
15 + ConsumerState<CloudSyncScreen> createState() => _CloudSyncScreenState();
16 +}
17 +
18 +class _CloudSyncScreenState extends ConsumerState<CloudSyncScreen> {
19 + final _formKey = GlobalKey<FormState>();
20 + late final TextEditingController _endpoint;
21 + late final TextEditingController _bucket;
22 + late final TextEditingController _region;
23 + late final TextEditingController _prefix;
24 + final _accessKey = TextEditingController();
25 + final _secretKey = TextEditingController();
26 + final _sessionToken = TextEditingController();
27 + final _passphrase = TextEditingController();
28 + final _confirmPassphrase = TextEditingController();
29 + bool _pathStyle = true;
30 + bool _allowHttp = false;
31 + bool _working = false;
32 +
33 + @override
34 + void initState() {
35 + super.initState();
36 + final config = ref.read(cloudSyncControllerProvider.notifier).config;
37 + _endpoint = TextEditingController(
38 + text: config?.endpoint ?? 'https://s3.amazonaws.com',
39 + );
40 + _bucket = TextEditingController(text: config?.bucket ?? '');
41 + _region = TextEditingController(text: config?.region ?? 'us-east-1');
42 + _prefix = TextEditingController(text: config?.prefix ?? 'localmind');
43 + _pathStyle = config?.pathStyle ?? true;
44 + _allowHttp = config?.allowInsecureHttp ?? false;
45 + }
46 +
47 + @override
48 + void dispose() {
49 + _endpoint.dispose();
50 + _bucket.dispose();
51 + _region.dispose();
52 + _prefix.dispose();
53 + _accessKey.dispose();
54 + _secretKey.dispose();
55 + _sessionToken.dispose();
56 + _passphrase.dispose();
57 + _confirmPassphrase.dispose();
58 + super.dispose();
59 + }
60 +
61 + S3SyncConfig get _config => S3SyncConfig(
62 + endpoint: _endpoint.text.trim(),
63 + bucket: _bucket.text.trim(),
64 + region: _region.text.trim(),
65 + prefix: _prefix.text.trim(),
66 + pathStyle: _pathStyle,
67 + allowInsecureHttp: _allowHttp,
68 + );
69 +
70 + CloudSyncCredentials get _credentials => CloudSyncCredentials(
71 + accessKeyId: _accessKey.text.trim(),
72 + secretAccessKey: _secretKey.text,
73 + sessionToken: _sessionToken.text.trim().isEmpty
74 + ? null
75 + : _sessionToken.text.trim(),
76 + );
77 +
78 + Future<void> _run(Future<void> Function() action) async {
79 + if (!_formKey.currentState!.validate()) return;
80 + setState(() => _working = true);
81 + try {
82 + await action();
83 + if (mounted) {
84 + ScaffoldMessenger.of(context).showSnackBar(
85 + const SnackBar(content: Text('S3 connection succeeded.')),
86 + );
87 + }
88 + } catch (error) {
89 + if (mounted) {
90 + ScaffoldMessenger.of(
91 + context,
92 + ).showSnackBar(SnackBar(content: Text(error.toString())));
93 + }
94 + } finally {
95 + if (mounted) setState(() => _working = false);
96 + }
97 + }
98 +
99 + @override
100 + Widget build(BuildContext context) {
101 + final l10n = AppLocalizations.of(context)!;
102 + final status = ref.watch(cloudSyncControllerProvider);
103 + final enabled =
104 + ref.read(cloudSyncControllerProvider.notifier).config?.enabled == true;
105 + const fieldSpacing = SizedBox(height: 16);
106 + return Column(
107 + children: [
108 + SafeArea(
109 + bottom: false,
110 + child: Padding(
111 + padding: const EdgeInsets.fromLTRB(8, 8, 16, 8),
112 + child: Row(
113 + children: [
114 + IconButton(
115 + onPressed: () => context.canPop()
116 + ? context.pop()
117 + : context.go(AppRoutes.settings),
118 + icon: const Icon(Icons.arrow_back),
119 + ),
120 + Expanded(
121 + child: Text(
122 + l10n.cloud_sync,
123 + style: Theme.of(context).textTheme.titleLarge,
124 + overflow: TextOverflow.ellipsis,
125 + ),
126 + ),
127 + ],
128 + ),
129 + ),
130 + ),
131 + Expanded(
132 + child: Form(
133 + key: _formKey,
134 + child: ListView(
135 + padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
136 + children: [
137 + Center(
138 + child: ConstrainedBox(
139 + constraints: const BoxConstraints(maxWidth: 720),
140 + child: Column(
141 + crossAxisAlignment: CrossAxisAlignment.stretch,
142 + children: [
143 + CloudSyncStatusCard(status: status),
144 + const SizedBox(height: 20),
145 + if (!enabled) ...[
146 + TextFormField(
147 + controller: _endpoint,
148 + decoration: InputDecoration(
149 + labelText: l10n.cloud_sync_endpoint,
150 + ),
151 + keyboardType: TextInputType.url,
152 + validator: (_) => _config.validate(),
153 + ),
154 + fieldSpacing,
155 + TextFormField(
156 + controller: _bucket,
157 + decoration: InputDecoration(
158 + labelText: l10n.cloud_sync_bucket,
159 + ),
160 + ),
161 + fieldSpacing,
162 + TextFormField(
163 + controller: _region,
164 + decoration: InputDecoration(
165 + labelText: l10n.cloud_sync_region,
166 + ),
167 + ),
168 + fieldSpacing,
169 + TextFormField(
170 + controller: _prefix,
171 + decoration: InputDecoration(
172 + labelText: l10n.cloud_sync_prefix,
173 + ),
174 + ),
175 + fieldSpacing,
176 + TextFormField(
177 + controller: _accessKey,
178 + decoration: InputDecoration(
179 + labelText: l10n.cloud_sync_access_key,
180 + ),
181 + ),
182 + fieldSpacing,
183 + TextFormField(
184 + controller: _secretKey,
185 + obscureText: true,
186 + decoration: InputDecoration(
187 + labelText: l10n.cloud_sync_secret_key,
188 + ),
189 + ),
190 + fieldSpacing,
191 + TextFormField(
192 + controller: _sessionToken,
193 + obscureText: true,
194 + decoration: InputDecoration(
195 + labelText: l10n.cloud_sync_session_token,
196 + ),
197 + ),
198 + fieldSpacing,
199 + TextFormField(
200 + controller: _passphrase,
201 + obscureText: true,
202 + decoration: InputDecoration(
203 + labelText: l10n.cloud_sync_passphrase,
204 + ),
205 + validator: (value) => (value?.length ?? 0) < 8
206 + ? 'Use at least 8 characters.'
207 + : null,
208 + ),
209 + fieldSpacing,
210 + TextFormField(
211 + controller: _confirmPassphrase,
212 + obscureText: true,
213 + decoration: InputDecoration(
214 + labelText: l10n.cloud_sync_confirm_passphrase,
215 + ),
216 + validator: (value) => value != _passphrase.text
217 + ? l10n.cloud_sync_passphrase_mismatch
218 + : null,
219 + ),
220 + const SizedBox(height: 8),
221 + SwitchListTile(
222 + contentPadding: EdgeInsets.zero,
223 + title: Text(l10n.cloud_sync_path_style),
224 + value: _pathStyle,
225 + onChanged: (value) =>
226 + setState(() => _pathStyle = value),
227 + ),
228 + SwitchListTile(
229 + contentPadding: EdgeInsets.zero,
230 + title: Text(l10n.cloud_sync_allow_http),
231 + subtitle: Text(l10n.cloud_sync_http_warning),
232 + value: _allowHttp,
233 + onChanged: (value) =>
234 + setState(() => _allowHttp = value),
235 + ),
236 + const SizedBox(height: 12),
237 + OutlinedButton(
238 + onPressed: _working
239 + ? null
240 + : () => _run(
241 + () => ref
242 + .read(
243 + cloudSyncControllerProvider.notifier,
244 + )
245 + .testConnection(_config, _credentials),
246 + ),
247 + child: Text(l10n.cloud_sync_test),
248 + ),
249 + const SizedBox(height: 12),
250 + FilledButton(
251 + onPressed: _working
252 + ? null
253 + : () => _run(
254 + () => ref
255 + .read(
256 + cloudSyncControllerProvider.notifier,
257 + )
258 + .configure(
259 + config: _config,
260 + credentials: _credentials,
261 + passphrase: _passphrase.text,
262 + ),
263 + ),
264 + child: Text(l10n.cloud_sync_enable),
265 + ),
266 + ] else ...[
267 + FilledButton.icon(
268 + onPressed: status.phase == CloudSyncPhase.syncing
269 + ? null
270 + : () => ref
271 + .read(
272 + cloudSyncControllerProvider.notifier,
273 + )
274 + .syncNow(),
275 + icon: const Icon(Icons.sync),
276 + label: Text(l10n.cloud_sync_now),
277 + ),
278 + const SizedBox(height: 8),
279 + OutlinedButton(
280 + onPressed: status.phase == CloudSyncPhase.syncing
281 + ? null
282 + : () => ref
283 + .read(
284 + cloudSyncControllerProvider.notifier,
285 + )
286 + .disconnect(),
287 + child: Text(l10n.cloud_sync_disconnect),
288 + ),
289 + ],
290 + ],
291 + ),
292 + ),
293 + ),
294 + ],
295 + ),
296 + ),
297 + ),
298 + ],
299 + );
300 + }
301 +}
lib/features/cloud_sync/views/components/cloud_sync_status_card.dart new
+64
@@ -0,0 +1,64 @@
1 +import 'package:flutter/material.dart';
2 +
3 +import '../../../../l10n/app_localizations.dart';
4 +import '../../data/models/cloud_sync_models.dart';
5 +
6 +class CloudSyncStatusCard extends StatelessWidget {
7 + const CloudSyncStatusCard({super.key, required this.status});
8 +
9 + final CloudSyncStatus status;
10 +
11 + @override
12 + Widget build(BuildContext context) {
13 + final l10n = AppLocalizations.of(context)!;
14 + final color = switch (status.phase) {
15 + CloudSyncPhase.synced => Colors.green,
16 + CloudSyncPhase.syncing => Colors.blue,
17 + CloudSyncPhase.error || CloudSyncPhase.locked => Colors.red,
18 + _ => Colors.grey,
19 + };
20 + return Card(
21 + child: Padding(
22 + padding: const EdgeInsets.all(16),
23 + child: Column(
24 + crossAxisAlignment: CrossAxisAlignment.start,
25 + children: [
26 + Row(
27 + children: [
28 + Icon(Icons.cloud_done_outlined, color: color),
29 + const SizedBox(width: 10),
30 + Expanded(
31 + child: Text(
32 + status.message ?? l10n.cloud_sync_description,
33 + style: const TextStyle(fontWeight: FontWeight.w600),
34 + ),
35 + ),
36 + if (status.phase == CloudSyncPhase.syncing)
37 + const SizedBox(
38 + width: 18,
39 + height: 18,
40 + child: CircularProgressIndicator(strokeWidth: 2),
41 + ),
42 + ],
43 + ),
44 + const SizedBox(height: 8),
45 + Text(
46 + '${l10n.cloud_sync_last_synced}: '
47 + '${status.lastSyncedAt?.toLocal().toString() ?? l10n.cloud_sync_never}',
48 + ),
49 + if (status.conflictCount > 0)
50 + Text('${l10n.cloud_sync_conflicts}: ${status.conflictCount}'),
51 + for (final warning in status.warnings)
52 + Padding(
53 + padding: const EdgeInsets.only(top: 4),
54 + child: Text(
55 + warning,
56 + style: const TextStyle(color: Colors.orange),
57 + ),
58 + ),
59 + ],
60 + ),
61 + ),
62 + );
63 + }
64 +}
lib/features/conversations/providers/conversation_providers.dart
+21 -6
@@ -580,15 +580,30 @@ class ActiveConversationNotifier extends Notifier<Conversation?> {
580
581 @override
582 Conversation? build() {
583 - final conversationsAsync = ref.watch(conversationsProvider);
583 + // Use ref.listen instead of ref.watch to avoid triggering invalidateSelf
584 + // during a rebuild cycle, which causes a Riverpod assertion error on
585 + // pausedActiveSubscriptionCount.
586 + ref.listen<AsyncValue<List<Conversation>>>(conversationsProvider, (prev, next) {
587 + if (next.hasValue) {
588 + Future.microtask(() => _updateFromConversations(next.value!));
589 + }
590 + });
591 +
592 + final conversationsAsync = ref.read(conversationsProvider);
593 final conversations = conversationsAsync.value ?? [];
594 + return _resolveConversation(conversations, _activeConversationId);
595 + }
596 +
597 + Conversation? _resolveConversation(List<Conversation> conversations, String? id) {
598 + if (id == null) return null;
599 + return conversations.where((c) => c.id == id).firstOrNull;
600 + }
601
586 - if (_activeConversationId != null) {
587 - return conversations
588 - .where((c) => c.id == _activeConversationId)
589 - .firstOrNull;
602 + void _updateFromConversations(List<Conversation> conversations) {
603 + final resolved = _resolveConversation(conversations, _activeConversationId);
604 + if (resolved?.id != state?.id) {
605 + state = resolved;
606 }
591 - return null;
607 }
608
609 void setActiveConversation(Conversation? conversation) {
lib/features/personas/providers/personas_providers.dart
+14 -9
@@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
2
3 import '../../../core/providers/storage_providers.dart';
4 import '../../../core/storage/entities.dart';
5 +import '../../../core/storage/objectbox_store.dart';
6 import '../../../objectbox.g.dart';
7 import '../data/models/persona.dart';
8 import '../utils/persona_prompt_utils.dart';
@@ -84,16 +85,20 @@ final personasNotifierProvider =
85 class PersonasNotifier extends AsyncNotifier<List<Persona>> {
86 @override
87 Future<List<Persona>> build() async {
87 - return _loadAndSeed();
88 + return _loadAll();
89 }
90
90 - Future<List<Persona>> _loadAndSeed() async {
91 - final db = ref.read(databaseProvider);
91 + /// Seeds built-in personas when the database is first created. Called once
92 + /// during app bootstrap so the provider [build] stays a pure read.
93 + static void seedIfNeeded(ObjectBoxStore db) {
94 if (db.personaBox.isEmpty()) {
93 - for (final preset in _builtInPersonas) {
95 + for (final preset in builtInPersonas) {
96 db.personaBox.put(PersonaEntity.fromDomain(preset));
97 }
98 }
99 + }
100 +
101 + Future<List<Persona>> _loadAll() async {
102 return _loadWithoutSeeding();
103 }
104
@@ -120,7 +125,7 @@ class PersonasNotifier extends AsyncNotifier<List<Persona>> {
125 preferredParams: preferredParams,
126 );
127 db.personaBox.put(PersonaEntity.fromDomain(persona));
123 - state = AsyncData(await _loadAndSeed());
128 + state = AsyncData(await _loadAll());
129 return persona;
130 }
131
@@ -140,7 +145,7 @@ class PersonasNotifier extends AsyncNotifier<List<Persona>> {
145 }
146 db.personaBox.put(entity);
147
143 - state = AsyncData(await _loadAndSeed());
148 + state = AsyncData(await _loadAll());
149 }
150
151 Future<void> deletePersona(String id) async {
@@ -166,7 +171,7 @@ class PersonasNotifier extends AsyncNotifier<List<Persona>> {
171 Future<void> restoreBuiltInPersonas() async {
172 final db = ref.read(databaseProvider);
173 final existingIds = db.personaBox.getAll().map((e) => e.id).toSet();
169 - for (final preset in _builtInPersonas) {
174 + for (final preset in builtInPersonas) {
175 if (!existingIds.contains(preset.id)) {
176 db.personaBox.put(PersonaEntity.fromDomain(preset));
177 }
@@ -209,11 +214,11 @@ class PersonasNotifier extends AsyncNotifier<List<Persona>> {
214 );
215 final db = ref.read(databaseProvider);
216 db.personaBox.put(PersonaEntity.fromDomain(clone));
212 - state = AsyncData(await _loadAndSeed());
217 + state = AsyncData(await _loadAll());
218 return clone;
219 }
220
216 - static final List<Persona> _builtInPersonas = [
221 + static final List<Persona> builtInPersonas = [
222 Persona(
223 id: 'builtin-general',
224 name: 'General Assistant',
lib/features/servers/providers/server_providers.dart
-32
@@ -14,8 +14,6 @@ import '../../on_device/data/models/on_device_model.dart';
14
15 final _modelCache = ModelCache();
16
17 -bool _onDeviceServerEnsured = false;
18 -
17 void invalidateAvailableModelsCache(String serverId) {
18 _modelCache.invalidate(serverId);
19 }
@@ -24,36 +22,6 @@ void invalidateAllAvailableModelsCache() {
22 _modelCache.invalidateAll();
23 }
24
27 -final ensureOnDeviceServerProvider = FutureProvider<void>((ref) async {
28 - if (_onDeviceServerEnsured) return;
29 -
30 - final serversAsync = ref.watch(serversProvider);
31 -
32 - if (!serversAsync.hasValue) return;
33 -
34 - final servers = serversAsync.value!;
35 - final hasOnDevice = servers.any((s) => s.type == ServerType.onDevice);
36 - if (hasOnDevice) {
37 - _onDeviceServerEnsured = true;
38 - return;
39 - }
40 -
41 - final server = Server(
42 - id: 'on-device',
43 - name: 'On-Device',
44 - type: ServerType.onDevice,
45 - host: '',
46 - port: 0,
47 - isDefault: false,
48 - createdAt: DateTime.now(),
49 - lastConnectedAt: DateTime.now(),
50 - status: ConnectionStatus.connected,
51 - iconName: 'strokeRoundedSmartPhone01',
52 - );
53 - await ref.read(serversProvider.notifier).addServer(server);
54 - _onDeviceServerEnsured = true;
55 -});
56 -
25 final serversProvider = AsyncNotifierProvider<ServersNotifier, List<Server>>(
26 () {
27 return ServersNotifier();
lib/features/settings/views/data_backup_actions.dart
+8 -4
@@ -181,10 +181,14 @@ class DataBackupActions extends ConsumerWidget {
181 await _applySettingsPayload(ref, decoded);
182 }
183
184 - ref.invalidate(conversationsProvider);
185 - ref.invalidate(personasNotifierProvider);
186 - ref.invalidate(savedMessagesProvider);
187 - ref.invalidate(savedMessageFoldersProvider);
184 + // Defer invalidations until after the current build frame so widgets that
185 + // watch these providers (e.g. SettingsViews) don't flush them during build.
186 + WidgetsBinding.instance.addPostFrameCallback((_) {
187 + ref.invalidate(conversationsProvider);
188 + ref.invalidate(personasNotifierProvider);
189 + ref.invalidate(savedMessagesProvider);
190 + ref.invalidate(savedMessageFoldersProvider);
191 + });
192
193 if (context.mounted) {
194 ScaffoldMessenger.of(context).showSnackBar(
lib/features/settings/views/settings_screen.dart
+42 -29
@@ -20,7 +20,6 @@ import '../../servers/providers/server_providers.dart';
20 import '../data/models/app_settings.dart';
21 import 'data_backup_actions.dart';
22
23 -
23 class SettingsViews extends ConsumerWidget {
24 const SettingsViews({super.key});
25
@@ -395,8 +394,9 @@ class SettingsViews extends ConsumerWidget {
394 if (context.mounted) {
395 ScaffoldMessenger.of(context).showSnackBar(
396 SnackBar(
398 - content:
399 - Text(l10n.restore_builtin_personas_success),
397 + content: Text(
398 + l10n.restore_builtin_personas_success,
399 + ),
400 ),
401 );
402 }
@@ -426,6 +426,12 @@ class SettingsViews extends ConsumerWidget {
426 icon: HugeIcons.strokeRoundedRefresh,
427 accent: const Color(0xFFEF4444),
428 children: [
429 + _SectionActionButton(
430 + icon: HugeIcons.strokeRoundedCloud,
431 + label: l10n.cloud_sync,
432 + onPressed: () => context.push(AppRoutes.cloudSync),
433 + ),
434 + const SizedBox(height: 8),
435 const DataBackupActions(),
436 const SizedBox(height: 8),
437 _DangerousAction(
@@ -530,8 +536,8 @@ class SettingsViews extends ConsumerWidget {
536 aboutCard,
537 ],
538 ),
533 - const SizedBox(height: 24),
534 - _PrivacyPolicyLink(),
539 + const SizedBox(height: 24),
540 + _PrivacyPolicyLink(),
541 ],
542 ),
543 ),
@@ -593,8 +599,8 @@ class _SettingsHero extends StatelessWidget {
599 borderRadius: BorderRadius.circular(12),
600 ),
601 child: Center(
596 - child: HugeIcon(icon:
597 - HugeIcons.strokeRoundedSettings01,
602 + child: HugeIcon(
603 + icon: HugeIcons.strokeRoundedSettings01,
604 size: 18,
605 color: primary,
606 ),
@@ -787,8 +793,8 @@ class _MutedCallout extends StatelessWidget {
793 child: Row(
794 crossAxisAlignment: CrossAxisAlignment.start,
795 children: [
790 - HugeIcon(icon:
791 - HugeIcons.strokeRoundedMoon02,
796 + HugeIcon(
797 + icon: HugeIcons.strokeRoundedMoon02,
798 size: 18,
799 color: _mutedColor(context),
800 ),
@@ -856,8 +862,10 @@ class _AboutPanel extends StatelessWidget {
862 runSpacing: 6,
863 children: providers
864 .map(
859 - (provider) =>
860 - _AboutChip(label: provider, icon: HugeIcons.strokeRoundedShare01),
865 + (provider) => _AboutChip(
866 + label: provider,
867 + icon: HugeIcons.strokeRoundedShare01,
868 + ),
869 )
870 .toList(),
871 ),
@@ -884,7 +892,10 @@ class _AboutPanel extends StatelessWidget {
892 runSpacing: 6,
893 children: stack
894 .map(
887 - (item) => _AboutChip(label: item, icon: HugeIcons.strokeRoundedCode),
895 + (item) => _AboutChip(
896 + label: item,
897 + icon: HugeIcons.strokeRoundedCode,
898 + ),
899 )
900 .toList(),
901 ),
@@ -900,8 +911,8 @@ class _AboutPanel extends StatelessWidget {
911 child: Row(
912 crossAxisAlignment: CrossAxisAlignment.start,
913 children: [
903 - HugeIcon(icon:
904 - HugeIcons.strokeRoundedShare01,
914 + HugeIcon(
915 + icon: HugeIcons.strokeRoundedShare01,
916 size: 15,
917 color: _mutedColor(context),
918 ),
@@ -1016,8 +1027,8 @@ class _LanguageSetting extends StatelessWidget {
1027 value: null,
1028 child: Row(
1029 children: [
1019 - HugeIcon(icon:
1020 - HugeIcons.strokeRoundedSettings01,
1030 + HugeIcon(
1031 + icon: HugeIcons.strokeRoundedSettings01,
1032 size: 18,
1033 color: _mutedColor(context),
1034 ),
@@ -1320,10 +1331,7 @@ class _ImageCompressionLevelSetting extends StatelessWidget {
1331 }
1332
1333 class _TtsSkipSecondsSetting extends StatelessWidget {
1323 - const _TtsSkipSecondsSetting({
1324 - required this.value,
1325 - required this.onChanged,
1326 - });
1334 + const _TtsSkipSecondsSetting({required this.value, required this.onChanged});
1335
1336 final int value;
1337 final ValueChanged<int> onChanged;
@@ -1529,8 +1537,8 @@ class _ThemeOption extends StatelessWidget {
1537 borderRadius: BorderRadius.circular(10),
1538 ),
1539 child: Center(
1532 - child: HugeIcon(icon:
1533 - icon,
1540 + child: HugeIcon(
1541 + icon: icon,
1542 size: 16,
1543 color: isSelected ? primary : _mutedColor(context),
1544 ),
@@ -1546,8 +1554,8 @@ class _ThemeOption extends StatelessWidget {
1554 ),
1555 ),
1556 ),
1549 - HugeIcon(icon:
1550 - isSelected
1557 + HugeIcon(
1558 + icon: isSelected
1559 ? HugeIcons.strokeRoundedCheckmarkCircle01
1560 : HugeIcons.strokeRoundedCircle,
1561 size: 16,
@@ -1611,7 +1619,11 @@ class _DropdownSetting extends StatelessWidget {
1619 value: item.$1,
1620 child: Row(
1621 children: [
1614 - HugeIcon(icon: icon, size: 16, color: _mutedColor(context)),
1622 + HugeIcon(
1623 + icon: icon,
1624 + size: 16,
1625 + color: _mutedColor(context),
1626 + ),
1627 const SizedBox(width: 8),
1628 Flexible(
1629 child: Text(
@@ -2006,8 +2018,8 @@ class _VoiceChip extends StatelessWidget {
2018 child: Row(
2019 mainAxisSize: MainAxisSize.min,
2020 children: [
2009 - HugeIcon(icon:
2010 - selected
2021 + HugeIcon(
2022 + icon: selected
2023 ? HugeIcons.strokeRoundedCheckmarkCircle01
2024 : HugeIcons.strokeRoundedCircle,
2025 size: 16,
@@ -2279,7 +2291,8 @@ class _PrivacyPolicyLink extends StatelessWidget {
2291
2292 return Center(
2293 child: InkWell(
2282 - onTap: () => launchUrl(_privacyUrl, mode: LaunchMode.externalApplication),
2294 + onTap: () =>
2295 + launchUrl(_privacyUrl, mode: LaunchMode.externalApplication),
2296 borderRadius: BorderRadius.circular(8),
2297 child: Padding(
2298 padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
@@ -2376,4 +2389,4 @@ String _languageLabel(String? localeCode, AppLocalizations l10n) {
2389 }
2390
2391 return localeCode.toUpperCase();
2379 -}
\ No newline at end of file
2392 +}
lib/features/sidebar/sidebar_widget.dart
+30 -6
@@ -34,7 +34,8 @@ class SidebarWidget extends ConsumerWidget {
34 final isPersonas = location.startsWith(AppRoutes.personas);
35 final isLocalModels = location.startsWith(AppRoutes.onDeviceModels);
36 final isTtsModels = location.startsWith(AppRoutes.ttsModels);
37 - final isSettings = location.startsWith(AppRoutes.settings);
37 + final isCloudSync = location.startsWith(AppRoutes.cloudSync);
38 + final isSettings = location == AppRoutes.settings;
39 final isHome = location == AppRoutes.home || location == '/';
40 final hasActiveChat = ref.watch(hasActiveChatSessionProvider);
41 final isTemporary = ref.watch(chatProvider.select((s) => s.isTemporary));
@@ -60,7 +61,10 @@ class SidebarWidget extends ConsumerWidget {
61 padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
62 child: ShadButton(
63 width: double.infinity,
63 - leading: const HugeIcon(icon: HugeIcons.strokeRoundedAdd01, size: 20),
64 + leading: const HugeIcon(
65 + icon: HugeIcons.strokeRoundedAdd01,
66 + size: 20,
67 + ),
68 onPressed: () {
69 ref.read(chatProvider.notifier).startNewConversation();
70 context.go(AppRoutes.home);
@@ -78,7 +82,10 @@ class SidebarWidget extends ConsumerWidget {
82 child: isTemporary
83 ? ShadButton.outline(
84 width: double.infinity,
81 - leading: const HugeIcon(icon: HugeIcons.strokeRoundedArrowLeft01, size: 18),
85 + leading: const HugeIcon(
86 + icon: HugeIcons.strokeRoundedArrowLeft01,
87 + size: 18,
88 + ),
89 onPressed: () {
90 if (!isHome) context.go(AppRoutes.home);
91 if (Scaffold.maybeOf(context)?.isDrawerOpen ??
@@ -90,7 +97,10 @@ class SidebarWidget extends ConsumerWidget {
97 )
98 : ShadButton.secondary(
99 width: double.infinity,
93 - leading: const HugeIcon(icon: HugeIcons.strokeRoundedArrowLeft01, size: 18),
100 + leading: const HugeIcon(
101 + icon: HugeIcons.strokeRoundedArrowLeft01,
102 + size: 18,
103 + ),
104 onPressed: () {
105 if (!isHome) context.go(AppRoutes.home);
106 if (Scaffold.maybeOf(context)?.isDrawerOpen ??
@@ -161,9 +171,12 @@ class SidebarWidget extends ConsumerWidget {
171 iconData: HugeIcons.strokeRoundedAiSearch,
172 label: l10n.lm_studio_model_search,
173 isSelected: false,
164 - trailing: const LmDownloadIndicatorButton(compact: true),
174 + trailing: const LmDownloadIndicatorButton(
175 + compact: true,
176 + ),
177 onTap: () {
166 - if (Scaffold.maybeOf(context)?.isDrawerOpen ?? false) {
178 + if (Scaffold.maybeOf(context)?.isDrawerOpen ??
179 + false) {
180 Navigator.pop(context);
181 }
182 context.push(
@@ -208,6 +221,17 @@ class SidebarWidget extends ConsumerWidget {
221 const SizedBox(height: 8),
222 const Divider(height: 1, indent: 16, endIndent: 16),
223 const SizedBox(height: 8),
224 + DrawerNavItem(
225 + iconData: HugeIcons.strokeRoundedCloudSavingDone02,
226 + label: l10n.cloud_sync,
227 + isSelected: isCloudSync,
228 + onTap: () {
229 + if (Scaffold.maybeOf(context)?.isDrawerOpen ?? false) {
230 + Navigator.pop(context);
231 + }
232 + context.go(AppRoutes.cloudSync);
233 + },
234 + ),
235 DrawerNavItem(
236 iconData: HugeIcons.strokeRoundedSettings01,
237 label: l10n.nav_settings,
lib/l10n/app_ar.arb
+24 -2
@@ -824,5 +824,27 @@
824 "enter_context_length": "أدخل طول السياق...",
825 "openrouter_disclosure": "من خلال الاتصال بهذا المزود، سيتم إرسال رسائل الدردشة والمدخلات الخاصة بك إلى خوادمهم. لا يتتبع LocalMind محادثاتك أو يخزنها.",
826 "welcome_message_cloud": "يتم إرسال رسائلك إلى مزود الخدمة المتصل بك.",
827 - "privacy_policy": "سياسة الخصوصية"
828 -}
\ No newline at end of file
827 + "privacy_policy": "سياسة الخصوصية",
828 + "cloud_sync": "مزامنة S3 السحابية",
829 + "cloud_sync_description": "مزامنة مشفرة من طرف إلى طرف مع خادمك المتوافق مع S3",
830 + "cloud_sync_endpoint": "عنوان نقطة النهاية",
831 + "cloud_sync_bucket": "الحاوية",
832 + "cloud_sync_region": "المنطقة",
833 + "cloud_sync_prefix": "البادئة",
834 + "cloud_sync_access_key": "معرّف مفتاح الوصول",
835 + "cloud_sync_secret_key": "مفتاح الوصول السري",
836 + "cloud_sync_session_token": "رمز الجلسة (اختياري)",
837 + "cloud_sync_passphrase": "عبارة مرور التشفير",
838 + "cloud_sync_confirm_passphrase": "تأكيد عبارة المرور",
839 + "cloud_sync_path_style": "استخدام العنونة بنمط المسار",
840 + "cloud_sync_allow_http": "السماح باتصال HTTP غير الآمن",
841 + "cloud_sync_http_warning": "يكشف HTTP بيانات الطلب وبيانات الاعتماد للشبكة. استخدمه فقط مع خادم S3 محلي موثوق.",
842 + "cloud_sync_test": "اختبار الاتصال",
843 + "cloud_sync_enable": "تفعيل المزامنة المشفرة",
844 + "cloud_sync_now": "المزامنة الآن",
845 + "cloud_sync_disconnect": "فصل هذا الجهاز",
846 + "cloud_sync_last_synced": "آخر مزامنة",
847 + "cloud_sync_never": "أبدًا",
848 + "cloud_sync_conflicts": "التعارضات المحفوظة",
849 + "cloud_sync_passphrase_mismatch": "عبارتا المرور غير متطابقتين"
850 +}
lib/l10n/app_bn.arb
+24 -2
@@ -824,5 +824,27 @@
824 "enter_context_length": "প্রসঙ্গের দৈর্ঘ্য লিখুন...",
825 "openrouter_disclosure": "এই প্রদানকারীকে সংযুক্ত করার মাধ্যমে, আপনার চ্যাট বার্তা এবং ইনপুট তাদের সার্ভারে পাঠানো হবে। LocalMind আপনার কথোপকথন ট্র্যাক বা সংরক্ষণ করে না।",
826 "welcome_message_cloud": "আপনার বার্তাগুলি আপনার সংযুক্ত প্রদানকারীর কাছে পাঠানো হয়।",
827 - "privacy_policy": "গোপনীয়তা নীতি"
828 -}
\ No newline at end of file
827 + "privacy_policy": "গোপনীয়তা নীতি",
828 + "cloud_sync": "S3 ক্লাউড সিঙ্ক",
829 + "cloud_sync_description": "আপনার নিজস্ব S3-সামঞ্জস্যপূর্ণ সার্ভারে এন্ড-টু-এন্ড এনক্রিপ্টেড সিঙ্ক",
830 + "cloud_sync_endpoint": "এন্ডপয়েন্ট URL",
831 + "cloud_sync_bucket": "বাকেট",
832 + "cloud_sync_region": "অঞ্চল",
833 + "cloud_sync_prefix": "প্রিফিক্স",
834 + "cloud_sync_access_key": "অ্যাক্সেস কী আইডি",
835 + "cloud_sync_secret_key": "গোপন অ্যাক্সেস কী",
836 + "cloud_sync_session_token": "সেশন টোকেন (ঐচ্ছিক)",
837 + "cloud_sync_passphrase": "এনক্রিপশন পাসফ্রেজ",
838 + "cloud_sync_confirm_passphrase": "পাসফ্রেজ নিশ্চিত করুন",
839 + "cloud_sync_path_style": "পাথ-স্টাইল অ্যাড্রেসিং ব্যবহার করুন",
840 + "cloud_sync_allow_http": "অনিরাপদ HTTP অনুমতি দিন",
841 + "cloud_sync_http_warning": "HTTP নেটওয়ার্কে অনুরোধের তথ্য ও পরিচয়পত্র প্রকাশ করে। শুধু বিশ্বস্ত স্থানীয় S3 সার্ভারে এটি ব্যবহার করুন।",
842 + "cloud_sync_test": "সংযোগ পরীক্ষা করুন",
843 + "cloud_sync_enable": "এনক্রিপ্টেড সিঙ্ক চালু করুন",
844 + "cloud_sync_now": "এখন সিঙ্ক করুন",
845 + "cloud_sync_disconnect": "এই ডিভাইস সংযোগ বিচ্ছিন্ন করুন",
846 + "cloud_sync_last_synced": "সর্বশেষ সিঙ্ক",
847 + "cloud_sync_never": "কখনও নয়",
848 + "cloud_sync_conflicts": "সংরক্ষিত দ্বন্দ্ব",
849 + "cloud_sync_passphrase_mismatch": "পাসফ্রেজ মিলছে না"
850 +}
lib/l10n/app_en.arb
+23 -1
@@ -3009,5 +3009,27 @@
3009 "privacy_policy": "Privacy Policy",
3010 "@privacy_policy": {
3011 "description": "Privacy Policy link label"
3012 - }
3012 + },
3013 + "cloud_sync": "S3 Cloud Sync",
3014 + "cloud_sync_description": "End-to-end encrypted sync to your own S3-compatible server",
3015 + "cloud_sync_endpoint": "Endpoint URL",
3016 + "cloud_sync_bucket": "Bucket",
3017 + "cloud_sync_region": "Region",
3018 + "cloud_sync_prefix": "Prefix",
3019 + "cloud_sync_access_key": "Access key ID",
3020 + "cloud_sync_secret_key": "Secret access key",
3021 + "cloud_sync_session_token": "Session token (optional)",
3022 + "cloud_sync_passphrase": "Encryption passphrase",
3023 + "cloud_sync_confirm_passphrase": "Confirm passphrase",
3024 + "cloud_sync_path_style": "Use path-style addressing",
3025 + "cloud_sync_allow_http": "Allow insecure HTTP",
3026 + "cloud_sync_http_warning": "HTTP exposes request metadata and credentials to the network. Use it only for a trusted local S3 server.",
3027 + "cloud_sync_test": "Test connection",
3028 + "cloud_sync_enable": "Enable encrypted sync",
3029 + "cloud_sync_now": "Sync now",
3030 + "cloud_sync_disconnect": "Disconnect this device",
3031 + "cloud_sync_last_synced": "Last synced",
3032 + "cloud_sync_never": "Never",
3033 + "cloud_sync_conflicts": "Conflicts preserved",
3034 + "cloud_sync_passphrase_mismatch": "Passphrases do not match"
3035 }
lib/l10n/app_es.arb
+24 -2
@@ -824,5 +824,27 @@
824 "enter_context_length": "Introduce la longitud del contexto...",
825 "openrouter_disclosure": "Al conectar este proveedor, tus mensajes de chat y entradas se enviarán a sus servidores. LocalMind no rastrea ni almacena tus conversaciones.",
826 "welcome_message_cloud": "Tus mensajes se envían a tu proveedor conectado.",
827 - "privacy_policy": "Política de privacidad"
828 -}
\ No newline at end of file
827 + "privacy_policy": "Política de privacidad",
828 + "cloud_sync": "Sincronización S3",
829 + "cloud_sync_description": "Sincronización cifrada de extremo a extremo con tu servidor compatible con S3",
830 + "cloud_sync_endpoint": "URL del endpoint",
831 + "cloud_sync_bucket": "Bucket",
832 + "cloud_sync_region": "Región",
833 + "cloud_sync_prefix": "Prefijo",
834 + "cloud_sync_access_key": "ID de clave de acceso",
835 + "cloud_sync_secret_key": "Clave de acceso secreta",
836 + "cloud_sync_session_token": "Token de sesión (opcional)",
837 + "cloud_sync_passphrase": "Frase de cifrado",
838 + "cloud_sync_confirm_passphrase": "Confirmar frase",
839 + "cloud_sync_path_style": "Usar direccionamiento por ruta",
840 + "cloud_sync_allow_http": "Permitir HTTP inseguro",
841 + "cloud_sync_http_warning": "HTTP expone metadatos y credenciales a la red. Úsalo solo con un servidor S3 local de confianza.",
842 + "cloud_sync_test": "Probar conexión",
843 + "cloud_sync_enable": "Activar sincronización cifrada",
844 + "cloud_sync_now": "Sincronizar ahora",
845 + "cloud_sync_disconnect": "Desconectar este dispositivo",
846 + "cloud_sync_last_synced": "Última sincronización",
847 + "cloud_sync_never": "Nunca",
848 + "cloud_sync_conflicts": "Conflictos conservados",
849 + "cloud_sync_passphrase_mismatch": "Las frases no coinciden"
850 +}
lib/l10n/app_hi.arb
+24 -2
@@ -824,5 +824,27 @@
824 "enter_context_length": "संदर्भ की लंबाई दर्ज करें...",
825 "openrouter_disclosure": "इस प्रदाता को कनेक्ट करके, आपके चैट संदेश और इनपुट उनके सर्वर पर भेजे जाएंगे। LocalMind आपकी बातचीत को ट्रैक या संग्रहीत नहीं करता है।",
826 "welcome_message_cloud": "आपके संदेश आपके कनेक्टेड प्रदाता को भेजे जाते हैं।",
827 - "privacy_policy": "गोपनीयता नीति"
828 -}
\ No newline at end of file
827 + "privacy_policy": "गोपनीयता नीति",
828 + "cloud_sync": "S3 क्लाउड सिंक",
829 + "cloud_sync_description": "अपने S3-संगत सर्वर पर एंड-टू-एंड एन्क्रिप्टेड सिंक",
830 + "cloud_sync_endpoint": "एंडपॉइंट URL",
831 + "cloud_sync_bucket": "बकेट",
832 + "cloud_sync_region": "क्षेत्र",
833 + "cloud_sync_prefix": "प्रीफ़िक्स",
834 + "cloud_sync_access_key": "एक्सेस कुंजी ID",
835 + "cloud_sync_secret_key": "गुप्त एक्सेस कुंजी",
836 + "cloud_sync_session_token": "सत्र टोकन (वैकल्पिक)",
837 + "cloud_sync_passphrase": "एन्क्रिप्शन पासफ़्रेज़",
838 + "cloud_sync_confirm_passphrase": "पासफ़्रेज़ की पुष्टि करें",
839 + "cloud_sync_path_style": "पाथ-स्टाइल एड्रेसिंग का उपयोग करें",
840 + "cloud_sync_allow_http": "असुरक्षित HTTP की अनुमति दें",
841 + "cloud_sync_http_warning": "HTTP नेटवर्क पर अनुरोध की जानकारी और क्रेडेंशियल उजागर करता है। इसे केवल विश्वसनीय स्थानीय S3 सर्वर के लिए उपयोग करें।",
842 + "cloud_sync_test": "कनेक्शन जाँचें",
843 + "cloud_sync_enable": "एन्क्रिप्टेड सिंक चालू करें",
844 + "cloud_sync_now": "अभी सिंक करें",
845 + "cloud_sync_disconnect": "इस डिवाइस को डिस्कनेक्ट करें",
846 + "cloud_sync_last_synced": "अंतिम सिंक",
847 + "cloud_sync_never": "कभी नहीं",
848 + "cloud_sync_conflicts": "सहेजे गए विरोध",
849 + "cloud_sync_passphrase_mismatch": "पासफ़्रेज़ मेल नहीं खाते"
850 +}
lib/l10n/app_it.arb
+24 -2
@@ -824,5 +824,27 @@
824 "enter_context_length": "Inserisci lunghezza del contesto...",
825 "openrouter_disclosure": "Collegando questo provider, i tuoi messaggi di chat e input verranno inviati ai loro server. LocalMind non traccia né memorizza le tue conversazioni.",
826 "welcome_message_cloud": "I tuoi messaggi vengono inviati al provider connesso.",
827 - "privacy_policy": "Informativa sulla privacy"
828 -}
\ No newline at end of file
827 + "privacy_policy": "Informativa sulla privacy",
828 + "cloud_sync": "Sincronizzazione S3",
829 + "cloud_sync_description": "Sincronizzazione crittografata end-to-end con il tuo server compatibile S3",
830 + "cloud_sync_endpoint": "URL endpoint",
831 + "cloud_sync_bucket": "Bucket",
832 + "cloud_sync_region": "Regione",
833 + "cloud_sync_prefix": "Prefisso",
834 + "cloud_sync_access_key": "ID chiave di accesso",
835 + "cloud_sync_secret_key": "Chiave di accesso segreta",
836 + "cloud_sync_session_token": "Token di sessione (facoltativo)",
837 + "cloud_sync_passphrase": "Passphrase di crittografia",
838 + "cloud_sync_confirm_passphrase": "Conferma passphrase",
839 + "cloud_sync_path_style": "Usa indirizzamento path-style",
840 + "cloud_sync_allow_http": "Consenti HTTP non sicuro",
841 + "cloud_sync_http_warning": "HTTP espone metadati e credenziali alla rete. Usalo solo con un server S3 locale attendibile.",
842 + "cloud_sync_test": "Verifica connessione",
843 + "cloud_sync_enable": "Attiva sincronizzazione crittografata",
844 + "cloud_sync_now": "Sincronizza ora",
845 + "cloud_sync_disconnect": "Disconnetti questo dispositivo",
846 + "cloud_sync_last_synced": "Ultima sincronizzazione",
847 + "cloud_sync_never": "Mai",
848 + "cloud_sync_conflicts": "Conflitti conservati",
849 + "cloud_sync_passphrase_mismatch": "Le passphrase non corrispondono"
850 +}
lib/l10n/app_ja.arb
+24 -2
@@ -824,5 +824,27 @@
824 "enter_context_length": "コンテキスト長を入力...",
825 "openrouter_disclosure": "このプロバイダーに接続すると、チャットメッセージと入力がそのサーバーに送信されます。LocalMindはお客様の会話を追跡または保存することはありません。",
826 "welcome_message_cloud": "メッセージは接続されたプロバイダーに送信されます。",
827 - "privacy_policy": "プライバシーポリシー"
828 -}
\ No newline at end of file
827 + "privacy_policy": "プライバシーポリシー",
828 + "cloud_sync": "S3クラウド同期",
829 + "cloud_sync_description": "お使いのS3互換サーバーへのエンドツーエンド暗号化同期",
830 + "cloud_sync_endpoint": "エンドポイントURL",
831 + "cloud_sync_bucket": "バケット",
832 + "cloud_sync_region": "リージョン",
833 + "cloud_sync_prefix": "プレフィックス",
834 + "cloud_sync_access_key": "アクセスキーID",
835 + "cloud_sync_secret_key": "シークレットアクセスキー",
836 + "cloud_sync_session_token": "セッショントークン(任意)",
837 + "cloud_sync_passphrase": "暗号化パスフレーズ",
838 + "cloud_sync_confirm_passphrase": "パスフレーズを確認",
839 + "cloud_sync_path_style": "パス形式のアドレス指定を使用",
840 + "cloud_sync_allow_http": "安全でないHTTPを許可",
841 + "cloud_sync_http_warning": "HTTPではリクエスト情報と認証情報がネットワークに公開されます。信頼できるローカルS3サーバーでのみ使用してください。",
842 + "cloud_sync_test": "接続をテスト",
843 + "cloud_sync_enable": "暗号化同期を有効化",
844 + "cloud_sync_now": "今すぐ同期",
845 + "cloud_sync_disconnect": "このデバイスを切断",
846 + "cloud_sync_last_synced": "最終同期",
847 + "cloud_sync_never": "未実行",
848 + "cloud_sync_conflicts": "保持された競合",
849 + "cloud_sync_passphrase_mismatch": "パスフレーズが一致しません"
850 +}
lib/l10n/app_localizations.dart
+132
@@ -5044,6 +5044,138 @@ abstract class AppLocalizations {
5044 /// In en, this message translates to:
5045 /// **'Privacy Policy'**
5046 String get privacy_policy;
5047 +
5048 + /// No description provided for @cloud_sync.
5049 + ///
5050 + /// In en, this message translates to:
5051 + /// **'S3 Cloud Sync'**
5052 + String get cloud_sync;
5053 +
5054 + /// No description provided for @cloud_sync_description.
5055 + ///
5056 + /// In en, this message translates to:
5057 + /// **'End-to-end encrypted sync to your own S3-compatible server'**
5058 + String get cloud_sync_description;
5059 +
5060 + /// No description provided for @cloud_sync_endpoint.
5061 + ///
5062 + /// In en, this message translates to:
5063 + /// **'Endpoint URL'**
5064 + String get cloud_sync_endpoint;
5065 +
5066 + /// No description provided for @cloud_sync_bucket.
5067 + ///
5068 + /// In en, this message translates to:
5069 + /// **'Bucket'**
5070 + String get cloud_sync_bucket;
5071 +
5072 + /// No description provided for @cloud_sync_region.
5073 + ///
5074 + /// In en, this message translates to:
5075 + /// **'Region'**
5076 + String get cloud_sync_region;
5077 +
5078 + /// No description provided for @cloud_sync_prefix.
5079 + ///
5080 + /// In en, this message translates to:
5081 + /// **'Prefix'**
5082 + String get cloud_sync_prefix;
5083 +
5084 + /// No description provided for @cloud_sync_access_key.
5085 + ///
5086 + /// In en, this message translates to:
5087 + /// **'Access key ID'**
5088 + String get cloud_sync_access_key;
5089 +
5090 + /// No description provided for @cloud_sync_secret_key.
5091 + ///
5092 + /// In en, this message translates to:
5093 + /// **'Secret access key'**
5094 + String get cloud_sync_secret_key;
5095 +
5096 + /// No description provided for @cloud_sync_session_token.
5097 + ///
5098 + /// In en, this message translates to:
5099 + /// **'Session token (optional)'**
5100 + String get cloud_sync_session_token;
5101 +
5102 + /// No description provided for @cloud_sync_passphrase.
5103 + ///
5104 + /// In en, this message translates to:
5105 + /// **'Encryption passphrase'**
5106 + String get cloud_sync_passphrase;
5107 +
5108 + /// No description provided for @cloud_sync_confirm_passphrase.
5109 + ///
5110 + /// In en, this message translates to:
5111 + /// **'Confirm passphrase'**
5112 + String get cloud_sync_confirm_passphrase;
5113 +
5114 + /// No description provided for @cloud_sync_path_style.
5115 + ///
5116 + /// In en, this message translates to:
5117 + /// **'Use path-style addressing'**
5118 + String get cloud_sync_path_style;
5119 +
5120 + /// No description provided for @cloud_sync_allow_http.
5121 + ///
5122 + /// In en, this message translates to:
5123 + /// **'Allow insecure HTTP'**
5124 + String get cloud_sync_allow_http;
5125 +
5126 + /// No description provided for @cloud_sync_http_warning.
5127 + ///
5128 + /// In en, this message translates to:
5129 + /// **'HTTP exposes request metadata and credentials to the network. Use it only for a trusted local S3 server.'**
5130 + String get cloud_sync_http_warning;
5131 +
5132 + /// No description provided for @cloud_sync_test.
5133 + ///
5134 + /// In en, this message translates to:
5135 + /// **'Test connection'**
5136 + String get cloud_sync_test;
5137 +
5138 + /// No description provided for @cloud_sync_enable.
5139 + ///
5140 + /// In en, this message translates to:
5141 + /// **'Enable encrypted sync'**
5142 + String get cloud_sync_enable;
5143 +
5144 + /// No description provided for @cloud_sync_now.
5145 + ///
5146 + /// In en, this message translates to:
5147 + /// **'Sync now'**
5148 + String get cloud_sync_now;
5149 +
5150 + /// No description provided for @cloud_sync_disconnect.
5151 + ///
5152 + /// In en, this message translates to:
5153 + /// **'Disconnect this device'**
5154 + String get cloud_sync_disconnect;
5155 +
5156 + /// No description provided for @cloud_sync_last_synced.
5157 + ///
5158 + /// In en, this message translates to:
5159 + /// **'Last synced'**
5160 + String get cloud_sync_last_synced;
5161 +
5162 + /// No description provided for @cloud_sync_never.
5163 + ///
5164 + /// In en, this message translates to:
5165 + /// **'Never'**
5166 + String get cloud_sync_never;
5167 +
5168 + /// No description provided for @cloud_sync_conflicts.
5169 + ///
5170 + /// In en, this message translates to:
5171 + /// **'Conflicts preserved'**
5172 + String get cloud_sync_conflicts;
5173 +
5174 + /// No description provided for @cloud_sync_passphrase_mismatch.
5175 + ///
5176 + /// In en, this message translates to:
5177 + /// **'Passphrases do not match'**
5178 + String get cloud_sync_passphrase_mismatch;
5179 }
5180
5181 class _AppLocalizationsDelegate
lib/l10n/app_localizations_ar.dart
+68
@@ -2683,4 +2683,72 @@ class AppLocalizationsAr extends AppLocalizations {
2683
2684 @override
2685 String get privacy_policy => 'سياسة الخصوصية';
2686 +
2687 + @override
2688 + String get cloud_sync => 'مزامنة S3 السحابية';
2689 +
2690 + @override
2691 + String get cloud_sync_description =>
2692 + 'مزامنة مشفرة من طرف إلى طرف مع خادمك المتوافق مع S3';
2693 +
2694 + @override
2695 + String get cloud_sync_endpoint => 'عنوان نقطة النهاية';
2696 +
2697 + @override
2698 + String get cloud_sync_bucket => 'الحاوية';
2699 +
2700 + @override
2701 + String get cloud_sync_region => 'المنطقة';
2702 +
2703 + @override
2704 + String get cloud_sync_prefix => 'البادئة';
2705 +
2706 + @override
2707 + String get cloud_sync_access_key => 'معرّف مفتاح الوصول';
2708 +
2709 + @override
2710 + String get cloud_sync_secret_key => 'مفتاح الوصول السري';
2711 +
2712 + @override
2713 + String get cloud_sync_session_token => 'رمز الجلسة (اختياري)';
2714 +
2715 + @override
2716 + String get cloud_sync_passphrase => 'عبارة مرور التشفير';
2717 +
2718 + @override
2719 + String get cloud_sync_confirm_passphrase => 'تأكيد عبارة المرور';
2720 +
2721 + @override
2722 + String get cloud_sync_path_style => 'استخدام العنونة بنمط المسار';
2723 +
2724 + @override
2725 + String get cloud_sync_allow_http => 'السماح باتصال HTTP غير الآمن';
2726 +
2727 + @override
2728 + String get cloud_sync_http_warning =>
2729 + 'يكشف HTTP بيانات الطلب وبيانات الاعتماد للشبكة. استخدمه فقط مع خادم S3 محلي موثوق.';
2730 +
2731 + @override
2732 + String get cloud_sync_test => 'اختبار الاتصال';
2733 +
2734 + @override
2735 + String get cloud_sync_enable => 'تفعيل المزامنة المشفرة';
2736 +
2737 + @override
2738 + String get cloud_sync_now => 'المزامنة الآن';
2739 +
2740 + @override
2741 + String get cloud_sync_disconnect => 'فصل هذا الجهاز';
2742 +
2743 + @override
2744 + String get cloud_sync_last_synced => 'آخر مزامنة';
2745 +
2746 + @override
2747 + String get cloud_sync_never => 'أبدًا';
2748 +
2749 + @override
2750 + String get cloud_sync_conflicts => 'التعارضات المحفوظة';
2751 +
2752 + @override
2753 + String get cloud_sync_passphrase_mismatch => 'عبارتا المرور غير متطابقتين';
2754 }
lib/l10n/app_localizations_bn.dart
+68
@@ -2703,4 +2703,72 @@ class AppLocalizationsBn extends AppLocalizations {
2703
2704 @override
2705 String get privacy_policy => 'গোপনীয়তা নীতি';
2706 +
2707 + @override
2708 + String get cloud_sync => 'S3 ক্লাউড সিঙ্ক';
2709 +
2710 + @override
2711 + String get cloud_sync_description =>
2712 + 'আপনার নিজস্ব S3-সামঞ্জস্যপূর্ণ সার্ভারে এন্ড-টু-এন্ড এনক্রিপ্টেড সিঙ্ক';
2713 +
2714 + @override
2715 + String get cloud_sync_endpoint => 'এন্ডপয়েন্ট URL';
2716 +
2717 + @override
2718 + String get cloud_sync_bucket => 'বাকেট';
2719 +
2720 + @override
2721 + String get cloud_sync_region => 'অঞ্চল';
2722 +
2723 + @override
2724 + String get cloud_sync_prefix => 'প্রিফিক্স';
2725 +
2726 + @override
2727 + String get cloud_sync_access_key => 'অ্যাক্সেস কী আইডি';
2728 +
2729 + @override
2730 + String get cloud_sync_secret_key => 'গোপন অ্যাক্সেস কী';
2731 +
2732 + @override
2733 + String get cloud_sync_session_token => 'সেশন টোকেন (ঐচ্ছিক)';
2734 +
2735 + @override
2736 + String get cloud_sync_passphrase => 'এনক্রিপশন পাসফ্রেজ';
2737 +
2738 + @override
2739 + String get cloud_sync_confirm_passphrase => 'পাসফ্রেজ নিশ্চিত করুন';
2740 +
2741 + @override
2742 + String get cloud_sync_path_style => 'পাথ-স্টাইল অ্যাড্রেসিং ব্যবহার করুন';
2743 +
2744 + @override
2745 + String get cloud_sync_allow_http => 'অনিরাপদ HTTP অনুমতি দিন';
2746 +
2747 + @override
2748 + String get cloud_sync_http_warning =>
2749 + 'HTTP নেটওয়ার্কে অনুরোধের তথ্য ও পরিচয়পত্র প্রকাশ করে। শুধু বিশ্বস্ত স্থানীয় S3 সার্ভারে এটি ব্যবহার করুন।';
2750 +
2751 + @override
2752 + String get cloud_sync_test => 'সংযোগ পরীক্ষা করুন';
2753 +
2754 + @override
2755 + String get cloud_sync_enable => 'এনক্রিপ্টেড সিঙ্ক চালু করুন';
2756 +
2757 + @override
2758 + String get cloud_sync_now => 'এখন সিঙ্ক করুন';
2759 +
2760 + @override
2761 + String get cloud_sync_disconnect => 'এই ডিভাইস সংযোগ বিচ্ছিন্ন করুন';
2762 +
2763 + @override
2764 + String get cloud_sync_last_synced => 'সর্বশেষ সিঙ্ক';
2765 +
2766 + @override
2767 + String get cloud_sync_never => 'কখনও নয়';
2768 +
2769 + @override
2770 + String get cloud_sync_conflicts => 'সংরক্ষিত দ্বন্দ্ব';
2771 +
2772 + @override
2773 + String get cloud_sync_passphrase_mismatch => 'পাসফ্রেজ মিলছে না';
2774 }
lib/l10n/app_localizations_en.dart
+68
@@ -2691,4 +2691,72 @@ class AppLocalizationsEn extends AppLocalizations {
2691
2692 @override
2693 String get privacy_policy => 'Privacy Policy';
2694 +
2695 + @override
2696 + String get cloud_sync => 'S3 Cloud Sync';
2697 +
2698 + @override
2699 + String get cloud_sync_description =>
2700 + 'End-to-end encrypted sync to your own S3-compatible server';
2701 +
2702 + @override
2703 + String get cloud_sync_endpoint => 'Endpoint URL';
2704 +
2705 + @override
2706 + String get cloud_sync_bucket => 'Bucket';
2707 +
2708 + @override
2709 + String get cloud_sync_region => 'Region';
2710 +
2711 + @override
2712 + String get cloud_sync_prefix => 'Prefix';
2713 +
2714 + @override
2715 + String get cloud_sync_access_key => 'Access key ID';
2716 +
2717 + @override
2718 + String get cloud_sync_secret_key => 'Secret access key';
2719 +
2720 + @override
2721 + String get cloud_sync_session_token => 'Session token (optional)';
2722 +
2723 + @override
2724 + String get cloud_sync_passphrase => 'Encryption passphrase';
2725 +
2726 + @override
2727 + String get cloud_sync_confirm_passphrase => 'Confirm passphrase';
2728 +
2729 + @override
2730 + String get cloud_sync_path_style => 'Use path-style addressing';
2731 +
2732 + @override
2733 + String get cloud_sync_allow_http => 'Allow insecure HTTP';
2734 +
2735 + @override
2736 + String get cloud_sync_http_warning =>
2737 + 'HTTP exposes request metadata and credentials to the network. Use it only for a trusted local S3 server.';
2738 +
2739 + @override
2740 + String get cloud_sync_test => 'Test connection';
2741 +
2742 + @override
2743 + String get cloud_sync_enable => 'Enable encrypted sync';
2744 +
2745 + @override
2746 + String get cloud_sync_now => 'Sync now';
2747 +
2748 + @override
2749 + String get cloud_sync_disconnect => 'Disconnect this device';
2750 +
2751 + @override
2752 + String get cloud_sync_last_synced => 'Last synced';
2753 +
2754 + @override
2755 + String get cloud_sync_never => 'Never';
2756 +
2757 + @override
2758 + String get cloud_sync_conflicts => 'Conflicts preserved';
2759 +
2760 + @override
2761 + String get cloud_sync_passphrase_mismatch => 'Passphrases do not match';
2762 }
lib/l10n/app_localizations_es.dart
+68
@@ -2707,4 +2707,72 @@ class AppLocalizationsEs extends AppLocalizations {
2707
2708 @override
2709 String get privacy_policy => 'Política de privacidad';
2710 +
2711 + @override
2712 + String get cloud_sync => 'Sincronización S3';
2713 +
2714 + @override
2715 + String get cloud_sync_description =>
2716 + 'Sincronización cifrada de extremo a extremo con tu servidor compatible con S3';
2717 +
2718 + @override
2719 + String get cloud_sync_endpoint => 'URL del endpoint';
2720 +
2721 + @override
2722 + String get cloud_sync_bucket => 'Bucket';
2723 +
2724 + @override
2725 + String get cloud_sync_region => 'Región';
2726 +
2727 + @override
2728 + String get cloud_sync_prefix => 'Prefijo';
2729 +
2730 + @override
2731 + String get cloud_sync_access_key => 'ID de clave de acceso';
2732 +
2733 + @override
2734 + String get cloud_sync_secret_key => 'Clave de acceso secreta';
2735 +
2736 + @override
2737 + String get cloud_sync_session_token => 'Token de sesión (opcional)';
2738 +
2739 + @override
2740 + String get cloud_sync_passphrase => 'Frase de cifrado';
2741 +
2742 + @override
2743 + String get cloud_sync_confirm_passphrase => 'Confirmar frase';
2744 +
2745 + @override
2746 + String get cloud_sync_path_style => 'Usar direccionamiento por ruta';
2747 +
2748 + @override
2749 + String get cloud_sync_allow_http => 'Permitir HTTP inseguro';
2750 +
2751 + @override
2752 + String get cloud_sync_http_warning =>
2753 + 'HTTP expone metadatos y credenciales a la red. Úsalo solo con un servidor S3 local de confianza.';
2754 +
2755 + @override
2756 + String get cloud_sync_test => 'Probar conexión';
2757 +
2758 + @override
2759 + String get cloud_sync_enable => 'Activar sincronización cifrada';
2760 +
2761 + @override
2762 + String get cloud_sync_now => 'Sincronizar ahora';
2763 +
2764 + @override
2765 + String get cloud_sync_disconnect => 'Desconectar este dispositivo';
2766 +
2767 + @override
2768 + String get cloud_sync_last_synced => 'Última sincronización';
2769 +
2770 + @override
2771 + String get cloud_sync_never => 'Nunca';
2772 +
2773 + @override
2774 + String get cloud_sync_conflicts => 'Conflictos conservados';
2775 +
2776 + @override
2777 + String get cloud_sync_passphrase_mismatch => 'Las frases no coinciden';
2778 }
lib/l10n/app_localizations_hi.dart
+68
@@ -2696,4 +2696,72 @@ class AppLocalizationsHi extends AppLocalizations {
2696
2697 @override
2698 String get privacy_policy => 'गोपनीयता नीति';
2699 +
2700 + @override
2701 + String get cloud_sync => 'S3 क्लाउड सिंक';
2702 +
2703 + @override
2704 + String get cloud_sync_description =>
2705 + 'अपने S3-संगत सर्वर पर एंड-टू-एंड एन्क्रिप्टेड सिंक';
2706 +
2707 + @override
2708 + String get cloud_sync_endpoint => 'एंडपॉइंट URL';
2709 +
2710 + @override
2711 + String get cloud_sync_bucket => 'बकेट';
2712 +
2713 + @override
2714 + String get cloud_sync_region => 'क्षेत्र';
2715 +
2716 + @override
2717 + String get cloud_sync_prefix => 'प्रीफ़िक्स';
2718 +
2719 + @override
2720 + String get cloud_sync_access_key => 'एक्सेस कुंजी ID';
2721 +
2722 + @override
2723 + String get cloud_sync_secret_key => 'गुप्त एक्सेस कुंजी';
2724 +
2725 + @override
2726 + String get cloud_sync_session_token => 'सत्र टोकन (वैकल्पिक)';
2727 +
2728 + @override
2729 + String get cloud_sync_passphrase => 'एन्क्रिप्शन पासफ़्रेज़';
2730 +
2731 + @override
2732 + String get cloud_sync_confirm_passphrase => 'पासफ़्रेज़ की पुष्टि करें';
2733 +
2734 + @override
2735 + String get cloud_sync_path_style => 'पाथ-स्टाइल एड्रेसिंग का उपयोग करें';
2736 +
2737 + @override
2738 + String get cloud_sync_allow_http => 'असुरक्षित HTTP की अनुमति दें';
2739 +
2740 + @override
2741 + String get cloud_sync_http_warning =>
2742 + 'HTTP नेटवर्क पर अनुरोध की जानकारी और क्रेडेंशियल उजागर करता है। इसे केवल विश्वसनीय स्थानीय S3 सर्वर के लिए उपयोग करें।';
2743 +
2744 + @override
2745 + String get cloud_sync_test => 'कनेक्शन जाँचें';
2746 +
2747 + @override
2748 + String get cloud_sync_enable => 'एन्क्रिप्टेड सिंक चालू करें';
2749 +
2750 + @override
2751 + String get cloud_sync_now => 'अभी सिंक करें';
2752 +
2753 + @override
2754 + String get cloud_sync_disconnect => 'इस डिवाइस को डिस्कनेक्ट करें';
2755 +
2756 + @override
2757 + String get cloud_sync_last_synced => 'अंतिम सिंक';
2758 +
2759 + @override
2760 + String get cloud_sync_never => 'कभी नहीं';
2761 +
2762 + @override
2763 + String get cloud_sync_conflicts => 'सहेजे गए विरोध';
2764 +
2765 + @override
2766 + String get cloud_sync_passphrase_mismatch => 'पासफ़्रेज़ मेल नहीं खाते';
2767 }
lib/l10n/app_localizations_it.dart
+69
@@ -2709,4 +2709,73 @@ class AppLocalizationsIt extends AppLocalizations {
2709
2710 @override
2711 String get privacy_policy => 'Informativa sulla privacy';
2712 +
2713 + @override
2714 + String get cloud_sync => 'Sincronizzazione S3';
2715 +
2716 + @override
2717 + String get cloud_sync_description =>
2718 + 'Sincronizzazione crittografata end-to-end con il tuo server compatibile S3';
2719 +
2720 + @override
2721 + String get cloud_sync_endpoint => 'URL endpoint';
2722 +
2723 + @override
2724 + String get cloud_sync_bucket => 'Bucket';
2725 +
2726 + @override
2727 + String get cloud_sync_region => 'Regione';
2728 +
2729 + @override
2730 + String get cloud_sync_prefix => 'Prefisso';
2731 +
2732 + @override
2733 + String get cloud_sync_access_key => 'ID chiave di accesso';
2734 +
2735 + @override
2736 + String get cloud_sync_secret_key => 'Chiave di accesso segreta';
2737 +
2738 + @override
2739 + String get cloud_sync_session_token => 'Token di sessione (facoltativo)';
2740 +
2741 + @override
2742 + String get cloud_sync_passphrase => 'Passphrase di crittografia';
2743 +
2744 + @override
2745 + String get cloud_sync_confirm_passphrase => 'Conferma passphrase';
2746 +
2747 + @override
2748 + String get cloud_sync_path_style => 'Usa indirizzamento path-style';
2749 +
2750 + @override
2751 + String get cloud_sync_allow_http => 'Consenti HTTP non sicuro';
2752 +
2753 + @override
2754 + String get cloud_sync_http_warning =>
2755 + 'HTTP espone metadati e credenziali alla rete. Usalo solo con un server S3 locale attendibile.';
2756 +
2757 + @override
2758 + String get cloud_sync_test => 'Verifica connessione';
2759 +
2760 + @override
2761 + String get cloud_sync_enable => 'Attiva sincronizzazione crittografata';
2762 +
2763 + @override
2764 + String get cloud_sync_now => 'Sincronizza ora';
2765 +
2766 + @override
2767 + String get cloud_sync_disconnect => 'Disconnetti questo dispositivo';
2768 +
2769 + @override
2770 + String get cloud_sync_last_synced => 'Ultima sincronizzazione';
2771 +
2772 + @override
2773 + String get cloud_sync_never => 'Mai';
2774 +
2775 + @override
2776 + String get cloud_sync_conflicts => 'Conflitti conservati';
2777 +
2778 + @override
2779 + String get cloud_sync_passphrase_mismatch =>
2780 + 'Le passphrase non corrispondono';
2781 }
lib/l10n/app_localizations_ja.dart
+67
@@ -2644,4 +2644,71 @@ class AppLocalizationsJa extends AppLocalizations {
2644
2645 @override
2646 String get privacy_policy => 'プライバシーポリシー';
2647 +
2648 + @override
2649 + String get cloud_sync => 'S3クラウド同期';
2650 +
2651 + @override
2652 + String get cloud_sync_description => 'お使いのS3互換サーバーへのエンドツーエンド暗号化同期';
2653 +
2654 + @override
2655 + String get cloud_sync_endpoint => 'エンドポイントURL';
2656 +
2657 + @override
2658 + String get cloud_sync_bucket => 'バケット';
2659 +
2660 + @override
2661 + String get cloud_sync_region => 'リージョン';
2662 +
2663 + @override
2664 + String get cloud_sync_prefix => 'プレフィックス';
2665 +
2666 + @override
2667 + String get cloud_sync_access_key => 'アクセスキーID';
2668 +
2669 + @override
2670 + String get cloud_sync_secret_key => 'シークレットアクセスキー';
2671 +
2672 + @override
2673 + String get cloud_sync_session_token => 'セッショントークン(任意)';
2674 +
2675 + @override
2676 + String get cloud_sync_passphrase => '暗号化パスフレーズ';
2677 +
2678 + @override
2679 + String get cloud_sync_confirm_passphrase => 'パスフレーズを確認';
2680 +
2681 + @override
2682 + String get cloud_sync_path_style => 'パス形式のアドレス指定を使用';
2683 +
2684 + @override
2685 + String get cloud_sync_allow_http => '安全でないHTTPを許可';
2686 +
2687 + @override
2688 + String get cloud_sync_http_warning =>
2689 + 'HTTPではリクエスト情報と認証情報がネットワークに公開されます。信頼できるローカルS3サーバーでのみ使用してください。';
2690 +
2691 + @override
2692 + String get cloud_sync_test => '接続をテスト';
2693 +
2694 + @override
2695 + String get cloud_sync_enable => '暗号化同期を有効化';
2696 +
2697 + @override
2698 + String get cloud_sync_now => '今すぐ同期';
2699 +
2700 + @override
2701 + String get cloud_sync_disconnect => 'このデバイスを切断';
2702 +
2703 + @override
2704 + String get cloud_sync_last_synced => '最終同期';
2705 +
2706 + @override
2707 + String get cloud_sync_never => '未実行';
2708 +
2709 + @override
2710 + String get cloud_sync_conflicts => '保持された競合';
2711 +
2712 + @override
2713 + String get cloud_sync_passphrase_mismatch => 'パスフレーズが一致しません';
2714 }
lib/l10n/app_localizations_ru.dart
+68
@@ -2710,4 +2710,72 @@ class AppLocalizationsRu extends AppLocalizations {
2710
2711 @override
2712 String get privacy_policy => 'Политика конфиденциальности';
2713 +
2714 + @override
2715 + String get cloud_sync => 'Синхронизация S3';
2716 +
2717 + @override
2718 + String get cloud_sync_description =>
2719 + 'Сквозное шифрование и синхронизация с вашим S3-совместимым сервером';
2720 +
2721 + @override
2722 + String get cloud_sync_endpoint => 'URL-адрес сервера';
2723 +
2724 + @override
2725 + String get cloud_sync_bucket => 'Бакет';
2726 +
2727 + @override
2728 + String get cloud_sync_region => 'Регион';
2729 +
2730 + @override
2731 + String get cloud_sync_prefix => 'Префикс';
2732 +
2733 + @override
2734 + String get cloud_sync_access_key => 'ID ключа доступа';
2735 +
2736 + @override
2737 + String get cloud_sync_secret_key => 'Секретный ключ доступа';
2738 +
2739 + @override
2740 + String get cloud_sync_session_token => 'Токен сеанса (необязательно)';
2741 +
2742 + @override
2743 + String get cloud_sync_passphrase => 'Пароль шифрования';
2744 +
2745 + @override
2746 + String get cloud_sync_confirm_passphrase => 'Подтвердите пароль';
2747 +
2748 + @override
2749 + String get cloud_sync_path_style => 'Использовать адресацию по пути';
2750 +
2751 + @override
2752 + String get cloud_sync_allow_http => 'Разрешить небезопасный HTTP';
2753 +
2754 + @override
2755 + String get cloud_sync_http_warning =>
2756 + 'HTTP раскрывает сетевые метаданные и учётные данные. Используйте его только для доверенного локального S3-сервера.';
2757 +
2758 + @override
2759 + String get cloud_sync_test => 'Проверить подключение';
2760 +
2761 + @override
2762 + String get cloud_sync_enable => 'Включить зашифрованную синхронизацию';
2763 +
2764 + @override
2765 + String get cloud_sync_now => 'Синхронизировать сейчас';
2766 +
2767 + @override
2768 + String get cloud_sync_disconnect => 'Отключить это устройство';
2769 +
2770 + @override
2771 + String get cloud_sync_last_synced => 'Последняя синхронизация';
2772 +
2773 + @override
2774 + String get cloud_sync_never => 'Никогда';
2775 +
2776 + @override
2777 + String get cloud_sync_conflicts => 'Сохранённые конфликты';
2778 +
2779 + @override
2780 + String get cloud_sync_passphrase_mismatch => 'Пароли не совпадают';
2781 }
lib/l10n/app_localizations_zh.dart
+132
@@ -2625,6 +2625,72 @@ class AppLocalizationsZh extends AppLocalizations {
2625
2626 @override
2627 String get privacy_policy => '隐私政策';
2628 +
2629 + @override
2630 + String get cloud_sync => 'S3 云同步';
2631 +
2632 + @override
2633 + String get cloud_sync_description => '与您自己的 S3 兼容服务器进行端到端加密同步';
2634 +
2635 + @override
2636 + String get cloud_sync_endpoint => '端点 URL';
2637 +
2638 + @override
2639 + String get cloud_sync_bucket => '存储桶';
2640 +
2641 + @override
2642 + String get cloud_sync_region => '区域';
2643 +
2644 + @override
2645 + String get cloud_sync_prefix => '前缀';
2646 +
2647 + @override
2648 + String get cloud_sync_access_key => '访问密钥 ID';
2649 +
2650 + @override
2651 + String get cloud_sync_secret_key => '秘密访问密钥';
2652 +
2653 + @override
2654 + String get cloud_sync_session_token => '会话令牌(可选)';
2655 +
2656 + @override
2657 + String get cloud_sync_passphrase => '加密密码短语';
2658 +
2659 + @override
2660 + String get cloud_sync_confirm_passphrase => '确认密码短语';
2661 +
2662 + @override
2663 + String get cloud_sync_path_style => '使用路径样式寻址';
2664 +
2665 + @override
2666 + String get cloud_sync_allow_http => '允许不安全的 HTTP';
2667 +
2668 + @override
2669 + String get cloud_sync_http_warning => 'HTTP 会向网络暴露请求信息和凭据。仅在可信的本地 S3 服务器上使用。';
2670 +
2671 + @override
2672 + String get cloud_sync_test => '测试连接';
2673 +
2674 + @override
2675 + String get cloud_sync_enable => '启用加密同步';
2676 +
2677 + @override
2678 + String get cloud_sync_now => '立即同步';
2679 +
2680 + @override
2681 + String get cloud_sync_disconnect => '断开此设备';
2682 +
2683 + @override
2684 + String get cloud_sync_last_synced => '上次同步';
2685 +
2686 + @override
2687 + String get cloud_sync_never => '从未';
2688 +
2689 + @override
2690 + String get cloud_sync_conflicts => '已保留冲突';
2691 +
2692 + @override
2693 + String get cloud_sync_passphrase_mismatch => '密码短语不匹配';
2694 }
2695
2696 /// The translations for Chinese, as used in Taiwan (`zh_TW`).
@@ -5249,4 +5315,70 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
5315
5316 @override
5317 String get privacy_policy => '隱私權政策';
5318 +
5319 + @override
5320 + String get cloud_sync => 'S3 雲端同步';
5321 +
5322 + @override
5323 + String get cloud_sync_description => '與您自己的 S3 相容伺服器進行端對端加密同步';
5324 +
5325 + @override
5326 + String get cloud_sync_endpoint => '端點 URL';
5327 +
5328 + @override
5329 + String get cloud_sync_bucket => '儲存桶';
5330 +
5331 + @override
5332 + String get cloud_sync_region => '區域';
5333 +
5334 + @override
5335 + String get cloud_sync_prefix => '前綴';
5336 +
5337 + @override
5338 + String get cloud_sync_access_key => '存取金鑰 ID';
5339 +
5340 + @override
5341 + String get cloud_sync_secret_key => '秘密存取金鑰';
5342 +
5343 + @override
5344 + String get cloud_sync_session_token => '工作階段權杖(選填)';
5345 +
5346 + @override
5347 + String get cloud_sync_passphrase => '加密密碼片語';
5348 +
5349 + @override
5350 + String get cloud_sync_confirm_passphrase => '確認密碼片語';
5351 +
5352 + @override
5353 + String get cloud_sync_path_style => '使用路徑樣式定址';
5354 +
5355 + @override
5356 + String get cloud_sync_allow_http => '允許不安全的 HTTP';
5357 +
5358 + @override
5359 + String get cloud_sync_http_warning => 'HTTP 會向網路公開請求資訊與憑證。僅限可信任的本機 S3 伺服器使用。';
5360 +
5361 + @override
5362 + String get cloud_sync_test => '測試連線';
5363 +
5364 + @override
5365 + String get cloud_sync_enable => '啟用加密同步';
5366 +
5367 + @override
5368 + String get cloud_sync_now => '立即同步';
5369 +
5370 + @override
5371 + String get cloud_sync_disconnect => '中斷此裝置連線';
5372 +
5373 + @override
5374 + String get cloud_sync_last_synced => '上次同步';
5375 +
5376 + @override
5377 + String get cloud_sync_never => '從未';
5378 +
5379 + @override
5380 + String get cloud_sync_conflicts => '已保留衝突';
5381 +
5382 + @override
5383 + String get cloud_sync_passphrase_mismatch => '密碼片語不相符';
5384 }
lib/l10n/app_ru.arb
+24 -2
@@ -821,5 +821,27 @@
821 "enter_context_length": "Введите длину контекста...",
822 "openrouter_disclosure": "Подключая этого поставщика, ваши сообщения чата и вводные данные будут отправлены на их серверы. LocalMind не отслеживает и не хранит ваши разговоры.",
823 "welcome_message_cloud": "Ваши сообщения отправляются вашему подключенному поставщику.",
824 - "privacy_policy": "Политика конфиденциальности"
825 -}
\ No newline at end of file
824 + "privacy_policy": "Политика конфиденциальности",
825 + "cloud_sync": "Синхронизация S3",
826 + "cloud_sync_description": "Сквозное шифрование и синхронизация с вашим S3-совместимым сервером",
827 + "cloud_sync_endpoint": "URL-адрес сервера",
828 + "cloud_sync_bucket": "Бакет",
829 + "cloud_sync_region": "Регион",
830 + "cloud_sync_prefix": "Префикс",
831 + "cloud_sync_access_key": "ID ключа доступа",
832 + "cloud_sync_secret_key": "Секретный ключ доступа",
833 + "cloud_sync_session_token": "Токен сеанса (необязательно)",
834 + "cloud_sync_passphrase": "Пароль шифрования",
835 + "cloud_sync_confirm_passphrase": "Подтвердите пароль",
836 + "cloud_sync_path_style": "Использовать адресацию по пути",
837 + "cloud_sync_allow_http": "Разрешить небезопасный HTTP",
838 + "cloud_sync_http_warning": "HTTP раскрывает сетевые метаданные и учётные данные. Используйте его только для доверенного локального S3-сервера.",
839 + "cloud_sync_test": "Проверить подключение",
840 + "cloud_sync_enable": "Включить зашифрованную синхронизацию",
841 + "cloud_sync_now": "Синхронизировать сейчас",
842 + "cloud_sync_disconnect": "Отключить это устройство",
843 + "cloud_sync_last_synced": "Последняя синхронизация",
844 + "cloud_sync_never": "Никогда",
845 + "cloud_sync_conflicts": "Сохранённые конфликты",
846 + "cloud_sync_passphrase_mismatch": "Пароли не совпадают"
847 +}
lib/l10n/app_zh.arb
+24 -2
@@ -824,5 +824,27 @@
824 "enter_context_length": "输入上下文长度...",
825 "openrouter_disclosure": "连接此提供商后,您的聊天消息和输入将被发送到他们的服务器。LocalMind 不会跟踪或存储您的对话。",
826 "welcome_message_cloud": "您的消息将发送到您连接的提供商。",
827 - "privacy_policy": "隐私政策"
828 -}
\ No newline at end of file
827 + "privacy_policy": "隐私政策",
828 + "cloud_sync": "S3 云同步",
829 + "cloud_sync_description": "与您自己的 S3 兼容服务器进行端到端加密同步",
830 + "cloud_sync_endpoint": "端点 URL",
831 + "cloud_sync_bucket": "存储桶",
832 + "cloud_sync_region": "区域",
833 + "cloud_sync_prefix": "前缀",
834 + "cloud_sync_access_key": "访问密钥 ID",
835 + "cloud_sync_secret_key": "秘密访问密钥",
836 + "cloud_sync_session_token": "会话令牌(可选)",
837 + "cloud_sync_passphrase": "加密密码短语",
838 + "cloud_sync_confirm_passphrase": "确认密码短语",
839 + "cloud_sync_path_style": "使用路径样式寻址",
840 + "cloud_sync_allow_http": "允许不安全的 HTTP",
841 + "cloud_sync_http_warning": "HTTP 会向网络暴露请求信息和凭据。仅在可信的本地 S3 服务器上使用。",
842 + "cloud_sync_test": "测试连接",
843 + "cloud_sync_enable": "启用加密同步",
844 + "cloud_sync_now": "立即同步",
845 + "cloud_sync_disconnect": "断开此设备",
846 + "cloud_sync_last_synced": "上次同步",
847 + "cloud_sync_never": "从未",
848 + "cloud_sync_conflicts": "已保留冲突",
849 + "cloud_sync_passphrase_mismatch": "密码短语不匹配"
850 +}
lib/l10n/app_zh_TW.arb
+24 -2
@@ -824,5 +824,27 @@
824 "enter_context_length": "輸入上下文長度...",
825 "openrouter_disclosure": "連接此提供者後,您的聊天訊息和輸入將被發送到他們的伺服器。LocalMind 不會追蹤或儲存您的對話。",
826 "welcome_message_cloud": "您的訊息將發送到您連接的提供者。",
827 - "privacy_policy": "隱私權政策"
828 -}
\ No newline at end of file
827 + "privacy_policy": "隱私權政策",
828 + "cloud_sync": "S3 雲端同步",
829 + "cloud_sync_description": "與您自己的 S3 相容伺服器進行端對端加密同步",
830 + "cloud_sync_endpoint": "端點 URL",
831 + "cloud_sync_bucket": "儲存桶",
832 + "cloud_sync_region": "區域",
833 + "cloud_sync_prefix": "前綴",
834 + "cloud_sync_access_key": "存取金鑰 ID",
835 + "cloud_sync_secret_key": "秘密存取金鑰",
836 + "cloud_sync_session_token": "工作階段權杖(選填)",
837 + "cloud_sync_passphrase": "加密密碼片語",
838 + "cloud_sync_confirm_passphrase": "確認密碼片語",
839 + "cloud_sync_path_style": "使用路徑樣式定址",
840 + "cloud_sync_allow_http": "允許不安全的 HTTP",
841 + "cloud_sync_http_warning": "HTTP 會向網路公開請求資訊與憑證。僅限可信任的本機 S3 伺服器使用。",
842 + "cloud_sync_test": "測試連線",
843 + "cloud_sync_enable": "啟用加密同步",
844 + "cloud_sync_now": "立即同步",
845 + "cloud_sync_disconnect": "中斷此裝置連線",
846 + "cloud_sync_last_synced": "上次同步",
847 + "cloud_sync_never": "從未",
848 + "cloud_sync_conflicts": "已保留衝突",
849 + "cloud_sync_passphrase_mismatch": "密碼片語不相符"
850 +}
pubspec.lock
+40
@@ -145,6 +145,22 @@ packages:
145 url: "https://pub.dev"
146 source: hosted
147 version: "3.0.2"
148 + aws_common:
149 + dependency: "direct main"
150 + description:
151 + name: aws_common
152 + sha256: b6775bcbbf6258bb35883b329f707255b40d2d2935a6b9458f3798e06242d3db
153 + url: "https://pub.dev"
154 + source: hosted
155 + version: "0.7.14"
156 + aws_signature_v4:
157 + dependency: "direct main"
158 + description:
159 + name: aws_signature_v4
160 + sha256: "599f03ff64af75fcf0ff9bf0067e5f7b4f9ee7a205a0fffe333fe78156a06ac3"
161 + url: "https://pub.dev"
162 + source: hosted
163 + version: "0.6.11"
164 background_downloader:
165 dependency: transitive
166 description:
@@ -313,6 +329,14 @@ packages:
329 url: "https://pub.dev"
330 source: hosted
331 version: "3.0.7"
332 + cryptography:
333 + dependency: "direct main"
334 + description:
335 + name: cryptography
336 + sha256: "3eda3029d34ec9095a27a198ac9785630fe525c0eb6a49f3d575272f8e792ef0"
337 + url: "https://pub.dev"
338 + source: hosted
339 + version: "2.9.0"
340 cue:
341 dependency: "direct main"
342 description:
@@ -757,6 +781,14 @@ packages:
781 url: "https://pub.dev"
782 source: hosted
783 version: "1.6.0"
784 + http2:
785 + dependency: transitive
786 + description:
787 + name: http2
788 + sha256: "382d3aefc5bd6dc68c6b892d7664f29b5beb3251611ae946a98d35158a82bbfa"
789 + url: "https://pub.dev"
790 + source: hosted
791 + version: "2.3.1"
792 http_client_helper:
793 dependency: transitive
794 description:
@@ -1133,6 +1165,14 @@ packages:
1165 url: "https://pub.dev"
1166 source: hosted
1167 version: "9.3.0"
1168 + os_detect:
1169 + dependency: transitive
1170 + description:
1171 + name: os_detect
1172 + sha256: "7d87c0dd98c6faf110d5aa498e9a6df02ffce4bb78cc9cfc8ad02929be9bb71f"
1173 + url: "https://pub.dev"
1174 + source: hosted
1175 + version: "2.0.3"
1176 package_config:
1177 dependency: transitive
1178 description:
pubspec.yaml
+3
@@ -44,6 +44,9 @@ dependencies:
44 url_launcher: ^6.3.1
45 dio: ^5.9.2
46 flutter_secure_storage: ^10.3.1
47 + aws_common: ^0.7.7
48 + aws_signature_v4: ^0.6.11
49 + cryptography: ^2.9.0
50 flutter_tts: ^4.2.5
51 file_picker: ^12.0.0-beta.5
52 path_provider: ^2.1.5
test/core/services/data_backup_service_test.dart
+124
@@ -3,7 +3,10 @@ import 'dart:convert';
3 import 'package:flutter_test/flutter_test.dart';
4 import 'package:localmind/core/services/data_backup_service.dart';
5 import 'package:localmind/core/storage/entities.dart';
6 +import 'package:localmind/core/models/enums.dart';
7 +import 'package:localmind/features/chat/data/models/message.dart';
8 import 'package:localmind/features/conversations/data/models/conversation.dart';
9 +import 'package:localmind/features/personas/data/models/persona.dart';
10 import 'package:localmind/objectbox.g.dart';
11
12 void main() {
@@ -86,6 +89,127 @@ void main() {
89 otherFolder.id,
90 );
91 });
92 +
93 + test('cloud export excludes temporary chats, built-ins, and secrets', () {
94 + final persistent = ConversationEntity.fromDomain(
95 + Conversation(
96 + id: 'persistent',
97 + title: 'Persistent',
98 + createdAt: DateTime.utc(2026, 7, 10),
99 + updatedAt: DateTime.utc(2026, 7, 10),
100 + ),
101 + );
102 + final temporary = ConversationEntity.fromDomain(
103 + Conversation(
104 + id: 'temporary',
105 + title: 'Temporary',
106 + createdAt: DateTime.utc(2026, 7, 10),
107 + updatedAt: DateTime.utc(2026, 7, 10),
108 + isTemporary: true,
109 + ),
110 + );
111 + MessageEntity message(String id, String conversationId) =>
112 + MessageEntity.fromDomain(
113 + Message(
114 + id: id,
115 + conversationId: conversationId,
116 + role: MessageRole.assistant,
117 + content: 'Hello',
118 + createdAt: DateTime.utc(2026, 7, 10),
119 + inputTokenCount: 4,
120 + tokensPerSecond: 12.5,
121 + ttftMs: 100,
122 + stopReason: 'stop',
123 + contentTokenCount: 2,
124 + ),
125 + );
126 + final builtIn = PersonaEntity.fromDomain(
127 + Persona(
128 + id: 'built-in',
129 + name: 'Built in',
130 + emoji: '🤖',
131 + systemPrompt: '',
132 + isBuiltIn: true,
133 + createdAt: DateTime.utc(2026),
134 + updatedAt: DateTime.utc(2026),
135 + ),
136 + );
137 + final custom = PersonaEntity.fromDomain(
138 + Persona(
139 + id: 'custom',
140 + name: 'Custom',
141 + emoji: '🧠',
142 + systemPrompt: 'Private',
143 + createdAt: DateTime.utc(2026),
144 + updatedAt: DateTime.utc(2026),
145 + ),
146 + );
147 + final orphanedSavedMessage = SavedMessageEntity(
148 + id: 'saved-orphan',
149 + sourceMessageId: 'deleted-message',
150 + conversationId: 'deleted-conversation',
151 + conversationTitle: 'Deleted chat',
152 + roleIndex: MessageRole.assistant.index,
153 + content: 'Keep me',
154 + savedAt: DateTime.utc(2026, 7, 10),
155 + );
156 + final store = _FakeStore({
157 + ConversationEntity: _FakeBox([persistent, temporary]),
158 + MessageEntity: _FakeBox([
159 + message('persistent-message', persistent.id),
160 + message('temporary-message', temporary.id),
161 + ]),
162 + PersonaEntity: _FakeBox([builtIn, custom]),
163 + SavedMessageEntity: _FakeBox([orphanedSavedMessage]),
164 + SavedMessageFolderEntity: _FakeBox<SavedMessageFolderEntity>(const []),
165 + ServerEntity: _FakeBox<ServerEntity>(const []),
166 + ConversationFolderEntity: _FakeBox<ConversationFolderEntity>(const []),
167 + });
168 +
169 + final payload = DataBackupService().exportCloudSync(
170 + store,
171 + jsonEncode({
172 + 'themeMode': 1,
173 + 'huggingFaceToken': 'secret',
174 + 'defaultServerId': 'server',
175 + 'hasCompletedOnboarding': true,
176 + 'hasAskedForNotifications': true,
177 + 'futureSecret': 'must-not-sync',
178 + }),
179 + );
180 +
181 + expect((payload['conversations'] as List).single['id'], persistent.id);
182 + expect((payload['messages'] as List).single['id'], 'persistent-message');
183 + expect((payload['messages'] as List).single['inputTokenCount'], 4);
184 + expect((payload['personas'] as List).single['id'], custom.id);
185 + expect(
186 + (payload['savedMessages'] as List).single['id'],
187 + orphanedSavedMessage.id,
188 + );
189 + expect(payload, isNot(contains('servers')));
190 + expect(payload, isNot(contains('modelMetadata')));
191 + expect(payload['settings'], isNot(contains('huggingFaceToken')));
192 + expect(payload['settings'], isNot(contains('defaultServerId')));
193 + expect(payload['settings'], isNot(contains('hasCompletedOnboarding')));
194 + expect(payload['settings'], isNot(contains('futureSecret')));
195 + });
196 +
197 + test(
198 + 'cloud import rejects incomplete replacement payloads before mutation',
199 + () {
200 + final malformed = <String, dynamic>{
201 + 'version': 1,
202 + 'type': 'cloudSync',
203 + 'settings': <String, dynamic>{},
204 + 'conversations': <dynamic>[],
205 + };
206 +
207 + expect(
208 + DataBackupService().importCloudSync(_FakeStore(const {}), malformed),
209 + throwsFormatException,
210 + );
211 + },
212 + );
213 }
214
215 class _FakeStore extends Fake implements Store {
test/features/cloud_sync/cloud_attachment_reference_resolver_test.dart new
+51
@@ -0,0 +1,51 @@
1 +import 'dart:convert';
2 +
3 +import 'package:flutter_test/flutter_test.dart';
4 +import 'package:localmind/features/cloud_sync/services/cloud_attachment_reference_resolver.dart';
5 +
6 +void main() {
7 + const resolver = CloudAttachmentReferenceResolver();
8 +
9 + String reference(String id, String name) =>
10 + 'cloud://$id/${base64UrlEncode(utf8.encode(name)).replaceAll('=', '')}';
11 +
12 + test(
13 + 'preserves the prior reference by filename when a local file is missing',
14 + () {
15 + final first = reference('first', 'one.txt');
16 + final second = reference('second', 'two.png');
17 +
18 + expect(
19 + resolver.findPreviousReference(
20 + previousReferences: [first, second],
21 + localPath: '/missing/two.png',
22 + index: 0,
23 + ),
24 + second,
25 + );
26 + },
27 + );
28 +
29 + test(
30 + 'falls back to stable attachment order and never invents a reference',
31 + () {
32 + final previous = reference('first', 'old-name.txt');
33 + expect(
34 + resolver.findPreviousReference(
35 + previousReferences: [previous],
36 + localPath: '/missing/renamed.txt',
37 + index: 0,
38 + ),
39 + previous,
40 + );
41 + expect(
42 + resolver.findPreviousReference(
43 + previousReferences: const [],
44 + localPath: '/missing/new.txt',
45 + index: 0,
46 + ),
47 + isNull,
48 + );
49 + },
50 + );
51 +}
test/features/cloud_sync/cloud_sync_crypto_service_test.dart new
+72
@@ -0,0 +1,72 @@
1 +import 'dart:convert';
2 +
3 +import 'package:flutter_test/flutter_test.dart';
4 +import 'package:localmind/features/cloud_sync/data/models/cloud_sync_models.dart';
5 +import 'package:localmind/features/cloud_sync/services/cloud_sync_crypto_service.dart';
6 +
7 +void main() {
8 + group('CloudSyncCryptoService', () {
9 + final service = CloudSyncCryptoService();
10 + final salt = List<int>.generate(16, (index) => index);
11 + final masterKey = List<int>.generate(32, (index) => index + 1);
12 +
13 + test('round trips authenticated compressed state', () async {
14 + final clear = utf8.encode('{"private":"chat"}');
15 + final encrypted = await service.encryptState(
16 + clear,
17 + masterKey: masterKey,
18 + salt: salt,
19 + );
20 +
21 + expect(utf8.decode(encrypted), isNot(contains('private')));
22 + expect(service.extractSalt(encrypted), salt);
23 + expect(await service.decryptState(encrypted, masterKey), clear);
24 + });
25 +
26 + test('rejects the wrong key without returning plaintext', () async {
27 + final encrypted = await service.encryptState(
28 + utf8.encode('secret'),
29 + masterKey: masterKey,
30 + salt: salt,
31 + );
32 +
33 + await expectLater(
34 + service.decryptState(encrypted, List<int>.filled(32, 9)),
35 + throwsA(
36 + isA<CloudSyncFailure>().having(
37 + (error) => error.kind,
38 + 'kind',
39 + CloudSyncFailureKind.passphrase,
40 + ),
41 + ),
42 + );
43 + });
44 +
45 + test('uses deterministic keyed attachment identifiers', () async {
46 + final first = await service.attachmentIdentifier([1, 2, 3], masterKey);
47 + final second = await service.attachmentIdentifier([1, 2, 3], masterKey);
48 + final different = await service.attachmentIdentifier([
49 + 1,
50 + 2,
51 + 4,
52 + ], masterKey);
53 +
54 + expect(first, second);
55 + expect(first, isNot(different));
56 + });
57 +
58 + test(
59 + 'derives a stable 256-bit key and validates passphrase length',
60 + () async {
61 + final first = await service.deriveMasterKey('correct horse', salt);
62 + final second = await service.deriveMasterKey('correct horse', salt);
63 + expect(first, hasLength(32));
64 + expect(first, second);
65 + await expectLater(
66 + service.deriveMasterKey('short', salt),
67 + throwsA(isA<CloudSyncFailure>()),
68 + );
69 + },
70 + );
71 + });
72 +}
test/features/cloud_sync/cloud_sync_merge_service_test.dart new
+289
@@ -0,0 +1,289 @@
1 +import 'package:flutter_test/flutter_test.dart';
2 +import 'package:localmind/features/cloud_sync/data/models/cloud_sync_models.dart';
3 +import 'package:localmind/features/cloud_sync/services/cloud_sync_merge_service.dart';
4 +
5 +void main() {
6 + final service = CloudSyncMergeService();
7 +
8 + CloudSyncSnapshot snapshot(
9 + String device,
10 + int revision,
11 + Map<String, dynamic> payload,
12 + ) => CloudSyncSnapshot(
13 + deviceId: device,
14 + revision: revision,
15 + updatedAt: DateTime.utc(2026, 7, 10, revision),
16 + payload: {
17 + 'settings': <String, dynamic>{},
18 + 'personas': <dynamic>[],
19 + 'conversations': <dynamic>[],
20 + 'messages': <dynamic>[],
21 + 'savedMessages': <dynamic>[],
22 + 'savedMessageFolders': <dynamic>[],
23 + 'conversationFolders': <dynamic>[],
24 + ...payload,
25 + },
26 + );
27 +
28 + test('unions independent records', () async {
29 + final remote = snapshot('remote-device', 1, {
30 + 'personas': [
31 + {'id': 'remote', 'name': 'Remote'},
32 + ],
33 + });
34 + final local = snapshot('local-device', 2, {
35 + 'personas': [
36 + {'id': 'local', 'name': 'Local'},
37 + ],
38 + });
39 +
40 + final result = await service.merge(
41 + local: local,
42 + remote: remote,
43 + journal: const {},
44 + );
45 +
46 + expect(
47 + (result.snapshot.payload['personas'] as List)
48 + .map((item) => item['id'])
49 + .toSet(),
50 + {'remote', 'local'},
51 + );
52 + expect(result.conflictCount, 0);
53 + });
54 +
55 + test(
56 + 'uses remote settings when linking a device for the first time',
57 + () async {
58 + final remote = snapshot('remote-device', 3, {
59 + 'settings': {'fontSize': 22.0},
60 + });
61 + final local = snapshot('local-device', 1, {
62 + 'settings': {'fontSize': 16.0},
63 + });
64 +
65 + final result = await service.merge(
66 + local: local,
67 + remote: remote,
68 + journal: const {},
69 + );
70 +
71 + expect(result.snapshot.payload['settings'], {'fontSize': 22.0});
72 + },
73 + );
74 +
75 + test('preserves conflicting persona and conversation branches', () async {
76 + final remote = snapshot('remote-device', 1, {
77 + 'personas': [
78 + {'id': 'p1', 'name': 'Remote'},
79 + ],
80 + 'conversations': [
81 + {'id': 'c1', 'title': 'Remote'},
82 + ],
83 + 'messages': [
84 + {'id': 'm1', 'conversationId': 'c1', 'content': 'Remote'},
85 + ],
86 + });
87 + final local = snapshot('local-device', 2, {
88 + 'personas': [
89 + {'id': 'p1', 'name': 'Local'},
90 + ],
91 + 'conversations': [
92 + {'id': 'c1', 'title': 'Local'},
93 + ],
94 + 'messages': [
95 + {'id': 'm1', 'conversationId': 'c1', 'content': 'Local'},
96 + ],
97 + });
98 +
99 + final result = await service.merge(
100 + local: local,
101 + remote: remote,
102 + journal: const {},
103 + );
104 +
105 + expect(result.conflictCount, 2);
106 + expect(result.snapshot.payload['personas'], hasLength(2));
107 + expect(result.snapshot.payload['conversations'], hasLength(2));
108 + expect(result.snapshot.payload['messages'], hasLength(2));
109 + });
110 +
111 + test(
112 + 'merges edits to different conversations without false conflicts',
113 + () async {
114 + final base = snapshot('base-device', 1, {
115 + 'conversations': [
116 + {'id': 'c1', 'title': 'One'},
117 + {'id': 'c2', 'title': 'Two'},
118 + ],
119 + 'messages': [
120 + {'id': 'm1', 'conversationId': 'c1', 'content': 'One'},
121 + {'id': 'm2', 'conversationId': 'c2', 'content': 'Two'},
122 + ],
123 + });
124 + final remote = snapshot('remote-device', 2, {
125 + 'conversations': [
126 + {'id': 'c1', 'title': 'Remote edit'},
127 + {'id': 'c2', 'title': 'Two'},
128 + ],
129 + 'messages': base.payload['messages'],
130 + });
131 + final local = snapshot('local-device', 2, {
132 + 'conversations': [
133 + {'id': 'c1', 'title': 'One'},
134 + {'id': 'c2', 'title': 'Local edit'},
135 + ],
136 + 'messages': base.payload['messages'],
137 + });
138 + final journal = {
139 + 'payloadHash': await service.fingerprint(base.payload),
140 + 'remoteRevision': base.revision,
141 + 'remoteDeviceId': base.deviceId,
142 + 'recordHashes': await service.recordHashes(base.payload),
143 + };
144 +
145 + final result = await service.merge(
146 + local: local,
147 + remote: remote,
148 + journal: journal,
149 + );
150 +
151 + final conversations = {
152 + for (final item in result.snapshot.payload['conversations'] as List)
153 + item['id']: item['title'],
154 + };
155 + expect(conversations, {'c1': 'Remote edit', 'c2': 'Local edit'});
156 + expect(result.conflictCount, 0);
157 + },
158 + );
159 +
160 + test('applies tombstones after merging', () async {
161 + final remote = snapshot('remote-device', 1, {
162 + 'personas': [
163 + {'id': 'deleted', 'name': 'Delete me'},
164 + ],
165 + });
166 + final local = CloudSyncSnapshot(
167 + deviceId: 'local-device',
168 + revision: 2,
169 + updatedAt: DateTime.utc(2026, 7, 10, 2),
170 + payload: snapshot('local-device', 2, {}).payload,
171 + tombstones: const {
172 + 'personas': ['deleted'],
173 + },
174 + );
175 +
176 + final result = await service.merge(
177 + local: local,
178 + remote: remote,
179 + journal: const {},
180 + );
181 + expect(result.snapshot.payload['personas'], isEmpty);
182 + });
183 +
184 + test(
185 + 'preserves an edit that races with a deletion as a conflict copy',
186 + () async {
187 + final base = snapshot('base-device', 1, {
188 + 'personas': [
189 + {'id': 'p1', 'name': 'Original'},
190 + ],
191 + 'conversations': [
192 + {'id': 'c1', 'title': 'Original'},
193 + ],
194 + 'messages': [
195 + {'id': 'm1', 'conversationId': 'c1', 'content': 'Original'},
196 + ],
197 + });
198 + final remote = snapshot('remote-device', 2, {
199 + 'personas': [
200 + {'id': 'p1', 'name': 'Edited'},
201 + ],
202 + 'conversations': [
203 + {'id': 'c1', 'title': 'Edited'},
204 + ],
205 + 'messages': [
206 + {'id': 'm1', 'conversationId': 'c1', 'content': 'Edited'},
207 + ],
208 + });
209 + final local = CloudSyncSnapshot(
210 + deviceId: 'local-device',
211 + revision: 2,
212 + updatedAt: DateTime.utc(2026, 7, 10, 2),
213 + payload: snapshot('local-device', 2, {}).payload,
214 + tombstones: const {
215 + 'personas': ['p1'],
216 + 'conversations': ['c1'],
217 + },
218 + );
219 + final journal = {
220 + 'payloadHash': await service.fingerprint(base.payload),
221 + 'remoteRevision': base.revision,
222 + 'remoteDeviceId': base.deviceId,
223 + 'recordHashes': await service.recordHashes(base.payload),
224 + };
225 +
226 + final result = await service.merge(
227 + local: local,
228 + remote: remote,
229 + journal: journal,
230 + );
231 +
232 + final personas = result.snapshot.payload['personas'] as List;
233 + final conversations = result.snapshot.payload['conversations'] as List;
234 + final messages = result.snapshot.payload['messages'] as List;
235 + expect(personas, hasLength(1));
236 + expect(personas.single['id'], startsWith('p1-conflict-'));
237 + expect(conversations, hasLength(1));
238 + expect(conversations.single['id'], startsWith('c1-conflict-'));
239 + expect(messages.single['conversationId'], conversations.single['id']);
240 + expect(result.conflictCount, 2);
241 + },
242 + );
243 +
244 + test('retains cumulative tombstones on a local-only fast path', () async {
245 + final remote = CloudSyncSnapshot(
246 + deviceId: 'remote-device',
247 + revision: 4,
248 + updatedAt: DateTime.utc(2026, 7, 10),
249 + payload: snapshot('remote-device', 4, {
250 + 'settings': {'fontSize': 16.0},
251 + }).payload,
252 + tombstones: const {
253 + 'personas': ['deleted-persona'],
254 + },
255 + );
256 + final local = CloudSyncSnapshot(
257 + deviceId: 'local-device',
258 + revision: 5,
259 + updatedAt: DateTime.utc(2026, 7, 10, 1),
260 + payload: snapshot('local-device', 5, {
261 + 'settings': {'fontSize': 18.0},
262 + }).payload,
263 + tombstones: remote.tombstones,
264 + );
265 + final journal = {
266 + 'payloadHash': await service.fingerprint(remote.payload),
267 + 'remoteRevision': remote.revision,
268 + 'remoteDeviceId': remote.deviceId,
269 + };
270 +
271 + final result = await service.merge(
272 + local: local,
273 + remote: remote,
274 + journal: journal,
275 + );
276 +
277 + expect(result.snapshot.tombstones, remote.tombstones);
278 + final withoutTombstones = CloudSyncSnapshot(
279 + deviceId: result.snapshot.deviceId,
280 + revision: result.snapshot.revision,
281 + updatedAt: result.snapshot.updatedAt,
282 + payload: result.snapshot.payload,
283 + );
284 + expect(
285 + await service.snapshotFingerprint(result.snapshot),
286 + isNot(await service.snapshotFingerprint(withoutTombstones)),
287 + );
288 + });
289 +}
test/features/cloud_sync/cloud_sync_models_test.dart new
+98
@@ -0,0 +1,98 @@
1 +import 'package:flutter_test/flutter_test.dart';
2 +import 'package:localmind/features/cloud_sync/data/models/cloud_sync_models.dart';
3 +
4 +void main() {
5 + test('S3 config validates HTTPS and explicit HTTP opt-in', () {
6 + expect(
7 + const S3SyncConfig(
8 + endpoint: 'https://minio.example.com',
9 + bucket: 'localmind',
10 + ).validate(),
11 + isNull,
12 + );
13 + expect(
14 + const S3SyncConfig(
15 + endpoint: 'http://192.168.1.2:9000',
16 + bucket: 'localmind',
17 + ).validate(),
18 + contains('insecure HTTP'),
19 + );
20 + expect(
21 + const S3SyncConfig(
22 + endpoint: 'http://192.168.1.2:9000',
23 + bucket: 'localmind',
24 + allowInsecureHttp: true,
25 + ).validate(),
26 + isNull,
27 + );
28 + });
29 +
30 + test('config JSON excludes credentials by construction', () {
31 + final json = const S3SyncConfig(
32 + endpoint: 'https://s3.example.com',
33 + bucket: 'bucket',
34 + enabled: true,
35 + ).toJson();
36 + expect(json, isNot(contains('secretAccessKey')));
37 + expect(S3SyncConfig.fromJson(json).enabled, isTrue);
38 + });
39 +
40 + Map<String, dynamic> validSnapshot() => {
41 + 'schema': 1,
42 + 'deviceId': 'device-12345678',
43 + 'revision': 1,
44 + 'updatedAt': DateTime.utc(2026, 7, 10).toIso8601String(),
45 + 'tombstones': <String, dynamic>{},
46 + 'payload': {
47 + 'version': 1,
48 + 'type': 'cloudSync',
49 + 'settings': <String, dynamic>{'fontSize': 16.0},
50 + 'conversations': [
51 + {
52 + 'id': 'conversation',
53 + 'isTemporary': false,
54 + 'createdAt': DateTime.utc(2026, 7, 10).toIso8601String(),
55 + 'updatedAt': DateTime.utc(2026, 7, 10).toIso8601String(),
56 + },
57 + ],
58 + 'messages': [
59 + {
60 + 'id': 'message',
61 + 'conversationId': 'conversation',
62 + 'createdAt': DateTime.utc(2026, 7, 10).toIso8601String(),
63 + 'attachmentPaths': ['cloud://identifier/ZmlsZS50eHQ'],
64 + },
65 + ],
66 + 'personas': <dynamic>[],
67 + 'savedMessages': <dynamic>[],
68 + 'savedMessageFolders': <dynamic>[],
69 + 'conversationFolders': <dynamic>[],
70 + },
71 + };
72 +
73 + test('snapshot validation rejects incomplete or out-of-scope data', () {
74 + expect(CloudSyncSnapshot.fromJson(validSnapshot()).revision, 1);
75 +
76 + final missingCollection = validSnapshot();
77 + (missingCollection['payload'] as Map).remove('messages');
78 + expect(
79 + () => CloudSyncSnapshot.fromJson(missingCollection),
80 + throwsFormatException,
81 + );
82 +
83 + final secretSetting = validSnapshot();
84 + ((secretSetting['payload'] as Map)['settings'] as Map)['huggingFaceToken'] =
85 + 'secret';
86 + expect(
87 + () => CloudSyncSnapshot.fromJson(secretSetting),
88 + throwsFormatException,
89 + );
90 +
91 + final localPath = validSnapshot();
92 + (((localPath['payload'] as Map)['messages'] as List).single
93 + as Map)['attachmentPaths'] = [
94 + '/private/file.txt',
95 + ];
96 + expect(() => CloudSyncSnapshot.fromJson(localPath), throwsFormatException);
97 + });
98 +}
test/features/cloud_sync/cloud_sync_screen_test.dart new
+77
@@ -0,0 +1,77 @@
1 +import 'package:flutter/material.dart';
2 +import 'package:flutter_localizations/flutter_localizations.dart';
3 +import 'package:flutter_riverpod/flutter_riverpod.dart';
4 +import 'package:flutter_test/flutter_test.dart';
5 +import 'package:localmind/core/providers/storage_providers.dart';
6 +import 'package:localmind/features/cloud_sync/views/cloud_sync_screen.dart';
7 +import 'package:localmind/l10n/app_localizations.dart';
8 +import 'package:shared_preferences/shared_preferences.dart';
9 +
10 +void main() {
11 + Future<void> pumpCloudSyncScreen(
12 + WidgetTester tester, {
13 + Size size = const Size(800, 600),
14 + }) async {
15 + tester.view.physicalSize = size;
16 + tester.view.devicePixelRatio = 1;
17 + addTearDown(tester.view.resetPhysicalSize);
18 + addTearDown(tester.view.resetDevicePixelRatio);
19 +
20 + SharedPreferences.setMockInitialValues({});
21 + final preferences = await SharedPreferences.getInstance();
22 + await tester.pumpWidget(
23 + ProviderScope(
24 + overrides: [sharedPreferencesProvider.overrideWithValue(preferences)],
25 + child: const MaterialApp(
26 + localizationsDelegates: [
27 + AppLocalizations.delegate,
28 + GlobalMaterialLocalizations.delegate,
29 + GlobalWidgetsLocalizations.delegate,
30 + GlobalCupertinoLocalizations.delegate,
31 + ],
32 + supportedLocales: AppLocalizations.supportedLocales,
33 + home: Scaffold(body: CloudSyncScreen()),
34 + ),
35 + ),
36 + );
37 + await tester.pumpAndSettle();
38 + }
39 +
40 + testWidgets('shows setup fields and encryption controls when disabled', (
41 + tester,
42 + ) async {
43 + await pumpCloudSyncScreen(tester);
44 +
45 + expect(find.text('S3 Cloud Sync'), findsOneWidget);
46 + expect(find.text('Endpoint URL'), findsOneWidget);
47 + expect(find.text('Bucket'), findsOneWidget);
48 + expect(find.text('Encryption passphrase'), findsOneWidget);
49 + await tester.drag(find.byType(ListView), const Offset(0, -1000));
50 + await tester.pumpAndSettle();
51 + expect(find.text('Test connection'), findsOneWidget);
52 + expect(find.text('Enable encrypted sync'), findsOneWidget);
53 + });
54 +
55 + testWidgets('keeps setup fields separated on a compact viewport', (
56 + tester,
57 + ) async {
58 + await pumpCloudSyncScreen(tester, size: const Size(320, 568));
59 +
60 + final endpoint = find.widgetWithText(TextFormField, 'Endpoint URL');
61 + final bucket = find.widgetWithText(TextFormField, 'Bucket');
62 +
63 + expect(endpoint, findsOneWidget);
64 + expect(bucket, findsOneWidget);
65 + expect(
66 + tester.getBottomLeft(endpoint).dy,
67 + lessThan(tester.getTopLeft(bucket).dy),
68 + );
69 + expect(tester.takeException(), isNull);
70 +
71 + await tester.drag(find.byType(ListView), const Offset(0, -1200));
72 + await tester.pumpAndSettle();
73 +
74 + expect(find.text('Enable encrypted sync'), findsOneWidget);
75 + expect(tester.takeException(), isNull);
76 + });
77 +}
test/features/cloud_sync/minio_integration_test.dart new
+72
@@ -0,0 +1,72 @@
1 +import 'dart:convert';
2 +import 'dart:io';
3 +
4 +import 'package:flutter_test/flutter_test.dart';
5 +import 'package:localmind/features/cloud_sync/data/models/cloud_sync_models.dart';
6 +import 'package:localmind/features/cloud_sync/data/repositories/s3_cloud_sync_repository.dart';
7 +import 'package:localmind/features/cloud_sync/services/cloud_sync_crypto_service.dart';
8 +
9 +void main() {
10 + final endpoint = Platform.environment['LOCALMIND_MINIO_ENDPOINT'];
11 +
12 + test(
13 + 'two clients exchange encrypted state through MinIO',
14 + () async {
15 + final prefix =
16 + 'localmind-integration-${DateTime.now().microsecondsSinceEpoch}';
17 + final config = S3SyncConfig(
18 + endpoint: endpoint!,
19 + bucket: Platform.environment['LOCALMIND_MINIO_BUCKET']!,
20 + region: Platform.environment['LOCALMIND_MINIO_REGION'] ?? 'us-east-1',
21 + prefix: prefix,
22 + pathStyle: true,
23 + allowInsecureHttp: endpoint.startsWith('http://'),
24 + );
25 + final credentials = CloudSyncCredentials(
26 + accessKeyId: Platform.environment['LOCALMIND_MINIO_ACCESS_KEY']!,
27 + secretAccessKey: Platform.environment['LOCALMIND_MINIO_SECRET_KEY']!,
28 + );
29 + final firstClient = S3CloudSyncRepository(
30 + config: config,
31 + credentials: credentials,
32 + );
33 + final secondClient = S3CloudSyncRepository(
34 + config: config,
35 + credentials: credentials,
36 + );
37 + final crypto = CloudSyncCryptoService();
38 + final salt = crypto.newSalt();
39 + final key = await crypto.deriveMasterKey('integration-passphrase', salt);
40 + final encrypted = await crypto.encryptState(
41 + utf8.encode('{"device":"one"}'),
42 + masterKey: key,
43 + salt: salt,
44 + );
45 +
46 + try {
47 + await firstClient.testConnection();
48 + final etag = await firstClient.writeState(encrypted, createOnly: true);
49 + final downloaded = await secondClient.readState();
50 + expect(downloaded, isNotNull);
51 + expect(
52 + utf8.decode(await crypto.decryptState(downloaded!.bytes, key)),
53 + '{"device":"one"}',
54 + );
55 + await expectLater(
56 + crypto.decryptState(downloaded.bytes, List<int>.filled(32, 5)),
57 + throwsA(isA<CloudSyncFailure>()),
58 + );
59 + await secondClient.writeState(encrypted, expectedEtag: etag);
60 + } finally {
61 + await firstClient.deleteObject(firstClient.stateKey);
62 + }
63 + },
64 + skip:
65 + endpoint == null ||
66 + Platform.environment['LOCALMIND_MINIO_BUCKET'] == null ||
67 + Platform.environment['LOCALMIND_MINIO_ACCESS_KEY'] == null ||
68 + Platform.environment['LOCALMIND_MINIO_SECRET_KEY'] == null
69 + ? 'Set LOCALMIND_MINIO_* environment variables to run this test.'
70 + : false,
71 + );
72 +}
test/features/cloud_sync/s3_cloud_sync_repository_test.dart new
+157
@@ -0,0 +1,157 @@
1 +import 'dart:io';
2 +
3 +import 'package:flutter_test/flutter_test.dart';
4 +import 'package:localmind/features/cloud_sync/data/models/cloud_sync_models.dart';
5 +import 'package:localmind/features/cloud_sync/data/repositories/s3_cloud_sync_repository.dart';
6 +
7 +void main() {
8 + test('connection test uses signed path-style conditional requests', () async {
9 + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
10 + final paths = <String>[];
11 + final authorizations = <String?>[];
12 + final sessionTokens = <String?>[];
13 + final objects = <String, List<int>>{};
14 + final etags = <String, String>{};
15 + var revision = 0;
16 + final subscription = server.listen((request) async {
17 + paths.add(request.uri.path);
18 + authorizations.add(
19 + request.headers.value(HttpHeaders.authorizationHeader),
20 + );
21 + sessionTokens.add(request.headers.value('x-amz-security-token'));
22 + final key = request.uri.path;
23 + if (request.method == 'PUT') {
24 + final ifNoneMatch = request.headers.value(
25 + HttpHeaders.ifNoneMatchHeader,
26 + );
27 + final ifMatch = request.headers.value(HttpHeaders.ifMatchHeader);
28 + if ((ifNoneMatch == '*' && objects.containsKey(key)) ||
29 + (ifMatch != null && etags[key] != ifMatch)) {
30 + request.response.statusCode = HttpStatus.preconditionFailed;
31 + } else {
32 + objects[key] = await request.fold<List<int>>(
33 + <int>[],
34 + (all, chunk) => all..addAll(chunk),
35 + );
36 + etags[key] = '"etag-${revision++}"';
37 + request.response.headers.set(HttpHeaders.etagHeader, etags[key]!);
38 + }
39 + } else if (request.method == 'GET') {
40 + final bytes = objects[key];
41 + if (bytes == null) {
42 + request.response.statusCode = HttpStatus.notFound;
43 + } else {
44 + request.response.headers.set(HttpHeaders.etagHeader, etags[key]!);
45 + request.response.add(bytes);
46 + }
47 + } else if (request.method == 'HEAD') {
48 + if (!objects.containsKey(key)) {
49 + request.response.statusCode = HttpStatus.notFound;
50 + } else {
51 + request.response.headers.set(HttpHeaders.etagHeader, etags[key]!);
52 + }
53 + } else if (request.method == 'DELETE') {
54 + objects.remove(key);
55 + etags.remove(key);
56 + request.response.statusCode = HttpStatus.noContent;
57 + }
58 + await request.response.close();
59 + });
60 +
61 + final repository = S3CloudSyncRepository(
62 + config: S3SyncConfig(
63 + endpoint: 'http://${server.address.address}:${server.port}',
64 + bucket: 'private-bucket',
65 + prefix: 'my sync',
66 + allowInsecureHttp: true,
67 + ),
68 + credentials: const CloudSyncCredentials(
69 + accessKeyId: 'access',
70 + secretAccessKey: 'secret',
71 + sessionToken: 'session',
72 + ),
73 + );
74 +
75 + await repository.testConnection();
76 + final attachmentKey = repository.attachmentKey('attachment');
77 + expect(await repository.objectExists(attachmentKey), isFalse);
78 + await repository.writeObject(attachmentKey, const [1, 2, 3]);
79 + expect(await repository.objectExists(attachmentKey), isTrue);
80 + await repository.deleteObject(attachmentKey);
81 +
82 + expect(paths, isNotEmpty);
83 + expect(
84 + paths.every((path) => path.startsWith('/private-bucket/my%20sync/')),
85 + isTrue,
86 + );
87 + expect(
88 + authorizations.whereType<String>().every(
89 + (value) => value.startsWith('AWS4-HMAC-SHA256'),
90 + ),
91 + isTrue,
92 + );
93 + expect(sessionTokens.whereType<String>(), isNotEmpty);
94 + expect(
95 + objects,
96 + isEmpty,
97 + reason: 'connection test object must be cleaned up',
98 + );
99 +
100 + await subscription.cancel();
101 + await server.close(force: true);
102 + });
103 +
104 + test('connection test rejects servers that ignore stale ETags', () async {
105 + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
106 + List<int>? object;
107 + var revision = 0;
108 + final subscription = server.listen((request) async {
109 + if (request.method == 'PUT') {
110 + object = await request.fold<List<int>>(
111 + <int>[],
112 + (all, chunk) => all..addAll(chunk),
113 + );
114 + request.response.headers.set(
115 + HttpHeaders.etagHeader,
116 + '"etag-${revision++}"',
117 + );
118 + } else if (request.method == 'GET') {
119 + if (object == null) {
120 + request.response.statusCode = HttpStatus.notFound;
121 + } else {
122 + request.response.headers.set(HttpHeaders.etagHeader, '"etag-0"');
123 + request.response.add(object!);
124 + }
125 + } else if (request.method == 'DELETE') {
126 + object = null;
127 + request.response.statusCode = HttpStatus.noContent;
128 + }
129 + await request.response.close();
130 + });
131 + final repository = S3CloudSyncRepository(
132 + config: S3SyncConfig(
133 + endpoint: 'http://${server.address.address}:${server.port}',
134 + bucket: 'bucket',
135 + allowInsecureHttp: true,
136 + ),
137 + credentials: const CloudSyncCredentials(
138 + accessKeyId: 'access',
139 + secretAccessKey: 'secret',
140 + ),
141 + );
142 +
143 + await expectLater(
144 + repository.testConnection(),
145 + throwsA(
146 + isA<CloudSyncFailure>().having(
147 + (error) => error.kind,
148 + 'kind',
149 + CloudSyncFailureKind.incompatibleServer,
150 + ),
151 + ),
152 + );
153 +
154 + await subscription.cancel();
155 + await server.close(force: true);
156 + });
157 +}