diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 000000000..d0e0fbc9b --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1 @@ +-keep class net.sqlcipher.** { *; } \ No newline at end of file diff --git a/assets/cheatsheet.md b/assets/cheatsheet.md index d7dc0544b..a51ce6ea7 100644 --- a/assets/cheatsheet.md +++ b/assets/cheatsheet.md @@ -62,4 +62,90 @@ if (true) { }, ); -``` \ No newline at end of file +``` + +```dart + // invite and membership events are different + + // {membership: join, displayname: usbfingers, avatar_url: mxc://matrix.org/RrRcMHnqXaJshyXZpGrZloyh } + // {is_direct: true, membership: invite, displayname: ereio, avatar_url: mxc://matrix.org/JllILpqzdFAUOvrTPSkDryzW} + +``` + +```dart + +/** + * OneTimeKey Data Model + * + * https://matrix.org/docs/spec/client_server/latest#id468 + * { + "failures": {}, + "one_time_keys": { + "@alice:example.com": { + "JLAFKJWSCS": { + "signed_curve25519:AAAAHg": { + "key": "zKbLg+NrIjpnagy+pIY6uPL4ZwEG2v+8F9lmgsnlZzs", + "signatures": { + "@alice:example.com": { + "ed25519:JLAFKJWSCS": "FLWxXqGbwrb8SM3Y795eB6OA8bwBcoMZFXBqnTn58AYWZSqiD45tlBVcDa2L7RwdKXebW/VzDlnfVJ+9jok1Bw" + } + } + } + } + } + } + } + */ + ``` + + + ```dart +/* + Opening storage path on mobile devices (main thread only) +*/ + Future initStorageLocation() async { + var storageLocation; + + try { + if (Platform.isIOS || Platform.isAndroid) { + storageLocation = await getApplicationDocumentsDirectory(); + return storageLocation.path; + } + + if (Platform.isMacOS) { + storageLocation = await File('cache').create().then( + (value) => value.writeAsString( + '{}', + flush: true, + ), + ); + + return storageLocation.path; + } + + if (Platform.isLinux) { + storageLocation = await getApplicationDocumentsDirectory(); + return storageLocation.path; + } + + debugPrint('[initStorageLocation] no cache support'); + return null; + } catch (error) { + debugPrint('[initStorageLocation] $error'); + return null; + } +} + +``` + +```dart + // reduce several maps to one map + final allDirectUsers = roomsDirectUsers.fold( + {}, + (usersAll, users) { + (usersAll as Map).addAll(users); + return usersAll; + }, + ); + + ``` \ No newline at end of file diff --git a/build.yaml b/build.yaml deleted file mode 100644 index 5e8fd297e..000000000 --- a/build.yaml +++ /dev/null @@ -1,8 +0,0 @@ -targets: - $default: - builders: - reflectable: - generate_for: - - lib/main.dart - options: - formatted: true \ No newline at end of file diff --git a/ios/Podfile.lock b/ios/Podfile.lock index c0ce1cf74..173406c5c 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -41,6 +41,11 @@ PODS: - Flutter - flutter_secure_storage (3.3.1): - Flutter + - FMDB (2.7.5): + - FMDB/standard (= 2.7.5) + - FMDB/SQLCipher (2.7.5): + - SQLCipher + - FMDB/standard (2.7.5) - image_picker (0.0.1): - Flutter - OLMKit (3.1.0): @@ -60,6 +65,18 @@ PODS: - SDWebImage/Core (~> 5.6) - shared_preferences (0.0.1): - Flutter + - sqflite (0.0.2): + - Flutter + - FMDB (>= 2.7.5) + - sqflite_sqlcipher (0.0.1): + - Flutter + - FMDB/SQLCipher (~> 2.7.5) + - SQLCipher (= 4.4.1) + - SQLCipher (4.4.1): + - SQLCipher/standard (= 4.4.1) + - SQLCipher/common (4.4.1) + - SQLCipher/standard (4.4.1): + - SQLCipher/common - url_launcher (0.0.1): - Flutter - webview_flutter (0.0.1): @@ -76,6 +93,8 @@ DEPENDENCIES: - package_info (from `.symlinks/plugins/package_info/ios`) - path_provider (from `.symlinks/plugins/path_provider/ios`) - shared_preferences (from `.symlinks/plugins/shared_preferences/ios`) + - sqflite (from `.symlinks/plugins/sqflite/ios`) + - sqflite_sqlcipher (from `.symlinks/plugins/sqflite_sqlcipher/ios`) - url_launcher (from `.symlinks/plugins/url_launcher/ios`) - webview_flutter (from `.symlinks/plugins/webview_flutter/ios`) @@ -84,9 +103,11 @@ SPEC REPOS: - DKImagePickerController - DKPhotoGallery - FLAnimatedImage + - FMDB - OLMKit - SDWebImage - SDWebImageFLPlugin + - SQLCipher EXTERNAL SOURCES: device_info: @@ -107,6 +128,10 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/path_provider/ios" shared_preferences: :path: ".symlinks/plugins/shared_preferences/ios" + sqflite: + :path: ".symlinks/plugins/sqflite/ios" + sqflite_sqlcipher: + :path: ".symlinks/plugins/sqflite_sqlcipher/ios" url_launcher: :path: ".symlinks/plugins/url_launcher/ios" webview_flutter: @@ -121,6 +146,7 @@ SPEC CHECKSUMS: Flutter: 0e3d915762c693b495b44d77113d4970485de6ec flutter_local_notifications: 9e4738ce2471c5af910d961a6b7eadcf57c50186 flutter_secure_storage: 7953c38a04c3fdbb00571bcd87d8e3b5ceb9daec + FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a image_picker: 9c3312491f862b28d21ecd8fdf0ee14e601b3f09 OLMKit: 4ee0159d63feeb86d836fdcfefe418e163511639 package_info: 873975fc26034f0b863a300ad47e7f1ac6c7ec62 @@ -128,6 +154,9 @@ SPEC CHECKSUMS: SDWebImage: 84000f962cbfa70c07f19d2234cbfcf5d779b5dc SDWebImageFLPlugin: 6c2295fb1242d44467c6c87dc5db6b0a13228fd8 shared_preferences: af6bfa751691cdc24be3045c43ec037377ada40d + sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904 + sqflite_sqlcipher: cc00ecef02643b799857eb5c66c7074660aa658a + SQLCipher: 0b81a39b21247c559c52c3bd2234808e626c008b url_launcher: 6fef411d543ceb26efce54b05a0a40bfd74cbbef webview_flutter: d2b4d6c66968ad042ad94cbb791f5b72b4678a96 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 1db7ac857..c51e6c44e 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -219,10 +219,12 @@ "${BUILT_PRODUCTS_DIR}/DKImagePickerController/DKImagePickerController.framework", "${BUILT_PRODUCTS_DIR}/DKPhotoGallery/DKPhotoGallery.framework", "${BUILT_PRODUCTS_DIR}/FLAnimatedImage/FLAnimatedImage.framework", + "${BUILT_PRODUCTS_DIR}/FMDB/FMDB.framework", "${PODS_ROOT}/../Flutter/Flutter.framework", "${BUILT_PRODUCTS_DIR}/OLMKit/OLMKit.framework", "${BUILT_PRODUCTS_DIR}/SDWebImage/SDWebImage.framework", "${BUILT_PRODUCTS_DIR}/SDWebImageFLPlugin/SDWebImageFLPlugin.framework", + "${BUILT_PRODUCTS_DIR}/SQLCipher/SQLCipher.framework", "${BUILT_PRODUCTS_DIR}/device_info/device_info.framework", "${BUILT_PRODUCTS_DIR}/file_picker/file_picker.framework", "${BUILT_PRODUCTS_DIR}/flutter_local_notifications/flutter_local_notifications.framework", @@ -231,6 +233,8 @@ "${BUILT_PRODUCTS_DIR}/package_info/package_info.framework", "${BUILT_PRODUCTS_DIR}/path_provider/path_provider.framework", "${BUILT_PRODUCTS_DIR}/shared_preferences/shared_preferences.framework", + "${BUILT_PRODUCTS_DIR}/sqflite/sqflite.framework", + "${BUILT_PRODUCTS_DIR}/sqflite_sqlcipher/sqflite_sqlcipher.framework", "${BUILT_PRODUCTS_DIR}/url_launcher/url_launcher.framework", "${BUILT_PRODUCTS_DIR}/webview_flutter/webview_flutter.framework", ); @@ -239,10 +243,12 @@ "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/DKImagePickerController.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/DKPhotoGallery.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FLAnimatedImage.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FMDB.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Flutter.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/OLMKit.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SDWebImage.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SDWebImageFLPlugin.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SQLCipher.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/device_info.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/file_picker.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/flutter_local_notifications.framework", @@ -251,6 +257,8 @@ "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/package_info.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/path_provider.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/shared_preferences.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/sqflite.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/sqflite_sqlcipher.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/url_launcher.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/webview_flutter.framework", ); diff --git a/lib/global/cache/index.dart b/lib/global/cache/index.dart index 439d9bbac..6fd1ce51b 100644 --- a/lib/global/cache/index.dart +++ b/lib/global/cache/index.dart @@ -1,27 +1,31 @@ import 'dart:io'; -import 'package:flutter/material.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:hive/hive.dart'; +import 'package:encrypt/encrypt.dart'; +import 'package:path/path.dart'; import 'package:path_provider/path_provider.dart'; -import 'package:steel_crypt/steel_crypt.dart'; + +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:sembast/sembast.dart'; +import 'package:sembast/sembast_io.dart'; +import 'package:sembast_sqflite/sembast_sqflite.dart'; +import 'package:syphon/global/print.dart'; import 'package:syphon/global/values.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart' as sqflite_ffi; -class CacheSecure { +class Cache { // encryption references (in memory only) static String ivKey; static String ivKeyNext; static String cryptKey; - // cache refrences - static Box cacheMain; - static Box cacheRooms; - static Box cacheCrypto; + // hot cachee refrences + static Database cacheMain; + + // inital store caches for reload + static Map cacheStores = {}; // cache storage identifiers static const cacheKeyMain = '${Values.appNameLabel}-main-cache'; - static const cacheKeyRooms = '${Values.appNameLabel}-room-cache'; - static const cacheKeyCrypto = '${Values.appNameLabel}-crypto-cache'; // cache key identifiers static const ivKeyLocation = '${Values.appNameLabel}@ivKey'; @@ -29,82 +33,76 @@ class CacheSecure { static const cryptKeyLocation = '${Values.appNameLabel}@cryptKey'; // background data identifiers - static const roomNamesKey = 'roomNamesKey'; + static const userIdKey = 'userId'; static const protocolKey = 'protocol'; + static const lastSinceKey = 'lastSince'; static const homeserverKey = 'homeserver'; + static const roomNamesKey = 'roomNamesKey'; static const accessTokenKey = 'accessToken'; - static const lastSinceKey = 'lastSince'; - static const userIdKey = 'userId'; -} - -Future initCache() async { - // Init storage location - final String storageLocation = await initStorageLocation(); - - // Init configuration - Hive.init(storageLocation); - - CacheSecure.cacheMain = await unlockMainCache(); - CacheSecure.cacheRooms = await unlockRoomCache(); - CacheSecure.cacheCrypto = await unlockCryptoCache(); } -Future initStorageLocation() async { - var storageLocation; +/** + * Init Cache + * + * (needs cold storage extracted as it's own entity) + */ +Future initCache() async { + // Configure cache encryption/decryption instance + Cache.ivKey = await unlockIVKey(); + Cache.ivKeyNext = await unlockIVKeyNext(); + Cache.cryptKey = await unlockCryptKey(); try { - if (Platform.isIOS || Platform.isAndroid) { - storageLocation = await getApplicationDocumentsDirectory(); - return storageLocation.path; + var cachePath = '${Cache.cacheKeyMain}.db'; + var cacheFactory; + + if (Platform.isAndroid || Platform.isIOS) { + var directory = await getApplicationDocumentsDirectory(); + await directory.create(); + cachePath = join(directory.path, '${Cache.cacheKeyMain}.db'); + cacheFactory = databaseFactoryIo; } - if (Platform.isMacOS) { - storageLocation = await File('cache').create().then( - (value) => value.writeAsString( - '{}', - flush: true, - ), - ); - - return storageLocation.path; + /// Supports Windows/Linux/MacOS for now. + if (Platform.isLinux || Platform.isWindows || Platform.isMacOS) { + // open cache w/ sqflite ffi for desktop compat + cacheFactory = getDatabaseFactorySqflite( + sqflite_ffi.databaseFactoryFfi, + ); } - if (Platform.isLinux) { - storageLocation = await getApplicationDocumentsDirectory(); - return storageLocation.path; + if (cacheFactory == null) { + throw UnsupportedError( + 'Sorry, Syphon does not support your platform yet. Hope to do so soon!', + ); } - debugPrint('[initStorageLocation] no cache support'); - return null; + Cache.cacheMain = await cacheFactory.openDatabase( + cachePath, + ); + + return Cache.cacheMain; } catch (error) { - debugPrint('[initStorageLocation] $error'); + printError('[initCache] ${error}'); return null; } } // // Closes and saves storage -void closeCache() async { - if (CacheSecure.cacheMain != null && CacheSecure.cacheMain.isOpen) { - CacheSecure.cacheMain.close(); - } - - if (CacheSecure.cacheRooms != null && CacheSecure.cacheRooms.isOpen) { - CacheSecure.cacheRooms.close(); - } - - if (CacheSecure.cacheCrypto != null && CacheSecure.cacheCrypto.isOpen) { - CacheSecure.cacheCrypto.close(); +void closeCache(Database cache) async { + if (cache != null) { + cache.close(); } } String createIVKey() { - return CryptKey().genDart(); + return Key.fromSecureRandom(16).base64; } Future saveIVKey(String ivKey) async { // Check if storage has been created before return await FlutterSecureStorage().write( - key: CacheSecure.ivKeyLocation, + key: Cache.ivKeyLocation, value: ivKey, ); } @@ -112,7 +110,7 @@ Future saveIVKey(String ivKey) async { Future saveIVKeyNext(String ivKey) async { // Check if storage has been created before return await FlutterSecureStorage().write( - key: CacheSecure.ivKeyNextLocation, + key: Cache.ivKeyNextLocation, value: ivKey, ); } @@ -122,7 +120,7 @@ Future unlockIVKey() async { final storageEngine = FlutterSecureStorage(); final ivKeyStored = await storageEngine.read( - key: CacheSecure.ivKeyLocation, + key: Cache.ivKeyLocation, ); // Create a encryptionKey if a serialized one is not found @@ -134,7 +132,7 @@ Future unlockIVKeyNext() async { final storageEngine = FlutterSecureStorage(); final ivKeyStored = await storageEngine.read( - key: CacheSecure.ivKeyNextLocation, + key: Cache.ivKeyNextLocation, ); // Create a encryptionKey if a serialized one is not found @@ -149,60 +147,21 @@ Future unlockCryptKey() async { try { // Check if crypt key already exists cryptKey = await storageEngine.read( - key: CacheSecure.cryptKeyLocation, + key: Cache.cryptKeyLocation, ); } catch (error) { - debugPrint('[unlockCryptKey] ${error}'); + printError('[unlockCryptKey] ${error}'); } // Create a encryptionKey if a serialized one is not found if (cryptKey == null) { - cryptKey = CryptKey().genFortuna(len: 32); // 256 bits + cryptKey = Key.fromSecureRandom(32).base64; await storageEngine.write( - key: CacheSecure.cryptKeyLocation, + key: Cache.cryptKeyLocation, value: cryptKey, ); } return cryptKey; } - -Future unlockMainCache() async { - try { - return await Hive.openBox( - CacheSecure.cacheKeyMain, - crashRecovery: true, - compactionStrategy: (entries, deletedEntries) => deletedEntries > 1, - ); - } catch (error) { - debugPrint('[unlockMainCache] $error'); - return null; - } -} - -Future unlockRoomCache() async { - try { - return await Hive.openBox( - CacheSecure.cacheKeyRooms, - crashRecovery: true, - compactionStrategy: (entries, deletedEntries) => deletedEntries > 1, - ); - } catch (error) { - debugPrint('[unlockRoomCache] $error'); - return null; - } -} - -Future unlockCryptoCache() async { - try { - return await Hive.openBox( - CacheSecure.cacheKeyCrypto, - crashRecovery: true, - compactionStrategy: (entries, deletedEntries) => deletedEntries > 1, - ); - } catch (error) { - debugPrint('[unlockCryptoCache] $error'); - return null; - } -} diff --git a/lib/global/cache/serializer.dart b/lib/global/cache/serializer.dart index 2aa019fe8..4e3347640 100644 --- a/lib/global/cache/serializer.dart +++ b/lib/global/cache/serializer.dart @@ -8,14 +8,15 @@ import 'package:flutter/material.dart'; // Package imports: import 'package:redux_persist/redux_persist.dart'; -import 'package:steel_crypt/steel_crypt.dart'; +import 'package:sembast/sembast.dart'; import 'package:syphon/global/cache/index.dart'; import 'package:syphon/global/cache/threadables.dart'; -import 'package:syphon/global/libs/hive/encoder.dart'; -import 'package:syphon/global/libs/hive/index.dart'; +import 'package:syphon/global/print.dart'; // Project imports: import 'package:syphon/store/crypto/state.dart'; +import 'package:syphon/store/events/model.dart'; +import 'package:syphon/store/events/state.dart'; import 'package:syphon/store/index.dart'; import 'package:syphon/store/sync/state.dart'; import 'package:syphon/store/user/state.dart'; @@ -30,13 +31,17 @@ import 'package:syphon/store/settings/state.dart'; * Handles serialization, encryption, and storage for caching redux stores */ class CacheSerializer implements StateSerializer { + final Database cache; + final Map> preloaded; + + CacheSerializer({this.cache, this.preloaded}); + @override Uint8List encode(AppState state) { - final stores = [ + final List stores = [ state.authStore, state.syncStore, state.cryptoStore, - state.roomStore, state.mediaStore, state.settingsStore, state.userStore, @@ -46,64 +51,78 @@ class CacheSerializer implements StateSerializer { // if the previously schedule task has not finished Future.microtask(() async { // // create a new IV for the encrypted cache - CacheSecure.ivKey = createIVKey(); + Cache.ivKey = createIVKey(); + // // backup the IV in case the app is force closed before caching finishes - await saveIVKeyNext(CacheSecure.ivKey); + await saveIVKeyNext(Cache.ivKey); // run through all redux stores for encryption and encoding await Future.wait(stores.map((store) async { try { - var jsonEncoded; - var jsonEncrypted; - - // encode the store contents to json - // HACK: unable to pass both listed stores direct to an isolate - // final sensitiveStorage = [AuthStore, SyncStore, CryptoStore]; - // if (!sensitiveStorage.contains(store.runtimeType)) { - // jsonEncoded = await compute(jsonEncode, store); - // } else { - jsonEncoded = json.encode(store); - // } - - // encrypt the store contents previously converted to json - jsonEncrypted = await compute(encryptJsonBackground, { - 'ivKey': CacheSecure.ivKey, - 'cryptKey': CacheSecure.cryptKey, - 'type': store.runtimeType.toString(), - 'json': jsonEncoded, - }); - - // cache redux store to main cache storage - // caching room and crypto stores with additional hive level error handling - switch (store.runtimeType) { - case RoomStore: - await CacheSecure.cacheRooms.put( - store.runtimeType.toString(), - jsonEncrypted, - ); - break; - case CryptoStore: - await CacheSecure.cacheCrypto.put( - store.runtimeType.toString(), - jsonEncrypted, - ); - break; - default: - await CacheSecure.cacheMain.put( - store.runtimeType.toString(), - jsonEncrypted, - ); - break; + String jsonEncoded; + String jsonEncrypted; + String type = store.runtimeType.toString(); + + // serialize the store contents + // Stopwatch stopwatchSerialize = new Stopwatch()..start(); + try { + // HACK: unable to pass certain stores directly to an isolate + final sensitiveStorage = [MediaStore]; + if (sensitiveStorage.contains(store.runtimeType)) { + jsonEncoded = await compute(jsonEncode, store); + } else { + jsonEncoded = json.encode(store); + } + } catch (error) { + jsonEncoded = json.encode(store); + print( + '[CacheSerializer] ${type} failed $error', + ); + } + + // debugPrint( + // '[CacheSerializer] ${stopwatchSerialize.elapsed} ${type} serialize', + // ); + + // Stopwatch stopwatchEncrypt = new Stopwatch()..start(); + // encrypt the store contents + jsonEncrypted = await compute( + encryptJsonBackground, + { + 'ivKey': Cache.ivKey, + 'cryptKey': Cache.cryptKey, + 'type': type, + 'json': jsonEncoded, + }, + debugLabel: 'encryptJsonBackground', + ); + + // debugPrint( + // '[CacheSerializer] ${stopwatchEncrypt.elapsed} ${type} encrypt', + // ); + + try { + // Stopwatch stopwatchSave = new Stopwatch()..start(); + final storeRef = StoreRef.main(); + await storeRef.record(type).put(cache, jsonEncrypted); + + // debugPrint( + // '[CacheSerializer] ${stopwatchSave.elapsed} ${type} saved', + // ); + } catch (error) { + print('[CacheSerializer] ERROR $error'); } } catch (error) { debugPrint( - '[CacheSerializer.encode] $error', + '[CacheSerializer] $error', ); } })); // Rotate encryption for the next save - await saveIVKey(CacheSecure.ivKey); + await saveIVKey(Cache.ivKey); + + return Future.value(null); }); // Disregard redux persist storage saving @@ -111,127 +130,50 @@ class CacheSerializer implements StateSerializer { } AppState decode(Uint8List data) { - final aes = AesCrypt(key: CacheSecure.cryptKey, padding: PaddingAES.pkcs7); - AuthStore authStore = AuthStore(); SyncStore syncStore = SyncStore(); + UserStore userStore = UserStore(); CryptoStore cryptoStore = CryptoStore(); MediaStore mediaStore = MediaStore(); - RoomStore roomStore = RoomStore(); SettingsStore settingsStore = SettingsStore(); - UserStore userStore = UserStore(); - - final List stores = [ - authStore, - syncStore, - mediaStore, - roomStore, - cryptoStore, - settingsStore, - userStore, - ]; - // TODO: remove after most have upgraded to 0.1.4/0.1.5 - if ((Cache.state != null || Cache.stateRooms != null) && - Cache.migration == null) { - debugPrint( - '[Legacy Cache Found] ***** FOUND ****** loading and removing cache', - ); - final legacyAppState = LegacyEncoder.decodeHive(); - deleteLegacyStorage(); - return legacyAppState; - } + // Load stores previously fetched from cache, + // mutable global due to redux_presist not extendable beyond Uint8List + final stores = Cache.cacheStores; // decode each store cache synchronously - stores.forEach((store) { + stores.forEach((type, store) { try { - Map decodedJson = {}; - - var encryptedJson; - - // fetch from main cache storage - // fetching room and crypto store has additional hive level error handling - switch (store.runtimeType) { - case RoomStore: - encryptedJson = CacheSecure.cacheRooms.get( - store.runtimeType.toString(), - defaultValue: null, - ); - break; - case CryptoStore: - encryptedJson = CacheSecure.cacheCrypto.get( - store.runtimeType.toString(), - defaultValue: null, - ); - break; - default: - encryptedJson = CacheSecure.cacheMain.get( - store.runtimeType.toString(), - defaultValue: null, - ); - break; - } - - // attempt to decrypt encrypted state after loaded from RAM - if (encryptedJson != null) { - try { - final decryptedJson = aes.ctr.decrypt( - enc: encryptedJson, - iv: CacheSecure.ivKey, - ); - decodedJson = json.decode(decryptedJson); - } catch (error) { - debugPrint('[CacheSerializer.decode] $error'); - decodedJson = {}; - } - } - - // decryption may fail if force closed, attempt with new iv generated before close - if (decodedJson.isEmpty) { - try { - // decrypt encrypted state after loaded from RAM - final decryptedJson = aes.ctr.decrypt( - enc: encryptedJson, - iv: CacheSecure.ivKeyNext, - ); - decodedJson = json.decode(decryptedJson); - } catch (error) { - debugPrint('[CacheSerializer.decode] $error'); - decodedJson = {}; - } - } - // if all else fails, just pass back a fresh store to avoid a crash - if (decodedJson.isEmpty) return; + if (store == null || store.isEmpty) return; // this stinks, but dart doesn't allow reflection for factories/contructors - switch (store.runtimeType) { - case AuthStore: - authStore = AuthStore.fromJson(decodedJson); - break; - case SyncStore: - syncStore = SyncStore.fromJson(decodedJson); + switch (type) { + case 'AuthStore': + authStore = AuthStore.fromJson(store); break; - case CryptoStore: - cryptoStore = CryptoStore.fromJson(decodedJson); + case 'SyncStore': + syncStore = SyncStore.fromJson(store); break; - case MediaStore: - mediaStore = MediaStore.fromJson(decodedJson); + case 'CryptoStore': + cryptoStore = CryptoStore.fromJson(store); break; - case RoomStore: - roomStore = RoomStore.fromJson(decodedJson); + case 'MediaStore': + mediaStore = MediaStore.fromJson(store); break; - case SettingsStore: - settingsStore = SettingsStore.fromJson(decodedJson); + case 'SettingsStore': + settingsStore = SettingsStore.fromJson(store); break; - case UserStore: - userStore = UserStore.fromJson(decodedJson); + case 'UserStore': + userStore = UserStore.fromJson(store); break; + case 'RoomStore': + // --- cold storage only --- default: break; } } catch (error) { - debugPrint('[CacheSerializer.decode] $error'); + printError('[CacheSerializer.decode] $error'); } }); @@ -240,10 +182,17 @@ class CacheSerializer implements StateSerializer { authStore: authStore ?? AuthStore(), syncStore: syncStore ?? SyncStore(), cryptoStore: cryptoStore ?? CryptoStore(), - roomStore: roomStore ?? RoomStore(), - userStore: userStore ?? UserStore(), mediaStore: mediaStore ?? MediaStore(), settingsStore: settingsStore ?? SettingsStore(), + roomStore: RoomStore().copyWith( + rooms: preloaded['rooms'] ?? {}, + ), + userStore: userStore.copyWith( + users: preloaded['users'] ?? {}, + ), + eventStore: EventStore().copyWith( + messages: preloaded['messages'] ?? Map>(), + ), ); } } diff --git a/lib/global/cache/storage.dart b/lib/global/cache/storage.dart new file mode 100644 index 000000000..387f3d840 --- /dev/null +++ b/lib/global/cache/storage.dart @@ -0,0 +1,76 @@ +import 'dart:typed_data'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:redux_persist/redux_persist.dart'; +import 'package:sembast/sembast.dart'; +import 'package:syphon/global/cache/index.dart'; +import 'package:syphon/global/cache/threadables.dart'; +import 'package:syphon/global/print.dart'; +import 'package:syphon/store/auth/state.dart'; +import 'package:syphon/store/crypto/state.dart'; +import 'package:syphon/store/media/state.dart'; +import 'package:syphon/store/rooms/state.dart'; +import 'package:syphon/store/settings/state.dart'; +import 'package:syphon/store/sync/state.dart'; +import 'package:syphon/store/user/state.dart'; + +final List stores = [ + AuthStore(), + SyncStore(), + UserStore(), + MediaStore(), + CryptoStore(), + SettingsStore(), +]; + +class CacheStorage implements StorageEngine { + final Database cache; + + CacheStorage({this.cache}); + + @override + Future load() async { + await Future.wait(stores.map((store) async { + final type = store.runtimeType.toString(); + try { + // Fetch from database + final table = StoreRef.main(); + final record = table.record(store.runtimeType.toString()); + final jsonEncrypted = await record.get(cache); + + // Decrypt from database + final jsonDecoded = await compute( + decryptJsonBackground, + { + 'ivKey': Cache.ivKey, + 'ivKeyNext': Cache.ivKeyNext, + 'cryptKey': Cache.cryptKey, + 'type': type, + 'json': jsonEncrypted, + }, + debugLabel: 'decryptJsonBackground', + ); + + // Load for CacheSerializer to use later + Cache.cacheStores[type] = jsonDecoded; + } catch (error) { + printError(error.toString(), title: 'CacheStorage|$type'); + } + })); + + // unlock redux_persist after cache loaded from sqflite + return Uint8List(0); + } + + @override + Future save(Uint8List data) { + return null; + } + + Future saveOffload(String jsonEncrypted, {String type}) async { + final table = StoreRef.main(); + final record = table.record(type); + await record.put(cache, jsonEncrypted); + } +} diff --git a/lib/global/cache/threadables.dart b/lib/global/cache/threadables.dart index 3600623bd..d8f39ce41 100644 --- a/lib/global/cache/threadables.dart +++ b/lib/global/cache/threadables.dart @@ -1,33 +1,75 @@ import 'dart:convert'; - -import 'dart:ui'; import 'dart:async'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:steel_crypt/steel_crypt.dart'; +import 'package:encrypt/encrypt.dart'; import 'package:syphon/global/cache/index.dart'; +import 'package:syphon/global/print.dart'; Future encryptJsonBackground(Map params) async { String ivKey = params['ivKey']; String cryptKey = params['cryptKey']; String json = params['json']; - final cryptor = AesCrypt(key: cryptKey, padding: PaddingAES.pkcs7); + final iv = IV.fromBase64(ivKey); + final key = Key.fromBase64(cryptKey); + + final encrypter = Encrypter(AES(key, mode: AESMode.ctr, padding: null)); + final encrypted = encrypter.encrypt(json, iv: iv); - return cryptor.ctr.encrypt(inp: json, iv: ivKey); + return encrypted.base64; } -// TODO: deserialization is required synchronous by redux_persist :/ -Future decryptJsonBackground(Map params) async { +Future decryptJsonBackground(Map params) async { String ivKey = params['ivKey']; + String ivKeyNext = params['ivKeyNext']; + String type = params['type']; String cryptKey = params['cryptKey']; - String json = params['json']; + String jsonEncrypted = params['json']; + + String jsonDecrypted; + Map jsonDecoded = {}; + + final iv = IV.fromBase64(ivKey); + final ivNext = IV.fromBase64(ivKeyNext); + final key = Key.fromBase64(cryptKey); + + final encrypter = Encrypter(AES(key, mode: AESMode.ctr, padding: null)); + + if (jsonEncrypted == null) return null; + + try { + jsonDecrypted = encrypter.decrypt64( + jsonEncrypted, + iv: iv, + ); + } catch (error) { + printDebug('[decryptJsonBackground] error $error'); + } - final cryptor = AesCrypt(key: cryptKey, padding: PaddingAES.pkcs7); + if (jsonDecoded.isEmpty) { + try { + jsonDecrypted = encrypter.decrypt64( + jsonEncrypted, + iv: ivNext, + ); + } catch (error) { + printDebug('[decryptJsonBackground] error $error'); + jsonDecoded = {}; + } + } - return cryptor.ctr.decrypt(enc: json, iv: ivKey); + // Failed to decrypt data + if (jsonDecrypted == null) { + printDebug('[decryptJsonBackground] decryption failed ${type}'); + return null; + } + + // decode serialized object + jsonDecoded = json.decode(jsonDecrypted); + + printDebug('[decryptJsonBackground] decryption succeed ${type}'); + return jsonDecoded; } // TODO: needs plugins that work in isolates, still having @@ -36,23 +78,27 @@ Future decryptJsonBackground(Map params) async { // to the isolate // responsibile for both json serialization and encryption Future serializeJsonBackground(Object store) async { - WidgetsFlutterBinding.ensureInitialized(); - window.onPlatformMessage = BinaryMessages.handlePlatformMessage; - try { final storageEngine = FlutterSecureStorage(); - final ivKey = await storageEngine.read(key: CacheSecure.ivKeyLocation); - final cryptKey = - await storageEngine.read(key: CacheSecure.cryptKeyLocation); + final ivKey = await storageEngine.read( + key: Cache.ivKeyLocation, + ); + final cryptKey = await storageEngine.read( + key: Cache.cryptKeyLocation, + ); + + final iv = IV.fromBase64(ivKey); + final key = Key.fromBase64(cryptKey); final jsonEncoded = jsonEncode(store); - final cryptor = AesCrypt(key: cryptKey, padding: PaddingAES.pkcs7); + final encrypter = Encrypter(AES(key, mode: AESMode.ctr)); + final encrypted = encrypter.encrypt(jsonEncoded, iv: iv); - return cryptor.ctr.encrypt(inp: jsonEncoded, iv: ivKey); + return encrypted.base64; } catch (error) { - debugPrint('[serializeJsonBackground] $error'); + printError('[serializeJsonBackground] $error'); return null; } } diff --git a/lib/global/libs/hive/encoder.dart b/lib/global/libs/hive/encoder.dart deleted file mode 100644 index e87f6aaf4..000000000 --- a/lib/global/libs/hive/encoder.dart +++ /dev/null @@ -1,148 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter/material.dart'; -import 'package:syphon/global/libs/hive/index.dart'; -import 'package:syphon/store/auth/state.dart'; -// Project imports: -import 'package:syphon/global/libs/hive/index.dart'; -import 'package:syphon/store/crypto/state.dart'; -import 'package:syphon/store/index.dart'; -import 'package:syphon/store/media/state.dart'; -import 'package:syphon/store/rooms/state.dart'; -import 'package:syphon/store/settings/state.dart'; -import 'package:syphon/store/sync/state.dart'; -import 'package:syphon/store/user/state.dart'; - -/** - * - * Ripper API (temp) - * - * One way convertion of the Hive cache to a manually encrypted / encoded state cache - */ -class LegacyEncoder { - static Future encodeHive(AppState state) async { - try { - Cache.state.put( - state.syncStore.runtimeType.toString(), - state.syncStore, - ); - // debugPrint('[Hive Storage] caching syncStore'); - } catch (error) { - debugPrint('[Hive Serializer Encode] $error'); - } - - try { - Cache.stateRooms.put( - state.roomStore.runtimeType.toString(), - state.roomStore, - ); - // debugPrint('[Hive Storage] caching roomStore'); - } catch (error) { - debugPrint('[Hive Serializer Encode] $error'); - } - - try { - Cache.state.put( - state.mediaStore.runtimeType.toString(), - state.mediaStore, - ); - // debugPrint('[Hive Storage] caching mediaStore'); - } catch (error) { - debugPrint('[Hive Serializer Encode] $error'); - } - - try { - Cache.state.put( - state.settingsStore.runtimeType.toString(), - state.settingsStore, - ); - // debugPrint('[Hive Storage] caching settingsStore'); - } catch (error) { - debugPrint('[Hive Serializer Encode] $error'); - } - - try { - Cache.state.put( - state.cryptoStore.runtimeType.toString(), - state.cryptoStore, - ); - // debugPrint('[Hive Storage] caching cryptoStore'); - } catch (error) { - debugPrint('[Hive Serializer Encode] $error'); - } - } - - static AppState decodeHive() { - AuthStore authStoreConverted = AuthStore(); - SyncStore syncStoreConverted = SyncStore(); - CryptoStore cryptoStoreConverted = CryptoStore(); - MediaStore mediaStoreConverted = MediaStore(); - RoomStore roomStoreConverted = RoomStore(); - SettingsStore settingsStoreConverted = SettingsStore(); - - try { - authStoreConverted = Cache.state.get( - authStoreConverted.runtimeType.toString(), - defaultValue: null, - ); - } catch (error) { - debugPrint('[Hive Serializer Decode] $error'); - } - - try { - syncStoreConverted = Cache.state.get( - syncStoreConverted.runtimeType.toString(), - defaultValue: null, - ); - } catch (error) { - debugPrint('[Hive Serializer Decode] $error'); - } - - try { - cryptoStoreConverted = Cache.state.get( - cryptoStoreConverted.runtimeType.toString(), - defaultValue: null, - ); - } catch (error) { - debugPrint('[Hive Serializer Decode] $error'); - } - - try { - roomStoreConverted = Cache.stateRooms.get( - roomStoreConverted.runtimeType.toString(), - defaultValue: null, - ); - } catch (error) { - debugPrint('[Hive Serializer Decode] $error'); - } - - try { - mediaStoreConverted = Cache.state.get( - mediaStoreConverted.runtimeType.toString(), - defaultValue: null, - ); - } catch (error) { - debugPrint('[Hive Serializer Decode] $error'); - } - - try { - settingsStoreConverted = Cache.state.get( - settingsStoreConverted.runtimeType.toString(), - defaultValue: null, - ); - } catch (error) { - debugPrint('[Hive Serializer Decode] $error'); - } - - return AppState( - loading: false, - authStore: authStoreConverted ?? AuthStore(), - syncStore: syncStoreConverted ?? SyncStore(), - cryptoStore: cryptoStoreConverted ?? CryptoStore(), - roomStore: roomStoreConverted ?? RoomStore(), - mediaStore: mediaStoreConverted ?? MediaStore(), - settingsStore: settingsStoreConverted ?? SettingsStore(), - userStore: UserStore(), - ); - } -} diff --git a/lib/global/libs/hive/index.dart b/lib/global/libs/hive/index.dart deleted file mode 100644 index a64834831..000000000 --- a/lib/global/libs/hive/index.dart +++ /dev/null @@ -1,336 +0,0 @@ -// Dart imports: -import 'dart:io'; - -// Flutter imports: -import 'package:flutter/material.dart'; - -// Package imports: -import 'package:convert/convert.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:hive/hive.dart'; -import 'package:path_provider/path_provider.dart'; - -// Project imports: -import 'package:syphon/global/themes.dart'; -import 'package:syphon/global/values.dart'; -import 'package:syphon/store/auth/state.dart'; -import 'package:syphon/store/crypto/keys/model.dart'; -import 'package:syphon/store/crypto/model.dart'; -import 'package:syphon/store/crypto/state.dart'; -import 'package:syphon/store/media/state.dart'; -import 'package:syphon/store/rooms/events/ephemeral/m.read/model.dart'; -import 'package:syphon/store/rooms/events/model.dart'; -import 'package:syphon/store/rooms/room/model.dart'; -import 'package:syphon/store/rooms/state.dart'; -import 'package:syphon/store/settings/chat-settings/model.dart'; -import 'package:syphon/store/settings/devices-settings/model.dart'; -import 'package:syphon/store/settings/state.dart'; -import 'package:syphon/store/sync/state.dart'; -import 'package:syphon/store/user/model.dart'; - -// Global cache -class Cache { - static Box state; - static Box stateRooms; - static Box stateMedia; - static LazyBox sync; - - static String migration; - - static const group_id = '${Values.appNameLabel}'; - static const encryptionKeyLocation = '${Values.appNameLabel}@publicKey'; - - static const syncKey = '${Values.appNameLabel}_sync'; - static const stateKey = '${Values.appNameLabel}_cache'; - static const stateRoomKey = '${Values.appNameLabel}_cache_2'; - - static const syncKeyUNSAFE = '${Values.appNameLabel}_sync_unsafe'; - static const stateKeyUNSAFE = '${Values.appNameLabel}_cache_unsafe'; - static const stateKeyRoomsUNSAFE = - '${Values.appNameLabel}_cache_rooms_unsafe'; - - static const backgroundKeyUNSAFE = - '${Values.appNameLabel}_background_cache_unsafe_alt'; - - static const roomNames = 'room_names'; - static const syncData = 'sync_data'; - static const protocol = 'protocol'; - static const homeserver = 'homeserver'; - static const accessTokenKey = 'accessToken'; - static const lastSinceKey = 'lastSince'; - static const currentUser = 'currentUser'; - - static const migrationKey = 'migrationKey'; -} - -/** - * TODO: Whole cache with this configuration will be deprecated after 0.1.4 - */ - -/** - * - * Init Hive - * - * For testing purposes only - should be encrypting hive - */ -Future initHive() async { - // NOTE: done in initCache - // Init storage location - final storageLocation = await initStorageLocation(); - - // Init hive cache - // Hive.init(storageLocation); - - // Init configuration - await initHiveConfiguration(); - - // ignore if already migrated cache - final storageEngine = FlutterSecureStorage(); - Cache.migration = await storageEngine.read(key: Cache.migrationKey); - if (Cache.migration != null) return; - - // otherwise, open and load the previous hive cache - if ((Platform.isAndroid || Platform.isIOS)) { - Cache.sync = await openHiveSync(); - Cache.state = await openHiveState(); - Cache.stateRooms = await openHiveStateRooms(); - } -} - -Future initStorageLocation() async { - var storageLocation; - - try { - if (Platform.isIOS || Platform.isAndroid) { - storageLocation = await getApplicationDocumentsDirectory(); - return storageLocation.path; - } - - if (Platform.isMacOS) { - storageLocation = await File('cache').create().then( - (value) => value.writeAsString( - '{}', - flush: true, - ), - ); - - return storageLocation.path; - } - - if (Platform.isLinux) { - storageLocation = await getApplicationDocumentsDirectory(); - return storageLocation.path; - } - - debugPrint('[initStorageLocation] no cache support'); - return null; - } catch (error) { - debugPrint('[initStorageLocation] $error'); - return null; - } -} - -Future initHiveConfiguration() async { -// Future initHiveConfiguration(String storageLocationPath) async { - - // Init Custom Models - Hive.registerAdapter(ThemeTypeAdapter()); - Hive.registerAdapter(ChatSettingAdapter()); - Hive.registerAdapter(RoomAdapter()); - Hive.registerAdapter(MessageAdapter()); - Hive.registerAdapter(EventAdapter()); - Hive.registerAdapter(ReadStatusAdapter()); - Hive.registerAdapter(UserAdapter()); - Hive.registerAdapter(DeviceAdapter()); - Hive.registerAdapter(DeviceKeyAdapter()); - Hive.registerAdapter(OneTimeKeyAdapter()); - // Hive.registerAdapter(AccountAdapter()); - - // Custom Store Models - Hive.registerAdapter(AuthStoreAdapter()); - Hive.registerAdapter(SyncStoreAdapter()); - Hive.registerAdapter(CryptoStoreAdapter()); - Hive.registerAdapter(RoomStoreAdapter()); - Hive.registerAdapter(MediaStoreAdapter()); - Hive.registerAdapter(SettingsStoreAdapter()); -} - -Future> unlockEncryptionKey() async { - // Check if storage has been created before - final storageEngine = FlutterSecureStorage(); - - var encryptionKey = await storageEngine.read( - key: Cache.encryptionKeyLocation, - ); - - // Create a encryptionKey if a serialized one is not found - if (encryptionKey == null) { - encryptionKey = hex.encode(Hive.generateSecureKey()); - - await storageEngine.write( - key: Cache.encryptionKeyLocation, - value: encryptionKey, - ); - } - - return hex.decode(encryptionKey); -} - -/** - * openHiveState UNSAFE - * - * For testing purposes only - should be encrypting hive - */ -Future openHiveBackgroundUnsafe() async { - var storageLocation; - - // Init storage location - try { - storageLocation = await getApplicationDocumentsDirectory(); - } catch (error) { - debugPrint('[openHiveBackgroundUnsafe] Storage Failure $error'); - } - - // Init hive cache + adapters - Hive.init(storageLocation.path); - return await Hive.openBox(Cache.backgroundKeyUNSAFE); -} - -/** - * Open Hive State - * - * Separating the rest of state from room data to - * improve performance - * Initializes encrypted storage for caching current state - */ -Future openHiveState() async { - try { - final encryptionKey = await unlockEncryptionKey(); - - return await Hive.openBox( - Cache.stateKey, - crashRecovery: false, - encryptionCipher: HiveAesCipher(encryptionKey), - compactionStrategy: (entries, deletedEntries) => deletedEntries > 1, - ); - } catch (error) { - debugPrint('[openHiveState] $error'); - return null; - } -} - -/** - * Open Hive State - * - * Initializes encrypted storage for caching current state - */ -Future openHiveStateRooms() async { - try { - final encryptionKey = await unlockEncryptionKey(); - - return await Hive.openBox( - Cache.stateRoomKey, - crashRecovery: false, - encryptionCipher: HiveAesCipher(encryptionKey), - compactionStrategy: (entries, deletedEntries) => deletedEntries > 1, - ); - } catch (error) { - debugPrint('[openHiveState] $error'); - return null; - } -} - -/** - * Open Hive Sync - * - * Initializes encrypted storage for caching sync - */ -Future openHiveSync() async { - try { - final encryptionKey = await unlockEncryptionKey(); - - return await Hive.openLazyBox( - Cache.syncKey, - crashRecovery: false, - encryptionCipher: HiveAesCipher(encryptionKey), - compactionStrategy: (entries, deletedEntries) => deletedEntries > 1, - ); - } catch (error) { - debugPrint('[openHiveState] $error'); - return null; - } -} - -// // Closes and saves storage -void closeStorage() async { - if (Cache.sync != null && Cache.sync.isOpen) { - Cache.sync.close(); - } - - if (Cache.state != null && Cache.state.isOpen) { - Cache.sync.close(); - } -} - -// Wipe all old cached files to deprecate old storage caches -Future deleteLegacyStorage() async { - try { - (await Hive.openBox(Cache.syncKeyUNSAFE)).deleteFromDisk(); - debugPrint('[deleteStorage] deleting Cache.state'); - } catch (error) { - debugPrint('[deleteStorage] ${error}'); - } - - try { - (await Hive.openBox(Cache.stateKeyUNSAFE)).deleteFromDisk(); - debugPrint('[deleteStorage] deleting Cache.stateKeyUNSAFE'); - } catch (error) { - debugPrint('[deleteStorage] ${error}'); - } - - try { - (await Hive.openBox(Cache.stateKeyRoomsUNSAFE)).deleteFromDisk(); - debugPrint('[deleteStorage] deleting Cache.stateKeyRoomsUNSAFE'); - } catch (error) { - debugPrint('[deleteStorage] ${error}'); - } - - try { - (await Hive.openBox(Cache.backgroundKeyUNSAFE)).deleteFromDisk(); - debugPrint('[deleteStorage] deleting Cache.backgroundKeyUNSAFE'); - } catch (error) { - debugPrint('[deleteStorage] ${error}'); - } - - try { - (await Hive.openLazyBox(Cache.syncKey)).deleteFromDisk(); - debugPrint('[deleteStorage] deleting Cache.syncKey'); - } catch (error) { - debugPrint('[deleteStorage] ${error}'); - } - - try { - (await Hive.openBox(Cache.stateKey)).deleteFromDisk(); - debugPrint('[deleteStorage] deleting Cache.stateKey'); - } catch (error) { - debugPrint('[deleteStorage] ${error}'); - } - - try { - (await Hive.openBox(Cache.stateRoomKey)).deleteFromDisk(); - debugPrint('[deleteStorage] deleting Cache.stateRoomKey'); - } catch (error) { - debugPrint('[deleteStorage] ${error}'); - } - - Cache.sync = null; - Cache.state = null; - Cache.stateRooms = null; - - final storageEngine = FlutterSecureStorage(); - await storageEngine.write(key: Cache.migrationKey, value: 'yes'); - - debugPrint('[deleteLegacyStorage] ran and saved migration status'); - - return true; -} diff --git a/lib/global/libs/hive/type-ids.dart b/lib/global/libs/hive/type-ids.dart deleted file mode 100644 index 06a5a7a09..000000000 --- a/lib/global/libs/hive/type-ids.dart +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Top Level accounting for hive ids assigned in the persisted cache - */ - -// Objects -const int ThemeTypeHiveId = 2; -const int ChatSettingsHiveId = 3; -const int MessageHiveId = 4; -const int EventHiveId = 5; -const int RoomHiveId = 6; -const int ReadStatusHiveId = 7; -const int UserHiveId = 8; -const int DevicesHiveId = 9; -const int NotificationSettingsHiveId = 10; -const int PusherHiveId = 11; -const int RuleHiveId = 12; -const int DeviceKeyHiveId = 13; -const int OneTimeKeyHiveId = 14; - -// Actual Redux State Stores -const int MediaStoreHiveId = 0; -const int SettingsStoreHiveId = 1; -const int RoomStoreHiveId = 100; -const int AuthStoreHiveId = 101; -const int UserStoreHiveId = 102; -const int SyncStoreHiveId = 103; -const int CryptoStoreHiveId = 104; diff --git a/lib/global/libs/matrix/errors.dart b/lib/global/libs/matrix/errors.dart index cdf763406..5cf2341f1 100644 --- a/lib/global/libs/matrix/errors.dart +++ b/lib/global/libs/matrix/errors.dart @@ -1,7 +1,7 @@ class MatrixErrors { static const String room_unknown = 'M_UNKNOWN'; static const String not_authorized = 'M_UNAUTHORIZED'; - static const String room_not_found = 'M_NOT_FOUND'; + static const String not_found = "M_NOT_FOUND"; static const String user_in_use = 'M_USER_IN_USE'; static const String unknown_token = 'M_UNKNOWN_TOKEN'; static const String email_in_use = 'M_THREEPID_IN_USE'; diff --git a/lib/global/libs/matrix/events.dart b/lib/global/libs/matrix/events.dart index e142ff632..a57f04695 100644 --- a/lib/global/libs/matrix/events.dart +++ b/lib/global/libs/matrix/events.dart @@ -8,7 +8,8 @@ import 'package:syphon/global/algos.dart'; // Project imports: import 'package:syphon/global/libs/matrix/encryption.dart'; -import 'package:syphon/store/rooms/events/model.dart'; +import 'package:syphon/global/print.dart'; +import 'package:syphon/store/events/model.dart'; abstract class Events { /** @@ -50,16 +51,17 @@ abstract class Events { String from, String to, int limit = 10, // default limit by matrix - bool desc = true, // Direction of events + bool desc = true, // direction of events }) async { String url = '$protocol$homeserver/_matrix/client/r0/rooms/$roomId/messages'; - // Params url += '?limit=$limit'; url += from != null ? '&from=${from}' : ''; url += to != null ? '&to=${to}' : ''; url += desc ? '&dir=b' : '&dir=f'; + // TODO: remove after implementing reactions + url += '&filter={"not_types":["${EventTypes.member}", "m.reaction"]}'; Map headers = { 'Authorization': 'Bearer $accessToken', diff --git a/lib/global/libs/matrix/index.dart b/lib/global/libs/matrix/index.dart index beb9ec2f8..4f57be845 100644 --- a/lib/global/libs/matrix/index.dart +++ b/lib/global/libs/matrix/index.dart @@ -56,9 +56,9 @@ abstract class MatrixApi { static final updateAvatarUri = Users.updateAvatarUri; // Users - static final blockUser = Users.blockUser; static final inviteUser = Users.inviteUser; static final fetchUserProfile = Users.fetchUserProfile; + static final updateBlockedUsers = Users.updateBlockedUsers; // Media static final fetchThumbnail = Media.fetchThumbnail; diff --git a/lib/global/libs/matrix/user.dart b/lib/global/libs/matrix/user.dart index 82fa39bc0..8c64cfce4 100644 --- a/lib/global/libs/matrix/user.dart +++ b/lib/global/libs/matrix/user.dart @@ -6,7 +6,7 @@ import 'dart:convert'; import 'package:http/http.dart' as http; // Project imports: -import 'package:syphon/store/rooms/events/model.dart'; +import 'package:syphon/store/events/model.dart'; abstract class Users { /** @@ -86,30 +86,28 @@ abstract class Users { * to the user that set the account_data. The config will be synced * to clients in the top-level account_data. */ - static Future blockUser({ + static Future updateBlockedUsers({ String protocol = 'https://', String homeserver = 'matrix.org', String accessToken, String userId, - List roomIds = const [], + Map blockUserList = const {"ignored_users": {}}, }) async { String url = - '$protocol$homeserver/_matrix/client/r0/user/$userId/account_data/${EventTypes.ignoredUserList}'; + '$protocol$homeserver/_matrix/client/r0/user/$userId/account_data/${AccountDataTypes.ignoredUserList}'; Map headers = { 'Authorization': 'Bearer $accessToken', }; - final accountData = {userId: []}; - - if (roomIds.isNotEmpty) { - accountData[userId] = roomIds; - } + final body = { + 'ignored_users': blockUserList ?? {}, + }; final saveResponse = await http.put( url, headers: headers, - body: json.encode(accountData), + body: json.encode(body), ); return await json.decode( diff --git a/lib/global/print.dart b/lib/global/print.dart index 789dea1fa..ace2d5c5c 100644 --- a/lib/global/print.dart +++ b/lib/global/print.dart @@ -1,19 +1,33 @@ -void printInfo(String content, {String title}) { +import 'package:ansicolor/ansicolor.dart'; +import 'package:flutter/material.dart'; + +typedef PrintDebug = void Function(String message, {String title}); +typedef PrintError = void Function(String message, {String title}); + +void _printInfo(String content, {String title}) { + final pen = AnsiPen()..white(bold: true); final body = title != null ? '[$title] $content' : content; - print('\u001b[32m$body\u001b[0m'); + print(pen(body)); } -void printWarning(String content, {String title}) { +void _printWarning(String content, {String title}) { + final pen = AnsiPen()..yellow(bold: true); final body = title != null ? '[$title] $content' : content; - print('\u001b[34m$body\u001b[0m'); + print(pen(body)); } -void printError(String content, {String title}) { +void _printError(String content, {String title}) { + final pen = AnsiPen()..red(bold: true); final body = title != null ? '[$title] $content' : content; - print('\u001b[31m$body\u001b[0m'); + print(pen(body)); } -void printDebug(String content, {String title}) { +void _printDebug(String content, {String title}) { final body = title != null ? '[$title] $content' : content; - print(body); + debugPrint(body); } + +PrintDebug printInfo = _printInfo; +PrintDebug printDebug = _printDebug; +PrintError printError = _printError; +PrintError printWarning = _printWarning; diff --git a/lib/global/storage/codec.dart b/lib/global/storage/codec.dart new file mode 100644 index 000000000..40f0e4448 --- /dev/null +++ b/lib/global/storage/codec.dart @@ -0,0 +1,119 @@ +import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; +import 'package:encrypt/encrypt.dart'; +import 'package:meta/meta.dart'; +import 'package:sembast/src/api/v2/sembast.dart'; +import 'package:syphon/global/print.dart'; + +var _random = Random.secure(); + +/// Random bytes generator +Uint8List _randBytes(int length) { + return Uint8List.fromList( + List.generate(length, (i) => _random.nextInt(256))); +} + +/// Generate an encryption password based on a user input password +/// +/// It uses MD5 which generates a 16 bytes blob, size needed for Salsa20 +Uint8List _generateEncryptPassword(String password) { + var blob = Uint8List.fromList(md5.convert(utf8.encode(password)).bytes); + assert(blob.length == 16); + return blob; +} + +/// Salsa20 based encoder +class _EncryptEncoder extends Converter { + final Salsa20 salsa20; + + _EncryptEncoder(this.salsa20); + + @override + String convert(dynamic input) { + // Generate random initial value + final iv = _randBytes(8); + final ivEncoded = base64.encode(iv); + assert(ivEncoded.length == 12); + + // Encode the input value + final encoded = + Encrypter(salsa20).encrypt(json.encode(input), iv: IV(iv)).base64; + + // Prepend the initial value + return '$ivEncoded$encoded'; + } +} + +/// Salsa20 based decoder +class _EncryptDecoder extends Converter { + final Salsa20 salsa20; + + _EncryptDecoder(this.salsa20); + + @override + dynamic convert(String input) { + // Read the initial value that was prepended + assert(input.length >= 12); + final iv = base64.decode(input.substring(0, 12)); + + // Extract the real input + input = input.substring(12); + + // Decode the input + var decoded = json.decode(Encrypter(salsa20).decrypt64(input, iv: IV(iv))); + if (decoded is Map) { + return decoded.cast(); + } + return decoded; + } +} + +/// Salsa20 based Codec +class _EncryptCodec extends Codec { + _EncryptEncoder _encoder; + _EncryptDecoder _decoder; + + _EncryptCodec(Uint8List passwordBytes) { + var salsa20 = Salsa20(Key(passwordBytes)); + _encoder = _EncryptEncoder(salsa20); + _decoder = _EncryptDecoder(salsa20); + } + + @override + Converter get decoder => _decoder; + + @override + Converter get encoder => _encoder; +} + +/// Our plain text signature +const _encryptCodecSignature = 'encrypt'; + +/// Create a codec to use to open a database with encrypted stored data. +/// +/// Hash (md5) of the password is used (but never stored) as a key to encrypt +/// the data using the Salsa20 algorithm with a random (8 bytes) initial value +/// +/// This is just used as a demonstration and should not be considered as a +/// reference since its implementation (and storage format) might change. +/// +/// No performance metrics has been made to check whether this is a viable +/// solution for big databases. +/// +/// The usage is then +/// +/// ```dart +/// // Initialize the encryption codec with a user password +/// var codec = getEncryptSembastCodec(password: '[your_user_password]'); +/// // Open the database with the codec +/// Database db = await factory.openDatabase(dbPath, codec: codec); +/// +/// // ...your database is ready to use +/// ``` +SembastCodec getEncryptSembastCodec({@required String password}) => + SembastCodec( + signature: _encryptCodecSignature, + codec: _EncryptCodec(_generateEncryptPassword(password))); diff --git a/lib/global/storage/index.dart b/lib/global/storage/index.dart new file mode 100644 index 000000000..1ff7490d7 --- /dev/null +++ b/lib/global/storage/index.dart @@ -0,0 +1,128 @@ +import 'dart:io'; + +import 'package:meta/meta.dart'; +import 'package:flutter/material.dart'; +import 'package:sembast/sembast.dart'; +import 'package:sembast_sqflite/sembast_sqflite.dart'; +import 'package:syphon/global/cache/index.dart'; +import 'package:syphon/global/print.dart'; +import 'package:syphon/global/storage/codec.dart'; +import 'package:syphon/global/values.dart'; +import 'package:sqflite/sqflite.dart' as sqflite; +import 'package:sqflite_common_ffi/sqflite_ffi.dart' as sqflite_ffi; +import 'package:syphon/store/events/model.dart'; +import 'package:syphon/store/events/storage.dart'; +import 'package:syphon/store/rooms/room/model.dart'; +import 'package:syphon/store/rooms/storage.dart'; +import 'package:syphon/store/user/storage.dart'; + +class Storage { + // cold storage references + static Database main; + + // preloaded cold storage data + static Map storageData = {}; + + // storage identifiers + static const mainKey = '${Values.appNameLabel}-main-storage.db'; +} + +Future initStorage() async { + try { + DatabaseFactory storageFactory; + + if (Platform.isAndroid || Platform.isIOS) { + // always open cold storage as sqflite + storageFactory = getDatabaseFactorySqflite( + sqflite.databaseFactory, + ); + } + + /// Supports Windows/Linux/MacOS for now. + if (Platform.isLinux || Platform.isWindows || Platform.isMacOS) { + storageFactory = getDatabaseFactorySqflite( + sqflite_ffi.databaseFactoryFfi, + ); + } + + if (factory == null) { + throw UnsupportedError( + 'Sorry, Syphon does not support your platform yet. Hope to do so soon!', + ); + } + + final codec = getEncryptSembastCodec(password: Cache.cryptKey); + + // TODO: make actions have reference to the storage/cache through state + Storage.main = await storageFactory.openDatabase( + Storage.mainKey, + codec: codec, + ); + + return Storage.main; + } catch (error) { + debugPrint('[initStorage] $error'); + return null; + } +} + +// // Closes and saves storage +void closeStorage() async { + if (Storage.main != null) { + Storage.main.close(); + } +} + +Future deleteStorage() async { + try { + DatabaseFactory storageFactory; + + if (Platform.isAndroid || Platform.isIOS) { + storageFactory = getDatabaseFactorySqflite( + sqflite.databaseFactory, + ); + } + + if (Platform.isLinux || Platform.isWindows || Platform.isMacOS) { + storageFactory = getDatabaseFactorySqflite( + sqflite_ffi.databaseFactoryFfi, + ); + } + + Storage.main = await storageFactory.deleteDatabase( + Storage.mainKey, + ); + } catch (error) { + printError('[deleteStorage] ${error.toString()}'); + } +} + +Future>> loadStorage(Database storage) async { + // load all rooms from cold storages + final rooms = await loadRooms( + storage: storage, + ); + + final users = await loadUsers( + storage: storage, + ); + + // load message using rooms loaded from cold storage + Map> messages = new Map(); + for (Room room in rooms.values) { + messages[room.id] = await loadMessages( + room.messageIds, + storage: storage, + encrypted: room.encryptionEnabled, + ); + printError( + '[loadMessages] ${messages[room.id]?.length} ${room.name} loaded', + ); + } + + return { + 'users': users, + 'rooms': rooms, + 'messages': messages.isNotEmpty ? messages : null, + }; +} diff --git a/lib/global/themes.dart b/lib/global/themes.dart index 48ec6c357..5375abb82 100644 --- a/lib/global/themes.dart +++ b/lib/global/themes.dart @@ -1,24 +1,12 @@ // Flutter imports: import 'package:flutter/material.dart'; -// Package imports: -import 'package:hive/hive.dart'; - -// Project imports: -import 'package:syphon/global/libs/hive/type-ids.dart'; import 'colours.dart'; -part 'themes.g.dart'; - -@HiveType(typeId: ThemeTypeHiveId) enum ThemeType { - @HiveField(0) LIGHT, - @HiveField(1) DARK, - @HiveField(2) DARKER, - @HiveField(3) NIGHT, } diff --git a/lib/main.dart b/lib/main.dart index 9df1407f8..6839f22af 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -12,14 +12,17 @@ import 'package:flutter/scheduler.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_redux/flutter_redux.dart'; import 'package:redux/redux.dart'; +import 'package:sembast/sembast.dart'; import 'package:syphon/global/cache/index.dart'; import 'package:syphon/global/formatters.dart'; +import 'package:syphon/global/print.dart'; +import 'package:syphon/global/storage/index.dart'; // Project imports: -import 'package:syphon/global/libs/hive/index.dart'; import 'package:syphon/global/themes.dart'; import 'package:syphon/store/alerts/actions.dart'; import 'package:syphon/store/auth/actions.dart'; +import 'package:syphon/store/events/actions.dart'; import 'package:syphon/store/index.dart'; import 'package:syphon/store/settings/state.dart'; import 'package:syphon/store/sync/actions.dart'; @@ -38,6 +41,7 @@ void main() async { // disable debugPrint when in release mode if (kReleaseMode) { debugPrint = (String message, {int wrapWidth}) {}; + printDebug = (String message, {String title}) {}; } // init platform overrides for compatability with dart libs @@ -53,36 +57,57 @@ void main() async { // init background sync for Android only if (Platform.isAndroid) { final backgroundSyncStatus = await BackgroundSync.init(); - debugPrint('[main] background service started $backgroundSyncStatus'); + printDebug('[main] background service started $backgroundSyncStatus'); } - // init cold cache (mobile only) - await initCache(); + // init hot cache and cold storage + final cache = await initCache(); - // TODO: remove after 0.1.4 - await initHive(); + // init cold storage and load to data + final storage = await initStorage(); + + // init redux store + final store = await initStore(cache, storage); // init hot cache and start - runApp(Syphon(store: await initStore())); + runApp(Syphon(store: store, cache: cache, storage: storage)); } class Syphon extends StatefulWidget { + final Database cache; + final Database storage; final Store store; - const Syphon({Key key, this.store}) : super(key: key); + + const Syphon({ + Key key, + this.store, + this.cache, + this.storage, + }) : super(key: key); @override - SyphonState createState() => SyphonState(store: store); + SyphonState createState() => SyphonState( + store: store, + cache: cache, + storage: storage, + ); } class SyphonState extends State with WidgetsBindingObserver { - SyphonState({this.store}); - + final Database cache; + final Database storage; final Store store; final GlobalKey globalScaffold = GlobalKey(); Widget defaultHome = Home(); StreamSubscription alertsListener; + SyphonState({ + this.store, + this.cache, + this.storage, + }); + @override void initState() { WidgetsBinding.instance.addObserver(this); @@ -190,8 +215,7 @@ class SyphonState extends State with WidgetsBindingObserver { @override void deactivate() { - closeStorage(); - closeCache(); + closeCache(cache); WidgetsBinding.instance.removeObserver(this); store.dispatch(stopAuthObserver()); store.dispatch(stopAlertsObserver()); diff --git a/lib/store/auth/actions.dart b/lib/store/auth/actions.dart index 603e3130e..96610f464 100644 --- a/lib/store/auth/actions.dart +++ b/lib/store/auth/actions.dart @@ -18,6 +18,7 @@ import 'package:syphon/global/libs/matrix/auth.dart'; import 'package:syphon/global/libs/matrix/errors.dart'; import 'package:syphon/global/libs/matrix/index.dart'; import 'package:syphon/global/notifications.dart'; +import 'package:syphon/global/storage/index.dart'; import 'package:syphon/global/strings.dart'; import 'package:syphon/global/values.dart'; import 'package:syphon/store/alerts/actions.dart'; @@ -366,6 +367,7 @@ ThunkAction logoutUser() { } final temp = '${store.state.authStore.user.accessToken}'; store.state.authStore.authObserver.add(null); + final data = await MatrixApi.logoutUser( protocol: protocol, homeserver: store.state.authStore.user.homeserver, @@ -380,6 +382,10 @@ ThunkAction logoutUser() { } } + // wipe cold storage + await deleteStorage(); + + // tell authObserver to wipe auth user store.state.authStore.authObserver.add(null); } catch (error) { store.dispatch(addAlert( diff --git a/lib/store/auth/state.dart b/lib/store/auth/state.dart index 6d60b9f08..178bb7b4b 100644 --- a/lib/store/auth/state.dart +++ b/lib/store/auth/state.dart @@ -3,26 +3,26 @@ import 'dart:async'; // Package imports: import 'package:equatable/equatable.dart'; -import 'package:hive/hive.dart'; import 'package:json_annotation/json_annotation.dart'; // Project imports: -import 'package:syphon/global/libs/hive/type-ids.dart'; import 'package:syphon/global/values.dart'; import 'package:syphon/store/auth/credential/model.dart'; import 'package:syphon/store/user/model.dart'; part 'state.g.dart'; -@HiveType(typeId: AuthStoreHiveId) -@JsonSerializable(ignoreUnannotated: true) +@JsonSerializable() class AuthStore extends Equatable { - @HiveField(0) - @JsonKey(name: 'user') final User user; + + @JsonKey(ignore: true) User get currentUser => user; + @JsonKey(ignore: true) final StreamController authObserver; + + @JsonKey(ignore: true) Stream get onAuthStateChanged => authObserver != null ? authObserver.stream : null; diff --git a/lib/store/crypto/actions.dart b/lib/store/crypto/actions.dart index 4bbaea6c1..c5394bd61 100644 --- a/lib/store/crypto/actions.dart +++ b/lib/store/crypto/actions.dart @@ -24,7 +24,7 @@ import 'package:syphon/store/crypto/events/actions.dart'; import 'package:syphon/store/crypto/keys/model.dart'; import 'package:syphon/store/crypto/model.dart'; import 'package:syphon/store/index.dart'; -import 'package:syphon/store/rooms/events/model.dart'; +import 'package:syphon/store/events/model.dart'; import 'package:syphon/store/rooms/room/model.dart'; import 'package:syphon/store/user/model.dart'; @@ -591,14 +591,14 @@ ThunkAction claimOneTimeKeys({ }) { return (Store store) async { try { - final roomUsers = room.users.values; + final roomUserIds = room.userIds; final deviceKeys = store.state.cryptoStore.deviceKeys; final outboundKeySessions = store.state.cryptoStore.outboundKeySessions; final currentUser = store.state.authStore.user; // get deviceKeys for every user present in the chat - final List roomDeviceKeys = List.from(roomUsers - .map((user) => (deviceKeys[user.userId] ?? {}).values) + final List roomDeviceKeys = List.from(roomUserIds + .map((userId) => (deviceKeys[userId] ?? {}).values) .expand((x) => x)); // Create a map of all the oneTimeKeys to claim @@ -1012,17 +1012,23 @@ ThunkAction exportMessageSession({String roomId}) { * fetches the keys uploaded to the matrix homeserver * by other users */ -ThunkAction fetchDeviceKeys({Map users}) { +ThunkAction fetchDeviceKeys({ + List userIds, +}) { return (Store store) async { try { - final userMap = users.map((userId, user) => MapEntry(userId, const [])); + final Map userIdMap = Map.fromIterable( + userIds, + key: (userId) => userId, + value: (userId) => const [], + ); final data = await MatrixApi.fetchKeys( protocol: protocol, homeserver: store.state.authStore.user.homeserver, accessToken: store.state.authStore.user.accessToken, lastSince: store.state.syncStore.lastSince, - users: userMap, + users: userIdMap, ); final Map deviceKeys = data['device_keys']; @@ -1054,9 +1060,7 @@ ThunkAction fetchDeviceKeys({Map users}) { ThunkAction fetchDeviceKeysOwned(User user) { return (Store store) async { final deviceKeys = await store.dispatch( - fetchDeviceKeys(users: { - user.userId: user, - }), + fetchDeviceKeys(userIds: [user.userId]), ); return deviceKeys[user.userId]; }; diff --git a/lib/store/crypto/events/actions.dart b/lib/store/crypto/events/actions.dart index 099539742..61ea2f3c3 100644 --- a/lib/store/crypto/events/actions.dart +++ b/lib/store/crypto/events/actions.dart @@ -17,7 +17,7 @@ import 'package:syphon/store/alerts/actions.dart'; import 'package:syphon/store/crypto/actions.dart'; import 'package:syphon/store/crypto/model.dart'; import 'package:syphon/store/index.dart'; -import 'package:syphon/store/rooms/events/model.dart'; +import 'package:syphon/store/events/model.dart'; /** * Encrypt event content with loaded outbound session for room diff --git a/lib/store/crypto/keys/model.dart b/lib/store/crypto/keys/model.dart index 2e017fae0..5e112793b 100644 --- a/lib/store/crypto/keys/model.dart +++ b/lib/store/crypto/keys/model.dart @@ -1,51 +1,15 @@ // Package imports: import 'package:equatable/equatable.dart'; -import 'package:hive/hive.dart'; import 'package:json_annotation/json_annotation.dart'; -// Project imports: -import 'package:syphon/global/libs/hive/type-ids.dart'; - part 'model.g.dart'; -/** - * - * OneTimeKey Data Model - * - * - * https://matrix.org/docs/spec/client_server/latest#id468 - * { - "failures": {}, - "one_time_keys": { - "@alice:example.com": { - "JLAFKJWSCS": { - "signed_curve25519:AAAAHg": { - "key": "zKbLg+NrIjpnagy+pIY6uPL4ZwEG2v+8F9lmgsnlZzs", - "signatures": { - "@alice:example.com": { - "ed25519:JLAFKJWSCS": "FLWxXqGbwrb8SM3Y795eB6OA8bwBcoMZFXBqnTn58AYWZSqiD45tlBVcDa2L7RwdKXebW/VzDlnfVJ+9jok1Bw" - } - } - } - } - } - } - } - */ -@HiveType(typeId: OneTimeKeyHiveId) @JsonSerializable() class OneTimeKey extends Equatable { - @HiveField(0) final String userId; - @HiveField(1) final String deviceId; - - // Map - @HiveField(2) - final Map keys; - - // Map - @HiveField(3) + final Map keys; // Map + // Map> final Map> signatures; const OneTimeKey({ diff --git a/lib/store/crypto/model.dart b/lib/store/crypto/model.dart index 6b64913a9..bfc3f32b7 100644 --- a/lib/store/crypto/model.dart +++ b/lib/store/crypto/model.dart @@ -1,34 +1,19 @@ // Package imports: import 'package:equatable/equatable.dart'; -import 'package:hive/hive.dart'; import 'package:json_annotation/json_annotation.dart'; - -// Project imports: -import 'package:syphon/global/libs/hive/type-ids.dart'; import 'package:syphon/global/libs/matrix/encryption.dart'; part 'model.g.dart'; -@HiveType(typeId: DeviceKeyHiveId) @JsonSerializable() class DeviceKey extends Equatable { - @HiveField(0) final String userId; - @HiveField(1) final String deviceId; - @HiveField(2) final List algorithms; - @HiveField(3) final Map keys; - @HiveField(4) final Map signatures; - @HiveField(5) final Map extras; - // DEPRRECATED - @HiveField(6) - final Map privateKeys; - const DeviceKey({ this.userId, this.deviceId, @@ -36,7 +21,6 @@ class DeviceKey extends Equatable { this.keys, this.signatures, this.extras, - this.privateKeys, }); @override @@ -47,7 +31,6 @@ class DeviceKey extends Equatable { keys, signatures, extras, - privateKeys, ]; Map toMatrix() { diff --git a/lib/store/crypto/state.dart b/lib/store/crypto/state.dart index f40f1f6bd..7102a8c67 100644 --- a/lib/store/crypto/state.dart +++ b/lib/store/crypto/state.dart @@ -1,75 +1,58 @@ // Package imports: import 'package:equatable/equatable.dart'; -import 'package:hive/hive.dart'; import 'package:json_annotation/json_annotation.dart'; import 'package:olm/olm.dart'; // Project imports: -import 'package:syphon/global/libs/hive/type-ids.dart'; import 'package:syphon/store/crypto/keys/model.dart'; import 'package:syphon/store/crypto/model.dart'; part 'state.g.dart'; -// Next Hive Field Number: 14 -@HiveType(typeId: CryptoStoreHiveId) @JsonSerializable() class CryptoStore extends Equatable { // Active olm account (loaded from olmAccountKey) @JsonKey(ignore: true) final Account olmAccount; + // the private key for one time keys is saved in olm? + // Map deviceKeys + @JsonKey(ignore: true) + final Map oneTimeKeysOwned; + // Serialized olm account - @HiveField(3) final String olmAccountKey; // Map // megolm - message index - @HiveField(10) final Map messageSessionIndex; // Map // megolm - index per chat - @HiveField(15) final Map> messageSessionIndexNEW; // Map // megolm - messages - @HiveField(5) final Map outboundMessageSessions; // Map // megolm - messages per chat - @HiveField(11) final Map> inboundMessageSessions; // Map // olmv1 - key-sharing per identity - @HiveField(8) final Map inboundKeySessions; // Map // olmv1 - key-sharing per identity - @HiveField(6) final Map outboundKeySessions; // Map deviceKeys - @HiveField(0) final Map> deviceKeys; // Map deviceKeysOwned - @HiveField(1) final Map deviceKeysOwned; // key is deviceId - @HiveField(2) final bool deviceKeysExist; // Track last known uploaded key amounts - @HiveField(7) final Map oneTimeKeysCounts; - @HiveField(9) - final Map oneTimeKeysClaimed; // claimed - - // @HiveField(?) TODO: consider saving generated keys? - // the private key for one time keys is saved in olm? - // Map deviceKeys - @JsonKey(ignore: true) - final Map oneTimeKeysOwned; + final Map oneTimeKeysClaimed; const CryptoStore({ this.olmAccount, diff --git a/lib/store/rooms/events/actions.dart b/lib/store/events/actions.dart similarity index 81% rename from lib/store/rooms/events/actions.dart rename to lib/store/events/actions.dart index 31f1cbf4b..47dfb54cb 100644 --- a/lib/store/rooms/events/actions.dart +++ b/lib/store/events/actions.dart @@ -9,30 +9,80 @@ import 'package:flutter/material.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:redux/redux.dart'; import 'package:redux_thunk/redux_thunk.dart'; +import 'package:syphon/global/algos.dart'; // Project imports: import 'package:syphon/global/libs/matrix/index.dart'; +import 'package:syphon/global/print.dart'; +import 'package:syphon/global/storage/index.dart'; import 'package:syphon/store/alerts/actions.dart'; import 'package:syphon/store/crypto/actions.dart'; import 'package:syphon/store/crypto/events/actions.dart'; +import 'package:syphon/store/events/storage.dart'; import 'package:syphon/store/index.dart'; import 'package:syphon/store/rooms/actions.dart'; -import 'package:syphon/store/rooms/events/model.dart'; +import 'package:syphon/store/events/model.dart'; import 'package:syphon/store/rooms/room/model.dart'; final protocol = DotEnv().env['PROTOCOL']; +class ResetEvents {} + +class SetMessages { + final String roomId; + final List messages; + SetMessages({this.roomId, this.messages}); +} + +class SetState { + final String roomId; + final List states; + SetState({this.roomId, this.states}); +} + +ThunkAction setMessageEvents({ + Room room, + List messages, + int offset = 0, + int limit = 20, +}) => + (Store store) { + return store.dispatch(SetMessages(roomId: room.id, messages: messages)); + }; + /** * Load Message Events * - * Pulls next message events from cold storage + * Pulls initial messages from storage or paginates through + * those existing in cold storage depending on requests from client + * + * Make sure these have been exhausted before calling fetchMessageEvents + * + * TODO: still needs work */ -ThunkAction loadMessageEvents({Room room}) { +ThunkAction loadMessageEvents({ + Room room, + int offset = 0, + int limit = 20, +}) { return (Store store) async { try { store.dispatch(UpdateRoom(id: room.id, syncing: true)); + + final messagesStored = await loadMessages( + room.messageIds, + storage: Storage.main, + offset: offset, // offset from the most recent eventId found + limit: !room.encryptionEnabled ? limit : room.messageIds.length, + ); + + // load cold storage messages to state + store.dispatch(SetMessages( + roomId: room.id, + messages: messagesStored, + )); } catch (error) { - debugPrint('[fetchMessageEvents] $error'); + printError('[fetchMessageEvents] $error'); } finally { store.dispatch(UpdateRoom(id: room.id, syncing: false)); } @@ -90,7 +140,47 @@ ThunkAction fetchMessageEvents({ }), ); } catch (error) { - debugPrint('[fetchMessageEvents] $error'); + debugPrint('[fetchMessageEvents] error $error'); + } finally { + store.dispatch(UpdateRoom(id: room.id, syncing: false)); + } + }; +} + +/** + * Decrypt Events + * + * Reattribute decrypted events to the timeline + */ +ThunkAction decryptEvents(Room room, Map json) { + return (Store store) async { + try { + // First past to decrypt encrypted events + final List timelineEvents = json['timeline']['events']; + + // map through each event and decrypt if possible + final decryptTimelineActions = timelineEvents.map((event) async { + final eventType = event['type']; + switch (eventType) { + case EventTypes.encrypted: + return await store.dispatch( + decryptMessageEvent(roomId: room.id, event: event), + ); + default: + return event; + } + }); + + // add the decrypted events back to the + final decryptedTimelineEvents = await Future.wait( + decryptTimelineActions, + ); + + return decryptedTimelineEvents; + } catch (error) { + debugPrint( + '[decryptEvents] ${room.name ?? 'Unknown Room Name'} ${error.toString()}', + ); } finally { store.dispatch(UpdateRoom(id: room.id, syncing: false)); } diff --git a/lib/store/rooms/events/ephemeral/m.read/model.dart b/lib/store/events/ephemeral/m.read/model.dart similarity index 83% rename from lib/store/rooms/events/ephemeral/m.read/model.dart rename to lib/store/events/ephemeral/m.read/model.dart index 57cabba88..f4f11cfbd 100644 --- a/lib/store/rooms/events/ephemeral/m.read/model.dart +++ b/lib/store/events/ephemeral/m.read/model.dart @@ -1,21 +1,12 @@ // Package imports: -import 'package:hive/hive.dart'; import 'package:json_annotation/json_annotation.dart'; -// Project imports: -import 'package:syphon/global/libs/hive/type-ids.dart'; - part 'model.g.dart'; -@HiveType(typeId: ReadStatusHiveId) @JsonSerializable() class ReadStatus { - @HiveField(0) final int latestRead; - - // UserId -> timestamp - @HiveField(1) - final Map userReads; + final Map userReads; // UserId -> timestamp const ReadStatus({ this.latestRead = 0, diff --git a/lib/store/rooms/events/model.dart b/lib/store/events/model.dart similarity index 84% rename from lib/store/rooms/events/model.dart rename to lib/store/events/model.dart index a7bec9dd6..f46a11bcc 100644 --- a/lib/store/rooms/events/model.dart +++ b/lib/store/events/model.dart @@ -1,10 +1,6 @@ // Package imports: -import 'package:hive/hive.dart'; import 'package:json_annotation/json_annotation.dart'; -// Project imports: -import 'package:syphon/global/libs/hive/type-ids.dart'; - part 'model.g.dart'; /** @@ -25,10 +21,8 @@ class EventTypes { static const creation = 'm.room.create'; static const message = 'm.room.message'; static const encrypted = 'm.room.encrypted'; - - // {membership: join, displayname: usbfingers, avatar_url: mxc://matrix.org/RrRcMHnqXaJshyXZpGrZloyh } - // {is_direct: true, membership: invite, displayname: ereio, avatar_url: mxc://matrix.org/JllILpqzdFAUOvrTPSkDryzW} static const member = 'm.room.member'; + static const reaction = 'm.reaction'; static const guestAccess = 'm.room.guest_access'; static const joinRules = 'm.room.join_rules'; @@ -36,8 +30,6 @@ class EventTypes { static const powerLevels = 'm.room.power_levels'; static const encryption = 'm.room.encryption'; static const roomKey = 'm.room_key'; - - static const ignoredUserList = 'm.ignored_user_list'; } class MessageTypes { @@ -49,6 +41,7 @@ class MessageTypes { static const AUDIO = 'm.text'; static const LOCATION = 'm.location'; static const VIDEO = 'm.video'; + static const ANNOTATIONO = 'm.annotation'; } class MediumType { @@ -58,22 +51,14 @@ class MediumType { static const encryption = 'encryption'; } -@HiveType(typeId: EventHiveId) @JsonSerializable() class Event { - @HiveField(0) final String id; // event_id - @HiveField(1) final String userId; - @HiveField(2) final String roomId; - @HiveField(3) final String type; - @HiveField(4) final String sender; - @HiveField(5) final String stateKey; - @HiveField(6) final int timestamp; /* @@ -127,51 +112,31 @@ class Event { } // TODO: make this actually inherit Event but also allow immutability (dart says no?) -@HiveType(typeId: MessageHiveId) @JsonSerializable() class Message { - @HiveField(0) final String id; // event_id - @HiveField(1) final String userId; - @HiveField(2) final String roomId; - @HiveField(3) final String type; - @HiveField(4) final String sender; - @HiveField(5) final String stateKey; - @HiveField(6) final int timestamp; - @HiveField(7) final bool pending; - @HiveField(8) final bool syncing; - @HiveField(9) final bool failed; // Message Only - @HiveField(10) final String body; - @HiveField(11) final String msgtype; - @HiveField(12) final String format; - @HiveField(13) final String filename; - @HiveField(14) final String formattedBody; // Encrypted Messages only - @HiveField(15) final String ciphertext; - @HiveField(16) final String algorithm; - // The Curve25519 key of the device which initiated the session originally. - @HiveField(17) - final String senderKey; + final String senderKey; // Curve25519 device key which initiated the session /* * TODO: content will not always be a string? configure parsing data diff --git a/lib/store/events/parsers.dart b/lib/store/events/parsers.dart new file mode 100644 index 000000000..32b428b08 --- /dev/null +++ b/lib/store/events/parsers.dart @@ -0,0 +1,133 @@ +import 'dart:collection'; + +/** + * + * Event Parsers + * + * It's going to be difficult to parse external to room context + * because so much of the rooms context is gather through the DAG of + * events. You'd need to pass back both an updated room AND a list of messages + * to save seperately, or sacrific iterating through the message list again + * + */ +import 'package:sembast/sembast.dart'; +import 'package:syphon/store/events/model.dart'; +import 'package:syphon/store/rooms/room/model.dart'; + +Map parseMessages({ + Room room, + List messages = const [], + List existing = const [], +}) { + bool limited; + int lastUpdate = room.lastUpdate; + List outbox = List.from(room.outbox ?? []); + List messagesAll = List.from(existing ?? []); + + // Converting only message events + final hasEncrypted = messages.firstWhere( + (msg) => msg.type == EventTypes.encrypted, + orElse: () => null, + ); + + // See if the newest message has a greater timestamp + if (messages.isNotEmpty && lastUpdate < messages[0].timestamp) { + lastUpdate = messages[0].timestamp; + } + + // limited indicates need to fetch additional data for room timelines + if (room.limited) { + // Check to see if the new messages contain those existing in cache + if (messages.isNotEmpty && room.messageIds.isNotEmpty) { + final messageLatest = room.messageIds.firstWhere( + (id) => id == messages[0].id, + orElse: () => null, + ); + // Set limited to false if they now exist + limited = messageLatest != null; + } + + // Set limited to false false if + // - the oldest hash (lastHash) is non-existant + // - the previous hash (most recent) is non-existant + // - the oldest hash equals the previously fetched hash + if (room.lastHash == null || + room.prevHash == null || + room.lastHash == room.prevHash) { + limited = false; + } + } + + // Combine current and existing messages on unique ids + messagesAll.addAll(messages); + + // Map messages to ids to filter out ids and outbox + final messagesAllMap = HashMap.fromIterable( + messagesAll, + key: (message) => message.id, + value: (message) => message, + ); + + // Remove outboxed messages + outbox.removeWhere( + (message) => messagesAllMap.containsKey(message.id), + ); + + // save messages and unique message id updates + final messageIdsAll = Set.from(room.messageIds) + ..addAll(messagesAllMap.keys); + + return { + 'messages': messages, + 'room': room.copyWith( + outbox: outbox, + messageIds: messageIdsAll.toList(), + limited: limited ?? room.limited, + lastUpdate: lastUpdate ?? room.lastUpdate, + encryptionEnabled: room.encryptionEnabled || hasEncrypted != null, + ), + }; +} + +Map parseEvents( + Map json, { + Database database, +}) { + List stateEvents = []; + List messageEvents = []; + + if (json['state'] != null) { + final List stateEventsRaw = json['state']['events']; + + stateEvents = + stateEventsRaw.map((event) => Event.fromMatrix(event)).toList(); + } + + if (json['invite_state'] != null) { + final List stateEventsRaw = json['invite_state']['events']; + + stateEvents = + stateEventsRaw.map((event) => Event.fromMatrix(event)).toList(); + } + + if (json['timeline'] != null) { + final List timelineEventsRaw = json['timeline']['events']; + + final List timelineEvents = List.from( + timelineEventsRaw.map((event) => Event.fromMatrix(event)), + ); + + for (dynamic event in timelineEvents) { + if (event.type == EventTypes.message || + event.type == EventTypes.encrypted) { + messageEvents.add(Message.fromEvent(event)); + } else { + stateEvents.add(event); + } + } + } + return { + 'states': stateEvents, + "messages": messageEvents, + }; +} diff --git a/lib/store/events/reducer.dart b/lib/store/events/reducer.dart new file mode 100644 index 000000000..836bc3f42 --- /dev/null +++ b/lib/store/events/reducer.dart @@ -0,0 +1,40 @@ +// Project imports: +import './actions.dart'; +import '../events/model.dart'; +import './state.dart'; + +EventStore eventReducer( + [EventStore state = const EventStore(), dynamic action]) { + switch (action.runtimeType) { + case SetMessages: + final roomId = action.roomId; + final messages = Map>.from(state.messages); + final messagesOld = Map.fromIterable( + messages[roomId] ?? [], + key: (msg) => msg.id, + value: (msg) => msg, + ); + final messagesNew = Map.fromIterable( + action.messages ?? [], + key: (msg) => msg.id, + value: (msg) => msg, + ); + + final messagesAll = messagesOld..addAll(messagesNew); + + messages[roomId] = messagesAll.values.toList(); + + return state.copyWith(messages: messages); + + case SetState: + final roomId = action.roomId; + final states = Map>.from(state.states); + states[roomId] = action.state; + return state.copyWith(states: states); + + case ResetEvents: + return EventStore(); + default: + return state; + } +} diff --git a/lib/store/events/selectors.dart b/lib/store/events/selectors.dart new file mode 100644 index 000000000..e308a27ad --- /dev/null +++ b/lib/store/events/selectors.dart @@ -0,0 +1,50 @@ +// Project imports: +import 'package:syphon/store/events/model.dart'; +import 'package:syphon/store/index.dart'; + +List roomMessages(AppState state, String roomId) { + return state.eventStore.messages[roomId] ?? []; +} + +// remove messages from blocked users +List filterMessages(List messages, List blocked) { + return messages + ..removeWhere( + (message) => blocked.contains(message.sender), + ); +} + +List latestMessages(List messages) { + final sortedList = List.from(messages ?? []); + + // sort descending + sortedList.sort((a, b) { + if (a.pending && !b.pending) { + return -1; + } + + if (a.timestamp > b.timestamp) { + return -1; + } + if (a.timestamp < b.timestamp) { + return 1; + } + + return 0; + }); + + return sortedList; +} + +List wrapOutboxMessages({ + List messages, + List outbox, +}) { + return [outbox, messages].expand((x) => x).toList(); +} + +bool isTextMessage({Message message}) { + return message.msgtype == MessageTypes.TEXT || + message.msgtype == MessageTypes.EMOTE || + message.msgtype == MessageTypes.NOTICE; +} diff --git a/lib/store/events/state.dart b/lib/store/events/state.dart new file mode 100644 index 000000000..2a537d4ac --- /dev/null +++ b/lib/store/events/state.dart @@ -0,0 +1,44 @@ +// Dart imports: +import 'dart:async'; + +// Package imports: +import 'package:equatable/equatable.dart'; +import 'package:json_annotation/json_annotation.dart'; + +import 'model.dart'; + +part 'state.g.dart'; + +@JsonSerializable() +class EventStore extends Equatable { + final Map> states; // indexed by roomId + final Map> messages; // indexed by roomId + final Map> receipts; + + const EventStore({ + this.states = const {}, + this.messages = const {}, + this.receipts = const {}, + }); + + @override + List get props => [ + states, + messages, + receipts, + ]; + + EventStore copyWith({ + states, + messages, + }) => + EventStore( + states: states ?? this.states, + messages: messages ?? this.messages, + receipts: receipts ?? this.receipts, + ); + + Map toJson() => _$EventStoreToJson(this); + factory EventStore.fromJson(Map json) => + _$EventStoreFromJson(json); +} diff --git a/lib/store/events/storage.dart b/lib/store/events/storage.dart new file mode 100644 index 000000000..d7941c256 --- /dev/null +++ b/lib/store/events/storage.dart @@ -0,0 +1,73 @@ +import 'dart:convert'; + +import 'package:sembast/sembast.dart'; +import 'package:syphon/global/print.dart'; +import 'package:syphon/store/events/ephemeral/m.read/model.dart'; +import 'package:syphon/store/events/model.dart'; + +const String MESSAGES = 'messages'; +const String RECEIPTS = 'receipts'; + +Future saveMessages( + List messages, { + Database storage, +}) async { + final store = StoreRef(MESSAGES); + + return await storage.transaction((txn) async { + for (Message message in messages) { + final record = store.record(message.id); + await record.put(txn, json.encode(message)); + } + }); +} + +/** + * Load Messages (Cold Storage) + * + * In storage, messages are indexed by eventId + * In redux, they're indexed by RoomID and placed in a list + */ +Future> loadMessages( + List eventIds, { + Database storage, + bool encrypted, + int offset = 0, + int limit = 20, // default amount loaded +}) async { + final List messages = []; + + try { + final store = StoreRef(MESSAGES); + + // TODO: properly paginate through cold storage messages instead of loading all + final eventIdsPaginated = + eventIds ?? []; //.skip(offset).take(limit).toList(); + + final messagesPaginated = + await store.records(eventIdsPaginated).get(storage); + + for (String message in messagesPaginated) { + messages.add(Message.fromJson(json.decode(message))); + } + + return messages; + } catch (error) { + printError(error.toString(), title: 'loadMessages'); + return null; + } +} + +Future saveReceipts( + Map receipts, { + Database storage, +}) async { + final store = StoreRef(RECEIPTS); + + return await storage.transaction((txn) async { + for (String roomId in receipts.keys) { + final record = store.record(roomId); + await record.put(txn, json.encode(receipts[roomId])); + } + }); +} diff --git a/lib/store/index.dart b/lib/store/index.dart index 384380812..3a95c6b3c 100644 --- a/lib/store/index.dart +++ b/lib/store/index.dart @@ -7,7 +7,10 @@ import 'package:equatable/equatable.dart'; import 'package:redux/redux.dart'; import 'package:redux_persist/redux_persist.dart'; import 'package:redux_thunk/redux_thunk.dart'; -import 'package:syphon/global/cache/index.dart'; +import 'package:sembast/sembast.dart'; +import 'package:syphon/global/algos.dart'; +import 'package:syphon/global/cache/storage.dart'; +import 'package:syphon/global/storage/index.dart'; // Project imports: import 'package:syphon/store/alerts/model.dart'; @@ -16,6 +19,8 @@ import 'package:syphon/store/auth/reducer.dart'; import 'package:syphon/store/crypto/actions.dart'; import 'package:syphon/store/crypto/reducer.dart'; import 'package:syphon/store/crypto/state.dart'; +import 'package:syphon/store/events/reducer.dart'; +import 'package:syphon/store/events/state.dart'; import 'package:syphon/store/media/reducer.dart'; import 'package:syphon/global/cache/serializer.dart'; import 'package:syphon/store/sync/actions.dart'; @@ -42,6 +47,7 @@ class AppState extends Equatable { final MediaStore mediaStore; final SettingsStore settingsStore; final RoomStore roomStore; + final EventStore eventStore; final UserStore userStore; final SyncStore syncStore; final CryptoStore cryptoStore; @@ -52,6 +58,7 @@ class AppState extends Equatable { this.alertsStore = const AlertsStore(), this.syncStore = const SyncStore(), this.roomStore = const RoomStore(), + this.eventStore = const EventStore(), this.userStore = const UserStore(), this.mediaStore = const MediaStore(), this.searchStore = const SearchStore(), @@ -68,6 +75,7 @@ class AppState extends Equatable { roomStore, userStore, mediaStore, + eventStore, searchStore, settingsStore, cryptoStore, @@ -80,6 +88,7 @@ AppState appReducer(AppState state, action) => AppState( alertsStore: alertsReducer(state.alertsStore, action), mediaStore: mediaReducer(state.mediaStore, action), roomStore: roomReducer(state.roomStore, action), + eventStore: eventReducer(state.eventStore, action), syncStore: syncReducer(state.syncStore, action), userStore: userReducer(state.userStore, action), searchStore: searchReducer(state.searchStore, action), @@ -89,22 +98,20 @@ AppState appReducer(AppState state, action) => AppState( /** * Initialize Store - * - Hot redux state cache for top level data - * * Consider still using hive here - * - * PLEASE NOTE redux persist manages when the store - * should persist and if it can, not where it's persisting too - * this is why the "storage: MemoryStore()" property is set and - * the Hive Serializer has been impliemented + * - Hot redux state cache for top level data */ -Future initStore() async { +Future initStore(Database cache, Database storage) async { + // partially load storage to memory to rehydrate cache + final data = await loadStorage(storage); + // Configure redux persist instance final persistor = Persistor( - storage: MemoryStorage(), - serializer: CacheSerializer(), - throttleDuration: Duration(milliseconds: 4500), + storage: CacheStorage(cache: cache), + serializer: CacheSerializer(cache: cache, preloaded: data), + // TODO: can remove once cold storage is in place + throttleDuration: Duration(milliseconds: 4000), shouldSave: (Store store, dynamic action) { - // TODO: can remove once sqlcipher storage is in place + // TODO: can remove once cold storage is in place switch (action.runtimeType) { case SetSynced: if (action.synced) { @@ -125,11 +132,6 @@ Future initStore() async { }, ); - // Configure cache encryption/decryption instance - CacheSecure.ivKey = await unlockIVKey(); - CacheSecure.ivKeyNext = await unlockIVKeyNext(); - CacheSecure.cryptKey = await unlockCryptKey(); - // Finally load persisted store var initialState; diff --git a/lib/store/media/state.dart b/lib/store/media/state.dart index 324e91b58..efa947b7c 100644 --- a/lib/store/media/state.dart +++ b/lib/store/media/state.dart @@ -3,31 +3,13 @@ import 'dart:typed_data'; // Package imports: import 'package:equatable/equatable.dart'; -import 'package:hive/hive.dart'; -import 'package:json_annotation/json_annotation.dart'; -// Project imports: -import 'package:syphon/global/libs/hive/type-ids.dart'; - -part 'state.g.dart'; - -// NOTE: custom json converter to allow Uint8List when in cache -// TODO: figure out how to make image-matrix.dart play nice with in component coonversions -// Would repeatedly update even if a locally cached version matched // @JsonSerializable(nullable: true, includeIfNull: true) -@HiveType(typeId: MediaStoreHiveId) class MediaStore extends Equatable { - @HiveField(0) final bool fetching; - @HiveField(1) + final Map mediaChecks; // Map final Map mediaCache; - // Map - @HiveField(2) - final Map mediaChecks; - - static const hiveBox = 'MediaStore'; - const MediaStore({ this.fetching = false, this.mediaCache = const {}, @@ -40,6 +22,7 @@ class MediaStore extends Equatable { mediaCache, mediaChecks, ]; + MediaStore copyWith({ fetching, mediaCache, @@ -51,6 +34,9 @@ class MediaStore extends Equatable { mediaChecks: mediaChecks ?? this.mediaChecks, ); + // custom json converter to allow Uint8List when in cache + // TODO: figure out how to make image-matrix.dart play nice with in component coonversions + // Would repeatedly update even if a locally cached version matched factory MediaStore.fromJson(Map json) { return MediaStore( fetching: json['fetching'] as bool, diff --git a/lib/store/rooms/actions.dart b/lib/store/rooms/actions.dart index 90af3ef61..639d88b5a 100644 --- a/lib/store/rooms/actions.dart +++ b/lib/store/rooms/actions.dart @@ -10,9 +10,11 @@ import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:redux/redux.dart'; import 'package:redux_thunk/redux_thunk.dart'; import 'package:syphon/global/algos.dart'; +import 'package:syphon/global/print.dart'; +import 'package:syphon/global/storage/index.dart'; +import 'package:syphon/store/events/storage.dart'; // Project imports: -import 'package:syphon/store/rooms/selectors.dart' as roomSelectors; import 'package:syphon/global/libs/matrix/encryption.dart'; import 'package:syphon/global/libs/matrix/errors.dart'; import 'package:syphon/global/libs/matrix/index.dart'; @@ -20,11 +22,14 @@ import 'package:syphon/store/alerts/actions.dart'; import 'package:syphon/store/crypto/events/actions.dart'; import 'package:syphon/store/index.dart'; import 'package:syphon/store/media/actions.dart'; -import 'package:syphon/store/rooms/events/actions.dart'; -import 'package:syphon/store/rooms/events/selectors.dart'; +import 'package:syphon/store/events/actions.dart'; +import 'package:syphon/store/events/selectors.dart'; +import 'package:syphon/store/rooms/storage.dart'; import 'package:syphon/store/sync/actions.dart'; +import 'package:syphon/store/user/actions.dart'; +import 'package:syphon/store/user/storage.dart'; import 'package:syphon/store/user/model.dart'; -import 'events/model.dart'; +import '../events/model.dart'; import 'room/model.dart'; final protocol = DotEnv().env['PROTOCOL']; @@ -64,12 +69,13 @@ class UpdateRoom { } class RemoveRoom { - final Room room; - RemoveRoom({this.room}); + final String roomId; + RemoveRoom({this.roomId}); } -class ResetRooms { - ResetRooms(); +class AddArchive { + final String roomId; + AddArchive({this.roomId}); } /** @@ -90,17 +96,11 @@ class SaveOutboxMessage { class DeleteOutboxMessage { final Message message; // room id - DeleteOutboxMessage({ - this.message, - }); + DeleteOutboxMessage({this.message}); } -class AddArchive { - final String roomId; - - AddArchive({ - this.roomId, - }); +class ResetRooms { + ResetRooms(); } /** @@ -129,37 +129,47 @@ ThunkAction syncRooms(Map roomData) { // First past to decrypt encrypted events if (room.encryptionEnabled) { - final List timelineEvents = json['timeline']['events']; - - // map through each event and decrypt if possible - final decryptTimelineActions = timelineEvents.map((event) async { - final eventType = event['type']; - switch (eventType) { - case EventTypes.encrypted: - return await store.dispatch( - decryptMessageEvent(roomId: room.id, event: event), - ); - default: - return event; - } - }); - - // add the decrypted events back to the - final decryptedTimelineEvents = await Future.wait( - decryptTimelineActions, - ); - // reassign the mapped decrypted evets to the json timeline - json['timeline']['events'] = decryptedTimelineEvents; + json['timeline']['events'] = await store.dispatch( + decryptEvents(room, json), + ); } - // Filter through parsers + // TODO: eventually remove the need for this with modular parsers room = room.fromSync( json: json, currentUser: user, lastSince: lastSince, ); + printDebug( + '[syncRooms] ${room.name} new msg count ${room.messagesNew.length}', + ); + printDebug( + '[syncRooms] ${room.name} ids msg count ${room.messageIds.length}', + ); + + // update store + await store.dispatch( + setUsers(room.usersNew), + ); + await store.dispatch( + setMessageEvents(room: room, messages: room.messagesNew), + ); + + // update cold storage + await Future.wait([ + saveUsers(room.usersNew, storage: Storage.main), + saveRooms({room.id: room}, storage: Storage.main), + saveMessages(room.messagesNew, storage: Storage.main), + ]); + + // TODO: remove with parsers - clear users from parsed room objects + room = room.copyWith( + users: Map(), + messagesNew: List(), + ); + // fetch avatar if a uri was found if (room.avatarUri != null) { store.dispatch(fetchThumbnail( @@ -238,6 +248,7 @@ ThunkAction fetchRooms() { '${room.id}': { 'state': { 'events': stateEvents, + 'prev_batch': messageEvents['from'], }, 'timeline': { 'events': messageEvents['chunk'], @@ -417,10 +428,9 @@ ThunkAction createRoom({ value: (user) => user, ); + // generate user invite map to cache recent users room = room.copyWith(users: userInviteMap); - printJson(userInviteMap); - if (avatarFile != null) { await store.dispatch( updateRoomAvatar(roomId: room.id, localFile: avatarFile), @@ -513,9 +523,8 @@ ThunkAction markRoomRead({String roomId}) { // send read receipt remotely to mark locally on /sync if (store.state.settingsStore.readReceipts) { - final messagesSorted = latestMessages( - roomSelectors.room(id: roomId, state: store.state).messages, - ); + final messagesSorted = + latestMessages(roomMessages(store.state, roomId)); if (messagesSorted.isNotEmpty) { store.dispatch(sendReadReceipts( @@ -590,27 +599,29 @@ ThunkAction toggleDirectRoom({Room room, bool enabled}) { // Find the other user in the direct room final currentUser = store.state.authStore.user; - final otherUser = room.users.values.firstWhere( - (user) => user.userId != currentUser.userId, + + // only the other user id, and not the user object, is needed here + final otherUserId = room.userIds.firstWhere( + (userId) => userId != currentUser.userId, ); - if (otherUser == null) { + if (otherUserId == null) { throw 'Cannot toggle room to direct without other users'; } // Pull the direct room for that specific user Map directRoomUsers = data as Map; - final usersDirectRooms = directRoomUsers[otherUser.userId] ?? []; + final usersDirectRooms = directRoomUsers[otherUserId] ?? []; if (usersDirectRooms.isEmpty && enabled) { - directRoomUsers[otherUser.userId] = [room.id]; + directRoomUsers[otherUserId] = [room.id]; } // Toggle the direct room data based on user actions directRoomUsers = directRoomUsers.map((userId, rooms) { List updatedRooms = List.from(rooms ?? []); - if (userId != otherUser.userId) { + if (userId != otherUserId) { return MapEntry(userId, updatedRooms); } @@ -863,9 +874,9 @@ ThunkAction removeRoom({Room room}) { // Remove the room locally if it's already been removed remotely if (leaveData['errcode'] != null) { if (leaveData['errcode'] == MatrixErrors.room_unknown) { - await store.dispatch(RemoveRoom(room: Room(id: room.id))); - } else if (leaveData['errcode'] == MatrixErrors.room_not_found) { - await store.dispatch(RemoveRoom(room: Room(id: room.id))); + await store.dispatch(RemoveRoom(roomId: room.id)); + } else if (leaveData['errcode'] == MatrixErrors.not_found) { + await store.dispatch(RemoveRoom(roomId: room.id)); } if (room.direct) { @@ -882,8 +893,8 @@ ThunkAction removeRoom({Room room}) { ); if (forgetData['errcode'] != null) { - if (leaveData['errcode'] == MatrixErrors.room_not_found) { - await store.dispatch(RemoveRoom(room: Room(id: room.id))); + if (leaveData['errcode'] == MatrixErrors.not_found) { + await store.dispatch(RemoveRoom(roomId: room.id)); } if (room.direct) { await store.dispatch(toggleDirectRoom(room: room, enabled: false)); @@ -898,7 +909,7 @@ ThunkAction removeRoom({Room room}) { if (room.direct) { await store.dispatch(toggleDirectRoom(room: room, enabled: false)); } - await store.dispatch(RemoveRoom(room: Room(id: room.id))); + await store.dispatch(RemoveRoom(roomId: room.id)); store.dispatch(SetLoading(loading: false)); } catch (error) { debugPrint('[removeRoom] $error'); @@ -938,11 +949,11 @@ ThunkAction leaveRoom({Room room}) { if (deleteData['errcode'] != null) { if (deleteData['errcode'] == MatrixErrors.room_unknown) { - store.dispatch(RemoveRoom(room: Room(id: room.id))); + store.dispatch(RemoveRoom(roomId: room.id)); } throw deleteData['error']; } - store.dispatch(RemoveRoom(room: Room(id: room.id))); + store.dispatch(RemoveRoom(roomId: room.id)); } catch (error) { debugPrint('[leaveRoom] $error'); } finally { @@ -1023,7 +1034,7 @@ ThunkAction archiveRoom({Room room}) { // createRoom( // name: room.name, // topic: room.topic, -// invites: room.users, +// invites: room.userIds, // isDirect: room.direct, // ), // ); diff --git a/lib/store/rooms/events/selectors.dart b/lib/store/rooms/events/selectors.dart deleted file mode 100644 index d58533a8d..000000000 --- a/lib/store/rooms/events/selectors.dart +++ /dev/null @@ -1,36 +0,0 @@ -// Project imports: -import 'package:syphon/store/rooms/events/model.dart'; -import 'package:syphon/store/rooms/room/model.dart'; - -List latestMessages(List messages) { - final sortedList = messages ?? []; - - // sort descending - messages.sort((a, b) { - if (a.pending && !b.pending) { - return -1; - } - - if (a.timestamp > b.timestamp) { - return -1; - } - if (a.timestamp < b.timestamp) { - return 1; - } - - return 0; - }); - - return sortedList; -} - -List wrapOutboxMessages( - {List messages, List outbox}) { - return [outbox, messages].expand((x) => x).toList(); -} - -bool isTextMessage({Message message}) { - return message.msgtype == MessageTypes.TEXT || - message.msgtype == MessageTypes.EMOTE || - message.msgtype == MessageTypes.NOTICE; -} diff --git a/lib/store/rooms/reducer.dart b/lib/store/rooms/reducer.dart index 09c50383e..71740d97c 100644 --- a/lib/store/rooms/reducer.dart +++ b/lib/store/rooms/reducer.dart @@ -1,6 +1,6 @@ // Project imports: import './actions.dart'; -import './events/model.dart'; +import '../events/model.dart'; import './room/model.dart'; import './state.dart'; @@ -42,7 +42,7 @@ RoomStore roomReducer([RoomStore state = const RoomStore(), dynamic action]) { case RemoveRoom: final rooms = Map.from(state.rooms); - rooms.remove(action.room.id); + rooms.remove(action.roomId); return state.copyWith(rooms: rooms); case SaveOutboxMessage: diff --git a/lib/store/rooms/room/model.dart b/lib/store/rooms/room/model.dart index 63c151b0d..c278fc7cf 100644 --- a/lib/store/rooms/room/model.dart +++ b/lib/store/rooms/room/model.dart @@ -2,19 +2,15 @@ import 'dart:collection'; // Package imports: -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; -import 'package:hive/hive.dart'; import 'package:json_annotation/json_annotation.dart'; -import 'package:syphon/global/algos.dart'; +import 'package:syphon/global/print.dart'; // Project imports: -import 'package:syphon/global/libs/hive/type-ids.dart'; import 'package:syphon/global/strings.dart'; -import 'package:syphon/store/rooms/events/ephemeral/m.read/model.dart'; -import 'package:syphon/store/rooms/events/model.dart'; +import 'package:syphon/store/events/ephemeral/m.read/model.dart'; +import 'package:syphon/store/events/model.dart'; import 'package:syphon/store/user/model.dart'; -import 'package:syphon/store/user/selectors.dart'; part 'model.g.dart'; @@ -24,84 +20,61 @@ class RoomPresets { static const public = 'public_chat'; } -@HiveType(typeId: RoomHiveId) @JsonSerializable() class Room { - @HiveField(0) final String id; - @HiveField(1) final String name; - @HiveField(2) final String alias; - @HiveField(3) final String homeserver; - @HiveField(4) final String avatarUri; - @HiveField(5) final String topic; - @HiveField(29) final String joinRule; // "public", "knock", "invite", "private" - @HiveField(28) final int namePriority; - @HiveField(6) + final int lastRead; final bool direct; - @HiveField(7) final bool syncing; - @HiveField(8) final bool sending; - @HiveField(9) final bool isDraftRoom; + final bool invite; + final bool guestEnabled; + final bool encryptionEnabled; + final bool worldReadable; - @HiveField(11) final String lastHash; // oldest hash in timeline - @HiveField(26) final String prevHash; // most recent prev_batch (not the lastHash) - @HiveField(10) final String nextHash; // most recent next_batch - @HiveField(12) final int lastUpdate; - @HiveField(13) final int totalJoinedUsers; - @HiveField(14) - final bool guestEnabled; - @HiveField(15) - final bool encryptionEnabled; - @HiveField(16) - final bool worldReadable; - // Event lists and handlers - @HiveField(17) final Message draft; - // TODO: removed until state timeline work can be done - // final List state; - - @HiveField(20) - final List messages; - - @HiveField(21) + // Associated user ids + final List userIds; + final List messageIds; final List outbox; - // Not cached - @JsonKey(ignore: true) - final bool userTyping; + // TODO: removed until state timeline work can be done @JsonKey(ignore: true) - final List usersTyping; + final List state; - @HiveField(23) - final int lastRead; + @JsonKey(ignore: true) + final List messagesNew; - @HiveField(24) + // TODO: offload messageReads, for large rooms these are ridiculously large + @JsonKey(ignore: true) final Map messageReads; - @HiveField(25) - final Map users; + @JsonKey(ignore: true) + final Map usersNew; - @HiveField(27) - final bool invite; + @JsonKey(ignore: true) + final bool userTyping; + + @JsonKey(ignore: true) + final List usersTyping; @JsonKey(ignore: true) final bool limited; @@ -137,9 +110,11 @@ class Room { this.sending = false, this.limited = false, this.draft, - this.users, + this.userIds = const [], this.outbox = const [], - this.messages = const [], + this.usersNew = const {}, + this.messagesNew = const [], + this.messageIds = const [], this.lastRead = 0, this.lastUpdate = 0, this.namePriority = 4, @@ -154,12 +129,13 @@ class Room { this.nextHash, this.prevHash, this.messageReads, + this.state, }); Room copyWith({ - id, - name, - homeserver, + String id, + String name, + String homeserver, avatar, avatarUri, topic, @@ -180,14 +156,16 @@ class Room { isDraftRoom, draft, users, + userIds, events, - outbox, - messages, + List outbox, + List messagesNew, + List messageIds, messageReads, lastHash, prevHash, nextHash, - // state, + state, }) => Room( id: id ?? this.id, @@ -213,13 +191,15 @@ class Room { usersTyping: usersTyping ?? this.usersTyping, isDraftRoom: isDraftRoom ?? this.isDraftRoom, outbox: outbox ?? this.outbox, - messages: messages ?? this.messages, - users: users ?? this.users, + messageIds: messageIds ?? this.messageIds, + messagesNew: messagesNew ?? this.messagesNew, + usersNew: users ?? this.usersNew, + userIds: userIds ?? this.userIds, messageReads: messageReads ?? this.messageReads, lastHash: lastHash ?? this.lastHash, prevHash: prevHash ?? this.prevHash, nextHash: nextHash ?? this.nextHash, - // state: state ?? this.state, + state: state ?? this.state, ); Map toJson() => _$RoomToJson(this); @@ -331,7 +311,7 @@ class Room { currentUser: currentUser, ) .fromMessageEvents( - events: messageEvents, + messages: messageEvents, lastHash: lastHash, prevHash: prevHash, nextHash: lastSince, @@ -388,10 +368,14 @@ class Room { bool direct = this.direct ?? false; int lastUpdate = this.lastUpdate; int namePriority = this.namePriority != 4 ? this.namePriority : 4; - Map users = this.users ?? Map(); - try { - events.forEach((event) { + var usersAdd = Map.from(this.usersNew ?? {}); + var userIdsRemove = List(); + + Set userIds = Set.from(this.userIds ?? []); + + events.forEach((event) { + try { final timestamp = event.timestamp ?? 0; lastUpdate = timestamp > lastUpdate ? event.timestamp : lastUpdate; @@ -432,18 +416,23 @@ class Room { case 'm.room.member': final displayName = event.content['displayname']; final memberAvatarUri = event.content['avatar_url']; + final membership = event.content['membership']; direct = !direct ? event.content['is_direct'] ?? false : direct; // Cache user to rooms user cache if not present - if (!users.containsKey(event.sender)) { - users[event.sender] = User( - userId: event.sender, + if (!usersAdd.containsKey(event.stateKey)) { + usersAdd[event.stateKey] = User( + userId: event.stateKey, displayName: displayName, avatarUri: memberAvatarUri, ); } + if (membership == 'leave') { + userIdsRemove.add(event.stateKey); + } + break; case 'm.room.encryption': encryptionEnabled = true; @@ -453,10 +442,13 @@ class Room { default: break; } - }); - } catch (error) { - debugPrint('[Room.fromStateEvents] ${error}'); - } + } catch (error) { + debugPrint('[Room.fromStateEvents] ${error} ${event.type}'); + } + }); + + userIds = userIds..addAll(usersAdd.keys ?? []); + userIds = userIds..removeWhere((id) => userIdsRemove.contains(id)); try { // checks to make sure someone didn't name the room after the authed user @@ -464,9 +456,9 @@ class Room { name == currentUser.displayName || name == currentUser.userId; // no name room check - if ((namePriority > 3 && users.isNotEmpty && direct) || badRoomName) { + if ((namePriority > 3 && usersAdd.isNotEmpty && direct) || badRoomName) { // Filter out number of non current users to show preview of total - final otherUsers = users.values.where( + final otherUsers = usersAdd.values.where( (user) => user.userId != currentUser.userId && user.displayName != currentUser.displayName, @@ -479,7 +471,7 @@ class Room { // set name and avi to first non user or that + total others name = hasMultipleUsers - ? '${shownUser.displayName} and ${users.values.length - 1}' + ? '${shownUser.displayName} and ${usersAdd.values.length - 1}' : shownUser.displayName; // set avatar if one has not been assigned @@ -495,10 +487,11 @@ class Room { return this.copyWith( name: name ?? this.name ?? Strings.labelRoomNameDefault, topic: topic ?? this.topic, - users: users ?? this.users, + users: usersAdd ?? this.usersNew, direct: direct ?? this.direct, invite: invite ?? this.invite, limited: limited ?? this.limited, + userIds: userIds != null ? userIds.toList() : this.userIds ?? [], avatarUri: avatarUri ?? this.avatarUri, joinRule: joinRule ?? this.joinRule, lastUpdate: lastUpdate > 0 ? lastUpdate : this.lastUpdate, @@ -515,39 +508,43 @@ class Room { * outside displaying messages */ Room fromMessageEvents({ - List events, + List messages = const [], String lastHash, String prevHash, // previously fetched hash String nextHash, }) { try { + printDebug( + '[fromMessageEvents] ${this.name} ${messages.length}', + ); + bool limited; int lastUpdate = this.lastUpdate; - List messagesNew = events ?? []; List outbox = List.from(this.outbox ?? []); - List messagesExisting = List.from(this.messages ?? []); + final messageIds = this.messageIds ?? []; // Converting only message events - final hasEncrypted = messagesNew.firstWhere( + final hasEncrypted = messages.firstWhere( (msg) => msg.type == EventTypes.encrypted, orElse: () => null, ); // See if the newest message has a greater timestamp - if (messagesNew.isNotEmpty && lastUpdate < messagesNew[0].timestamp) { - lastUpdate = messagesNew[0].timestamp; + if (messages.isNotEmpty && lastUpdate < messages[0].timestamp) { + lastUpdate = messages[0].timestamp; } // limited indicates need to fetch additional data for room timelines if (this.limited) { // Check to see if the new messages contain those existing in cache - if (messagesNew.isNotEmpty && messagesExisting.isNotEmpty) { - final messageLatest = messagesExisting.firstWhere( - (msg) => msg.id == messagesNew[0].id, + if (messages.isNotEmpty && messageIds.isNotEmpty) { + final messageKnown = messageIds.firstWhere( + (id) => id == messages[0].id, orElse: () => null, ); + // Set limited to false if they now exist - limited = messageLatest != null; + limited = messageKnown != null; } // Set limited to false false if @@ -562,9 +559,8 @@ class Room { } // Combine current and existing messages on unique ids - messagesExisting.addAll(messagesNew); final messagesMap = HashMap.fromIterable( - messagesExisting, + messages, key: (message) => message.id, value: (message) => message, ); @@ -574,13 +570,17 @@ class Room { (message) => messagesMap.containsKey(message.id), ); - // Filter to find startTime and endTime - final messagesAll = List.from(messagesMap.values); + // save messages and unique message id updates + final messageIdsNew = Set.from(messagesMap.keys); + final messagesNew = List.from(messagesMap.values); + final messageIdsAll = Set.from(this.messageIds ?? []) + ..addAll(messageIdsNew); // Save values to room return this.copyWith( outbox: outbox, - messages: messagesAll, + messagesNew: messagesNew, + messageIds: messageIdsAll.toList(), limited: limited ?? this.limited, encryptionEnabled: this.encryptionEnabled || hasEncrypted != null, lastUpdate: lastUpdate ?? this.lastUpdate, diff --git a/lib/store/rooms/room/selectors.dart b/lib/store/rooms/room/selectors.dart index dbe9cfa28..41c53f147 100644 --- a/lib/store/rooms/room/selectors.dart +++ b/lib/store/rooms/room/selectors.dart @@ -1,7 +1,7 @@ // Project imports: import 'package:syphon/global/strings.dart'; -import 'package:syphon/store/rooms/events/model.dart'; -import 'package:syphon/store/rooms/events/selectors.dart'; +import 'package:syphon/store/events/model.dart'; +import 'package:syphon/store/events/selectors.dart'; import 'package:syphon/store/rooms/room/model.dart'; List availableRooms(List rooms, {List hidden = const []}) { @@ -23,9 +23,7 @@ String formatTotalUsers(int totalUsers) { return totalUsers.toString(); } -String formatPreview({Room room, List prefetched}) { - var messages = prefetched ?? room.messages; - +String formatPreview({Room room, List messages}) { // Prioritize drafts for any room, regardless of state if (room.draft != null && room.draft.body != null) { return 'Draft: ${formatPreviewMessage(room.draft.body)}'; @@ -46,10 +44,6 @@ String formatPreview({Room room, List prefetched}) { } // sort messages found - if (prefetched == null) { - messages = latestMessages(messages); - } - final recentMessage = messages[0]; var body = formatPreviewMessage(recentMessage.body); diff --git a/lib/store/rooms/selectors.dart b/lib/store/rooms/selectors.dart index b2e81b21b..c1a0be282 100644 --- a/lib/store/rooms/selectors.dart +++ b/lib/store/rooms/selectors.dart @@ -2,18 +2,27 @@ import 'package:syphon/store/index.dart'; import './room/model.dart'; +Room room({AppState state, String id}) { + if (state.roomStore.rooms == null) return Room(); + return state.roomStore.rooms[id] ?? Room(); +} + List rooms(AppState state) { return state.roomStore.roomList; } -Room room({AppState state, String id}) { - if (state.roomStore.rooms == null) return Room(); - return state.roomStore.rooms[id] ?? Room(); +List filterBlockedRooms(List rooms, List blocked) { + final List roomList = rooms != null ? rooms : []; + + return roomList + ..removeWhere((room) => + room.userIds.length == 2 && + room.userIds.any((userId) => blocked.contains(userId))) + ..toList(); } -List sortedPrioritizedRooms(Map rooms) { - final List sortedList = - rooms != null ? List.from(rooms.values) : []; +List sortedPrioritizedRooms(List rooms) { + final sortedList = rooms != null ? rooms : []; // sort descending sortedList.sort((a, b) { diff --git a/lib/store/rooms/state.dart b/lib/store/rooms/state.dart index 07fbaa42c..9701074ff 100644 --- a/lib/store/rooms/state.dart +++ b/lib/store/rooms/state.dart @@ -3,85 +3,53 @@ import 'dart:async'; // Package imports: import 'package:equatable/equatable.dart'; -import 'package:hive/hive.dart'; import 'package:json_annotation/json_annotation.dart'; -// Project imports: -import 'package:syphon/global/libs/hive/type-ids.dart'; import './room/model.dart'; part 'state.g.dart'; -@HiveType(typeId: RoomStoreHiveId) @JsonSerializable() class RoomStore extends Equatable { - @JsonKey(ignore: true) - final bool loading; - - @HiveField(0) - final bool synced; - - @HiveField(3) - final int lastUpdate; // Last timestamp for actual new info - - // consider renaming to nextBatch - @HiveField(4) - final String lastSince; // Since we last checked for new info - - @HiveField(5) final Map rooms; @JsonKey(ignore: true) - final Map archive; // TODO: actually archive + final List archive; // TODO: archive by ID + @JsonKey(ignore: true) - final List roomsHidden; + final List roomsHidden; // TODO: hidden by ID @JsonKey(ignore: true) - final Timer roomObserver; + final bool loading; - bool get isSynced => lastUpdate != null && lastUpdate != 0; + @JsonKey(ignore: true) List get roomList => rooms != null ? List.from(rooms.values) : []; const RoomStore({ this.rooms = const {}, - this.archive = const {}, - this.synced = false, + this.archive = const [], this.loading = false, - this.lastUpdate = 0, - this.lastSince, - this.roomObserver, this.roomsHidden = const [], }); @override List get props => [ rooms, - synced, archive, - lastUpdate, - lastSince, - roomObserver, roomsHidden, ]; RoomStore copyWith({ rooms, - synced, archive, loading, - lastUpdate, lastSince, - roomObserver, roomsHidden, }) => RoomStore( rooms: rooms ?? this.rooms, - synced: synced ?? this.synced, archive: archive ?? this.archive, loading: loading ?? this.loading, - lastUpdate: lastUpdate ?? this.lastUpdate, - lastSince: lastSince ?? this.lastSince, - roomObserver: roomObserver ?? this.roomObserver, roomsHidden: roomsHidden ?? this.roomsHidden, ); diff --git a/lib/store/rooms/storage.dart b/lib/store/rooms/storage.dart new file mode 100644 index 000000000..b1bb093c8 --- /dev/null +++ b/lib/store/rooms/storage.dart @@ -0,0 +1,69 @@ +import 'dart:convert'; + +import 'package:sembast/sembast.dart'; +import 'package:syphon/global/print.dart'; +import 'package:syphon/store/rooms/room/model.dart'; + +Future saveRooms( + Map rooms, { + Database cache, + Database storage, +}) async { + final store = StoreRef('rooms'); + + return await storage.transaction((txn) async { + for (Room room in rooms.values) { + final record = store.record(room.id); + await record.put(txn, jsonEncode(room)); + } + }); +} + +Future> loadRooms({ + Database cache, + Database storage, + int offset = 0, + int limit = 10, +}) async { + final Map rooms = {}; + + try { + final store = StoreRef('rooms'); + final count = await store.count(storage); + + final finder = Finder( + limit: limit, + offset: offset, + ); + + final roomsPaginated = await store.find( + storage, + finder: finder, + ); + + if (roomsPaginated.isEmpty) { + return rooms; + } + + for (RecordSnapshot record in roomsPaginated) { + rooms[record.key] = Room.fromJson(json.decode(record.value)); + } + + if (offset < count) { + rooms.addAll(await loadRooms( + offset: offset + limit, + storage: storage, + )); + } + + if (rooms.isEmpty) { + return null; + } + + printInfo('[rooms] loaded ${rooms.length.toString()}'); + return rooms; + } catch (error) { + printError(error.toString(), title: 'loadRooms'); + return null; + } +} diff --git a/lib/store/settings/chat-settings/model.dart b/lib/store/settings/chat-settings/model.dart index 4707cef18..43d1a0a7d 100644 --- a/lib/store/settings/chat-settings/model.dart +++ b/lib/store/settings/chat-settings/model.dart @@ -1,26 +1,18 @@ // Package imports: import 'package:equatable/equatable.dart'; -import 'package:hive/hive.dart'; import 'package:json_annotation/json_annotation.dart'; // Project imports: import 'package:syphon/global/colours.dart'; -import 'package:syphon/global/libs/hive/type-ids.dart'; part 'model.g.dart'; -@HiveType(typeId: ChatSettingsHiveId) @JsonSerializable() class ChatSetting extends Equatable { - @HiveField(0) final String roomId; - @HiveField(1) final int primaryColor; - @HiveField(2) final bool smsEnabled; - @HiveField(3) final bool notificationsEnabled; - @HiveField(4) final String language; const ChatSetting({ diff --git a/lib/store/settings/devices-settings/model.dart b/lib/store/settings/devices-settings/model.dart index fe06c7dbf..4cda415a7 100644 --- a/lib/store/settings/devices-settings/model.dart +++ b/lib/store/settings/devices-settings/model.dart @@ -1,25 +1,15 @@ // Package imports: import 'package:equatable/equatable.dart'; -import 'package:hive/hive.dart'; import 'package:json_annotation/json_annotation.dart'; -// Project imports: -import 'package:syphon/global/libs/hive/type-ids.dart'; - part 'model.g.dart'; -@HiveType(typeId: DevicesHiveId) @JsonSerializable() class Device extends Equatable { - @HiveField(0) final String deviceId; - @HiveField(4) final String deviceIdPrivate; - @HiveField(1) final String displayName; - @HiveField(2) final String lastSeenIp; - @HiveField(3) final int lastSeenTs; const Device({ diff --git a/lib/store/settings/notification-settings/model.dart b/lib/store/settings/notification-settings/model.dart index bd0827b7f..cacab44b8 100644 --- a/lib/store/settings/notification-settings/model.dart +++ b/lib/store/settings/notification-settings/model.dart @@ -1,23 +1,17 @@ // Package imports: import 'package:equatable/equatable.dart'; -import 'package:hive/hive.dart'; import 'package:json_annotation/json_annotation.dart'; // Project imports: -import 'package:syphon/global/libs/hive/type-ids.dart'; import './pushers/model.dart'; import './rules/model.dart'; part 'model.g.dart'; -@HiveType(typeId: NotificationSettingsHiveId) @JsonSerializable() class NotificationSettings extends Equatable { - @HiveField(0) - final List pushers; - - @HiveField(1) final List rules; + final List pushers; const NotificationSettings({this.pushers, this.rules}); diff --git a/lib/store/settings/notification-settings/pushers/model.dart b/lib/store/settings/notification-settings/pushers/model.dart index e9c17d409..69fefd14c 100644 --- a/lib/store/settings/notification-settings/pushers/model.dart +++ b/lib/store/settings/notification-settings/pushers/model.dart @@ -1,23 +1,14 @@ // Package imports: import 'package:equatable/equatable.dart'; -import 'package:hive/hive.dart'; import 'package:json_annotation/json_annotation.dart'; -// Project imports: -import 'package:syphon/global/libs/hive/type-ids.dart'; - part 'model.g.dart'; -@HiveType(typeId: PusherHiveId) @JsonSerializable() class Pusher extends Equatable { - @HiveField(0) final String key; - @HiveField(1) final String kind; - @HiveField(2) final String appId; - @HiveField(3) final String appDisplayName; const Pusher({ diff --git a/lib/store/settings/notification-settings/rules/model.dart b/lib/store/settings/notification-settings/rules/model.dart index 3e3b23a77..afd63b94b 100644 --- a/lib/store/settings/notification-settings/rules/model.dart +++ b/lib/store/settings/notification-settings/rules/model.dart @@ -1,23 +1,13 @@ // Package imports: import 'package:equatable/equatable.dart'; -import 'package:hive/hive.dart'; import 'package:json_annotation/json_annotation.dart'; -// Project imports: -import 'package:syphon/global/libs/hive/type-ids.dart'; - part 'model.g.dart'; -@HiveType(typeId: RuleHiveId) @JsonSerializable() class Rule extends Equatable { - @HiveField(0) final String id; // rule_id - - @HiveField(1) final bool enabled; - - @HiveField(2) final bool isDefault; // determine if these can be saved without being parsed diff --git a/lib/store/settings/state.dart b/lib/store/settings/state.dart index 47be1f261..12bfe9ead 100644 --- a/lib/store/settings/state.dart +++ b/lib/store/settings/state.dart @@ -1,12 +1,10 @@ // Package imports: import 'package:equatable/equatable.dart'; -import 'package:hive/hive.dart'; import 'package:json_annotation/json_annotation.dart'; // Project imports: import "package:syphon/global/themes.dart"; import 'package:syphon/global/colours.dart'; -import 'package:syphon/global/libs/hive/type-ids.dart'; import 'package:syphon/store/settings/devices-settings/model.dart'; import 'package:syphon/store/settings/notification-settings/model.dart'; import './chat-settings/model.dart'; @@ -14,63 +12,41 @@ import './chat-settings/model.dart'; part 'state.g.dart'; // Next Field ID: 21 -@HiveType(typeId: SettingsStoreHiveId) @JsonSerializable() class SettingsStore extends Equatable { - @HiveField(0) final int primaryColor; - @HiveField(1) final int accentColor; - @HiveField(15) final int appBarColor; - @HiveField(2) final int brightness; - @HiveField(10) final ThemeType theme; - @HiveField(4) final bool enterSend; // TODO: rename *enabled - @HiveField(3) final bool smsEnabled; - @HiveField(5) final bool readReceipts; // TODO: rename *enabled - @HiveField(6) final bool typingIndicators; // TODO: rename *enabled - @HiveField(7) final bool notificationsEnabled; - @HiveField(8) final bool membershipEventsEnabled; - @HiveField(18) final bool roomTypeBadgesEnabled; - @HiveField(19) final bool timeFormat24Enabled; - @HiveField(16) final String fontName; - @HiveField(17) final String fontSize; - @HiveField(9) final String language; - @HiveField(20) final String avatarShape; - @HiveField(12) final List devices; // Map - @HiveField(11) final Map customChatSettings; - @HiveField(13) final NotificationSettings notificationSettings; - @HiveField(14) final String alphaAgreement; // a timestamp of agreement for alpha TOS - @JsonKey(ignore: true) // temp + @JsonKey(ignore: true) final String pusherToken; // NOTE: can be device token for APNS - @JsonKey(ignore: true) // temp + @JsonKey(ignore: true) final bool loading; const SettingsStore({ diff --git a/lib/store/sync/actions.dart b/lib/store/sync/actions.dart index 4ddc04dfa..50805b577 100644 --- a/lib/store/sync/actions.dart +++ b/lib/store/sync/actions.dart @@ -16,6 +16,7 @@ import 'package:redux_thunk/redux_thunk.dart'; import 'package:syphon/global/algos.dart'; import 'package:syphon/global/libs/matrix/errors.dart'; import 'package:syphon/global/libs/matrix/index.dart'; +import 'package:syphon/global/print.dart'; import 'package:syphon/store/crypto/actions.dart'; import 'package:syphon/store/crypto/events/actions.dart'; import 'package:syphon/store/index.dart'; @@ -159,11 +160,13 @@ ThunkAction stopSyncObserver() { ThunkAction initialSync() { return (Store store) async { // Start initial sync in background - store.dispatch(fetchSync()); + await store.dispatch(SetSyncing(syncing: true)); + await store.dispatch(fetchSync()); - // Fetch All Room Ids - await store.dispatch(fetchRooms()); + // Fetch All Room Ids - continue showing a sync await store.dispatch(fetchDirectRooms()); + await store.dispatch(fetchRooms()); + await store.dispatch(SetSyncing(syncing: false)); }; } @@ -205,7 +208,7 @@ ThunkAction fetchSync({String since, bool forceFull = false}) { 'homeserver': store.state.authStore.user.homeserver, 'accessToken': store.state.authStore.user.accessToken, 'fullState': forceFull || store.state.roomStore.rooms == null, - 'since': forceFull ? null : since ?? store.state.roomStore.lastSince, + 'since': forceFull ? null : since ?? store.state.syncStore.lastSince, 'filter': filterId, 'timeout': 10000 }); @@ -219,13 +222,15 @@ ThunkAction fetchSync({String since, bool forceFull = false}) { throw data['error']; } + // TODO: Unfiltered + // final Map rawLeft = data['rooms']['leave']; + // final Map presence = data['presence']; + final nextBatch = data['next_batch']; final oneTimeKeyCount = data['device_one_time_keys_count']; final Map rawJoined = data['rooms']['join']; final Map rawInvites = data['rooms']['invite']; - final Map rawLeft = data['rooms']['leave']; final Map rawToDevice = data['to_device']; - // TODO: final Map presence = data['presence']; // Updates for rooms await store.dispatch(syncRooms(rawJoined)); @@ -237,11 +242,6 @@ ThunkAction fetchSync({String since, bool forceFull = false}) { // Update encryption one time key count store.dispatch(updateOneTimeKeyCounts(oneTimeKeyCount)); - // TODO: cold storage cache the full sync in encrypted file - // if (isFullSync) { - // store.dispatch(saveSync(data)); - // } - // Update synced to indicate init sync and next batch id (lastSince) store.dispatch(SetSynced( synced: true, @@ -253,19 +253,8 @@ ThunkAction fetchSync({String since, bool forceFull = false}) { debugPrint('[fetchSync] full sync completed'); } } catch (error) { - String message = ''; - - try { - // try to understand the error message - message = (error.message as String); - } catch (error) { - debugPrint('[fetchSync] $error'); - } - - if (message.contains('SocketException')) { - debugPrint('[fetchSync] $error'); - store.dispatch(SetOffline(offline: true)); - } + store.dispatch(SetOffline(offline: true)); + printError('[fetchSync] ${error.toString()}'); final backoff = store.state.syncStore.backoff; final nextBackoff = backoff != 0 ? backoff + 1 : 5; diff --git a/lib/store/sync/background/service.dart b/lib/store/sync/background/service.dart index 0862e4cd0..ae459283e 100644 --- a/lib/store/sync/background/service.dart +++ b/lib/store/sync/background/service.dart @@ -18,7 +18,6 @@ import 'dart:math'; // Package imports: import 'package:flutter_local_notifications/flutter_local_notifications.dart'; -import 'package:hive/hive.dart'; // Project imports: import 'package:syphon/global/libs/matrix/index.dart'; @@ -61,27 +60,27 @@ class BackgroundSync { await Future.wait([ secureStorage.write( - key: CacheSecure.protocolKey, + key: Cache.protocolKey, value: protocol, ), secureStorage.write( - key: CacheSecure.homeserverKey, + key: Cache.homeserverKey, value: homeserver, ), secureStorage.write( - key: CacheSecure.accessTokenKey, + key: Cache.accessTokenKey, value: accessToken, ), secureStorage.write( - key: CacheSecure.lastSinceKey, + key: Cache.lastSinceKey, value: lastSince, ), secureStorage.write( - key: CacheSecure.userIdKey, + key: Cache.userIdKey, value: currentUser, ), secureStorage.write( - key: CacheSecure.roomNamesKey, + key: Cache.roomNamesKey, value: jsonEncode(roomNames), ) ]); @@ -125,17 +124,15 @@ void notificationSyncIsolate() async { try { final secureStorage = FlutterSecureStorage(); - protocol = await secureStorage.read(key: CacheSecure.protocolKey); - homeserver = await secureStorage.read(key: CacheSecure.homeserverKey); - accessToken = await secureStorage.read(key: CacheSecure.accessTokenKey); - lastSince = await secureStorage.read(key: CacheSecure.lastSinceKey); - userId = await secureStorage.read(key: CacheSecure.userIdKey); + protocol = await secureStorage.read(key: Cache.protocolKey); + homeserver = await secureStorage.read(key: Cache.homeserverKey); + accessToken = await secureStorage.read(key: Cache.accessTokenKey); + lastSince = await secureStorage.read(key: Cache.lastSinceKey); + userId = await secureStorage.read(key: Cache.userIdKey); roomNames = jsonDecode( - await secureStorage.read(key: CacheSecure.roomNamesKey), + await secureStorage.read(key: Cache.roomNamesKey), ); - - // Init hive cache + adapters } catch (error) { print('[notificationSyncIsolate] $error'); } @@ -178,9 +175,8 @@ void notificationSyncIsolate() async { * Save Full Sync */ FutureOr syncLoop({ - Box cache, - FlutterLocalNotificationsPlugin pluginInstance, Map params, + FlutterLocalNotificationsPlugin pluginInstance, }) async { try { final protocol = params['protocol']; @@ -201,17 +197,16 @@ FutureOr syncLoop({ final secureStorage = FlutterSecureStorage(); lastSinceNew = await secureStorage.read( - key: CacheSecure.lastSinceKey, + key: Cache.lastSinceKey, ); - // Init hive cache + adapters } catch (error) { print('[syncLoop] $error'); } /** * Check last since and see if any new messages arrived in the payload - * No need to update the hive store for now, just do not save the lastSince - * to the store and the next foreground fetchSync will update the state + * do not save the lastSince to the store and + * the next foreground fetchSync will update the state */ final data = await MatrixApi.sync( protocol: protocol, @@ -230,10 +225,9 @@ FutureOr syncLoop({ final secureStorage = FlutterSecureStorage(); await secureStorage.write( - key: CacheSecure.lastSinceKey, + key: Cache.lastSinceKey, value: lastSinceNew, ); - // Init hive cache + adapters } catch (error) { print('[syncLoop] $error'); } @@ -241,9 +235,10 @@ FutureOr syncLoop({ // Filter each room through the parser rawRooms.forEach((roomId, json) { final room = Room().fromSync(json: json, lastSince: lastSinceNew); + final messagesNew = room.messagesNew; - if (room.messages.length == 1) { - final String messageSender = room.messages[0].sender; + if (messagesNew.length == 1) { + final String messageSender = messagesNew[0].sender; final String formattedSender = trimAlias(messageSender); if (!formattedSender.contains(userId)) { diff --git a/lib/store/sync/state.dart b/lib/store/sync/state.dart index 1d8f49511..db01e3d7f 100644 --- a/lib/store/sync/state.dart +++ b/lib/store/sync/state.dart @@ -3,50 +3,34 @@ import 'dart:async'; // Package imports: import 'package:equatable/equatable.dart'; -import 'package:hive/hive.dart'; import 'package:json_annotation/json_annotation.dart'; -// Project imports: -import 'package:syphon/global/libs/hive/type-ids.dart'; - part 'state.g.dart'; -@HiveType(typeId: SyncStoreHiveId) -@JsonSerializable(ignoreUnannotated: true) +@JsonSerializable() class SyncStore extends Equatable { - @HiveField(0) - @JsonKey(name: 'synced') final bool synced; + final bool offline; + final bool backgrounded; - @HiveField(3) - @JsonKey(name: 'lastUpdate') final int lastUpdate; // Last timestamp for actual new info - - @HiveField(4) - @JsonKey(name: 'lastSince') + final int lastAttempt; // last attempt to sync final String lastSince; // Since we last checked for new info - static const default_interval = 1; - - @HiveField(5) - @JsonKey(name: 'interval') - final int interval = default_interval; - - @JsonKey(name: 'offline') - final bool offline; + @JsonKey(ignore: true) + final int interval; + @JsonKey(ignore: true) final int backoff; + + @JsonKey(ignore: true) final bool syncing; - final bool unauthed; - final Timer syncObserver; - @HiveField(6) - @JsonKey(name: 'lastAttempt') - final int lastAttempt; // last attempt to sync + @JsonKey(ignore: true) + final bool unauthed; - @HiveField(7) - @JsonKey(name: 'backgrounded') - final bool backgrounded; + @JsonKey(ignore: true) + final Timer syncObserver; const SyncStore({ this.synced = false, @@ -57,6 +41,7 @@ class SyncStore extends Equatable { this.lastUpdate = 0, this.lastAttempt = 0, this.backoff = 0, + this.interval = 2, // default_interval this.lastSince, this.syncObserver, }); @@ -83,27 +68,22 @@ class SyncStore extends Equatable { bool unauthed, bool backgrounded, int lastUpdate, - lastAttempt, - syncObserver, - lastSince, - }) { - return SyncStore( - synced: synced ?? this.synced, - syncing: syncing ?? this.syncing, - offline: offline ?? this.offline, - unauthed: unauthed ?? this.unauthed, - lastUpdate: lastUpdate ?? this.lastUpdate, - lastAttempt: lastAttempt ?? - this.lastAttempt ?? - 0, // TODO: remove after version 0.1.4 - lastSince: lastSince ?? this.lastSince, - syncObserver: syncObserver ?? this.syncObserver, - backgrounded: backgrounded ?? - this.backgrounded ?? - false, // TODO: remove after version 0.1.4 - backoff: backoff ?? this.backoff, - ); - } + int lastAttempt, + Timer syncObserver, + String lastSince, + }) => + SyncStore( + synced: synced ?? this.synced, + syncing: syncing ?? this.syncing, + offline: offline ?? this.offline, + unauthed: unauthed ?? this.unauthed, + lastUpdate: lastUpdate ?? this.lastUpdate, + lastAttempt: lastAttempt ?? this.lastAttempt, + lastSince: lastSince ?? this.lastSince, + syncObserver: syncObserver ?? this.syncObserver, + backgrounded: backgrounded ?? this.backgrounded, + backoff: backoff ?? this.backoff, + ); Map toJson() => _$SyncStoreToJson(this); diff --git a/lib/store/user/actions.dart b/lib/store/user/actions.dart index 31711a197..c1f805c27 100644 --- a/lib/store/user/actions.dart +++ b/lib/store/user/actions.dart @@ -2,10 +2,13 @@ import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:redux/redux.dart'; import 'package:redux_thunk/redux_thunk.dart'; +import 'package:syphon/global/algos.dart'; +import 'package:syphon/global/libs/matrix/errors.dart'; import 'package:syphon/global/libs/matrix/index.dart'; +import 'package:syphon/global/print.dart'; import 'package:syphon/store/alerts/actions.dart'; import 'package:syphon/store/index.dart'; -import 'package:syphon/store/rooms/events/model.dart'; +import 'package:syphon/store/events/model.dart'; import 'package:syphon/store/rooms/room/model.dart'; import 'package:syphon/store/user/model.dart'; @@ -25,6 +28,16 @@ class SaveUser { SaveUser({this.user}); } +class SetUsers { + final Map users; + SetUsers({this.users}); +} + +class SetUsersBlocked { + final List userIds; + SetUsersBlocked({this.userIds}); +} + class SetUserInvites { final List users; SetUserInvites({this.users}); @@ -32,14 +45,26 @@ class SetUserInvites { class ClearUserInvites {} +ThunkAction setUsers(Map users) { + return (Store store) { + store.dispatch(SetUsers(users: users)); + }; +} + +ThunkAction setUsersBlocked(List userIds) { + return (Store store) { + store.dispatch(SetUsersBlocked(userIds: userIds)); + }; +} + ThunkAction setUserInvites({List users}) { - return (Store store) async { + return (Store store) { store.dispatch(SetUserInvites(users: users)); }; } ThunkAction clearUserInvites() { - return (Store store) async { + return (Store store) { store.dispatch(ClearUserInvites()); }; } @@ -75,70 +100,53 @@ ThunkAction fetchUserProfile({User user}) { } /** - * Toggle Direct Room + * Toggle Block User * - * NOTE: https://github.com/matrix-org/matrix-doc/issues/1519 - * - * Fetch the direct rooms list and recalculate it without the - * given alias + * Fetch the blocked user list and recalculate + * events without the given user id */ -ThunkAction toggleBlockUser({User user, Room room, bool block}) { +ThunkAction toggleBlockUser({User user}) { return (Store store) async { try { store.dispatch(SetLoading(loading: true)); // Pull remote direct room data final data = await MatrixApi.fetchAccountData( - protocol: protocol, - homeserver: store.state.authStore.user.homeserver, - accessToken: store.state.authStore.user.accessToken, - userId: store.state.authStore.user.userId, - type: AccountDataTypes.ignoredUserList); + protocol: protocol, + homeserver: store.state.authStore.user.homeserver, + accessToken: store.state.authStore.user.accessToken, + userId: store.state.authStore.user.userId, + type: AccountDataTypes.ignoredUserList, + ); + // skip error if there's no blocked users list yet if (data['errcode'] != null) { - throw data['error']; + if (data['errcode'] != MatrixErrors.not_found) { + throw data['error']; + } } - return false; - // Pull the direct room for that specific user - Map directRoomUsers = data as Map; - final usersDirectRooms = directRoomUsers[user] ?? []; + Map usersBlocked = data['ignored_users'] ?? {}; - if (usersDirectRooms.isEmpty && block) { - directRoomUsers[user.userId] = [room.id]; + // toggle based on if the id is already present + if (!usersBlocked.containsKey(user.userId)) { + usersBlocked[user.userId] = {}; + } else { + usersBlocked.remove(user.userId); } - // Toggle the direct room data based on user actions - directRoomUsers = directRoomUsers.map((userId, rooms) { - List updatedRooms = List.from(rooms ?? []); - - if (userId != user.userId) { - return MapEntry(userId, updatedRooms); - } + // locally track the blocked users list + final usersBlockedList = usersBlocked.keys.toList(); + await store.dispatch(setUsersBlocked(usersBlockedList)); - if (block) { - updatedRooms.add(room.id); - } else { - updatedRooms.removeWhere((roomId) => roomId == room.id); - } - - return MapEntry(userId, updatedRooms); - }); - - // Filter out empty list entries for a user - directRoomUsers.removeWhere((key, value) { - final roomIds = value ?? []; - return roomIds.isEmpty; - }); - - final saveData = await MatrixApi.saveAccountData( + // save blocked users list back to account_data remotely + final saveData = await MatrixApi.updateBlockedUsers( protocol: protocol, accessToken: store.state.authStore.user.accessToken, homeserver: store.state.authStore.user.homeserver, userId: store.state.authStore.user.userId, - type: AccountDataTypes.ignoredUserList, - accountData: directRoomUsers, + blockUserList: usersBlocked, ); if (saveData['errcode'] != null) { @@ -147,8 +155,8 @@ ThunkAction toggleBlockUser({User user, Room room, bool block}) { } catch (error) { store.dispatch(addAlert( error: error, - message: "Failed to load users profile", - origin: 'fetchUserProfile', + message: "Failed to toggle user on blocklist", + origin: 'toggleBlockUser', )); } finally { store.dispatch(SetLoading(loading: false)); diff --git a/lib/store/user/model.dart b/lib/store/user/model.dart index d1f2e9409..2068a9d05 100644 --- a/lib/store/user/model.dart +++ b/lib/store/user/model.dart @@ -1,32 +1,19 @@ // Package imports: import 'package:equatable/equatable.dart'; import 'package:flutter/material.dart'; -import 'package:hive/hive.dart'; import 'package:json_annotation/json_annotation.dart'; -// Project imports: -import 'package:syphon/global/libs/hive/type-ids.dart'; - part 'model.g.dart'; -@HiveType(typeId: UserHiveId) @JsonSerializable() class User extends Equatable { - @HiveField(0) final String userId; - @HiveField(1) final String deviceId; // current device id - @HiveField(7) final String idserver; - @HiveField(2) final String homeserver; - @HiveField(6) final String homeserverName; - @HiveField(3) final String accessToken; - @HiveField(4) final String displayName; - @HiveField(5) final String avatarUri; const User({ diff --git a/lib/store/user/reducer.dart b/lib/store/user/reducer.dart index 4281f14f4..d9673c1d0 100644 --- a/lib/store/user/reducer.dart +++ b/lib/store/user/reducer.dart @@ -9,8 +9,12 @@ UserStore userReducer([UserStore state = const UserStore(), dynamic action]) { return state.copyWith(loading: action.loading); case SetUserInvites: return state.copyWith(invites: action.users); - case ClearUserInvites: - return state.copyWith(invites: const []); + case SetUsersBlocked: + return state.copyWith(blocked: action.userIds); + case SetUsers: + final users = Map.from(state.users); + users.addAll(action.users); + return state.copyWith(users: users); case SaveUser: final user = action.user as User; final users = Map.from(state.users); @@ -26,6 +30,8 @@ UserStore userReducer([UserStore state = const UserStore(), dynamic action]) { } return state.copyWith(users: action.users); + case ClearUserInvites: + return state.copyWith(invites: const []); default: return state; } diff --git a/lib/store/user/selectors.dart b/lib/store/user/selectors.dart index 27b44e135..7e039060c 100644 --- a/lib/store/user/selectors.dart +++ b/lib/store/user/selectors.dart @@ -12,19 +12,16 @@ dynamic homeserver(AppState state) { // Users the authed user has dm'ed List friendlyUsers(AppState state) { - final rooms = state.roomStore.rooms.values as Iterable; + final rooms = state.roomStore.rooms.values; + final users = state.userStore.users; + final userCurrent = state.authStore.user.userId; final roomsDirect = rooms.where((room) => room.direct); - final roomsDirectUsers = roomsDirect.map((room) => room.users); + final roomUserIdsList = roomsDirect.map((room) => room.userIds); + final roomDirectUserIdsAll = roomUserIdsList.expand((pair) => pair).toList(); + final roomDirectUserIds = roomDirectUserIdsAll..remove(userCurrent); + final roomsDirectUsers = roomDirectUserIds.map((userId) => users[userId]); - final allDirectUsers = roomsDirectUsers.fold( - {}, - (usersAll, users) { - (usersAll as Map).addAll(users); - return usersAll; - }, - ); - - return List.from(allDirectUsers.values); + return List.from(roomsDirectUsers); } /* @@ -66,7 +63,17 @@ String formatInitials(String fullword) { return initials.toUpperCase(); } -List searchUsersLocal(List users, {String searchText = ''}) { +List roomUsers(AppState state, String roomId) { + final room = state.roomStore.rooms[roomId] ?? Room(id: roomId); + return room.userIds.map((userId) => state.userStore.users[userId]).toList(); +} + +List searchUsersLocal( + AppState state, { + String roomId, + String searchText = '', +}) { + var users = roomUsers(state, roomId); if (searchText == null || searchText.isEmpty) { return users; } diff --git a/lib/store/user/state.dart b/lib/store/user/state.dart index 01c705b12..61bc0096b 100644 --- a/lib/store/user/state.dart +++ b/lib/store/user/state.dart @@ -1,30 +1,30 @@ // Package imports: import 'package:equatable/equatable.dart'; -import 'package:hive/hive.dart'; import 'package:json_annotation/json_annotation.dart'; // Project imports: -import 'package:syphon/global/libs/hive/type-ids.dart'; import 'package:syphon/store/user/model.dart'; part 'state.g.dart'; -@HiveType(typeId: UserStoreHiveId) -@JsonSerializable(nullable: true, includeIfNull: true) +@JsonSerializable() class UserStore extends Equatable { - @JsonKey(ignore: true) - final bool loading; + // user.id's + final List blocked; - @HiveField(0) + @JsonKey(ignore: true) final Map users; - @HiveField(1) + @JsonKey(ignore: true) + final bool loading; + @JsonKey(ignore: true) final List invites; const UserStore({ this.users = const {}, this.invites = const [], + this.blocked = const [], this.loading = false, }); @@ -35,19 +35,20 @@ class UserStore extends Equatable { loading, ]; - Map toJson() => _$UserStoreToJson(this); - - factory UserStore.fromJson(Map json) => - _$UserStoreFromJson(json); - UserStore copyWith({ bool loading, List invites, Map users, + List blocked, }) => UserStore( users: users ?? this.users, invites: invites ?? this.invites, loading: loading ?? this.loading, + blocked: blocked ?? this.blocked, ); + Map toJson() => _$UserStoreToJson(this); + + factory UserStore.fromJson(Map json) => + _$UserStoreFromJson(json); } diff --git a/lib/store/user/storage.dart b/lib/store/user/storage.dart new file mode 100644 index 000000000..0bca3c151 --- /dev/null +++ b/lib/store/user/storage.dart @@ -0,0 +1,74 @@ +import 'dart:convert'; + +import 'package:sembast/sembast.dart'; +import 'package:syphon/global/print.dart'; +import 'package:syphon/store/user/model.dart'; + +Future saveUsers( + Map users, { + Database cache, + Database storage, +}) async { + final store = StoreRef('users'); + + return await storage.transaction((txn) async { + for (User user in users.values) { + final record = store.record(user.userId); + await record.put(txn, jsonEncode(user)); + } + }); +} + +/** + * Load Users (Cold Storage) + * + * Example of useful recursion + */ +Future> loadUsers({ + Database cache, + Database storage, + int offset = 0, + int page = 5000, +}) async { + final Map users = {}; + + try { + final store = StoreRef('users'); + final count = await store.count(storage); + + final finder = Finder( + limit: page, + offset: offset, + ); + + final usersPaginated = await store.find( + storage, + finder: finder, + ); + + if (usersPaginated.isEmpty) { + return users; + } + + for (RecordSnapshot record in usersPaginated) { + users[record.key] = User.fromJson(json.decode(record.value)); + } + + if (offset < count) { + users.addAll(await loadUsers( + offset: offset + page, + storage: storage, + )); + } + + if (users.isEmpty) { + return null; + } + + printInfo('[users] loaded ${users.length}'); + return users; + } catch (error) { + printError(error.toString(), title: 'loadUsers'); + return null; + } +} diff --git a/lib/views/home/chat/chat-input.dart b/lib/views/home/chat/chat-input.dart index 3cb31abb7..1142af0bb 100644 --- a/lib/views/home/chat/chat-input.dart +++ b/lib/views/home/chat/chat-input.dart @@ -7,7 +7,7 @@ import 'package:syphon/global/assets.dart'; // Project imports: import 'package:syphon/global/colours.dart'; import 'package:syphon/global/strings.dart'; -import 'package:syphon/store/rooms/events/model.dart'; +import 'package:syphon/store/events/model.dart'; class ChatInput extends StatelessWidget { final bool sendable; diff --git a/lib/views/home/chat/details-all-users.dart b/lib/views/home/chat/details-all-users.dart index b19370e88..75509ce5a 100644 --- a/lib/views/home/chat/details-all-users.dart +++ b/lib/views/home/chat/details-all-users.dart @@ -81,7 +81,6 @@ class ChatUsersDetailState extends State { isScrollControlled: true, backgroundColor: Colors.transparent, builder: (context) => ModalUserDetails( - roomId: roomId, userId: userId, ), ); @@ -217,9 +216,8 @@ class _Props extends Equatable { searchText: store.state.searchStore.searchText, room: store.state.roomStore.rooms[roomId] ?? Room(), usersFiltered: searchUsersLocal( - List.from( - (store.state.roomStore.rooms[roomId] ?? Room()).users.values, - ), + store.state, + roomId: roomId, searchText: store.state.searchStore.searchText, ), onSearch: (text) { diff --git a/lib/views/home/chat/details-chat.dart b/lib/views/home/chat/details-chat.dart index 05c92cac1..13afc73e1 100644 --- a/lib/views/home/chat/details-chat.dart +++ b/lib/views/home/chat/details-chat.dart @@ -7,6 +7,7 @@ import 'package:equatable/equatable.dart'; import 'package:flutter_redux/flutter_redux.dart'; import 'package:redux/redux.dart'; import 'package:syphon/global/strings.dart'; +import 'package:syphon/store/user/selectors.dart'; import 'package:syphon/views/home/chat/details-all-users.dart'; import 'package:syphon/views/widgets/containers/card-section.dart'; import 'package:syphon/views/widgets/lists/list-user-bubbles.dart'; @@ -17,8 +18,8 @@ import 'package:syphon/global/colours.dart'; import 'package:syphon/global/dimensions.dart'; import 'package:syphon/store/index.dart'; import 'package:syphon/store/rooms/actions.dart'; -import 'package:syphon/store/rooms/events/model.dart'; -import 'package:syphon/store/rooms/events/selectors.dart'; +import 'package:syphon/store/events/model.dart'; +import 'package:syphon/store/events/selectors.dart'; import 'package:syphon/store/rooms/room/model.dart'; import 'package:syphon/store/rooms/selectors.dart' as roomSelectors; import 'package:syphon/store/settings/chat-settings/actions.dart'; @@ -247,7 +248,7 @@ class ChatDetailsState extends State { ), Container( child: Text( - ' (${props.room.users.length})', + ' (${props.room.userIds.length})', textAlign: TextAlign.start, ), ), @@ -263,7 +264,7 @@ class ChatDetailsState extends State { maxHeight: Dimensions.avatarSizeLarge, ), child: ListUserBubbles( - users: props.userList, + users: props.users, roomId: props.room.id, ), ) @@ -500,7 +501,7 @@ class _Props extends Equatable { final bool loading; final Color roomPrimaryColor; final List messages; - final List userList; + final List users; final Function onLeaveChat; final Function onSelectPrimaryColor; @@ -509,7 +510,7 @@ class _Props extends Equatable { _Props({ @required this.room, - @required this.userList, + @required this.users, @required this.loading, @required this.messages, @required this.onLeaveChat, @@ -530,12 +531,8 @@ class _Props extends Equatable { static _Props mapStateToProps(Store store, String roomId) => _Props( room: roomSelectors.room(id: roomId, state: store.state), loading: store.state.roomStore.loading, - userList: List.from( - roomSelectors.room(id: roomId, state: store.state).users.values, - ), - messages: latestMessages( - roomSelectors.room(id: roomId, state: store.state).messages, - ), + users: roomUsers(store.state, roomId), + messages: roomMessages(store.state, roomId), onLeaveChat: () async { await store.dispatch(removeRoom(room: Room(id: roomId))); }, diff --git a/lib/views/home/chat/details-message.dart b/lib/views/home/chat/details-message.dart index 6a94e4d7a..afe10ff8f 100644 --- a/lib/views/home/chat/details-message.dart +++ b/lib/views/home/chat/details-message.dart @@ -13,8 +13,8 @@ import 'package:redux/redux.dart'; import 'package:syphon/global/dimensions.dart'; import 'package:syphon/global/themes.dart'; import 'package:syphon/store/index.dart'; -import 'package:syphon/store/rooms/events/ephemeral/m.read/model.dart'; -import 'package:syphon/store/rooms/events/model.dart'; +import 'package:syphon/store/events/ephemeral/m.read/model.dart'; +import 'package:syphon/store/events/model.dart'; import 'package:syphon/views/widgets/messages/message.dart'; final String debug = DotEnv().env['DEBUG']; diff --git a/lib/views/home/chat/index.dart b/lib/views/home/chat/index.dart index 8a3192c0a..36894b042 100644 --- a/lib/views/home/chat/index.dart +++ b/lib/views/home/chat/index.dart @@ -19,16 +19,18 @@ import 'package:syphon/global/assets.dart'; // Project imports: import 'package:syphon/global/colours.dart'; import 'package:syphon/global/dimensions.dart'; +import 'package:syphon/global/print.dart'; import 'package:syphon/global/strings.dart'; import 'package:syphon/global/themes.dart'; import 'package:syphon/store/crypto/actions.dart'; import 'package:syphon/store/index.dart'; import 'package:syphon/store/rooms/actions.dart'; -import 'package:syphon/store/rooms/events/actions.dart'; -import 'package:syphon/store/rooms/events/model.dart'; -import 'package:syphon/store/rooms/events/selectors.dart'; +import 'package:syphon/store/events/actions.dart'; +import 'package:syphon/store/events/model.dart'; +import 'package:syphon/store/events/selectors.dart'; import 'package:syphon/store/rooms/room/model.dart'; import 'package:syphon/store/rooms/selectors.dart' as roomSelectors; +import 'package:syphon/store/user/model.dart'; import 'package:syphon/views/home/chat/chat-input.dart'; import 'package:syphon/views/home/chat/dialog-encryption.dart'; import 'package:syphon/views/home/chat/dialog-invite.dart'; @@ -130,7 +132,7 @@ class ChatViewState extends State { }); } - if (props.room.messages.length < 10) { + if (props.messages.length < 10) { props.onLoadFirstBatch(); } @@ -291,15 +293,11 @@ class ChatViewState extends State { @protected onViewUserDetails({Message message, String userId}) { - final arguements = - ModalRoute.of(context).settings.arguments as ChatViewArguements; - showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, builder: (context) => ModalUserDetails( - roomId: arguements.roomId, userId: userId ?? message.sender, ), ); @@ -419,9 +417,9 @@ class ChatViewState extends State { controller: messagesController, children: [ MessageTypingWidget( + roomUsers: props.users, typing: props.room.userTyping, usersTyping: props.room.usersTyping, - roomUsers: props.room.users, selectedMessageId: this.selectedMessage != null ? this.selectedMessage.id : null, @@ -453,7 +451,7 @@ class ChatViewState extends State { ? this.selectedMessage.id : null; - final avatarUri = props.room.users[message.sender]?.avatarUri; + final avatarUri = props.users[message.sender]?.avatarUri; return MessageWidget( message: message, @@ -654,6 +652,7 @@ class _Props extends Equatable { final String userId; final bool loading; final ThemeType theme; + final Map users; final List messages; final Color roomPrimaryColor; final bool timeFormat24Enabled; @@ -676,6 +675,7 @@ class _Props extends Equatable { @required this.room, @required this.theme, @required this.userId, + @required this.users, @required this.messages, @required this.loading, @required this.roomPrimaryColor, @@ -698,6 +698,7 @@ class _Props extends Equatable { @override List get props => [ userId, + users, messages, room, roomPrimaryColor, @@ -713,10 +714,14 @@ class _Props extends Equatable { store.state.settingsStore.timeFormat24Enabled ?? false, loading: (store.state.roomStore.rooms[roomId] ?? Room()).syncing, room: roomSelectors.room(id: roomId, state: store.state), + users: store.state.userStore.users, messages: latestMessages( - wrapOutboxMessages( - messages: roomSelectors.room(id: roomId, state: store.state).messages, - outbox: roomSelectors.room(id: roomId, state: store.state).outbox, + filterMessages( + wrapOutboxMessages( + messages: roomMessages(store.state, roomId).toList(), + outbox: roomSelectors.room(id: roomId, state: store.state).outbox, + ), + store.state.userStore.blocked, ), ), roomPrimaryColor: () { @@ -733,7 +738,7 @@ class _Props extends Equatable { final room = store.state.roomStore.rooms[roomId]; final usersDeviceKeys = await store.dispatch( - fetchDeviceKeys(users: room.users), + fetchDeviceKeys(userIds: room.userIds), ); store.dispatch(setDeviceKeys(usersDeviceKeys)); @@ -793,10 +798,12 @@ class _Props extends Equatable { }, onLoadFirstBatch: () { final room = store.state.roomStore.rooms[roomId] ?? Room(); + printDebug('[onLoadFirstBatch]'); store.dispatch( fetchMessageEvents( room: room, from: room.nextHash, + limit: 25, ), ); }, @@ -809,8 +816,22 @@ class _Props extends Equatable { onLoadMoreMessages: () { final room = store.state.roomStore.rooms[roomId] ?? Room(); + // load message from cold storage + // TODO: everything is loaded when opening the app for now + // final messages = roomMessages(store.state, roomId); + // if (messages.length < room.messageIds.length) { + // printDebug( + // '[onLoadMoreMessages] loading from cold storage ${messages.length} ${room.messageIds.length}'); + // return store.dispatch( + // loadMessageEvents( + // room: room, + // offset: messages.length, + // ), + // ); + // } + // fetch messages beyond the oldest known message - lastHash - store.dispatch(fetchMessageEvents( + return store.dispatch(fetchMessageEvents( room: room, from: room.lastHash, oldest: true, @@ -826,8 +847,9 @@ class _Props extends Equatable { store.dispatch(updateKeySessions(room: room)); final usersDeviceKeys = await store.dispatch( - fetchDeviceKeys(users: room.users), + fetchDeviceKeys(userIds: room.userIds), ); + printJson(usersDeviceKeys); }); } diff --git a/lib/views/home/index.dart b/lib/views/home/index.dart index a02ec7f29..2eb79ae67 100644 --- a/lib/views/home/index.dart +++ b/lib/views/home/index.dart @@ -11,7 +11,7 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'package:redux/redux.dart'; import 'package:syphon/global/colours.dart'; import 'package:syphon/global/themes.dart'; -import 'package:syphon/store/rooms/events/selectors.dart'; +import 'package:syphon/store/events/selectors.dart'; import 'package:url_launcher/url_launcher.dart'; // Project imports: @@ -22,7 +22,7 @@ import 'package:syphon/global/strings.dart'; import 'package:syphon/global/values.dart'; import 'package:syphon/store/index.dart'; import 'package:syphon/store/rooms/actions.dart'; -import 'package:syphon/store/rooms/events/model.dart'; +import 'package:syphon/store/events/model.dart'; import 'package:syphon/store/rooms/room/model.dart'; import 'package:syphon/store/rooms/room/selectors.dart'; import 'package:syphon/store/rooms/selectors.dart'; @@ -282,11 +282,11 @@ class HomeViewState extends State { itemCount: rooms.length, itemBuilder: (BuildContext context, int index) { final room = rooms[index]; - final messages = room.messages ?? const []; - final messagesLatest = latestMessages(room.messages); + final messages = props.messages[room.id] ?? const []; + final messagesLatest = latestMessages(messages); final messagePreview = formatPreview( room: room, - prefetched: messagesLatest, + messages: messagesLatest, ); final roomSettings = props.chatSettings[room.id] ?? null; @@ -331,6 +331,7 @@ class HomeViewState extends State { textStyle = TextStyle(fontStyle: FontStyle.italic); } + // display message as being 'unread' if (messages != null && messages.isNotEmpty) { final messageRecent = messagesLatest[0]; @@ -594,6 +595,7 @@ class _Props extends Equatable { final User currentUser; final ThemeType theme; final Map chatSettings; + final Map> messages; final Function onDebug; final Function onLeaveChat; @@ -609,6 +611,7 @@ class _Props extends Equatable { @required this.offline, @required this.syncing, @required this.unauthed, + @required this.messages, @required this.currentUser, @required this.chatSettings, @required this.roomTypeBadgesEnabled, @@ -635,12 +638,19 @@ class _Props extends Equatable { static _Props mapStateToProps(Store store) => _Props( theme: store.state.settingsStore.theme, rooms: availableRooms( - sortedPrioritizedRooms(store.state.roomStore.rooms), + sortedPrioritizedRooms( + filterBlockedRooms( + store.state.roomStore.rooms.values.toList(), + store.state.userStore.blocked, + ), + ), hidden: store.state.roomStore.roomsHidden, ), + messages: store.state.eventStore.messages, unauthed: store.state.syncStore.unauthed, offline: store.state.syncStore.offline, syncing: () { + final synced = store.state.syncStore.synced; final syncing = store.state.syncStore.syncing; final offline = store.state.syncStore.offline; final backgrounded = store.state.syncStore.backgrounded; @@ -649,11 +659,16 @@ class _Props extends Equatable { final lastAttempt = DateTime.fromMillisecondsSinceEpoch( store.state.syncStore.lastAttempt ?? 0); - // See if the last attempted sync is older than 60 seconds + // See if the last attempted sy nc is older than 60 seconds final isLastAttemptOld = DateTime.now() .difference(lastAttempt) .compareTo(Duration(seconds: 90)); + // syncing for the first time + if (syncing && !synced) { + return true; + } + // syncing for the first time since going offline if (syncing && offline) { return true; diff --git a/lib/views/home/settings/blocked.dart b/lib/views/home/settings/blocked.dart new file mode 100644 index 000000000..72523c951 --- /dev/null +++ b/lib/views/home/settings/blocked.dart @@ -0,0 +1,168 @@ +// Dart imports: +import 'dart:async'; + +// Flutter imports: +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +// Package imports: +import 'package:equatable/equatable.dart'; +import 'package:flutter_redux/flutter_redux.dart'; +import 'package:redux/redux.dart'; +import 'package:syphon/views/widgets/containers/card-section.dart'; +import 'package:syphon/views/widgets/modals/modal-user-details.dart'; + +// Project imports: +import 'package:syphon/global/colours.dart'; +import 'package:syphon/global/dimensions.dart'; +import 'package:syphon/store/index.dart'; +import 'package:syphon/store/user/model.dart'; +import 'package:syphon/store/user/selectors.dart'; +import 'package:syphon/views/widgets/avatars/avatar.dart'; + +class BlockedUsersView extends StatefulWidget { + const BlockedUsersView({Key key}) : super(key: key); + + @override + BlockedUsersState createState() => BlockedUsersState(); +} + +class BlockedUsersState extends State { + bool loading = false; + + BlockedUsersState({Key key}); + + // componentDidMount(){} + @override + void didChangeDependencies() { + super.didChangeDependencies(); + } + + @protected + onShowUserDetails({ + BuildContext context, + String userId, + }) async { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => ModalUserDetails( + userId: userId, + ), + ); + } + + @protected + Widget buildUserList(BuildContext context, _Props props) => ListView.builder( + shrinkWrap: true, + scrollDirection: Axis.vertical, + itemCount: props.usersBlocked.length, + itemBuilder: (BuildContext context, int index) { + final user = props.usersBlocked[index]; + + return GestureDetector( + onTap: () => this.onShowUserDetails( + context: context, + userId: user.userId, + ), + child: CardSection( + padding: EdgeInsets.zero, + elevation: 0, + child: Container( + child: ListTile( + leading: Avatar( + uri: user.avatarUri, + alt: user.displayName ?? user.userId, + size: Dimensions.avatarSizeMin, + background: Colours.hashedColor( + user.displayName ?? user.userId, + ), + ), + title: Text( + formatUsername(user), + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyText1, + ), + subtitle: Text( + user.userId, + style: Theme.of(context).textTheme.caption.merge( + TextStyle( + color: props.loading + ? Color(Colours.greyDisabled) + : null, + ), + ), + ), + ), + ), + ), + ); + }, + ); + + @override + Widget build(BuildContext context) { + return StoreConnector( + distinct: true, + converter: (Store store) => _Props.mapStateToProps(store), + builder: (context, props) => Scaffold( + appBar: AppBar( + title: Text('Blocked users'), + leading: IconButton( + icon: Icon(Icons.arrow_back, color: Colors.white), + onPressed: () => Navigator.pop(context, false), + ), + ), + body: Stack( + children: [ + buildUserList(context, props), + Positioned( + child: Visibility( + visible: this.loading, + child: Container( + margin: EdgeInsets.only(top: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + RefreshProgressIndicator( + strokeWidth: Dimensions.defaultStrokeWidth, + valueColor: new AlwaysStoppedAnimation( + Theme.of(context).primaryColor, + ), + value: null, + ), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +class _Props extends Equatable { + final bool loading; + final List usersBlocked; + + _Props({ + @required this.loading, + @required this.usersBlocked, + }); + + @override + List get props => [ + loading, + usersBlocked, + ]; + + static _Props mapStateToProps(Store store) => _Props( + loading: store.state.roomStore.loading, + usersBlocked: store.state.userStore.blocked + .map((id) => store.state.userStore.users[id]) + .toList(), + ); +} diff --git a/lib/views/home/settings/devices.dart b/lib/views/home/settings/devices.dart index 2bb4db0f6..d6a750970 100644 --- a/lib/views/home/settings/devices.dart +++ b/lib/views/home/settings/devices.dart @@ -118,15 +118,6 @@ class DeviceViewState extends State { ], ), actions: [ - IconButton( - icon: Icon(Icons.text_fields), - iconSize: Dimensions.buttonAppBarSize, - tooltip: 'TEST KEY FUNCTION GENERIC', - color: Colors.white, - onPressed: () { - props.onTest(); - }, - ), IconButton( icon: Icon(Icons.edit), iconSize: Dimensions.buttonAppBarSize, @@ -330,7 +321,6 @@ class Props extends Equatable { final Function onFetchDevices; final Function onDeleteDevices; - final Function onTest; Props({ @required this.loading, @@ -339,7 +329,6 @@ class Props extends Equatable { @required this.currentDeviceId, @required this.onFetchDevices, @required this.onDeleteDevices, - @required this.onTest, }); @override @@ -348,17 +337,11 @@ class Props extends Equatable { devices, ]; - static Props mapStateToProps( - Store store, - ) => - Props( + static Props mapStateToProps(Store store) => Props( loading: store.state.settingsStore.loading, devices: store.state.settingsStore.devices ?? const [], session: store.state.authStore.session, currentDeviceId: store.state.authStore.user.deviceId, - onTest: () { - store.dispatch(fetchDeviceKeys()); - }, onDeleteDevices: ( BuildContext context, List devices, { diff --git a/lib/views/home/settings/privacy.dart b/lib/views/home/settings/privacy.dart index f2d96c5fe..266f32f74 100644 --- a/lib/views/home/settings/privacy.dart +++ b/lib/views/home/settings/privacy.dart @@ -112,6 +112,19 @@ class PrivacyPreferences extends StatelessWidget { style: Theme.of(context).textTheme.caption, ), ), + ListTile( + onTap: () { + Navigator.pushNamed(context, '/blocked'); + }, + contentPadding: Dimensions.listPadding, + title: Text( + 'Blocked Users', + ), + subtitle: Text( + 'View and manage blocked users', + style: Theme.of(context).textTheme.caption, + ), + ), ], ), ), diff --git a/lib/views/navigation.dart b/lib/views/navigation.dart index 93c3a4dc2..a5c9dfde9 100644 --- a/lib/views/navigation.dart +++ b/lib/views/navigation.dart @@ -17,6 +17,7 @@ import 'package:syphon/views/home/search/search-groups.dart'; import 'package:syphon/views/home/search/search-rooms.dart'; import 'package:syphon/views/home/search/search-users.dart'; import 'package:syphon/views/home/settings/advanced.dart'; +import 'package:syphon/views/home/settings/blocked.dart'; import 'package:syphon/views/home/settings/chats.dart'; import 'package:syphon/views/home/settings/devices.dart'; import 'package:syphon/views/home/settings/index.dart'; @@ -86,6 +87,7 @@ class NavigationProvider { '/theming': (BuildContext context) => Theming(), '/devices': (BuildContext context) => DevicesView(), '/settings': (BuildContext context) => SettingsScreen(), + '/blocked': (BuildContext context) => BlockedUsersView(), '/loading': (BuildContext context) => Loading(), }; } diff --git a/lib/views/signup/index.dart b/lib/views/signup/index.dart index f8f94ac9c..0ef988b73 100644 --- a/lib/views/signup/index.dart +++ b/lib/views/signup/index.dart @@ -29,7 +29,6 @@ import './step-password.dart'; import './step-username.dart'; // Styling Widgets - final Duration nextAnimationDuration = Duration( milliseconds: Values.animationDurationDefault, ); diff --git a/lib/views/widgets/appbars/appbar-options-message.dart b/lib/views/widgets/appbars/appbar-options-message.dart index 044f39a32..a11607841 100644 --- a/lib/views/widgets/appbars/appbar-options-message.dart +++ b/lib/views/widgets/appbars/appbar-options-message.dart @@ -4,8 +4,8 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:syphon/store/rooms/events/model.dart'; -import 'package:syphon/store/rooms/events/selectors.dart'; +import 'package:syphon/store/events/model.dart'; +import 'package:syphon/store/events/selectors.dart'; import 'package:syphon/store/rooms/room/model.dart'; import 'package:syphon/views/home/chat/details-message.dart'; diff --git a/lib/views/widgets/messages/message-typing.dart b/lib/views/widgets/messages/message-typing.dart index 2550f6892..01084efe8 100644 --- a/lib/views/widgets/messages/message-typing.dart +++ b/lib/views/widgets/messages/message-typing.dart @@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:syphon/global/colours.dart'; +import 'package:syphon/global/print.dart'; // Project imports: import 'package:syphon/store/user/model.dart'; @@ -85,6 +86,7 @@ class MessageTypingState extends State } if (widget.usersTyping.length > 0) { + printDebug('[MessageTypingWidget] ${widget.usersTyping.length}'); final usernamesTyping = widget.usersTyping; userTyping = widget.roomUsers[usernamesTyping[0]]; } diff --git a/lib/views/widgets/messages/message.dart b/lib/views/widgets/messages/message.dart index a6e7ba0a1..d5f0220e3 100644 --- a/lib/views/widgets/messages/message.dart +++ b/lib/views/widgets/messages/message.dart @@ -9,7 +9,7 @@ import 'package:syphon/global/dimensions.dart'; import 'package:syphon/global/formatters.dart'; import 'package:syphon/global/strings.dart'; import 'package:syphon/global/themes.dart'; -import 'package:syphon/store/rooms/events/model.dart'; +import 'package:syphon/store/events/model.dart'; import 'package:syphon/views/widgets/avatars/avatar.dart'; class MessageWidget extends StatelessWidget { diff --git a/lib/views/widgets/modals/modal-user-details.dart b/lib/views/widgets/modals/modal-user-details.dart index 946ccfb90..a19f96262 100644 --- a/lib/views/widgets/modals/modal-user-details.dart +++ b/lib/views/widgets/modals/modal-user-details.dart @@ -1,5 +1,4 @@ // Flutter imports: -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -13,10 +12,11 @@ import 'package:syphon/global/colours.dart'; // Project imports: import 'package:syphon/global/dimensions.dart'; +import 'package:syphon/global/print.dart'; import 'package:syphon/global/strings.dart'; -import 'package:syphon/store/alerts/actions.dart'; import 'package:syphon/store/index.dart'; import 'package:syphon/store/rooms/actions.dart'; +import 'package:syphon/store/user/actions.dart'; import 'package:syphon/store/user/model.dart'; import 'package:syphon/store/user/selectors.dart'; import 'package:syphon/views/home/chat/index.dart'; @@ -29,13 +29,11 @@ class ModalUserDetails extends StatelessWidget { ModalUserDetails({ Key key, this.user, - this.roomId, this.userId, }) : super(key: key); final User user; final String userId; - final String roomId; @protected void onNavigateToProfile({BuildContext context, _Props props}) async { @@ -93,7 +91,6 @@ class ModalUserDetails extends StatelessWidget { converter: (Store store) => _Props.mapStateToProps( store, user: user, - roomId: roomId, userId: userId, ), builder: (context, props) => Container( @@ -173,6 +170,10 @@ class ModalUserDetails extends StatelessWidget { child: Column( children: [ ListTile( + onTap: () => this.onMessageUser( + context: context, + props: props, + ), title: Text( 'Send A Message', style: Theme.of(context).textTheme.subtitle1, @@ -189,12 +190,12 @@ class ModalUserDetails extends StatelessWidget { color: Theme.of(context).iconTheme.color, ), ), - onTap: () => this.onMessageUser( + ), + ListTile( + onTap: () => this.onNavigateToInvite( context: context, props: props, ), - ), - ListTile( title: Text( 'Invite To Room', style: Theme.of(context).textTheme.subtitle1, @@ -206,12 +207,12 @@ class ModalUserDetails extends StatelessWidget { size: Dimensions.iconSize, ), ), - onTap: () => this.onNavigateToInvite( + ), + ListTile( + onTap: () => this.onNavigateToProfile( context: context, props: props, ), - ), - ListTile( title: Text( 'View Profile', style: Theme.of(context).textTheme.subtitle1, @@ -223,28 +224,21 @@ class ModalUserDetails extends StatelessWidget { size: Dimensions.iconSize, ), ), - onTap: () => this.onNavigateToProfile( - context: context, - props: props, - ), ), - GestureDetector( - onTap: () => props.onDisabled(), - child: ListTile( - enabled: false, - title: Text( - 'Block', - ), - leading: Container( - padding: EdgeInsets.all(4), - child: Icon( - Icons.block, - size: Dimensions.iconSize, - ), + ListTile( + onTap: () async { + await props.onBlockUser(props.user); + Navigator.pop(context); + }, + title: Text( + props.blocked ? 'Unblock' : 'Block', + ), + leading: Container( + padding: EdgeInsets.all(4), + child: Icon( + Icons.block, + size: Dimensions.iconSize, ), - onTap: () async { - Navigator.pop(context); - }, ), ), ], @@ -258,14 +252,17 @@ class ModalUserDetails extends StatelessWidget { class _Props extends Equatable { final User user; - - final Function onDisabled; + final bool blocked; + final bool loading; + final Function onBlockUser; final Function onCreateChatDirect; _Props({ @required this.user, + @required this.loading, + @required this.blocked, @required this.onCreateChatDirect, - @required this.onDisabled, + @required this.onBlockUser, }); @override @@ -277,7 +274,6 @@ class _Props extends Equatable { Store store, { User user, String userId, - String roomId, }) => _Props( user: () { @@ -285,13 +281,17 @@ class _Props extends Equatable { return user; } - final room = store.state.roomStore.rooms[roomId]; - if (room != null) { - return room.users[userId]; + if (userId == null) { + return null; } - return null; + + return store.state.userStore.users[userId]; }(), - onDisabled: () => store.dispatch(addInProgress()), + loading: store.state.userStore.loading, + blocked: store.state.userStore.blocked.contains(userId ?? user.userId), + onBlockUser: (User user) async { + await store.dispatch(toggleBlockUser(user: user)); + }, onCreateChatDirect: ({User user}) async => store.dispatch( createRoom( isDirect: true, diff --git a/pubspec.lock b/pubspec.lock index 4d297d952..afefa26b7 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -22,6 +22,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.4.5+15" + ansicolor: + dependency: "direct main" + description: + name: ansicolor + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.1" archive: dependency: transitive description: @@ -42,7 +49,7 @@ packages: name: asn1lib url: "https://pub.dartlang.org" source: hosted - version: "0.6.5" + version: "0.8.1" async: dependency: transitive description: @@ -79,7 +86,7 @@ packages: source: hosted version: "2.1.4" build_resolvers: - dependency: "direct dev" + dependency: transitive description: name: build_resolvers url: "https://pub.dartlang.org" @@ -148,6 +155,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.1.4" + clock: + dependency: transitive + description: + name: clock + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" code_builder: dependency: transitive description: @@ -205,19 +219,12 @@ packages: source: hosted version: "0.1.3" dart_style: - dependency: "direct overridden" + dependency: transitive description: name: dart_style url: "https://pub.dartlang.org" source: hosted version: "1.3.4" - dartx: - dependency: transitive - description: - name: dartx - url: "https://pub.dartlang.org" - source: hosted - version: "0.5.0" device_info: dependency: "direct main" description: @@ -239,6 +246,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.3.3" + encrypt: + dependency: "direct main" + description: + name: encrypt + url: "https://pub.dartlang.org" + source: hosted + version: "4.1.0" equatable: dependency: "direct main" description: @@ -408,27 +422,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.2.0" - hive: - dependency: "direct main" - description: - name: hive - url: "https://pub.dartlang.org" - source: hosted - version: "1.4.4" - hive_flutter: - dependency: "direct main" - description: - name: hive_flutter - url: "https://pub.dartlang.org" - source: hosted - version: "0.3.1" - hive_generator: - dependency: "direct dev" - description: - name: hive_generator - url: "https://pub.dartlang.org" - source: hosted - version: "0.8.1" html: dependency: "direct main" description: @@ -591,12 +584,12 @@ packages: source: hosted version: "0.2.3" path: - dependency: "direct overridden" + dependency: transitive description: name: path url: "https://pub.dartlang.org" source: hosted - version: "1.7.0" + version: "1.8.0-nullsafety.1" path_drawing: dependency: transitive description: @@ -639,13 +632,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.3" - pc_steelcrypt: - dependency: transitive - description: - name: pc_steelcrypt - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.1" pedantic: dependency: transitive description: @@ -681,6 +667,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.2" + pointycastle: + dependency: transitive + description: + name: pointycastle + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" pool: dependency: transitive description: @@ -744,6 +737,20 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.3.0" + sembast: + dependency: "direct main" + description: + name: sembast + url: "https://pub.dartlang.org" + source: hosted + version: "2.4.8" + sembast_sqflite: + dependency: "direct main" + description: + name: sembast_sqflite + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" shared_preferences: dependency: transitive description: @@ -847,20 +854,48 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.7.0" - stack_trace: + sqflite: + dependency: "direct main" + description: + name: sqflite + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.2+1" + sqflite_common: dependency: transitive description: - name: stack_trace + name: sqflite_common url: "https://pub.dartlang.org" source: hosted - version: "1.9.5" - steel_crypt: + version: "1.0.2+1" + sqflite_common_ffi: dependency: "direct main" description: - name: steel_crypt + name: sqflite_common_ffi url: "https://pub.dartlang.org" source: hosted - version: "2.2.2+1" + version: "1.1.1" + sqflite_sqlcipher: + dependency: "direct main" + description: + name: sqflite_sqlcipher + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.2" + sqlite3: + dependency: transitive + description: + name: sqlite3 + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.8" + stack_trace: + dependency: transitive + description: + name: stack_trace + url: "https://pub.dartlang.org" + source: hosted + version: "1.9.5" stream_channel: dependency: transitive description: @@ -917,13 +952,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.3.11" - time: - dependency: transitive - description: - name: time - url: "https://pub.dartlang.org" - source: hosted - version: "1.3.0" timing: dependency: transitive description: @@ -1065,5 +1093,5 @@ packages: source: hosted version: "2.2.1" sdks: - dart: ">=2.10.0-110 <2.11.0" - flutter: ">=1.18.0-6.0.pre <2.0.0" + dart: ">=2.10.2 <2.11.0" + flutter: ">=1.22.2 <2.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index 365859166..d07cc6cac 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -44,7 +44,7 @@ description: a privacy focused matrix client # The following defines the version and build number for your application. # A version number is three numbers separated by dots, like 1.2.43 # followed by an optional build number separated by a +. -version: 0.1.4+141 +version: 0.1.5+150 environment: sdk: ">=2.9.0-13.0 <3.0.0" # <- modified to solve build_runner @@ -53,7 +53,7 @@ dependencies: flutter: sdk: flutter - # State Management + # state expandable: 3.0.1 equatable: 1.2.4 canonical_json: 1.0.0 @@ -68,29 +68,23 @@ dependencies: path_provider: 1.6.14 package_info: 0.4.3 - # Encryption + # encryption + olm: 1.2.1 crypt: 3.0.1 crypto: ^2.1.5 - steel_crypt: ^2.2.2+1 - olm: 1.2.1 - # flutter_olm: 1.0.1 - # olm: - # git: - # url: https://gitlab.com/famedly/libraries/dart-olm - # ref: 4853b5301519a50866a81b58ac3730830a4fe547 - # cryptography: 1.2.1 - - # Cache - hive: 1.4.4 - hive_flutter: 0.3.1 - flutter_secure_storage: 3.3.3 - json_annotation: ^3.1.0 + encrypt: 4.1.0 + + # cache/storage + sembast: ^2.4.8 + sqflite: ^1.3.2 + sembast_sqflite: 1.0.0 + sqflite_sqlcipher: 1.1.2 + sqflite_common_ffi: 1.1.1 checked_yaml: 1.0.2 - # sembast: 2.4.7+7 - # isolate_handler: 0.3.1 - # flutter_isolate: 1.0.0+14 + json_annotation: ^3.1.0 + flutter_secure_storage: 3.3.3 - # Services + # services http: ^0.12.0+2 html: ^0.13.3+3 intl: ^0.16.1 @@ -119,19 +113,16 @@ dependencies: flutter_material_color_picker: ^1.0.5 palette_generator: 0.2.3 + # fun + ansicolor: 1.1.1 + dev_dependencies: json_serializable: ^3.5.0 flutter_launcher_icons: "^0.7.5" build_runner: ^1.10.1 - hive_generator: 0.8.1 - # TODO: build fixes - remove later - build_resolvers: 1.3.10 # <- modified to solve build_runner - # TODO: build fixes - remove later dependency_overrides: - path: 1.7.0 - analyzer: 0.39.16 - dart_style: 1.3.4 + analyzer: 0.39.16 # <- override for json_serializable build_runner flutter_icons: android: true @@ -147,6 +138,7 @@ flutter: # the material Icons class. uses-material-design: true + # see https://flutter.dev/custom-fonts/#from-packages fonts: - family: Poppins fonts: @@ -207,26 +199,3 @@ flutter: - assets/icons/global/being-send-unlock.svg - assets/icons/global/being-chevrons-right.svg - assets/icons/global/feather-message-circle.svg - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/assets-and-images/#resolution-aware. - # For details regarding adding assets from package dependencies, see - # https://flutter.dev/assets-and-images/#from-packages - # To add custom fonts to your application, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts from package dependencies, - # see https://flutter.dev/custom-fonts/#from-packages