Skip to content

Commit bc928f7

Browse files
committed
wip legacy-data: Describe where legacy data is stored and how encoded; TODO test
1 parent 026dd6b commit bc928f7

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
@@ -5,10 +5,182 @@
55
/// See <https://github.com/zulip/zulip-mobile>.
66
library;
77

8+
import 'dart:convert';
9+
import 'dart:io';
10+
11+
import 'package:flutter/foundation.dart';
812
import 'package:json_annotation/json_annotation.dart';
13+
import 'package:path_provider/path_provider.dart';
14+
import 'package:sqlite3/sqlite3.dart';
915

1016
part 'legacy_app_data.g.dart';
1117

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

0 commit comments

Comments
 (0)