diff --git a/lib/migration/legacy_app_data.dart b/lib/migration/legacy_app_data.dart new file mode 100644 index 0000000000..26a12b06d5 --- /dev/null +++ b/lib/migration/legacy_app_data.dart @@ -0,0 +1,145 @@ +import 'dart:convert'; +import 'dart:developer'; +import 'dart:io'; +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:path/path.dart'; +import 'package:path_provider/path_provider.dart'; + +import 'migration_db.dart'; +import 'migration_utils.dart' as utils; + +class LegacyAppData { + static late LegacyDatabase db; + static late int version; + LegacyAppData(); + + /// Initializes the migration process by opening the database and checking its version. + static Future init() async { + try { + // this should map to /data/data/com.zulipmobile/files/SQLite/zulip.db + // its not tested yet. + final directory = await getApplicationDocumentsDirectory(); + final dbPath = join(directory.path, 'SQLite', 'zulip.db'); + final executor = NativeDatabase(File(dbPath)); + db = LegacyDatabase(executor); + version = await db.getVersion(); + if (version == -1) { + return false; + } + } + catch (e) { + log('Legacy Migration Error: $e'); + return false; + } + return true; + } + + static Future close() async { + await db.close(); + } + + /// gets the accounts stored in the legacy app database and apply necessary migrations. + static Future?> getAccountsData() async { + try { + var accounts = await db.getItem('reduxPersist:accounts'); + if (accounts == null) { + return null; + } + List accountsList = []; + final accountList = jsonDecode(accounts, reviver: utils.reviver) as List; + for(var i = 0; i < accountList.length; i++) { + final account = accountList[i] as Map; + var res = LegacyAppMigrations.applyAccountMigrations(account, version); + if (res != null ) { + + LegacyAccount accountData = LegacyAccount.fromJson(account); + accountsList.add(accountData); + } + } + if (accountsList.isNotEmpty) { + return accountsList; + } + } catch (e) { + log('Legacy Migration Error: $e'); + } + return null; + } + + /// gets the settings stored in the legacy app database and apply necessary migrations. + static Future?> getSettingsData() async { + try { + var jsonSettings = await db.getItem('reduxPersist:settings'); + if (jsonSettings == null) { + return null; + } + var settings = jsonDecode(jsonSettings) as Map; + var res = LegacyAppMigrations.applySettingMigrations(settings, version); + if (res != null) { + return res; + } + } catch (e) { + log('Legacy Migration Error: $e'); + } + return null; + } +} + + +class LegacyAccount { + final Uri? realm; + final String? apiKey; + final String? email; + final int? userId; + final String? zulipVersion; + final int? zulipFeatureLevel; + final String? ackedPushToken; + final DateTime? lastDismissedServerPushSetupNotice; + final DateTime? lastDismissedServerNotifsExpiringBanner; + final bool? silenceServerPushSetupWarnings; + + LegacyAccount({ + this.realm, + this.apiKey, + this.email, + this.userId, + this.zulipVersion, + this.zulipFeatureLevel, + this.ackedPushToken, + this.lastDismissedServerPushSetupNotice, + this.lastDismissedServerNotifsExpiringBanner, + this.silenceServerPushSetupWarnings, + }); + + factory LegacyAccount.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return LegacyAccount( + realm: serializer.fromJson(json['realm']), + apiKey: serializer.fromJson(json['apiKey']), + email: serializer.fromJson(json['email']), + userId: serializer.fromJson(json['userId']), + zulipVersion: serializer.fromJson(json['zulipVersion']), + zulipFeatureLevel: serializer.fromJson(json['zulipFeatureLevel']), + ackedPushToken: serializer.fromJson(json['ackedPushToken']), + lastDismissedServerPushSetupNotice: serializer.fromJson( + json['lastDismissedServerPushSetupNotice']), + lastDismissedServerNotifsExpiringBanner: serializer.fromJson( + json['lastDismissedServerNotifsExpiringBanner']), + silenceServerPushSetupWarnings: serializer.fromJson( + json['silenceServerPushSetupWarnings']), + ); + } + + @override + String toString() { + return 'LegacyAccount{realm: $realm, apiKey: $apiKey,' + ' email: $email, userId: $userId, zulipVersion: $zulipVersion,' + ' zulipFeatureLevel: $zulipFeatureLevel, ackedPushToken: $ackedPushToken,' + ' lastDismissedServerPushSetupNotice: $lastDismissedServerPushSetupNotice,' + ' lastDismissedServerNotifsExpiringBanner: $lastDismissedServerNotifsExpiringBanner,' + ' silenceServerPushSetupWarnings: $silenceServerPushSetupWarnings}'; + } +} \ No newline at end of file diff --git a/lib/migration/migration_db.dart b/lib/migration/migration_db.dart new file mode 100644 index 0000000000..c14b112094 --- /dev/null +++ b/lib/migration/migration_db.dart @@ -0,0 +1,222 @@ +import 'dart:convert'; +import 'package:drift/drift.dart'; + +import 'migration_utils.dart' as utils; + + +class LegacyDatabase extends GeneratedDatabase { + LegacyDatabase(super.e); + + @override + Iterable> get allTables => []; + + @override + int get schemaVersion => 1; + + Future>> rawQuery(String query) { + return customSelect(query).map((row) => row.data).get(); + } + + Future getVersion() async { + String? item = await getItem('reduxPersist:migrations'); + if (item == null) { + return -1; + } + var decodedValue = jsonDecode(item); + var version = decodedValue['version'] as int; + return version; + } + // This method is from the legacy RN codebase from + // src\storage\CompressedAsyncStorage.js and src\storage\AsyncStorage.js + Future getItem(String key) async { + final query = 'SELECT value FROM keyvalue WHERE key = ?'; + final rows = await customSelect(query, variables: [Variable(key)]) + .map((row) => row.data) + .get(); + String? item = rows.isNotEmpty ? rows[0]['value'] as String : null; + if (item == null) return null; + // It's possible that getItem() is called on uncompressed state, for + // example when a user updates their app from a version without + // compression to a version with compression. So we need to detect that. + // + // We can detect compressed states by inspecting the first few + // characters of `result`. First, a leading 'z' indicates a + // "Zulip"-compressed string; otherwise, the string is the only other + // format we've ever stored, namely uncompressed JSON (which, + // conveniently, never starts with a 'z'). + // + // Then, a Zulip-compressed string looks like `z|TRANSFORMS|DATA`, where + // TRANSFORMS is a space-separated list of the transformations that we + // applied, in order, to the data to produce DATA and now need to undo. + // E.g., `zlib base64` means DATA is a base64 encoding of a zlib + // encoding of the underlying data. We call the "z|TRANSFORMS|" part + // the "header" of the string. + if(item.startsWith('z')) { + String itemHeader = '${item.split('|').sublist(0, 2).join('|')}|'; + if (itemHeader == utils.header) { + // The string is compressed, so we need to decompress it. + String decompressedString = utils.decompress(item); + return decompressedString; + } else { + // Panic! If we are confronted with an unknown format, there is + // nothing we can do to save the situation. Log an error and ignore + // the data. This error should not happen unless a user downgrades + // their version of the app. + final err = Exception( + 'No decompression module found for format $itemHeader'); + throw err; + } + } + // Uncompressed state + return item; + + } +} + +class LegacyAppMigrations { + LegacyAppMigrations(); + + /// This method should return the json data of the account in the latest version + /// of migrations or null if the data can't be migrated. + static Map? applyAccountMigrations(Map json, int version) { + if (version < 9) { + // json['ackedPushToken'] should be set to null + json['ackedPushToken'] = null; + } + + if (version < 11) { + // removes multiple trailing slashes from json['realm']. + json['realm'] = json['realm'].replaceAll(RegExp(r'/+$'), ''); + } + + if (version < 12) { + // Add zulipVersion to accounts. + json['zulipVersion'] = null; + } + + // if (version < 13) { + // this should convert json['zulipVersion'] from `string | null` to `ZulipVersion | null` + // but we already have it as `string | null` in this app so no point of + // doing this then making it string back + // } + + if (version < 14) { + // Add zulipFeatureLevel to accounts. + json['zulipFeatureLevel'] = null; + } + + if (version < 15) { + // convert json['realm'] from string to Uri. + json['realm'] = Uri.parse(json['realm'] as String); + } + + if (version < 27) { + // Remove accounts with "in-progress" login state (empty json['email']) + // make all fields null + if (json['email'] == null || json['email'] == '') { + return null; + } + } + + if (version < 33) { + // Add userId to accounts. + json['userId'] = null; + } + + if (version < 36) { + // Add lastDismissedServerPushSetupNotice to accounts. + json['lastDismissedServerPushSetupNotice'] = null; + + } + + if (version < 58) { + const requiredKeys = [ + 'realm', + 'apiKey', + 'email', + 'userId', + 'zulipVersion', + 'zulipFeatureLevel', + 'ackedPushToken', + 'lastDismissedServerPushSetupNotice', + ]; + bool hasAllRequiredKeys = requiredKeys.every((key) => json.containsKey(key)); + if (!hasAllRequiredKeys) { + return null; + } + } + + if (version < 62) { + // Add silenceServerPushSetupWarnings to accounts. + json['silenceServerPushSetupWarnings'] = false; + } + + if (version < 66) { + // Add lastDismissedServerNotifsExpiringBanner to accounts. + json['lastDismissedServerNotifsExpiringBanner'] = null; + } + return json; + } + + static Map? applySettingMigrations(Map json, int version) { + if (version < 10) { + // Convert old locale names to new, more-specific locale names. + final newLocaleNames = { + 'zh': 'zh-Hans', + 'id': 'id-ID', + }; + if (newLocaleNames.containsKey(json['locale'])) { + json['locale'] = newLocaleNames[json['locale']]; + } + } + + if (version < 26) { + // Rename locale `id-ID` back to `id`. + if (json['locale'] == 'id-ID') { + json['locale'] = 'id'; + } + } + + if (version < 28) { + // Add "open links with in-app browser" setting. + json['browser'] = 'default'; + } + + if (version < 30) { + // Use valid language tag for Portuguese (Portugal). + if (json['locale'] == 'pt_PT') { + json['locale'] = 'pt-PT'; + } + } + + if (version < 31) { + // rename json['locale'] to json['language']. + json['language'] = json['locale']; + json.remove('locale'); + } + + if (version < 32) { + // Switch to zh-TW as a language option instead of zh-Hant. + if (json['language'] == 'zh-Hant') { + json['language'] = 'zh-TW'; + } + } + + if (version < 37) { + // Adds `doNotMarkMessagesAsRead` to `settings`. If the property is missing, it defaults to `false`. + json['doNotMarkMessagesAsRead'] = json['doNotMarkMessagesAsRead'] ?? false; + } + + if (version < 52) { + // Change boolean doNotMarkMessagesAsRead to enum markMessagesReadOnScroll. + if (json['doNotMarkMessagesAsRead'] == true) { + json['markMessagesReadOnScroll'] = 'never'; + } else { + json['markMessagesReadOnScroll'] = 'always'; + } + json.remove('doNotMarkMessagesAsRead'); + } + + return json; + } +} \ No newline at end of file diff --git a/lib/migration/migration_utils.dart b/lib/migration/migration_utils.dart new file mode 100644 index 0000000000..7935edfb66 --- /dev/null +++ b/lib/migration/migration_utils.dart @@ -0,0 +1,58 @@ +import 'dart:convert'; +import 'package:archive/archive.dart'; + +/// Custom reviver for inventive data types JSON doesn't handle. +/// +/// To be passed to `jsonDecode` as its `reviver` argument. New +/// reviving logic must also appear in the corresponding replacer +/// to stay in sync. +Object? reviver(Object? key, Object? value) { + const serializedTypeFieldName = '__serializedType__'; + if (value != null && + value is Map && + value.containsKey(serializedTypeFieldName)) { + final data = value['data']; + switch (value[serializedTypeFieldName]) { + case 'Date': + return DateTime.parse(data as String); + case 'ZulipVersion': + return data as String; + case 'URL': + return Uri.parse(data as String); + default: + // Fail immediately for unhandled types to avoid corrupt data structures. + throw FormatException( + 'Unhandled serialized type: ${value[serializedTypeFieldName]}', + ); + } + } + return value; +} + + +var header = "z|zlib base64|"; +String decompress(String input) { + // Convert input string to bytes using Latin1 encoding (equivalent to ISO-8859-1) + List inputBytes = latin1.encode(input); + + // Extract header length + int headerLength = header.length; + + // Get the Base64 content, skipping the header + String base64Content = latin1.decode(inputBytes.sublist(headerLength)); + + // Remove any whitespace or line breaks from the Base64 content + base64Content = base64Content.replaceAll(RegExp(r'\s+'), ''); + + // Decode the cleaned Base64 content + List decodedBytes = base64.decode(base64Content); + + // Create a ZLibDecoder for decompression + final decoder = ZLibDecoder(); + + // Decompress the bytes + List decompressedBytes = decoder.decodeBytes(decodedBytes); + + // Convert the bytes back to a string using UTF-8 encoding + return utf8.decode(decompressedBytes); +} \ No newline at end of file diff --git a/lib/model/database.dart b/lib/model/database.dart index 57910e7a50..126020687c 100644 --- a/lib/model/database.dart +++ b/lib/model/database.dart @@ -1,9 +1,12 @@ +import 'dart:io'; + import 'package:drift/drift.dart'; import 'package:drift/internal/versioned_schema.dart'; import 'package:drift/remote.dart'; import 'package:sqlite3/common.dart'; import '../log.dart'; +import '../migration/legacy_app_data.dart'; import 'schema_versions.g.dart'; import 'settings.dart'; @@ -180,6 +183,9 @@ class AppDatabase extends _$AppDatabase { await m.createAll(); // Corresponds to `from4to5` above. await into(globalSettings).insert(GlobalSettingsCompanion()); + if (Platform.isAndroid) { + await getDataFromLegacyApp(); + } } @override @@ -240,6 +246,87 @@ class AppDatabase extends _$AppDatabase { rethrow; } } + + Future getDataFromLegacyApp() async { + final canRetrieveLegacyData = await LegacyAppData.init(); + if (!canRetrieveLegacyData) { + return; + } + await getLegacyAccountsData(); + await getLegacySettingsData(); + await LegacyAppData.close(); + } + + Future getLegacyAccountsData() async { + final accounts = await LegacyAppData.getAccountsData(); + if (accounts == null) { + return; + } + for (var account in accounts) { + final values = AccountsCompanion( + realmUrl: Value(account.realm!), + userId: account.userId != null ? Value(account.userId!) : Value + .absent(), + email: Value(account.email!), + apiKey: Value(account.apiKey!), + zulipVersion: account.zulipVersion != null ? Value( + account.zulipVersion!) : Value.absent(), + zulipFeatureLevel: account.zulipFeatureLevel != null ? Value( + account.zulipFeatureLevel!) : Value.absent(), + ackedPushToken: account.ackedPushToken != null ? Value( + account.ackedPushToken!) : Value.absent(), + ); + try { + await createAccount(values); + } on AccountAlreadyExistsException { + continue; + } + } + } + + Future getLegacySettingsData() async { + // the only settings we care about are the browser and theme settings for now. + // if we add new settings that the RN app had, we should handle them here too. + var settings = await LegacyAppData.getSettingsData(); + await populateLegacySettingsData(settings); + } + + Future populateLegacySettingsData(Map? settings) async { + if (settings == null) { + return; + } + // the RN app had 3 options for browser: external, default and embedded. + // embedded: The in-app browser + // external: The user's default browser app + // default: 'external' on iOS, 'embedded' on Android + switch (settings['browser']) { + case 'external': + case 'default': + await update(globalSettings).write( + GlobalSettingsCompanion( + browserPreference: Value(BrowserPreference.external), + ), + ); + break; + case 'embedded': + await update(globalSettings).write( + GlobalSettingsCompanion( + browserPreference: Value(BrowserPreference.inApp), + ), + ); + break; + default: + } + + // the RN app had night for dark and default for system theme. + if (settings['theme'] == 'night') { + await update(globalSettings).write( + GlobalSettingsCompanion( + themeSetting: Value(ThemeSetting.dark), + ), + ); + } + } } class AccountAlreadyExistsException implements Exception {} diff --git a/pubspec.lock b/pubspec.lock index 191c48a987..3b81fcb628 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -33,6 +33,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.1" + archive: + dependency: "direct main" + description: + name: archive + sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" + url: "https://pub.dev" + source: hosted + version: "4.0.7" args: dependency: "direct dev" description: @@ -650,26 +658,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" + sha256: "8dcda04c3fc16c14f48a7bb586d4be1f0d1572731b6d81d51772ef47c02081e0" url: "https://pub.dev" source: hosted - version: "10.0.9" + version: "11.0.1" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" url: "https://pub.dev" source: hosted - version: "3.0.9" + version: "3.0.10" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" legacy_checks: dependency: "direct dev" description: @@ -862,6 +870,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.1" + posix: + dependency: transitive + description: + name: posix + sha256: f0d7856b6ca1887cfa6d1d394056a296ae33489db914e365e2044fdada449e62 + url: "https://pub.dev" + source: hosted + version: "6.0.2" powers: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 59117b3ab8..c1ffd7c7b6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -65,6 +65,7 @@ dependencies: wakelock_plus: ^1.2.8 zulip_plugin: path: ./packages/zulip_plugin + archive: ^4.0.5 # Keep list sorted when adding dependencies; it helps prevent merge conflicts. dependency_overrides: diff --git a/test/migration/legacy_db_test.dart b/test/migration/legacy_db_test.dart new file mode 100644 index 0000000000..8c2299cf62 --- /dev/null +++ b/test/migration/legacy_db_test.dart @@ -0,0 +1,226 @@ +import 'package:checks/checks.dart'; +import 'package:drift/drift.dart'; +import 'package:test/test.dart'; +import 'package:drift/native.dart'; +import 'package:zulip/migration/legacy_app_data.dart'; +import 'package:zulip/migration/migration_db.dart'; +import 'package:zulip/model/database.dart'; +import 'package:zulip/model/settings.dart'; +import '../model/database_test.dart'; +import '../stdlib_checks.dart'; +import 'text_decompression_test.dart' as decompression_test; + +void main() { + late LegacyDatabase db; + setUp(() async { + db = LegacyDatabase(NativeDatabase.memory()); + LegacyAppData.db = db; + await db.customStatement( + "CREATE TABLE keyvalue (key TEXT PRIMARY KEY, value TEXT)", + ); + }); + + tearDown(() async { + await db.close(); + }); + + group('LegacyDatabase.getItem', () { + test('should return uncompressed value', () async { + await db.customStatement( + "INSERT INTO keyvalue (key, value) VALUES ('testKey', 'testValue')", + ); + final result = await db.getItem('testKey'); + + check(result).equals('testValue'); + }); + + test('should return decompressed value for compressed data', () async { + final compressedValue = decompression_test.compressedAccountStr; + await db.customStatement( + "INSERT INTO keyvalue (key, value) VALUES ('testKey', ?)", + [compressedValue], + ); + final result = await db.getItem('testKey'); + + check(result).equals(decompression_test.accountStr); + }); + + test('should return null for non-existent key', () async { + final result = await db.getItem('nonExistentKey'); + + check(result).isNull(); + }); + + test('should throw exception for unknown compression format', () async { + final unknownCompressedValue = 'z|unknown|data'; + await db.customStatement( + "INSERT INTO keyvalue (key, value) VALUES ('testKey', ?)", + [unknownCompressedValue], + ); + + await expectLater(db.getItem('testKey'), throwsA(isA())); + }); + }); + + group('LegacyAppData.getgetAccountsData', () { + test('Inserting data to the Flutter app DB', () async { + AppDatabase appDb = AppDatabase(NativeDatabase.memory()); + final accountsData = ''' + [ + { + "realm":{"data":"https://chat.example","__serializedType__":"URL"}, + "email": "me@example.com", + "apiKey": "1234", + "userId": 22, + "zulipVersion":{"data":"10.0-119-g111c1357ad","__serializedType__":"ZulipVersion"}, + "zulipFeatureLevel": 123, + "ackedPushToken" : null, + "lastDismissedServerPushSetupNotice" : null + }, + { + "realm":{"data":"https://lolo.example","__serializedType__":"URL"}, + "email": "you@example.com", + "apiKey": "4567", + "userId": 23, + "zulipVersion":{"data":"10.0-119-g111c1357ad","__serializedType__":"ZulipVersion"}, + "zulipFeatureLevel": 123, + "ackedPushToken" : null, + "lastDismissedServerPushSetupNotice" : null + } + ] + '''; + await db.customStatement( + "INSERT INTO keyvalue (key, value) VALUES ('reduxPersist:accounts', ?)", + [accountsData], + ); + LegacyAppData.version = 33; + final result = await LegacyAppData.getAccountsData(); + + check(result).isNotNull(); + check(result!.length).equals(2); + for (var account in result) { + final values = AccountsCompanion( + realmUrl: Value(account.realm!), + userId: + account.userId != null ? Value(account.userId!) : Value.absent(), + email: Value(account.email!), + apiKey: Value(account.apiKey!), + zulipVersion: + account.zulipVersion != null + ? Value(account.zulipVersion!) + : Value.absent(), + zulipFeatureLevel: + account.zulipFeatureLevel != null + ? Value(account.zulipFeatureLevel!) + : Value.absent(), + ackedPushToken: + account.ackedPushToken != null + ? Value(account.ackedPushToken!) + : Value.absent(), + ); + // insert the values into the database and get the id + final accountId = await appDb.createAccount(values); + // check that the account is inserted successfully + final insertedAccount = + await (appDb.select(appDb.accounts) + ..where((a) => a.id.equals(accountId))).watchSingle().first; + check(insertedAccount.toCompanion(true).toJson()).deepEquals({ + ...values.toJson(), + 'id': (Subject it) => it.isA(), + }); + } + await appDb.close(); + }); + + test('should return null for null accounts data', () async { + await db.customStatement( + "INSERT INTO keyvalue (key, value) VALUES ('reduxPersist:accounts', NULL)", + ); + final result = await LegacyAppData.getAccountsData(); + + check(result).isNull(); + }); + }); + + group('LegacyAppData.getSettingsData', () { + test('should return null for null settings data', () async { + await db.customStatement( + "INSERT INTO keyvalue (key, value) VALUES ('reduxPersist:settings', NULL)", + ); + final result = await LegacyAppData.getSettingsData(); + + check(result).isNull(); + }); + + test('should return uncompressed settings data', () async { + final settingsData = ''' + { + "language":"en", + "theme":"night", + "browser":"default", + "experimentalFeaturesEnabled":false, + "markMessagesReadOnScroll":"always", + "offlineNotification":true, + "onlineNotification":true, + "streamNotification":false, + "displayEmojiReactionUsers":true + } + '''; + await db.customStatement( + "INSERT INTO keyvalue (key, value) VALUES ('reduxPersist:settings', ?)", + [settingsData], + ); + LegacyAppData.version = 66; + final result = await LegacyAppData.getSettingsData(); + + check(result).isNotNull(); + check(result).deepEquals({ + "language":"en", + "theme":"night", + "browser":"default", + "experimentalFeaturesEnabled":false, + "markMessagesReadOnScroll":"always", + "offlineNotification":true, + "onlineNotification":true, + "streamNotification":false, + "displayEmojiReactionUsers":true + }); + }); + + test('Inserting settings data to Flutter DB', () async { + AppDatabase appDb = AppDatabase(NativeDatabase.memory()); + final settingsData = ''' + { + "language":"en", + "theme":"night", + "browser":"default", + "experimentalFeaturesEnabled":false, + "markMessagesReadOnScroll":"always", + "offlineNotification":true, + "onlineNotification":true, + "streamNotification":false, + "displayEmojiReactionUsers":true + }'''; + await db.customStatement( + "INSERT INTO keyvalue (key, value) VALUES ('reduxPersist:settings', ?)", + [settingsData], + ); + LegacyAppData.version = 66; + final settings = await LegacyAppData.getSettingsData(); + check(settings).isNotNull(); + await appDb.populateLegacySettingsData(settings!); + + final globalSettings = appDb.globalSettings; + GlobalSettingsCompanion expectedPopulatedSettings = GlobalSettingsCompanion( + themeSetting: Value(ThemeSetting.dark), + browserPreference: Value(BrowserPreference.external), + ); + final actualSettings = await appDb.select(globalSettings).getSingle(); + check(actualSettings.toCompanion(true).toJson()).deepEquals({ + ...expectedPopulatedSettings.toJson(), + }); + + await appDb.close(); + }); + }); +} diff --git a/test/migration/migrations_test.dart b/test/migration/migrations_test.dart new file mode 100644 index 0000000000..606f651124 --- /dev/null +++ b/test/migration/migrations_test.dart @@ -0,0 +1,112 @@ +import 'package:checks/checks.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/migration/migration_db.dart'; + +import '../stdlib_checks.dart'; + + +void main() { + group('LegacyAppMigrations.applyAccountMigrations', () { + final baseAccount = { + 'email': 'me@example.com', + 'apiKey': '1234', + 'realm': 'https://chat.example', + }; + + test('version 3 to final version', () { + final migrated = LegacyAppMigrations.applyAccountMigrations( + {...baseAccount}, + 3, + ); + + check(migrated).deepEquals({ + 'email': 'me@example.com', + 'apiKey': '1234', + 'realm': Uri.parse('https://chat.example'), + 'ackedPushToken': null, + 'zulipFeatureLevel': null, + 'zulipVersion': null, + 'lastDismissedServerPushSetupNotice': null, + 'userId': null, + 'silenceServerPushSetupWarnings': false, + 'lastDismissedServerNotifsExpiringBanner': null, + }); + }); + + test('version 27 removes accounts with empty email', () { + final input = >[ + {...baseAccount}, + {'email': '', 'apiKey': '9999', 'realm': 'https://bad.example'}, + ]; + final migrated = input + .map((a) => LegacyAppMigrations.applyAccountMigrations(a, 3)) + .where((a) => a != null) + .toList(); + + check(migrated.length).equals(1); + check(migrated.first!['email']).equals('me@example.com'); + }); + + test('version 58 removes any account which has a missing requiredKeys',() { + final migrated = LegacyAppMigrations.applyAccountMigrations( + {...baseAccount}, + 57, + ); + + check(migrated).isNull(); + }); + }); + + group('LegacyAppMigrations.applySettingMigrations', () { + final baseSettings = { + 'locale': 'en', + 'theme': 'default', + 'offlineNotification': true, + 'onlineNotification': true, + 'experimentalFeaturesEnabled': false, + 'streamNotification': false, + }; + + test('version 3 to final version', () { + final migrated = LegacyAppMigrations.applySettingMigrations( + {...baseSettings}, + 3, + ); + + check(migrated).deepEquals({ + 'language': 'en', + 'theme': 'default', + 'offlineNotification': true, + 'onlineNotification': true, + 'experimentalFeaturesEnabled': false, + 'streamNotification': false, + 'browser': 'default', + 'markMessagesReadOnScroll': 'always', + }); + }); + + test('version 26 renames locale "id-ID" to language "id"', () { + final migrated = LegacyAppMigrations.applySettingMigrations( + { + ...baseSettings, + 'locale': 'id-ID', + }, + 25, + ); + check(migrated!['language']).equals('id'); + check(migrated).not((it) => it.containsKey('locale')); + }); + + test('version 52: doNotMarkMessagesAsRead to markMessagesReadOnScroll', () { + final migrated = LegacyAppMigrations.applySettingMigrations( + { + ...baseSettings, + 'doNotMarkMessagesAsRead': false, + }, + 51, + ); + + check(migrated!['markMessagesReadOnScroll']).equals('always'); + }); + }); +} diff --git a/test/migration/text_decompression_test.dart b/test/migration/text_decompression_test.dart new file mode 100644 index 0000000000..733edb3540 --- /dev/null +++ b/test/migration/text_decompression_test.dart @@ -0,0 +1,58 @@ +import 'package:checks/checks.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/migration/migration_utils.dart' as utils; + +// the compressions are obtained by running compress method compress function +// from android\app\src\main\java\com\zulipmobile\TextCompression.kt from the RN +// app + +var compressedStr1 = """z|zlib base64|eJzzSM3JyddRKM +nILFYAokSFktTiEoXikqLMvHRFAJdFCi4="""; +var str1 = "Hello, this is a test string!"; + +var compressedAccountStr = """z|zlib base64|eJyLruZS +KkpNzMlVslLKKCkpKLbS10/OSCzRS61IzC3ISdVX0uFSSs1NzMwB +KshNdYAK6yXn54JkEgsyvVMrgVKGRsYmSly1OlxYzMvJz8nXQ9KI +bKaCUmV+KS5TFZRMTM3MgcZyxQIAd0czmQ=="""; +var accountStr = """[{ +"realm":"https://chat.example/", +"email":"me@example.com", +"apiKey":"1234" +}, +{ +"realm":"https://lolo.example.com/", +"email": "you@example.com", +"apiKey": "4567" +} +]"""; + +var compressedSettingsStr = """z|zlib base64|eJx1jEE +KwzAMBO9+RdC5L+i9PeYParKiAlkutgyB0r83zjGQ484M+01kZWE +D3SeC0y1RvJGPuUK4WwxWREwdcwkVXTi0+F5E7RjSrx22D6pmeLA +9wdEr2sP5ZVj3SNjaqFpUcD49HPKX/ixeN8k="""; +var settingsStr = """{ +"locale": "en", +"theme": "default", +"offlineNotification": true, +"onlineNotification": true, +"experimentalFeaturesEnabled": false, +"streamNotification": false} +"""; + + +void main() { + test('decompress str1', () { + var decompressed = utils.decompress(compressedStr1); + check(decompressed).equals(str1); + }); + + test('decompress accounts', () { + var decompressed = utils.decompress(compressedAccountStr); + check(decompressed).equals(accountStr); + }); + + test('decompress settings', () { + var decompressed = utils.decompress(compressedSettingsStr); + check(decompressed).equals(settingsStr); + }); +} \ No newline at end of file