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
+}