diff --git a/lib/main.dart b/lib/main.dart index 50812f355..846aef632 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,6 +8,7 @@ import 'package:komodo_dex/packages/z_coin_activation/bloc/z_coin_activation_blo import 'package:komodo_dex/packages/z_coin_activation/bloc/z_coin_activation_state.dart'; import 'package:komodo_dex/packages/z_coin_activation/bloc/z_coin_notifications.dart'; import 'package:komodo_dex/packages/z_coin_activation/widgets/z_coin_status_list_tile.dart'; +import 'package:komodo_dex/services/db/persistence_manager.dart'; import '../app_config/app_config.dart'; import '../blocs/authenticate_bloc.dart'; import '../blocs/coins_bloc.dart'; @@ -71,6 +72,9 @@ Future startApp() async { await ZCoinProgressNotifications.initNotifications(); try { mmSe.metrics(); + + await PersistenceManager.init(); + startup.start(); return runApp( diff --git a/lib/model/wallet.dart b/lib/model/wallet.dart index d90b96f99..a3f1192d0 100644 --- a/lib/model/wallet.dart +++ b/lib/model/wallet.dart @@ -27,4 +27,10 @@ class Wallet { 'id': id ?? '', 'name': name ?? '', }; + + static bool areWalletsEqual(Wallet wallet1, Wallet wallet2) { + assert(wallet1 != null && wallet2 != null, "Can't compare null wallets"); + + return wallet1.id == wallet2.id && wallet1.name == wallet2.name; + } } diff --git a/lib/packages/account_addresses/api/account_addresses_api_hive.dart b/lib/packages/account_addresses/api/account_addresses_api_hive.dart new file mode 100644 index 000000000..292b6ad3a --- /dev/null +++ b/lib/packages/account_addresses/api/account_addresses_api_hive.dart @@ -0,0 +1,214 @@ +import 'dart:async'; +import 'package:hive/hive.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:komodo_dex/packages/account_addresses/api/account_addresses_api_interface.dart'; +import 'package:komodo_dex/packages/account_addresses/models/wallet_address.dart'; +import 'package:meta/meta.dart'; + +class AccountAddressesApiHive implements AccountAddressesApiInterface { + final Box _box; + + static bool _isAdapterRegistered = false; + + AccountAddressesApiHive._(this._box); + + static FutureOr initialize() async { + if (!_isAdapterRegistered) { + Hive.registerAdapter(WalletAddressAdapter()); + _isAdapterRegistered = true; + } + + Box box; + try { + box = await Hive.openBox('account_addresses'); + } catch (e) { + throw Exception('Failed to open Hive box: $e'); + } + return AccountAddressesApiHive._(box); + } + + String _getKey(String walletId, String address) { + return '${walletId}_$address'; + } + + @override + Future create({ + @required String walletId, + @required String address, + @required String ticker, + @required double availableBalance, + @required String accountId, + }) async { + _validateFields(walletId, address, availableBalance); + try { + await _box.put( + _getKey(walletId, address), + WalletAddress( + walletId: walletId, + address: address, + ticker: ticker, + availableBalance: availableBalance, + accountId: accountId, + ), + ); + } catch (e) { + throw Exception('Failed to create WalletAddress: $e'); + } + } + + @override + Future updateOrCreate({ + @required String walletId, + @required String address, + @required String ticker, + @required double availableBalance, + @required String accountId, + }) async { + final key = _getKey(walletId, address); + if (_box.containsKey(key)) { + await update( + walletId: walletId, + address: address, + ticker: ticker, + availableBalance: availableBalance, + accountId: accountId, + ); + } else { + await create( + walletId: walletId, + address: address, + ticker: ticker, + availableBalance: availableBalance, + accountId: accountId, + ); + } + } + + @override + Future update({ + @required String walletId, + @required String address, + String ticker, + double availableBalance, + String accountId, + }) async { + _validateFields(walletId, address, availableBalance); + final key = _getKey(walletId, address); + final existingWalletAddress = _box.get(key); + + if (existingWalletAddress == null) { + throw Exception('WalletAddress not found'); + } + + final updatedWalletAddress = WalletAddress( + walletId: walletId, + address: address, + ticker: ticker ?? existingWalletAddress.ticker, + availableBalance: + availableBalance ?? existingWalletAddress.availableBalance, + accountId: accountId ?? existingWalletAddress.accountId, + ); + + try { + await _box.put(key, updatedWalletAddress); + } catch (e) { + throw Exception('Failed to update WalletAddress: $e'); + } + } + + @override + Future deleteOne({ + @required String walletId, + @required String address, + }) async { + try { + await _box.delete(_getKey(walletId, address)); + } catch (e) { + throw Exception('Failed to delete WalletAddress: $e'); + } + } + + @override + Future deleteAll({@required String walletId}) async { + final keys = _box.keys.where((key) => key.startsWith(walletId)); + try { + await _box.deleteAll(keys); + } catch (e) { + throw Exception('Failed to delete WalletAddresses: $e'); + } + } + + @override + Future readOne({ + @required String walletId, + @required String address, + }) async { + try { + return _box.get(_getKey(walletId, address)); + } catch (e) { + throw Exception('Failed to read WalletAddress: $e'); + } + } + + @override + Future> readAll({@required String walletId}) async { + try { + return _box.values + .where((walletAddress) => walletAddress.walletId == walletId) + .toList(); + } catch (e) { + throw Exception('Failed to read WalletAddresses: $e'); + } + } + + @override + Stream watchAll({@required String walletId}) { + return _box + .watch() + .where( + (event) => + event.key.startsWith(walletId) && event.value is WalletAddress, + ) + .map((event) => event.value as WalletAddress); + } + + @override + Stream> watchAllList({@required String walletId}) async* { + List addresses = await readAll(walletId: walletId); + yield addresses; + + yield* watchAll(walletId: walletId).map>( + (walletAddress) => addresses = addresses + .map( + (address) => address.address == walletAddress.address + ? walletAddress + : address, + ) + .toList(), + ); + } + + Future close() async { + try { + await _box.close(); + } catch (e) { + throw Exception('Failed to close Hive box: $e'); + } + } + + void _validateFields( + String walletId, + String address, + double availableBalance, + ) { + if (walletId == null || walletId.isEmpty) { + throw ArgumentError('Wallet ID must not be empty'); + } + if (address == null || address.isEmpty) { + throw ArgumentError('Address must not be empty'); + } + if (availableBalance == null || availableBalance < 0) { + throw ArgumentError('Available balance must be non-negative'); + } + } +} diff --git a/lib/packages/account_addresses/api/account_addresses_api_interface.dart b/lib/packages/account_addresses/api/account_addresses_api_interface.dart new file mode 100644 index 000000000..9a054d2a8 --- /dev/null +++ b/lib/packages/account_addresses/api/account_addresses_api_interface.dart @@ -0,0 +1,54 @@ +import 'package:meta/meta.dart'; +import 'package:komodo_dex/packages/account_addresses/models/wallet_address.dart'; + +abstract class AccountAddressesApiInterface { + Future create({ + @required String walletId, + @required String address, + @required String ticker, + @required double availableBalance, + @required String accountId, + }); + + Future update({ + @required String walletId, + @required String address, + String ticker, + double availableBalance, + String accountId, + }); + + Future updateOrCreate({ + @required String walletId, + @required String address, + @required String ticker, + @required double availableBalance, + @required String accountId, + }); + + Future deleteOne({ + @required String walletId, + @required String address, + }); + + Future deleteAll({ + @required String walletId, + }); + + Future readOne({ + @required String walletId, + @required String address, + }); + + Future> readAll({ + @required String walletId, + }); + + Stream watchAll({ + @required String walletId, + }); + + Stream> watchAllList({ + @required String walletId, + }); +} diff --git a/lib/packages/account_addresses/models/wallet_address.dart b/lib/packages/account_addresses/models/wallet_address.dart new file mode 100644 index 000000000..4e5145659 --- /dev/null +++ b/lib/packages/account_addresses/models/wallet_address.dart @@ -0,0 +1,43 @@ +import 'package:meta/meta.dart'; +import 'package:hive/hive.dart'; + +part 'wallet_address.g.dart'; + +@HiveType(typeId: 0) +class WalletAddress { + @HiveField(0) + final String walletId; + + @HiveField(1) + final String address; + + @HiveField(2) + final String ticker; + + @HiveField(3) + final double availableBalance; + + @HiveField(4) + final String accountId; + + WalletAddress({ + @required this.walletId, + @required this.address, + @required this.ticker, + @required this.availableBalance, + @required this.accountId, + }); + + Map toJson() => { + 'walletId': walletId, + 'address': address, + 'ticker': ticker, + 'availableBalance': availableBalance, + 'accountId': accountId, + }; + + @override + String toString() { + return 'WalletAddress{walletId: $walletId, address: $address, ticker: $ticker, availableBalance: $availableBalance, accountId: $accountId}'; + } +} diff --git a/lib/packages/account_addresses/models/wallet_address.g.dart b/lib/packages/account_addresses/models/wallet_address.g.dart new file mode 100644 index 000000000..80788f2c9 --- /dev/null +++ b/lib/packages/account_addresses/models/wallet_address.g.dart @@ -0,0 +1,26 @@ +part of 'wallet_address.dart'; + +class WalletAddressAdapter extends TypeAdapter { + @override + final typeId = 0; + + @override + WalletAddress read(BinaryReader reader) { + return WalletAddress( + walletId: reader.read(), + address: reader.read(), + ticker: reader.read(), + availableBalance: reader.read(), + accountId: reader.read(), + ); + } + + @override + void write(BinaryWriter writer, WalletAddress obj) { + writer.write(obj.walletId); + writer.write(obj.address); + writer.write(obj.ticker); + writer.write(obj.availableBalance); + writer.write(obj.accountId); + } +} diff --git a/lib/packages/account_addresses/repository/account_addresses_repository.dart b/lib/packages/account_addresses/repository/account_addresses_repository.dart new file mode 100644 index 000000000..0b977133c --- /dev/null +++ b/lib/packages/account_addresses/repository/account_addresses_repository.dart @@ -0,0 +1,84 @@ +import 'dart:async'; +import 'package:komodo_dex/model/coin_balance.dart'; +import 'package:komodo_dex/packages/account_addresses/api/account_addresses_api_interface.dart'; +import 'package:komodo_dex/packages/account_addresses/models/wallet_address.dart'; +import 'package:komodo_dex/services/db/database.dart'; +import 'package:meta/meta.dart'; + +class AccountAddressesRepository { + final AccountAddressesApiInterface _accountAddressesApi; + + AccountAddressesRepository({ + @required AccountAddressesApiInterface accountAddressesApi, + }) : _accountAddressesApi = accountAddressesApi; + + Future clearAll(String walletId) async { + await _accountAddressesApi.deleteAll(walletId: walletId); + } + + Future storeSnapshot({ + @required List> snapshotListJson, + @required String walletId, + }) async { + if (snapshotListJson == null) return; + + for (final item in snapshotListJson ?? []) { + final coinBalance = CoinBalance.fromJson(item); + + final walletAddress = WalletAddress( + walletId: walletId, + address: coinBalance.balance.address, + ticker: coinBalance.coin.abbr, + availableBalance: coinBalance.balance.balance.toDouble(), + accountId: 'iguana', + ); + + await _accountAddressesApi.updateOrCreate( + walletId: walletAddress.walletId, + address: walletAddress.address, + ticker: walletAddress.ticker, + availableBalance: walletAddress.availableBalance, + accountId: walletAddress.accountId, + ); + } + } + + /// Returns a stream of all addresses for the current wallet. + /// + /// The stream emits all initial addresses for the current wallet, and then + /// watches for updated/created addresses for the current wallet. + /// + /// The stream will switch to a new stream when the current wallet changes. + /// + Stream watchCurrentWalletAddresses() async* { + String lastStreamWalletId; + + final didWalletChange = (wallet) => + wallet != null && + (wallet.id != lastStreamWalletId || lastStreamWalletId == null); + + await for (final wallet in Db.watchCurrentWallet().where(didWalletChange)) { + lastStreamWalletId = wallet.id; + + for (final address in await _accountAddressesApi.readAll( + walletId: lastStreamWalletId, + )) { + yield address; + } + + final addressesStream = _accountAddressesApi + .watchAll( + walletId: lastStreamWalletId, + ) + .takeWhile((address) => address.walletId == lastStreamWalletId); + + await for (final address in addressesStream) { + yield address; + + if (address.walletId != (await Db.getCurrentWallet())?.id) { + break; + } + } + } + } +} diff --git a/lib/services/db/database.dart b/lib/services/db/database.dart index 8a77ee034..16bf7deaa 100644 --- a/lib/services/db/database.dart +++ b/lib/services/db/database.dart @@ -2,6 +2,11 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'package:async/async.dart'; +import 'package:flutter/foundation.dart'; +import 'package:komodo_dex/packages/account_addresses/api/account_addresses_api_hive.dart'; +import 'package:komodo_dex/packages/account_addresses/repository/account_addresses_repository.dart'; + import '../../blocs/wallet_bloc.dart'; import '../../model/article.dart'; import '../../model/coin.dart'; @@ -16,19 +21,32 @@ class Db { static Database _db; static bool _initInvoked = false; + static AccountAddressesRepository _accountAddressesRepository; + static Future get db async { + assert(_initInvoked, 'Db.init must be invoked before accessing the db'); // Protect the database from being opened and initialized multiple times. if (_initInvoked) { await pauseUntil(() => _db != null); return _db; } - _initInvoked = true; _db = await _initDB(); return _db; } + static Future init({ + @required AccountAddressesRepository accountAddressesRepository, + }) async { + _initInvoked = true; + + _accountAddressesRepository = accountAddressesRepository; + _db = await _initDB(); + } + static Future _initDB() async { + if (_db != null) return _db; + final Directory documentsDirectory = await applicationDocumentsDirectory; final String path = join(documentsDirectory.path, 'AtomicDEX.db'); String _articleTable = ''' @@ -169,7 +187,12 @@ class Db { // even though they aren't in the db due to the ListOfCoinsActivated migration batch.execute('DELETE FROM WalletSnapshot'); - batch.commit(); + await batch.commit(); + + if (_walletSnapshotController.hasListener) { + // Wallet snapshots have been cleared + _walletSnapshotController.add(null); + } Log('database', 'initDB, onUpgrade, upgraded database to version $newVersion successfully'); } catch (e) { @@ -538,7 +561,28 @@ class Db { await db.insert('WalletSnapshot', {'wallet_id': wallet.id, 'snapshot': jsonStr}, conflictAlgorithm: ConflictAlgorithm.replace); - } catch (_) {} + + final decodedSnapshot = JsonDecoder().convert(jsonStr); + + // convert List to List> + final snapshotListJson = List>.from(decodedSnapshot); + + await _accountAddressesRepository.storeSnapshot( + snapshotListJson: snapshotListJson, + walletId: wallet.id, + ); + + if (_walletSnapshotController.hasListener) { + _walletSnapshotController.add( + {'wallet_id': wallet.id, 'snapshot': decodedSnapshot}, + ); + } + } catch (e) { + Log( + 'database:saveWalletSnapshot', + 'Failed to save wallet snapshot: $e. $jsonStr', + ); + } } static Future getWalletSnapshot() async { @@ -656,6 +700,46 @@ class Db { whereArgs: allWallets ? null : [currentWallet.id], ); - batch.commit(); + await batch.commit(); + + if (_activeWalletController.hasListener) { + _activeWalletController.add(currentWallet); + } + } + + // ===== Wallet watcher logic methods + static final StreamController _activeWalletController = + StreamController.broadcast(); + + static final StreamController> + _walletSnapshotController = + StreamController>.broadcast(); + + /// This is a stream that updates when the wallet snapshot table is saved. + /// + /// It is not guaranteed to be the same as the current wallet, and the data + /// may be repeated. + /// + /// The stream does not emit initial data. + static Stream> watchChangesWalletSnapshot() => + _walletSnapshotController.stream; + + static Stream watchCurrentWallet() async* { + final Database db = await Db.db; + + Wallet _lastStreamWallet = await getCurrentWallet(); + yield _lastStreamWallet; + + await for (final walletUpdate in _activeWalletController.stream + .where((wallet) => wallet?.id != _lastStreamWallet?.id)) + if (!Wallet.areWalletsEqual(_lastStreamWallet, walletUpdate)) { + _lastStreamWallet = walletUpdate; + + Log( + 'database:watchCurrentWallet', + 'Wallet updated, yielding new wallet of ID = ${walletUpdate.id}', + ); + yield walletUpdate; + } } } diff --git a/lib/services/db/persistence_manager.dart b/lib/services/db/persistence_manager.dart new file mode 100644 index 000000000..0b8a7aa69 --- /dev/null +++ b/lib/services/db/persistence_manager.dart @@ -0,0 +1,19 @@ +import 'package:hive/hive.dart'; +import 'package:komodo_dex/packages/account_addresses/api/account_addresses_api_hive.dart'; +import 'package:komodo_dex/packages/account_addresses/repository/account_addresses_repository.dart'; +import 'package:komodo_dex/services/db/database.dart'; +import 'package:komodo_dex/utils/utils.dart'; + +class PersistenceManager { + PersistenceManager._(); + + static Future init() async { + Hive.init((await applicationDocumentsDirectory).path + '/hive'); + + final accountAddressesRepository = AccountAddressesRepository( + accountAddressesApi: await AccountAddressesApiHive.initialize(), + ); + + await Db.init(accountAddressesRepository: accountAddressesRepository); + } +} diff --git a/pubspec.lock b/pubspec.lock index e5dea6c62..7cee58b04 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -434,6 +434,20 @@ 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: "2.2.3" + hive_flutter: + dependency: "direct main" + description: + name: hive_flutter + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" http: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 3b481cbb0..4f9b1f2fb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -167,6 +167,8 @@ dependencies: # "Flutter Favorite" plugin (https://docs.flutter.dev/packages-and-plugins/favorites) flutter_local_notifications: ^12.0.4 # TODO: Secure code review of this plugin. + hive: ^2.2.3 + hive_flutter: ^1.1.0