Skip to content

Commit 784938e

Browse files
committed
db: Read legacy app's database, and migrate data.
Fixes #1070.
1 parent 2739149 commit 784938e

9 files changed

+931
-6
lines changed

lib/migration/legacy_app_data.dart

+145
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import 'dart:convert';
2+
import 'dart:developer';
3+
import 'dart:io';
4+
import 'package:drift/drift.dart';
5+
import 'package:drift/native.dart';
6+
import 'package:path/path.dart';
7+
import 'package:path_provider/path_provider.dart';
8+
9+
import 'migration_db.dart';
10+
import 'migration_utils.dart' as utils;
11+
12+
class LegacyAppData {
13+
static late LegacyDatabase db;
14+
static late int version;
15+
LegacyAppData();
16+
17+
/// Initializes the migration process by opening the database and checking its version.
18+
static Future<bool> init() async {
19+
try {
20+
// this should map to /data/data/com.zulipmobile/files/SQLite/zulip.db
21+
// its not tested yet.
22+
final directory = await getApplicationDocumentsDirectory();
23+
final dbPath = join(directory.path, 'SQLite', 'zulip.db');
24+
final executor = NativeDatabase(File(dbPath));
25+
db = LegacyDatabase(executor);
26+
version = await db.getVersion();
27+
if (version == -1) {
28+
return false;
29+
}
30+
}
31+
catch (e) {
32+
log('Legacy Migration Error: $e');
33+
return false;
34+
}
35+
return true;
36+
}
37+
38+
static Future<void> close() async {
39+
await db.close();
40+
}
41+
42+
/// gets the accounts stored in the legacy app database and apply necessary migrations.
43+
static Future<List<LegacyAccount>?> getAccountsData() async {
44+
try {
45+
var accounts = await db.getItem('reduxPersist:accounts');
46+
if (accounts == null) {
47+
return null;
48+
}
49+
List<LegacyAccount> accountsList = [];
50+
final accountList = jsonDecode(accounts, reviver: utils.reviver) as List<dynamic>;
51+
for(var i = 0; i < accountList.length; i++) {
52+
final account = accountList[i] as Map<String,dynamic>;
53+
var res = LegacyAppMigrations.applyAccountMigrations(account, version);
54+
if (res != null ) {
55+
56+
LegacyAccount accountData = LegacyAccount.fromJson(account);
57+
accountsList.add(accountData);
58+
}
59+
}
60+
if (accountsList.isNotEmpty) {
61+
return accountsList;
62+
}
63+
} catch (e) {
64+
log('Legacy Migration Error: $e');
65+
}
66+
return null;
67+
}
68+
69+
/// gets the settings stored in the legacy app database and apply necessary migrations.
70+
static Future<Map<String, dynamic>?> getSettingsData() async {
71+
try {
72+
var jsonSettings = await db.getItem('reduxPersist:settings');
73+
if (jsonSettings == null) {
74+
return null;
75+
}
76+
var settings = jsonDecode(jsonSettings) as Map<String,dynamic>;
77+
var res = LegacyAppMigrations.applySettingMigrations(settings, version);
78+
if (res != null) {
79+
return res;
80+
}
81+
} catch (e) {
82+
log('Legacy Migration Error: $e');
83+
}
84+
return null;
85+
}
86+
}
87+
88+
89+
class LegacyAccount {
90+
final Uri? realm;
91+
final String? apiKey;
92+
final String? email;
93+
final int? userId;
94+
final String? zulipVersion;
95+
final int? zulipFeatureLevel;
96+
final String? ackedPushToken;
97+
final DateTime? lastDismissedServerPushSetupNotice;
98+
final DateTime? lastDismissedServerNotifsExpiringBanner;
99+
final bool? silenceServerPushSetupWarnings;
100+
101+
LegacyAccount({
102+
this.realm,
103+
this.apiKey,
104+
this.email,
105+
this.userId,
106+
this.zulipVersion,
107+
this.zulipFeatureLevel,
108+
this.ackedPushToken,
109+
this.lastDismissedServerPushSetupNotice,
110+
this.lastDismissedServerNotifsExpiringBanner,
111+
this.silenceServerPushSetupWarnings,
112+
});
113+
114+
factory LegacyAccount.fromJson(
115+
Map<String, dynamic> json, {
116+
ValueSerializer? serializer,
117+
}) {
118+
serializer ??= driftRuntimeOptions.defaultSerializer;
119+
return LegacyAccount(
120+
realm: serializer.fromJson<Uri?>(json['realm']),
121+
apiKey: serializer.fromJson<String?>(json['apiKey']),
122+
email: serializer.fromJson<String?>(json['email']),
123+
userId: serializer.fromJson<int?>(json['userId']),
124+
zulipVersion: serializer.fromJson<String?>(json['zulipVersion']),
125+
zulipFeatureLevel: serializer.fromJson<int?>(json['zulipFeatureLevel']),
126+
ackedPushToken: serializer.fromJson<String?>(json['ackedPushToken']),
127+
lastDismissedServerPushSetupNotice: serializer.fromJson<DateTime?>(
128+
json['lastDismissedServerPushSetupNotice']),
129+
lastDismissedServerNotifsExpiringBanner: serializer.fromJson<DateTime?>(
130+
json['lastDismissedServerNotifsExpiringBanner']),
131+
silenceServerPushSetupWarnings: serializer.fromJson<bool?>(
132+
json['silenceServerPushSetupWarnings']),
133+
);
134+
}
135+
136+
@override
137+
String toString() {
138+
return 'LegacyAccount{realm: $realm, apiKey: $apiKey,'
139+
' email: $email, userId: $userId, zulipVersion: $zulipVersion,'
140+
' zulipFeatureLevel: $zulipFeatureLevel, ackedPushToken: $ackedPushToken,'
141+
' lastDismissedServerPushSetupNotice: $lastDismissedServerPushSetupNotice,'
142+
' lastDismissedServerNotifsExpiringBanner: $lastDismissedServerNotifsExpiringBanner,'
143+
' silenceServerPushSetupWarnings: $silenceServerPushSetupWarnings}';
144+
}
145+
}

lib/migration/migration_db.dart

+222
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
import 'dart:convert';
2+
import 'package:drift/drift.dart';
3+
4+
import 'migration_utils.dart' as utils;
5+
6+
7+
class LegacyDatabase extends GeneratedDatabase {
8+
LegacyDatabase(super.e);
9+
10+
@override
11+
Iterable<TableInfo<Table,dynamic>> get allTables => [];
12+
13+
@override
14+
int get schemaVersion => 1;
15+
16+
Future<List<Map<String, dynamic>>> rawQuery(String query) {
17+
return customSelect(query).map((row) => row.data).get();
18+
}
19+
20+
Future<int> getVersion() async {
21+
String? item = await getItem('reduxPersist:migrations');
22+
if (item == null) {
23+
return -1;
24+
}
25+
var decodedValue = jsonDecode(item);
26+
var version = decodedValue['version'] as int;
27+
return version;
28+
}
29+
// This method is from the legacy RN codebase from
30+
// src\storage\CompressedAsyncStorage.js and src\storage\AsyncStorage.js
31+
Future<String?> getItem(String key) async {
32+
final query = 'SELECT value FROM keyvalue WHERE key = ?';
33+
final rows = await customSelect(query, variables: [Variable<String>(key)])
34+
.map((row) => row.data)
35+
.get();
36+
String? item = rows.isNotEmpty ? rows[0]['value'] as String : null;
37+
if (item == null) return null;
38+
// It's possible that getItem() is called on uncompressed state, for
39+
// example when a user updates their app from a version without
40+
// compression to a version with compression. So we need to detect that.
41+
//
42+
// We can detect compressed states by inspecting the first few
43+
// characters of `result`. First, a leading 'z' indicates a
44+
// "Zulip"-compressed string; otherwise, the string is the only other
45+
// format we've ever stored, namely uncompressed JSON (which,
46+
// conveniently, never starts with a 'z').
47+
//
48+
// Then, a Zulip-compressed string looks like `z|TRANSFORMS|DATA`, where
49+
// TRANSFORMS is a space-separated list of the transformations that we
50+
// applied, in order, to the data to produce DATA and now need to undo.
51+
// E.g., `zlib base64` means DATA is a base64 encoding of a zlib
52+
// encoding of the underlying data. We call the "z|TRANSFORMS|" part
53+
// the "header" of the string.
54+
if(item.startsWith('z')) {
55+
String itemHeader = '${item.split('|').sublist(0, 2).join('|')}|';
56+
if (itemHeader == utils.header) {
57+
// The string is compressed, so we need to decompress it.
58+
String decompressedString = utils.decompress(item);
59+
return decompressedString;
60+
} else {
61+
// Panic! If we are confronted with an unknown format, there is
62+
// nothing we can do to save the situation. Log an error and ignore
63+
// the data. This error should not happen unless a user downgrades
64+
// their version of the app.
65+
final err = Exception(
66+
'No decompression module found for format $itemHeader');
67+
throw err;
68+
}
69+
}
70+
// Uncompressed state
71+
return item;
72+
73+
}
74+
}
75+
76+
class LegacyAppMigrations {
77+
LegacyAppMigrations();
78+
79+
/// This method should return the json data of the account in the latest version
80+
/// of migrations or null if the data can't be migrated.
81+
static Map<String, dynamic>? applyAccountMigrations(Map<String, dynamic> json, int version) {
82+
if (version < 9) {
83+
// json['ackedPushToken'] should be set to null
84+
json['ackedPushToken'] = null;
85+
}
86+
87+
if (version < 11) {
88+
// removes multiple trailing slashes from json['realm'].
89+
json['realm'] = json['realm'].replaceAll(RegExp(r'/+$'), '');
90+
}
91+
92+
if (version < 12) {
93+
// Add zulipVersion to accounts.
94+
json['zulipVersion'] = null;
95+
}
96+
97+
// if (version < 13) {
98+
// this should convert json['zulipVersion'] from `string | null` to `ZulipVersion | null`
99+
// but we already have it as `string | null` in this app so no point of
100+
// doing this then making it string back
101+
// }
102+
103+
if (version < 14) {
104+
// Add zulipFeatureLevel to accounts.
105+
json['zulipFeatureLevel'] = null;
106+
}
107+
108+
if (version < 15) {
109+
// convert json['realm'] from string to Uri.
110+
json['realm'] = Uri.parse(json['realm'] as String);
111+
}
112+
113+
if (version < 27) {
114+
// Remove accounts with "in-progress" login state (empty json['email'])
115+
// make all fields null
116+
if (json['email'] == null || json['email'] == '') {
117+
return null;
118+
}
119+
}
120+
121+
if (version < 33) {
122+
// Add userId to accounts.
123+
json['userId'] = null;
124+
}
125+
126+
if (version < 36) {
127+
// Add lastDismissedServerPushSetupNotice to accounts.
128+
json['lastDismissedServerPushSetupNotice'] = null;
129+
130+
}
131+
132+
if (version < 58) {
133+
const requiredKeys = [
134+
'realm',
135+
'apiKey',
136+
'email',
137+
'userId',
138+
'zulipVersion',
139+
'zulipFeatureLevel',
140+
'ackedPushToken',
141+
'lastDismissedServerPushSetupNotice',
142+
];
143+
bool hasAllRequiredKeys = requiredKeys.every((key) => json.containsKey(key));
144+
if (!hasAllRequiredKeys) {
145+
return null;
146+
}
147+
}
148+
149+
if (version < 62) {
150+
// Add silenceServerPushSetupWarnings to accounts.
151+
json['silenceServerPushSetupWarnings'] = false;
152+
}
153+
154+
if (version < 66) {
155+
// Add lastDismissedServerNotifsExpiringBanner to accounts.
156+
json['lastDismissedServerNotifsExpiringBanner'] = null;
157+
}
158+
return json;
159+
}
160+
161+
static Map<String, dynamic>? applySettingMigrations(Map<String,dynamic> json, int version) {
162+
if (version < 10) {
163+
// Convert old locale names to new, more-specific locale names.
164+
final newLocaleNames = {
165+
'zh': 'zh-Hans',
166+
'id': 'id-ID',
167+
};
168+
if (newLocaleNames.containsKey(json['locale'])) {
169+
json['locale'] = newLocaleNames[json['locale']];
170+
}
171+
}
172+
173+
if (version < 26) {
174+
// Rename locale `id-ID` back to `id`.
175+
if (json['locale'] == 'id-ID') {
176+
json['locale'] = 'id';
177+
}
178+
}
179+
180+
if (version < 28) {
181+
// Add "open links with in-app browser" setting.
182+
json['browser'] = 'default';
183+
}
184+
185+
if (version < 30) {
186+
// Use valid language tag for Portuguese (Portugal).
187+
if (json['locale'] == 'pt_PT') {
188+
json['locale'] = 'pt-PT';
189+
}
190+
}
191+
192+
if (version < 31) {
193+
// rename json['locale'] to json['language'].
194+
json['language'] = json['locale'];
195+
json.remove('locale');
196+
}
197+
198+
if (version < 32) {
199+
// Switch to zh-TW as a language option instead of zh-Hant.
200+
if (json['language'] == 'zh-Hant') {
201+
json['language'] = 'zh-TW';
202+
}
203+
}
204+
205+
if (version < 37) {
206+
// Adds `doNotMarkMessagesAsRead` to `settings`. If the property is missing, it defaults to `false`.
207+
json['doNotMarkMessagesAsRead'] = json['doNotMarkMessagesAsRead'] ?? false;
208+
}
209+
210+
if (version < 52) {
211+
// Change boolean doNotMarkMessagesAsRead to enum markMessagesReadOnScroll.
212+
if (json['doNotMarkMessagesAsRead'] == true) {
213+
json['markMessagesReadOnScroll'] = 'never';
214+
} else {
215+
json['markMessagesReadOnScroll'] = 'always';
216+
}
217+
json.remove('doNotMarkMessagesAsRead');
218+
}
219+
220+
return json;
221+
}
222+
}

0 commit comments

Comments
 (0)