Skip to content

Commit e140bbb

Browse files
committed
legacy-data: Describe where legacy data is stored and how encoded
1 parent 9bcb2a5 commit e140bbb

File tree

1 file changed

+172
-0
lines changed

1 file changed

+172
-0
lines changed

lib/model/legacy_app_data.dart

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,182 @@
66
// TODO(#1593): write tests for this file
77
library;
88

9+
import 'dart:convert';
10+
import 'dart:io';
11+
12+
import 'package:flutter/foundation.dart';
913
import 'package:json_annotation/json_annotation.dart';
14+
import 'package:path_provider/path_provider.dart';
15+
import 'package:sqlite3/sqlite3.dart';
1016

1117
part 'legacy_app_data.g.dart';
1218

19+
Future<LegacyAppData?> readLegacyAppData() async {
20+
final LegacyAppDatabase db;
21+
try {
22+
final sqlDb = sqlite3.open(await LegacyAppDatabase._filename());
23+
24+
// For writing tests (but more refactoring needed):
25+
// sqlDb = sqlite3.openInMemory();
26+
27+
db = LegacyAppDatabase(sqlDb);
28+
} catch (_) {
29+
// Presumably the legacy database just doesn't exist,
30+
// e.g. because this is a fresh install, not an upgrade from the legacy app.
31+
return null;
32+
}
33+
34+
try {
35+
if (db.migrationVersion() != 1) {
36+
// The data is ancient.
37+
return null; // TODO(log)
38+
}
39+
40+
final migrationsState = db.getDecodedItem('reduxPersist:migrations',
41+
LegacyAppMigrationsState.fromJson);
42+
final migrationsVersion = migrationsState?.version;
43+
if (migrationsVersion == null) {
44+
// The data never got written in the first place,
45+
// at least not coherently.
46+
return null; // TODO(log)
47+
}
48+
if (migrationsVersion < 58) {
49+
// The data predates a migration that affected data we'll try to read.
50+
// Namely migration 58, from commit 49ed2ef5d, PR #5656, 2023-02.
51+
return null; // TODO(log)
52+
}
53+
if (migrationsVersion > 66) {
54+
// The data is from a future schema version this app is unaware of.
55+
return null; // TODO(log)
56+
}
57+
58+
final settingsStr = db.getItem('reduxPersist:settings');
59+
final accountsStr = db.getItem('reduxPersist:accounts');
60+
try {
61+
return LegacyAppData.fromJson({
62+
'settings': settingsStr == null ? null : jsonDecode(settingsStr),
63+
'accounts': accountsStr == null ? null : jsonDecode(accountsStr),
64+
});
65+
} catch (_) {
66+
return null; // TODO(log)
67+
}
68+
} on SqliteException {
69+
return null; // TODO(log)
70+
}
71+
}
72+
73+
class LegacyAppDatabase {
74+
LegacyAppDatabase(this._db);
75+
76+
final Database _db;
77+
78+
static Future<String> _filename() async {
79+
const baseName = 'zulip.db'; // from AsyncStorageImpl._initDb
80+
81+
final dir = await switch (defaultTargetPlatform) {
82+
// See node_modules/expo-sqlite/android/src/main/java/expo/modules/sqlite/SQLiteModule.kt
83+
// and the method SQLiteModule.pathForDatabaseName there:
84+
// works out to "${mContext.filesDir}/SQLite/$name",
85+
// so starting from:
86+
// https://developer.android.com/reference/kotlin/android/content/Context#getFilesDir()
87+
// That's what path_provider's getApplicationSupportDirectory gives.
88+
// (The latter actually has a fallback when Android's getFilesDir
89+
// returns null. But the Android docs say that can't happen. If it does,
90+
// SQLiteModule would just fail to make a database, and the legacy app
91+
// wouldn't have managed to store anything in the first place.)
92+
TargetPlatform.android => getApplicationSupportDirectory(),
93+
94+
// See node_modules/expo-sqlite/ios/EXSQLite/EXSQLite.m
95+
// and the method `pathForDatabaseName:` there:
96+
// works out to "${fileSystem.documentDirectory}/SQLite/$name",
97+
// The base directory there comes from:
98+
// node_modules/expo-modules-core/ios/Interfaces/FileSystem/EXFileSystemInterface.h
99+
// node_modules/expo-file-system/ios/EXFileSystem/EXFileSystem.m
100+
// so ultimately from an expression:
101+
// NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES)
102+
// which means here:
103+
// https://developer.apple.com/documentation/foundation/nssearchpathfordirectoriesindomains(_:_:_:)?language=objc
104+
// https://developer.apple.com/documentation/foundation/filemanager/searchpathdirectory/documentdirectory?language=objc
105+
// That's what path_provider's getApplicationDocumentsDirectory gives.
106+
TargetPlatform.iOS => getApplicationDocumentsDirectory(),
107+
108+
// On other platforms, there is no Zulip legacy app that this app replaces.
109+
// So there's nothing to migrate.
110+
_ => throw Exception(),
111+
};
112+
113+
return '${dir.path}/SQLite/$baseName';
114+
}
115+
116+
/// The migration version of the AsyncStorage database as a whole
117+
/// (not to be confused with the version within `state.migrations`).
118+
///
119+
/// This is always 1 since it was introduced,
120+
/// in commit caf3bf999 in 2022-04.
121+
///
122+
/// Corresponds to portions of AsyncStorageImpl._migrate .
123+
int migrationVersion() {
124+
final rows = _db.select('SELECT version FROM migration LIMIT 1');
125+
return rows.single.values.single as int;
126+
}
127+
128+
T? getDecodedItem<T>(String key, T Function(Map<String, Object?>) fromJson) {
129+
final valueStr = getItem(key);
130+
if (valueStr == null) return null;
131+
132+
try {
133+
return fromJson(jsonDecode(valueStr) as Map<String, Object?>);
134+
} catch (_) {
135+
return null; // TODO(log)
136+
}
137+
}
138+
139+
/// Corresponds to CompressedAsyncStorage.getItem.
140+
String? getItem(String key) {
141+
final item = getItemRaw(key);
142+
if (item == null) return null;
143+
if (item.startsWith('z')) {
144+
// A leading 'z' marks Zulip compression.
145+
// (It can't be the original uncompressed value, because all our values
146+
// are JSON, and no JSON encoding starts with a 'z'.)
147+
148+
if (defaultTargetPlatform != TargetPlatform.android) {
149+
return null; // TODO(log)
150+
}
151+
152+
/// Corresponds to `header` in android/app/src/main/java/com/zulipmobile/TextCompression.kt .
153+
const header = 'z|zlib base64|';
154+
if (!item.startsWith(header)) {
155+
return null; // TODO(log)
156+
}
157+
158+
// These steps correspond to `decompress` in android/app/src/main/java/com/zulipmobile/TextCompression.kt .
159+
final encodedSplit = item.substring(header.length);
160+
// Not sure how newlines get there into the data; but empirically
161+
// they do, after each 76 characters of `encodedSplit`.
162+
final encoded = encodedSplit.replaceAll('\n', '');
163+
final compressedBytes = base64Decode(encoded);
164+
final uncompressedBytes = zlib.decoder.convert(compressedBytes);
165+
return utf8.decode(uncompressedBytes);
166+
}
167+
return item;
168+
}
169+
170+
/// Corresponds to AsyncStorageImpl.getItem.
171+
String? getItemRaw(String key) {
172+
final rows = _db.select('SELECT value FROM keyvalue WHERE key = ?', [key]);
173+
final row = rows.firstOrNull;
174+
if (row == null) return null;
175+
return row.values.single as String;
176+
}
177+
178+
/// Corresponds to AsyncStorageImpl.getAllKeys.
179+
List<String> getAllKeys() {
180+
final rows = _db.select('SELECT key FROM keyvalue');
181+
return [for (final r in rows) r.values.single as String];
182+
}
183+
}
184+
13185
/// Represents the data from the legacy app's database,
14186
/// so far as it's relevant for this app.
15187
///

0 commit comments

Comments
 (0)