Skip to content

Commit f9f9a5f

Browse files
committed
Added migrations and handled decompression and reading data from legacy database.
1 parent c5d0abf commit f9f9a5f

File tree

7 files changed

+459
-0
lines changed

7 files changed

+459
-0
lines changed

lib/migration/example.dart

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import 'dart:convert';
2+
import 'dart:developer';
3+
import 'dart:io';
4+
import 'package:drift/native.dart';
5+
6+
import 'legacy_account.dart';
7+
import 'migration_db.dart';
8+
import 'migration_utils.dart' as utils;
9+
10+
var base = """
11+
[{
12+
"realm":"https://chat.example/",
13+
14+
"apiKey":"1234"
15+
}]
16+
""";
17+
18+
var base12 = """
19+
[{
20+
"realm":"https://chat.example/",
21+
22+
"apiKey":"1234",
23+
"zulipVersion":"10.0-119-g111c1357ad"
24+
}]
25+
""";
26+
27+
var base13 = """
28+
[{
29+
"realm":"https://chat.example/",
30+
31+
"apiKey":"1234",
32+
"zulipVersion":{"data":"10.0-119-g111c1357ad","__serializedType__":"ZulipVersion"}
33+
}]
34+
""";
35+
36+
main() async {
37+
// Example usage
38+
// should be /data/data/com.zulipmobile/files/SQLite/zulip.db
39+
// var path = 'E:\\zulip_data\\zulipmobile_backup\\apps\\com.zulipmobile\\f\\SQLite\\zulip.db';
40+
// final executor = NativeDatabase(File(path));
41+
// final db = MinimalDatabase(executor);
42+
// String? accounts;
43+
// int version = -1;
44+
// try {
45+
// version = await db.getVersion();
46+
// accounts = await db.getItem('reduxPersist:accounts');
47+
// } catch (e) {
48+
// log('Error: $e');
49+
// } finally {
50+
// // Clean up by closing the database
51+
// await db.close();
52+
// }
53+
dynamic json = jsonDecode(base, reviver: utils.reviver);
54+
Map<String,dynamic> jsonMap = json[0] as Map<String,dynamic>;
55+
var res = LegacyAccount.applyMigrations(jsonMap, 6);
56+
if (res != null) {
57+
LegacyAccount account = LegacyAccount.fromJson(jsonMap);
58+
print(account);
59+
}
60+
}

lib/migration/legacy_account.dart

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import 'package:drift/drift.dart';
2+
3+
class LegacyAccount {
4+
final Uri? realm;
5+
final String? apiKey;
6+
final String? email;
7+
final int? userId;
8+
final String? zulipVersion;
9+
final int? zulipFeatureLevel;
10+
final String? ackedPushToken;
11+
final DateTime? lastDismissedServerPushSetupNotice;
12+
final DateTime? lastDismissedServerNotifsExpiringBanner;
13+
final bool? silenceServerPushSetupWarnings;
14+
15+
LegacyAccount({
16+
this.realm,
17+
this.apiKey,
18+
this.email,
19+
this.userId,
20+
this.zulipVersion,
21+
this.zulipFeatureLevel,
22+
this.ackedPushToken,
23+
this.lastDismissedServerPushSetupNotice,
24+
this.lastDismissedServerNotifsExpiringBanner,
25+
this.silenceServerPushSetupWarnings,
26+
});
27+
28+
factory LegacyAccount.fromJson(
29+
Map<String, dynamic> json, {
30+
ValueSerializer? serializer,
31+
}) {
32+
serializer ??= driftRuntimeOptions.defaultSerializer;
33+
return LegacyAccount(
34+
realm: serializer.fromJson<Uri?>(json['realm']),
35+
apiKey: serializer.fromJson<String?>(json['apiKey']),
36+
email: serializer.fromJson<String?>(json['email']),
37+
userId: serializer.fromJson<int?>(json['userId']),
38+
zulipVersion: serializer.fromJson<String?>(json['zulipVersion']),
39+
zulipFeatureLevel: serializer.fromJson<int?>(json['zulipFeatureLevel']),
40+
ackedPushToken: serializer.fromJson<String?>(json['ackedPushToken']),
41+
lastDismissedServerPushSetupNotice: serializer.fromJson<DateTime?>(
42+
json['lastDismissedServerPushSetupNotice']),
43+
lastDismissedServerNotifsExpiringBanner: serializer.fromJson<DateTime?>(
44+
json['lastDismissedServerNotifsExpiringBanner']),
45+
silenceServerPushSetupWarnings: serializer.fromJson<bool?>(
46+
json['silenceServerPushSetupWarnings']),
47+
);
48+
}
49+
50+
@override
51+
String toString() {
52+
return 'LegacyAccount{realm: $realm, apiKey: $apiKey,'
53+
' email: $email, userId: $userId, zulipVersion: $zulipVersion,'
54+
' zulipFeatureLevel: $zulipFeatureLevel, ackedPushToken: $ackedPushToken,'
55+
' lastDismissedServerPushSetupNotice: $lastDismissedServerPushSetupNotice,'
56+
' lastDismissedServerNotifsExpiringBanner: $lastDismissedServerNotifsExpiringBanner,'
57+
' silenceServerPushSetupWarnings: $silenceServerPushSetupWarnings}';
58+
}
59+
60+
61+
/// This method should return the json data of the account in the latest version
62+
/// of migrations or null if the data can't be migrated.
63+
static Map<String, dynamic>? applyMigrations(Map<String, dynamic> json, int version) {
64+
if (version < 9) {
65+
// json['ackedPushToken'] should be set to null
66+
json['ackedPushToken'] = null;
67+
}
68+
69+
if (version < 11) {
70+
// removes multiple trailing slashes from json['realm'].
71+
json['realm'] = json['realm'].replaceAll(RegExp(r'/+$'), '');
72+
}
73+
74+
if (version < 12) {
75+
// Add zulipVersion to accounts.
76+
json['zulipVersion'] = null;
77+
}
78+
79+
// if (version < 13) {
80+
// this should convert json['zulipVersion'] from `string | null` to `ZulipVersion | null`
81+
// but we already have it as `string | null` in this app so no point of
82+
// doing this then making it string back
83+
// }
84+
85+
if (version < 14) {
86+
// Add zulipFeatureLevel to accounts.
87+
json['zulipFeatureLevel'] = null;
88+
}
89+
90+
if (version < 15) {
91+
// json['realm'] is a string not uri
92+
json['realm'] = Uri.parse(json['realm'] as String);
93+
}
94+
95+
if (version < 27) {
96+
// Remove accounts with "in-progress" login state (empty json['email'])
97+
// make all fields null
98+
if (json['email'] == null || json['email'] == '') {
99+
return null;
100+
}
101+
}
102+
103+
if (version < 33) {
104+
// Add userId to accounts.
105+
json['userId'] = null;
106+
}
107+
108+
if (version < 36) {
109+
// Add lastDismissedServerPushSetupNotice to accounts.
110+
json['lastDismissedServerPushSetupNotice'] = null;
111+
112+
}
113+
114+
if (version < 58) {
115+
const requiredKeys = [
116+
'realm',
117+
'apiKey',
118+
'email',
119+
'userId',
120+
'zulipVersion',
121+
'zulipFeatureLevel',
122+
'ackedPushToken',
123+
'lastDismissedServerPushSetupNotice',
124+
];
125+
bool hasAllRequiredKeys = requiredKeys.every((key) => json.containsKey(key));
126+
if (!hasAllRequiredKeys) {
127+
return null;
128+
}
129+
}
130+
131+
if (version < 62) {
132+
// Add silenceServerPushSetupWarnings to accounts.
133+
json['silenceServerPushSetupWarnings'] = false;
134+
}
135+
136+
if (version < 66) {
137+
// Add lastDismissedServerNotifsExpiringBanner to accounts.
138+
json['lastDismissedServerNotifsExpiringBanner'] = null;
139+
}
140+
return json;
141+
}
142+
}

lib/migration/migration_db.dart

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import 'dart:convert';
2+
import 'package:drift/drift.dart';
3+
4+
import 'migration_utils.dart' as utils;
5+
6+
7+
8+
9+
class MinimalDatabase extends GeneratedDatabase {
10+
MinimalDatabase(super.e);
11+
12+
@override
13+
Iterable<TableInfo> get allTables => [];
14+
15+
@override
16+
int get schemaVersion => 1;
17+
18+
Future<List<Map<String, dynamic>>> rawQuery(String query) {
19+
return customSelect(query).map((row) => row.data).get();
20+
}
21+
22+
Future<int> getVersion() async {
23+
String? item = await getItem('reduxPersist:migrations');
24+
if (item == null) {
25+
return -1;
26+
}
27+
var decodedValue = jsonDecode(item);
28+
var version = decodedValue['version'] as int;
29+
return version;
30+
}
31+
// This method is from the legacy RN codebase from
32+
// src\storage\CompressedAsyncStorage.js and src\storage\AsyncStorage.js
33+
Future<String?> getItem(String key) async {
34+
final query = 'SELECT value FROM keyvalue WHERE key = ?';
35+
final rows = await customSelect(query, variables: [Variable<String>(key)])
36+
.map((row) => row.data)
37+
.get();
38+
String? item = rows.isNotEmpty ? rows[0]['value'] as String : null;
39+
if (item == null) return null;
40+
// It's possible that getItem() is called on uncompressed state, for
41+
// example when a user updates their app from a version without
42+
// compression to a version with compression. So we need to detect that.
43+
//
44+
// We can detect compressed states by inspecting the first few
45+
// characters of `result`. First, a leading 'z' indicates a
46+
// "Zulip"-compressed string; otherwise, the string is the only other
47+
// format we've ever stored, namely uncompressed JSON (which,
48+
// conveniently, never starts with a 'z').
49+
//
50+
// Then, a Zulip-compressed string looks like `z|TRANSFORMS|DATA`, where
51+
// TRANSFORMS is a space-separated list of the transformations that we
52+
// applied, in order, to the data to produce DATA and now need to undo.
53+
// E.g., `zlib base64` means DATA is a base64 encoding of a zlib
54+
// encoding of the underlying data. We call the "z|TRANSFORMS|" part
55+
// the "header" of the string.
56+
if(item.startsWith('z')) {
57+
String itemHeader = '${item.split('|').sublist(0, 2).join('|')}|';
58+
if (itemHeader == utils.header) {
59+
// The string is compressed, so we need to decompress it.
60+
String decompressedString = utils.decompress(item);
61+
return decompressedString;
62+
} else {
63+
// Panic! If we are confronted with an unknown format, there is
64+
// nothing we can do to save the situation. Log an error and ignore
65+
// the data. This error should not happen unless a user downgrades
66+
// their version of the app.
67+
final err = Exception(
68+
'No decompression module found for format $itemHeader');
69+
throw err;
70+
}
71+
}
72+
// Uncompressed state
73+
return item;
74+
75+
}
76+
77+
}

lib/migration/migration_utils.dart

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import 'dart:convert';
2+
import 'package:archive/archive.dart';
3+
4+
/// Custom reviver for inventive data types JSON doesn't handle.
5+
///
6+
/// To be passed to `jsonDecode` as its `reviver` argument. New
7+
/// reviving logic must also appear in the corresponding replacer
8+
/// to stay in sync.
9+
Object? reviver(Object? key, Object? value) {
10+
const serializedTypeFieldName = '__serializedType__';
11+
if (value != null &&
12+
value is Map<String, dynamic> &&
13+
value.containsKey(serializedTypeFieldName)) {
14+
final data = value['data'];
15+
switch (value[serializedTypeFieldName]) {
16+
case 'Date':
17+
return DateTime.parse(data as String);
18+
case 'ZulipVersion':
19+
return data as String; // Assumes ZulipVersion has a constructor accepting data.
20+
case 'URL':
21+
return Uri.parse(data as String);
22+
default:
23+
// Fail immediately for unhandled types to avoid corrupt data structures.
24+
throw FormatException(
25+
'Unhandled serialized type: ${value[serializedTypeFieldName]}',
26+
);
27+
}
28+
}
29+
return value;
30+
}
31+
32+
33+
var header = "z|zlib base64|";
34+
String decompress(String input) {
35+
// Convert input string to bytes using Latin1 encoding (equivalent to ISO-8859-1)
36+
List<int> inputBytes = latin1.encode(input);
37+
38+
// Extract header length
39+
int headerLength = header.length;
40+
41+
// Get the Base64 content, skipping the header
42+
String base64Content = latin1.decode(inputBytes.sublist(headerLength));
43+
44+
// Remove any whitespace or line breaks from the Base64 content
45+
base64Content = base64Content.replaceAll(RegExp(r'\s+'), '');
46+
47+
// Decode the cleaned Base64 content
48+
List<int> decodedBytes = base64.decode(base64Content);
49+
50+
// Create a ZLibDecoder for decompression
51+
final decoder = ZLibDecoder();
52+
53+
// Decompress the bytes
54+
List<int> decompressedBytes = decoder.decodeBytes(decodedBytes);
55+
56+
// Convert the bytes back to a string using UTF-8 encoding
57+
return utf8.decode(decompressedBytes);
58+
}

0 commit comments

Comments
 (0)