diff --git a/build.bat b/build.bat index 0889dcf..e40afe0 100644 --- a/build.bat +++ b/build.bat @@ -1,6 +1,13 @@ @echo off dart run build_runner build --delete-conflicting-outputs +pushd packages\async_tools +call build.bat +popd +pushd packages\veilid_support +call build.bat +popd + pushd lib protoc --dart_out=proto -I veilid_support\proto -I veilid_support\dht_support\proto -I proto veilidchat.proto protoc --dart_out=proto -I veilid_support\proto -I veilid_support\dht_support\proto dht.proto diff --git a/build.sh b/build.sh index 96cce52..f723071 100755 --- a/build.sh +++ b/build.sh @@ -1,9 +1,16 @@ #!/bin/bash set -e + +pushd packages/async_tools > /dev/null +./build.sh +popd > /dev/null + +pushd packages/veilid_support > /dev/null +./build.sh +popd > /dev/null + dart run build_runner build --delete-conflicting-outputs -pushd lib > /dev/null -protoc --dart_out=proto -I veilid_support/proto -I veilid_support/dht_support/proto -I proto veilidchat.proto -protoc --dart_out=proto -I veilid_support/proto -I veilid_support/dht_support/proto dht.proto -protoc --dart_out=proto -I veilid_support/proto veilid.proto -popd > /dev/null +protoc --dart_out=lib/proto -I packages/veilid_support/lib/proto -I packages/veilid_support/lib/dht_support/proto -I lib/proto veilidchat.proto +sed -i '' 's/dht.pb.dart/package:veilid_support\/proto\/dht.pb.dart/g' lib/proto/veilidchat.pb.dart +sed -i '' 's/veilid.pb.dart/package:veilid_support\/proto\/veilid.pb.dart/g' lib/proto/veilidchat.pb.dart \ No newline at end of file diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 0000000..5c27c3e --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,2 @@ +extensions: + - provider: true \ No newline at end of file diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist index 9625e10..7c56964 100644 --- a/ios/Flutter/AppFrameworkInfo.plist +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -21,6 +21,6 @@ CFBundleVersion 1.0 MinimumOSVersion - 11.0 + 12.0 diff --git a/ios/Podfile b/ios/Podfile index bd3431c..2cbcaa2 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -44,4 +44,4 @@ post_install do |installer| File.open(xcconfig_path, "w") { |file| file << xcconfig_mod } end end -end \ No newline at end of file +end diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 125a3a8..636ef83 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -4,9 +4,6 @@ PODS: - Flutter (1.0.0) - flutter_native_splash (0.0.1): - Flutter - - FMDB (2.7.5): - - FMDB/standard (= 2.7.5) - - FMDB/standard (2.7.5) - GoogleDataTransport (9.2.5): - GoogleUtilities/Environment (~> 7.7) - nanopb (< 2.30910.0, >= 2.30908.0) @@ -55,7 +52,7 @@ PODS: - GTMSessionFetcher/Core (< 3.0, >= 1.1) - MLImage (= 1.0.0-beta4) - MLKitCommon (~> 9.0) - - mobile_scanner (3.2.0): + - mobile_scanner (3.5.6): - Flutter - GoogleMLKit/BarcodeScanning (~> 4.0.0) - nanopb (2.30909.0): @@ -78,7 +75,7 @@ PODS: - Flutter - sqflite (0.0.3): - Flutter - - FMDB (>= 2.7.5) + - FlutterMacOS - system_info_plus (0.0.1): - Flutter - url_launcher_ios (0.0.1): @@ -96,14 +93,13 @@ DEPENDENCIES: - share_plus (from `.symlinks/plugins/share_plus/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - smart_auth (from `.symlinks/plugins/smart_auth/ios`) - - sqflite (from `.symlinks/plugins/sqflite/ios`) + - sqflite (from `.symlinks/plugins/sqflite/darwin`) - system_info_plus (from `.symlinks/plugins/system_info_plus/ios`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - veilid (from `.symlinks/plugins/veilid/ios`) SPEC REPOS: trunk: - - FMDB - GoogleDataTransport - GoogleMLKit - GoogleToolboxForMac @@ -137,7 +133,7 @@ EXTERNAL SOURCES: smart_auth: :path: ".symlinks/plugins/smart_auth/ios" sqflite: - :path: ".symlinks/plugins/sqflite/ios" + :path: ".symlinks/plugins/sqflite/darwin" system_info_plus: :path: ".symlinks/plugins/system_info_plus/ios" url_launcher_ios: @@ -146,10 +142,9 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/veilid/ios" SPEC CHECKSUMS: - camera_avfoundation: 3125e8cd1a4387f6f31c6c63abb8a55892a9eeeb - Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854 + camera_avfoundation: 759172d1a77ae7be0de08fc104cfb79738b8a59e + Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef - FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a GoogleDataTransport: 54dee9d48d14580407f8f5fbf2f496e92437a2f2 GoogleMLKit: 2bd0dc6253c4d4f227aad460f69215a504b2980e GoogleToolboxForMac: 8bef7c7c5cf7291c687cf5354f39f9db6399ad34 @@ -160,19 +155,19 @@ SPEC CHECKSUMS: MLKitBarcodeScanning: 04e264482c5f3810cb89ebc134ef6b61e67db505 MLKitCommon: c1b791c3e667091918d91bda4bba69a91011e390 MLKitVision: 8baa5f46ee3352614169b85250574fde38c36f49 - mobile_scanner: 47056db0c04027ea5f41a716385542da28574662 + mobile_scanner: 38dcd8a49d7d485f632b7de65e4900010187aef2 nanopb: b552cce312b6c8484180ef47159bc0f65a1f0431 pasteboard: 982969ebaa7c78af3e6cc7761e8f5e77565d9ce0 - path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943 + path_provider_foundation: 3784922295ac71e43754bd15e0653ccfd36a147c PromisesObjC: c50d2056b5253dadbd6c2bea79b0674bd5a52fa4 - share_plus: 599aa54e4ea31d4b4c0e9c911bcc26c55e791028 - shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126 + share_plus: c3fef564749587fc939ef86ffb283ceac0baf9f5 + shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695 smart_auth: 4bedbc118723912d0e45a07e8ab34039c19e04f2 - sqflite: 31f7eba61e3074736dff8807a9b41581e4f7f15a + sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec system_info_plus: 5393c8da281d899950d751713575fbf91c7709aa - url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4 - veilid: 51243c25047dbc1ebbfd87d713560260d802b845 + url_launcher_ios: 6116280ddcfe98ab8820085d8d76ae7449447586 + veilid: f5c2e662f91907b30cf95762619526ac3e4512fd -PODFILE CHECKSUM: 7f4cf2154d55730d953b184299e6feee7a274740 +PODFILE CHECKSUM: 5d504085cd7c7a4d71ee600d7af087cb60ab75b2 -COCOAPODS: 1.14.2 +COCOAPODS: 1.15.2 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 3100ff0..72c3178 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -155,7 +155,7 @@ 97C146E61CF9000F007C117D /* Project object */ = { isa = PBXProject; attributes = { - LastUpgradeCheck = 1430; + LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { 97C146ED1CF9000F007C117D = { diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index a6b826d..5e31d3d 100644 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,6 +1,6 @@ { + AccountRecordCubit({ + required super.open, + }) : super(decodeState: proto.Account.fromBuffer); + + @override + Future close() async { + await super.close(); + } +} diff --git a/lib/account_manager/cubits/active_local_account_cubit.dart b/lib/account_manager/cubits/active_local_account_cubit.dart new file mode 100644 index 0000000..29a76c9 --- /dev/null +++ b/lib/account_manager/cubits/active_local_account_cubit.dart @@ -0,0 +1,46 @@ +import 'dart:async'; + +import 'package:bloc/bloc.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../init.dart'; +import '../repository/account_repository/account_repository.dart'; + +class ActiveLocalAccountCubit extends Cubit { + ActiveLocalAccountCubit(AccountRepository accountRepository) + : _accountRepository = accountRepository, + super(null) { + // Subscribe to streams + _initAccountRepositorySubscription(); + + // Initialize when we can + Future.delayed(Duration.zero, () async { + await eventualInitialized.future; + emit(_accountRepository.getActiveLocalAccount()); + }); + } + + void _initAccountRepositorySubscription() { + _accountRepositorySubscription = _accountRepository.stream.listen((change) { + switch (change) { + case AccountRepositoryChange.activeLocalAccount: + emit(_accountRepository.getActiveLocalAccount()); + break; + // Ignore these + case AccountRepositoryChange.localAccounts: + case AccountRepositoryChange.userLogins: + break; + } + }); + } + + @override + Future close() async { + await super.close(); + await _accountRepositorySubscription.cancel(); + } + + final AccountRepository _accountRepository; + late final StreamSubscription + _accountRepositorySubscription; +} diff --git a/lib/account_manager/cubits/cubits.dart b/lib/account_manager/cubits/cubits.dart new file mode 100644 index 0000000..6d2875d --- /dev/null +++ b/lib/account_manager/cubits/cubits.dart @@ -0,0 +1,4 @@ +export 'account_record_cubit.dart'; +export 'active_local_account_cubit.dart'; +export 'local_accounts_cubit.dart'; +export 'user_logins_cubit.dart'; diff --git a/lib/account_manager/cubits/local_accounts_cubit.dart b/lib/account_manager/cubits/local_accounts_cubit.dart new file mode 100644 index 0000000..376c810 --- /dev/null +++ b/lib/account_manager/cubits/local_accounts_cubit.dart @@ -0,0 +1,47 @@ +import 'dart:async'; + +import 'package:bloc/bloc.dart'; +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; + +import '../../init.dart'; +import '../models/models.dart'; +import '../repository/account_repository/account_repository.dart'; + +class LocalAccountsCubit extends Cubit> { + LocalAccountsCubit(AccountRepository accountRepository) + : _accountRepository = accountRepository, + super(IList()) { + // Subscribe to streams + _initAccountRepositorySubscription(); + + // Initialize when we can + Future.delayed(Duration.zero, () async { + await eventualInitialized.future; + emit(_accountRepository.getLocalAccounts()); + }); + } + + void _initAccountRepositorySubscription() { + _accountRepositorySubscription = _accountRepository.stream.listen((change) { + switch (change) { + case AccountRepositoryChange.localAccounts: + emit(_accountRepository.getLocalAccounts()); + break; + // Ignore these + case AccountRepositoryChange.userLogins: + case AccountRepositoryChange.activeLocalAccount: + break; + } + }); + } + + @override + Future close() async { + await super.close(); + await _accountRepositorySubscription.cancel(); + } + + final AccountRepository _accountRepository; + late final StreamSubscription + _accountRepositorySubscription; +} diff --git a/lib/account_manager/cubits/user_logins_cubit.dart b/lib/account_manager/cubits/user_logins_cubit.dart new file mode 100644 index 0000000..30269c1 --- /dev/null +++ b/lib/account_manager/cubits/user_logins_cubit.dart @@ -0,0 +1,47 @@ +import 'dart:async'; + +import 'package:bloc/bloc.dart'; +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; + +import '../../init.dart'; +import '../models/models.dart'; +import '../repository/account_repository/account_repository.dart'; + +class UserLoginsCubit extends Cubit> { + UserLoginsCubit(AccountRepository accountRepository) + : _accountRepository = accountRepository, + super(IList()) { + // Subscribe to streams + _initAccountRepositorySubscription(); + + // Initialize when we can + Future.delayed(Duration.zero, () async { + await eventualInitialized.future; + emit(_accountRepository.getUserLogins()); + }); + } + + void _initAccountRepositorySubscription() { + _accountRepositorySubscription = _accountRepository.stream.listen((change) { + switch (change) { + case AccountRepositoryChange.userLogins: + emit(_accountRepository.getUserLogins()); + break; + // Ignore these + case AccountRepositoryChange.localAccounts: + case AccountRepositoryChange.activeLocalAccount: + break; + } + }); + } + + @override + Future close() async { + await super.close(); + await _accountRepositorySubscription.cancel(); + } + + final AccountRepository _accountRepository; + late final StreamSubscription + _accountRepositorySubscription; +} diff --git a/lib/account_manager/models/account_info.dart b/lib/account_manager/models/account_info.dart new file mode 100644 index 0000000..7f2e058 --- /dev/null +++ b/lib/account_manager/models/account_info.dart @@ -0,0 +1,23 @@ +import 'package:meta/meta.dart'; + +import 'active_account_info.dart'; + +enum AccountInfoStatus { + noAccount, + accountInvalid, + accountLocked, + accountReady, +} + +@immutable +class AccountInfo { + const AccountInfo({ + required this.status, + required this.active, + required this.activeAccountInfo, + }); + + final AccountInfoStatus status; + final bool active; + final ActiveAccountInfo? activeAccountInfo; +} diff --git a/lib/account_manager/models/active_account_info.dart b/lib/account_manager/models/active_account_info.dart new file mode 100644 index 0000000..7a1437b --- /dev/null +++ b/lib/account_manager/models/active_account_info.dart @@ -0,0 +1,45 @@ +import 'dart:convert'; + +import 'package:meta/meta.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import 'local_account/local_account.dart'; +import 'user_login/user_login.dart'; + +@immutable +class ActiveAccountInfo { + const ActiveAccountInfo({ + required this.localAccount, + required this.userLogin, + //required this.accountRecord, + }); + // + + TypedKey get accountRecordKey => + userLogin.accountRecordInfo.accountRecord.recordKey; + + KeyPair get conversationWriter { + final identityKey = localAccount.identityMaster.identityPublicKey; + final identitySecret = userLogin.identitySecret; + return KeyPair(key: identityKey, secret: identitySecret.value); + } + + Future makeConversationCrypto( + TypedKey remoteIdentityPublicKey) async { + final identitySecret = userLogin.identitySecret; + final cs = await Veilid.instance.getCryptoSystem(identitySecret.kind); + final sharedSecret = await cs.generateSharedSecret( + remoteIdentityPublicKey.value, + identitySecret.value, + utf8.encode('VeilidChat Conversation')); + + final messagesCrypto = await DHTRecordCryptoPrivate.fromSecret( + identitySecret.kind, sharedSecret); + return messagesCrypto; + } + + // + final LocalAccount localAccount; + final UserLogin userLogin; + //final DHTRecord accountRecord; +} diff --git a/lib/account_manager/models/encryption_key_type.dart b/lib/account_manager/models/encryption_key_type.dart new file mode 100644 index 0000000..22897b4 --- /dev/null +++ b/lib/account_manager/models/encryption_key_type.dart @@ -0,0 +1,79 @@ +// Local account identitySecretKey is potentially encrypted with a key +// using the following mechanisms +// * None : no key, bytes are unencrypted +// * Pin : Code is a numeric pin (4-256 numeric digits) hashed with Argon2 +// * Password: Code is a UTF-8 string that is hashed with Argon2 + +import 'dart:typed_data'; + +import 'package:change_case/change_case.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../../proto/proto.dart' as proto; + +enum EncryptionKeyType { + none, + pin, + password; + + factory EncryptionKeyType.fromJson(dynamic j) => + EncryptionKeyType.values.byName((j as String).toCamelCase()); + + factory EncryptionKeyType.fromProto(proto.EncryptionKeyType p) { + // ignore: exhaustive_cases + switch (p) { + case proto.EncryptionKeyType.ENCRYPTION_KEY_TYPE_NONE: + return EncryptionKeyType.none; + case proto.EncryptionKeyType.ENCRYPTION_KEY_TYPE_PIN: + return EncryptionKeyType.pin; + case proto.EncryptionKeyType.ENCRYPTION_KEY_TYPE_PASSWORD: + return EncryptionKeyType.password; + } + throw StateError('unknown EncryptionKeyType enum value'); + } + String toJson() => name.toPascalCase(); + proto.EncryptionKeyType toProto() => switch (this) { + EncryptionKeyType.none => + proto.EncryptionKeyType.ENCRYPTION_KEY_TYPE_NONE, + EncryptionKeyType.pin => + proto.EncryptionKeyType.ENCRYPTION_KEY_TYPE_PIN, + EncryptionKeyType.password => + proto.EncryptionKeyType.ENCRYPTION_KEY_TYPE_PASSWORD, + }; + + Future encryptSecretToBytes( + {required SecretKey secret, + required CryptoKind cryptoKind, + String encryptionKey = ''}) async { + late final Uint8List secretBytes; + switch (this) { + case EncryptionKeyType.none: + secretBytes = secret.decode(); + case EncryptionKeyType.pin: + case EncryptionKeyType.password: + final cs = await Veilid.instance.getCryptoSystem(cryptoKind); + + secretBytes = + await cs.encryptAeadWithPassword(secret.decode(), encryptionKey); + } + return secretBytes; + } + + Future decryptSecretFromBytes( + {required Uint8List secretBytes, + required CryptoKind cryptoKind, + String encryptionKey = ''}) async { + late final SecretKey secret; + switch (this) { + case EncryptionKeyType.none: + secret = SecretKey.fromBytes(secretBytes); + case EncryptionKeyType.pin: + case EncryptionKeyType.password: + final cs = await Veilid.instance.getCryptoSystem(cryptoKind); + + secret = SecretKey.fromBytes( + await cs.decryptAeadWithPassword(secretBytes, encryptionKey)); + } + return secret; + } +} diff --git a/lib/account_manager/models/local_account/local_account.dart b/lib/account_manager/models/local_account/local_account.dart new file mode 100644 index 0000000..1998961 --- /dev/null +++ b/lib/account_manager/models/local_account/local_account.dart @@ -0,0 +1,39 @@ +import 'dart:typed_data'; + +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../models/encryption_key_type.dart'; + +part 'local_account.g.dart'; +part 'local_account.freezed.dart'; + +// Local Accounts are stored in a table locally and not backed by a DHT key +// and represents the accounts that have been added/imported +// on the current device. +// Stores a copy of the IdentityMaster associated with the account +// and the identitySecretKey optionally encrypted by an unlock code +// This is the root of the account information tree for VeilidChat +// +@freezed +class LocalAccount with _$LocalAccount { + const factory LocalAccount({ + // The master key record for the account, containing the identityPublicKey + required IdentityMaster identityMaster, + // The encrypted identity secret that goes with + // the identityPublicKey with appended salt + @Uint8ListJsonConverter() required Uint8List identitySecretBytes, + // The kind of encryption input used on the account + required EncryptionKeyType encryptionKeyType, + // If account is not hidden, password can be retrieved via + required bool biometricsEnabled, + // Keep account hidden unless account password is entered + // (tries all hidden accounts with auth method (no biometrics)) + required bool hiddenAccount, + // Display name for account until it is unlocked + required String name, + }) = _LocalAccount; + + factory LocalAccount.fromJson(dynamic json) => + _$LocalAccountFromJson(json as Map); +} diff --git a/lib/entities/local_account.freezed.dart b/lib/account_manager/models/local_account/local_account.freezed.dart similarity index 99% rename from lib/entities/local_account.freezed.dart rename to lib/account_manager/models/local_account/local_account.freezed.dart index 19dcd76..781d1de 100644 --- a/lib/entities/local_account.freezed.dart +++ b/lib/account_manager/models/local_account/local_account.freezed.dart @@ -12,7 +12,7 @@ part of 'local_account.dart'; T _$identity(T value) => value; final _privateConstructorUsedError = UnsupportedError( - 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods'); + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); LocalAccount _$LocalAccountFromJson(Map json) { return _LocalAccount.fromJson(json); @@ -225,7 +225,7 @@ class _$LocalAccountImpl implements _LocalAccount { } @override - bool operator ==(dynamic other) { + bool operator ==(Object other) { return identical(this, other) || (other.runtimeType == runtimeType && other is _$LocalAccountImpl && diff --git a/lib/entities/local_account.g.dart b/lib/account_manager/models/local_account/local_account.g.dart similarity index 100% rename from lib/entities/local_account.g.dart rename to lib/account_manager/models/local_account/local_account.g.dart diff --git a/lib/account_manager/models/models.dart b/lib/account_manager/models/models.dart new file mode 100644 index 0000000..d4b0ab5 --- /dev/null +++ b/lib/account_manager/models/models.dart @@ -0,0 +1,6 @@ +export 'account_info.dart'; +export 'active_account_info.dart'; +export 'encryption_key_type.dart'; +export 'local_account/local_account.dart'; +export 'new_profile_spec.dart'; +export 'user_login/user_login.dart'; diff --git a/lib/account_manager/models/new_profile_spec.dart b/lib/account_manager/models/new_profile_spec.dart new file mode 100644 index 0000000..173a382 --- /dev/null +++ b/lib/account_manager/models/new_profile_spec.dart @@ -0,0 +1,5 @@ +class NewProfileSpec { + NewProfileSpec({required this.name, required this.pronouns}); + String name; + String pronouns; +} diff --git a/lib/entities/user_login.dart b/lib/account_manager/models/user_login/user_login.dart similarity index 56% rename from lib/entities/user_login.dart rename to lib/account_manager/models/user_login/user_login.dart index 55a4fb2..4e23184 100644 --- a/lib/entities/user_login.dart +++ b/lib/account_manager/models/user_login/user_login.dart @@ -1,7 +1,6 @@ -import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; -import '../veilid_support/veilid_support.dart'; +import 'package:veilid_support/veilid_support.dart'; part 'user_login.freezed.dart'; part 'user_login.g.dart'; @@ -26,21 +25,3 @@ class UserLogin with _$UserLogin { factory UserLogin.fromJson(dynamic json) => _$UserLoginFromJson(json as Map); } - -// Represents a set of user logins -// and the currently selected account -@freezed -class ActiveLogins with _$ActiveLogins { - const factory ActiveLogins({ - // The list of current logged in accounts - required IList userLogins, - // The current selected account indexed by master record key - TypedKey? activeUserLogin, - }) = _ActiveLogins; - - factory ActiveLogins.empty() => - const ActiveLogins(userLogins: IListConst([])); - - factory ActiveLogins.fromJson(dynamic json) => - _$ActiveLoginsFromJson(json as Map); -} diff --git a/lib/entities/user_login.freezed.dart b/lib/account_manager/models/user_login/user_login.freezed.dart similarity index 63% rename from lib/entities/user_login.freezed.dart rename to lib/account_manager/models/user_login/user_login.freezed.dart index aca29fc..a25b4ab 100644 --- a/lib/entities/user_login.freezed.dart +++ b/lib/account_manager/models/user_login/user_login.freezed.dart @@ -12,7 +12,7 @@ part of 'user_login.dart'; T _$identity(T value) => value; final _privateConstructorUsedError = UnsupportedError( - 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods'); + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); UserLogin _$UserLoginFromJson(Map json) { return _UserLogin.fromJson(json); @@ -182,7 +182,7 @@ class _$UserLoginImpl implements _UserLogin { } @override - bool operator ==(dynamic other) { + bool operator ==(Object other) { return identical(this, other) || (other.runtimeType == runtimeType && other is _$UserLoginImpl && @@ -238,169 +238,3 @@ abstract class _UserLogin implements UserLogin { _$$UserLoginImplCopyWith<_$UserLoginImpl> get copyWith => throw _privateConstructorUsedError; } - -ActiveLogins _$ActiveLoginsFromJson(Map json) { - return _ActiveLogins.fromJson(json); -} - -/// @nodoc -mixin _$ActiveLogins { -// The list of current logged in accounts - IList get userLogins => - throw _privateConstructorUsedError; // The current selected account indexed by master record key - Typed? get activeUserLogin => - throw _privateConstructorUsedError; - - Map toJson() => throw _privateConstructorUsedError; - @JsonKey(ignore: true) - $ActiveLoginsCopyWith get copyWith => - throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $ActiveLoginsCopyWith<$Res> { - factory $ActiveLoginsCopyWith( - ActiveLogins value, $Res Function(ActiveLogins) then) = - _$ActiveLoginsCopyWithImpl<$Res, ActiveLogins>; - @useResult - $Res call( - {IList userLogins, - Typed? activeUserLogin}); -} - -/// @nodoc -class _$ActiveLoginsCopyWithImpl<$Res, $Val extends ActiveLogins> - implements $ActiveLoginsCopyWith<$Res> { - _$ActiveLoginsCopyWithImpl(this._value, this._then); - - // ignore: unused_field - final $Val _value; - // ignore: unused_field - final $Res Function($Val) _then; - - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? userLogins = null, - Object? activeUserLogin = freezed, - }) { - return _then(_value.copyWith( - userLogins: null == userLogins - ? _value.userLogins - : userLogins // ignore: cast_nullable_to_non_nullable - as IList, - activeUserLogin: freezed == activeUserLogin - ? _value.activeUserLogin - : activeUserLogin // ignore: cast_nullable_to_non_nullable - as Typed?, - ) as $Val); - } -} - -/// @nodoc -abstract class _$$ActiveLoginsImplCopyWith<$Res> - implements $ActiveLoginsCopyWith<$Res> { - factory _$$ActiveLoginsImplCopyWith( - _$ActiveLoginsImpl value, $Res Function(_$ActiveLoginsImpl) then) = - __$$ActiveLoginsImplCopyWithImpl<$Res>; - @override - @useResult - $Res call( - {IList userLogins, - Typed? activeUserLogin}); -} - -/// @nodoc -class __$$ActiveLoginsImplCopyWithImpl<$Res> - extends _$ActiveLoginsCopyWithImpl<$Res, _$ActiveLoginsImpl> - implements _$$ActiveLoginsImplCopyWith<$Res> { - __$$ActiveLoginsImplCopyWithImpl( - _$ActiveLoginsImpl _value, $Res Function(_$ActiveLoginsImpl) _then) - : super(_value, _then); - - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? userLogins = null, - Object? activeUserLogin = freezed, - }) { - return _then(_$ActiveLoginsImpl( - userLogins: null == userLogins - ? _value.userLogins - : userLogins // ignore: cast_nullable_to_non_nullable - as IList, - activeUserLogin: freezed == activeUserLogin - ? _value.activeUserLogin - : activeUserLogin // ignore: cast_nullable_to_non_nullable - as Typed?, - )); - } -} - -/// @nodoc -@JsonSerializable() -class _$ActiveLoginsImpl implements _ActiveLogins { - const _$ActiveLoginsImpl({required this.userLogins, this.activeUserLogin}); - - factory _$ActiveLoginsImpl.fromJson(Map json) => - _$$ActiveLoginsImplFromJson(json); - -// The list of current logged in accounts - @override - final IList userLogins; -// The current selected account indexed by master record key - @override - final Typed? activeUserLogin; - - @override - String toString() { - return 'ActiveLogins(userLogins: $userLogins, activeUserLogin: $activeUserLogin)'; - } - - @override - bool operator ==(dynamic other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$ActiveLoginsImpl && - const DeepCollectionEquality() - .equals(other.userLogins, userLogins) && - (identical(other.activeUserLogin, activeUserLogin) || - other.activeUserLogin == activeUserLogin)); - } - - @JsonKey(ignore: true) - @override - int get hashCode => Object.hash(runtimeType, - const DeepCollectionEquality().hash(userLogins), activeUserLogin); - - @JsonKey(ignore: true) - @override - @pragma('vm:prefer-inline') - _$$ActiveLoginsImplCopyWith<_$ActiveLoginsImpl> get copyWith => - __$$ActiveLoginsImplCopyWithImpl<_$ActiveLoginsImpl>(this, _$identity); - - @override - Map toJson() { - return _$$ActiveLoginsImplToJson( - this, - ); - } -} - -abstract class _ActiveLogins implements ActiveLogins { - const factory _ActiveLogins( - {required final IList userLogins, - final Typed? activeUserLogin}) = _$ActiveLoginsImpl; - - factory _ActiveLogins.fromJson(Map json) = - _$ActiveLoginsImpl.fromJson; - - @override // The list of current logged in accounts - IList get userLogins; - @override // The current selected account indexed by master record key - Typed? get activeUserLogin; - @override - @JsonKey(ignore: true) - _$$ActiveLoginsImplCopyWith<_$ActiveLoginsImpl> get copyWith => - throw _privateConstructorUsedError; -} diff --git a/lib/entities/user_login.g.dart b/lib/account_manager/models/user_login/user_login.g.dart similarity index 62% rename from lib/entities/user_login.g.dart rename to lib/account_manager/models/user_login/user_login.g.dart index a2b2143..267fc55 100644 --- a/lib/entities/user_login.g.dart +++ b/lib/account_manager/models/user_login/user_login.g.dart @@ -24,20 +24,3 @@ Map _$$UserLoginImplToJson(_$UserLoginImpl instance) => 'account_record_info': instance.accountRecordInfo.toJson(), 'last_active': instance.lastActive.toJson(), }; - -_$ActiveLoginsImpl _$$ActiveLoginsImplFromJson(Map json) => - _$ActiveLoginsImpl( - userLogins: IList.fromJson( - json['user_logins'], (value) => UserLogin.fromJson(value)), - activeUserLogin: json['active_user_login'] == null - ? null - : Typed.fromJson(json['active_user_login']), - ); - -Map _$$ActiveLoginsImplToJson(_$ActiveLoginsImpl instance) => - { - 'user_logins': instance.userLogins.toJson( - (value) => value.toJson(), - ), - 'active_user_login': instance.activeUserLogin?.toJson(), - }; diff --git a/lib/account_manager/repository/account_repository/account_repository.dart b/lib/account_manager/repository/account_repository/account_repository.dart new file mode 100644 index 0000000..a13b331 --- /dev/null +++ b/lib/account_manager/repository/account_repository/account_repository.dart @@ -0,0 +1,398 @@ +import 'dart:async'; + +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../../../proto/proto.dart' as proto; +import '../../../tools/tools.dart'; +import '../../models/models.dart'; + +const String veilidChatAccountKey = 'com.veilid.veilidchat'; + +enum AccountRepositoryChange { localAccounts, userLogins, activeLocalAccount } + +class AccountRepository { + AccountRepository._() + : _localAccounts = _initLocalAccounts(), + _userLogins = _initUserLogins(), + _activeLocalAccount = _initActiveAccount(), + _streamController = + StreamController.broadcast(); + + static TableDBValue> _initLocalAccounts() => TableDBValue( + tableName: 'local_account_manager', + tableKeyName: 'local_accounts', + valueFromJson: (obj) => obj != null + ? IList.fromJson( + obj, genericFromJson(LocalAccount.fromJson)) + : IList(), + valueToJson: (val) => val.toJson((la) => la.toJson())); + + static TableDBValue> _initUserLogins() => TableDBValue( + tableName: 'local_account_manager', + tableKeyName: 'user_logins', + valueFromJson: (obj) => obj != null + ? IList.fromJson(obj, genericFromJson(UserLogin.fromJson)) + : IList(), + valueToJson: (val) => val.toJson((la) => la.toJson())); + + static TableDBValue _initActiveAccount() => TableDBValue( + tableName: 'local_account_manager', + tableKeyName: 'active_local_account', + valueFromJson: (obj) => obj == null ? null : TypedKey.fromJson(obj), + valueToJson: (val) => val?.toJson()); + + ////////////////////////////////////////////////////////////// + /// Fields + + final TableDBValue> _localAccounts; + final TableDBValue> _userLogins; + final TableDBValue _activeLocalAccount; + final StreamController _streamController; + + ////////////////////////////////////////////////////////////// + /// Singleton initialization + + static AccountRepository instance = AccountRepository._(); + + Future init() async { + await _localAccounts.get(); + await _userLogins.get(); + await _activeLocalAccount.get(); + } + + Future close() async { + // ??? + } + + ////////////////////////////////////////////////////////////// + /// Streams + + Stream get stream => _streamController.stream; + + ////////////////////////////////////////////////////////////// + /// Selectors + IList getLocalAccounts() => _localAccounts.requireValue; + TypedKey? getActiveLocalAccount() => _activeLocalAccount.requireValue; + IList getUserLogins() => _userLogins.requireValue; + UserLogin? getActiveUserLogin() { + final activeLocalAccount = _activeLocalAccount.requireValue; + return activeLocalAccount == null + ? null + : fetchUserLogin(activeLocalAccount); + } + + LocalAccount? fetchLocalAccount(TypedKey accountMasterRecordKey) { + final localAccounts = _localAccounts.requireValue; + final idx = localAccounts.indexWhere( + (e) => e.identityMaster.masterRecordKey == accountMasterRecordKey); + if (idx == -1) { + return null; + } + return localAccounts[idx]; + } + + UserLogin? fetchUserLogin(TypedKey accountMasterRecordKey) { + final userLogins = _userLogins.requireValue; + final idx = userLogins + .indexWhere((e) => e.accountMasterRecordKey == accountMasterRecordKey); + if (idx == -1) { + return null; + } + return userLogins[idx]; + } + + AccountInfo getAccountInfo(TypedKey? accountMasterRecordKey) { + // Get active account if we have one + final activeLocalAccount = getActiveLocalAccount(); + if (accountMasterRecordKey == null) { + if (activeLocalAccount == null) { + // No user logged in + return const AccountInfo( + status: AccountInfoStatus.noAccount, + active: false, + activeAccountInfo: null); + } + accountMasterRecordKey = activeLocalAccount; + } + final active = accountMasterRecordKey == activeLocalAccount; + + // Get which local account we want to fetch the profile for + final localAccount = fetchLocalAccount(accountMasterRecordKey); + if (localAccount == null) { + // account does not exist + return AccountInfo( + status: AccountInfoStatus.noAccount, + active: active, + activeAccountInfo: null); + } + + // See if we've logged into this account or if it is locked + final userLogin = fetchUserLogin(accountMasterRecordKey); + if (userLogin == null) { + // Account was locked + return AccountInfo( + status: AccountInfoStatus.accountLocked, + active: active, + activeAccountInfo: null); + } + + // Got account, decrypted and decoded + return AccountInfo( + status: AccountInfoStatus.accountReady, + active: active, + activeAccountInfo: + ActiveAccountInfo(localAccount: localAccount, userLogin: userLogin), + ); + } + + ////////////////////////////////////////////////////////////// + /// Mutators + + /// Reorder accounts + Future reorderAccount(int oldIndex, int newIndex) async { + final localAccounts = await _localAccounts.get(); + final removedItem = Output(); + final updated = localAccounts + .removeAt(oldIndex, removedItem) + .insert(newIndex, removedItem.value!); + await _localAccounts.set(updated); + _streamController.add(AccountRepositoryChange.localAccounts); + } + + /// Creates a new master identity, an account associated with the master + /// identity, stores the account in the identity key and then logs into + /// that account with no password set at this time + Future createWithNewMasterIdentity( + NewProfileSpec newProfileSpec) async { + log.debug('Creating master identity'); + final imws = await IdentityMasterWithSecrets.create(); + try { + final localAccount = await _newLocalAccount( + identityMaster: imws.identityMaster, + identitySecret: imws.identitySecret, + newProfileSpec: newProfileSpec); + + // Log in the new account by default with no pin + final ok = await login(localAccount.identityMaster.masterRecordKey, + EncryptionKeyType.none, ''); + assert(ok, 'login with none should never fail'); + } on Exception catch (_) { + await imws.delete(); + rethrow; + } + } + + /// Creates a new Account associated with master identity + /// Adds a logged-out LocalAccount to track its existence on this device + Future _newLocalAccount( + {required IdentityMaster identityMaster, + required SecretKey identitySecret, + required NewProfileSpec newProfileSpec, + EncryptionKeyType encryptionKeyType = EncryptionKeyType.none, + String encryptionKey = ''}) async { + log.debug('Creating new local account'); + + final localAccounts = await _localAccounts.get(); + + // Add account with profile to DHT + await identityMaster.addAccountToIdentity( + identitySecret: identitySecret, + accountKey: veilidChatAccountKey, + createAccountCallback: (parent) async { + // Make empty contact list + log.debug('Creating contacts list'); + final contactList = await (await DHTShortArray.create(parent: parent)) + .scope((r) async => r.recordPointer); + + // Make empty contact invitation record list + log.debug('Creating contact invitation records list'); + final contactInvitationRecords = + await (await DHTShortArray.create(parent: parent)) + .scope((r) async => r.recordPointer); + + // Make empty chat record list + log.debug('Creating chat records list'); + final chatRecords = await (await DHTShortArray.create(parent: parent)) + .scope((r) async => r.recordPointer); + + // Make account object + final account = proto.Account() + ..profile = (proto.Profile() + ..name = newProfileSpec.name + ..pronouns = newProfileSpec.pronouns) + ..contactList = contactList.toProto() + ..contactInvitationRecords = contactInvitationRecords.toProto() + ..chatList = chatRecords.toProto(); + return account; + }); + + // Encrypt identitySecret with key + final identitySecretBytes = await encryptionKeyType.encryptSecretToBytes( + secret: identitySecret, + cryptoKind: identityMaster.identityRecordKey.kind, + encryptionKey: encryptionKey, + ); + + // Create local account object + // Does not contain the account key or its secret + // as that is not to be persisted, and only pulled from the identity key + // and optionally decrypted with the unlock password + final localAccount = LocalAccount( + identityMaster: identityMaster, + identitySecretBytes: identitySecretBytes, + encryptionKeyType: encryptionKeyType, + biometricsEnabled: false, + hiddenAccount: false, + name: newProfileSpec.name, + ); + + // Add local account object to internal store + final newLocalAccounts = localAccounts.add(localAccount); + + await _localAccounts.set(newLocalAccounts); + _streamController.add(AccountRepositoryChange.localAccounts); + + // Return local account object + return localAccount; + } + + /// Remove an account and wipe the messages for this account from this device + Future deleteLocalAccount(TypedKey accountMasterRecordKey) async { + await logout(accountMasterRecordKey); + + final localAccounts = await _localAccounts.get(); + final newLocalAccounts = localAccounts.removeWhere( + (la) => la.identityMaster.masterRecordKey == accountMasterRecordKey); + + await _localAccounts.set(newLocalAccounts); + _streamController.add(AccountRepositoryChange.localAccounts); + + // TO DO: wipe messages + + return true; + } + + /// Import an account from another VeilidChat instance + + /// Recover an account with the master identity secret + + /// Delete an account from all devices + + Future switchToAccount(TypedKey? accountMasterRecordKey) async { + final activeLocalAccount = await _activeLocalAccount.get(); + + if (activeLocalAccount == accountMasterRecordKey) { + // Nothing to do + return; + } + + if (accountMasterRecordKey != null) { + // Assert the specified record key can be found, will throw if not + final _ = _userLogins.requireValue.firstWhere( + (ul) => ul.accountMasterRecordKey == accountMasterRecordKey); + } + await _activeLocalAccount.set(accountMasterRecordKey); + _streamController.add(AccountRepositoryChange.activeLocalAccount); + } + + Future _decryptedLogin( + IdentityMaster identityMaster, SecretKey identitySecret) async { + // Verify identity secret works and return the valid cryptosystem + final cs = await identityMaster.validateIdentitySecret(identitySecret); + + // Read the identity key to get the account keys + final accountRecordInfoList = await identityMaster.readAccountsFromIdentity( + identitySecret: identitySecret, accountKey: veilidChatAccountKey); + if (accountRecordInfoList.length > 1) { + throw IdentityException.limitExceeded; + } else if (accountRecordInfoList.isEmpty) { + throw IdentityException.noAccount; + } + final accountRecordInfo = accountRecordInfoList.single; + + // Add to user logins and select it + final userLogins = await _userLogins.get(); + final now = Veilid.instance.now(); + final newUserLogins = userLogins.replaceFirstWhere( + (ul) => ul.accountMasterRecordKey == identityMaster.masterRecordKey, + (ul) => ul != null + ? ul.copyWith(lastActive: now) + : UserLogin( + accountMasterRecordKey: identityMaster.masterRecordKey, + identitySecret: + TypedSecret(kind: cs.kind(), value: identitySecret), + accountRecordInfo: accountRecordInfo, + lastActive: now), + addIfNotFound: true); + + await _userLogins.set(newUserLogins); + await _activeLocalAccount.set(identityMaster.masterRecordKey); + + _streamController + ..add(AccountRepositoryChange.userLogins) + ..add(AccountRepositoryChange.activeLocalAccount); + + return true; + } + + Future login(TypedKey accountMasterRecordKey, + EncryptionKeyType encryptionKeyType, String encryptionKey) async { + final localAccounts = await _localAccounts.get(); + + // Get account, throws if not found + final localAccount = localAccounts.firstWhere( + (la) => la.identityMaster.masterRecordKey == accountMasterRecordKey); + + // Log in with this local account + + // Derive key from password + if (localAccount.encryptionKeyType != encryptionKeyType) { + throw Exception('Wrong authentication type'); + } + + final identitySecret = + await localAccount.encryptionKeyType.decryptSecretFromBytes( + secretBytes: localAccount.identitySecretBytes, + cryptoKind: localAccount.identityMaster.identityRecordKey.kind, + encryptionKey: encryptionKey, + ); + + // Validate this secret with the identity public key and log in + return _decryptedLogin(localAccount.identityMaster, identitySecret); + } + + Future logout(TypedKey? accountMasterRecordKey) async { + // Resolve which user to log out + final activeLocalAccount = await _activeLocalAccount.get(); + final logoutUser = accountMasterRecordKey ?? activeLocalAccount; + if (logoutUser == null) { + log.error('missing user in logout: $accountMasterRecordKey'); + return; + } + + final logoutUserLogin = fetchUserLogin(logoutUser); + if (logoutUserLogin == null) { + // Already logged out + return; + } + + // Remove user from active logins list + final newUserLogins = (await _userLogins.get()) + .removeWhere((ul) => ul.accountMasterRecordKey == logoutUser); + await _userLogins.set(newUserLogins); + _streamController.add(AccountRepositoryChange.userLogins); + } + + Future openAccountRecord(UserLogin userLogin) async { + final localAccount = fetchLocalAccount(userLogin.accountMasterRecordKey)!; + + // Record not yet open, do it + final pool = DHTRecordPool.instance; + final record = await pool.openOwned( + userLogin.accountRecordInfo.accountRecord, + parent: localAccount.identityMaster.identityRecordKey); + + return record; + } +} diff --git a/lib/account_manager/repository/repository.dart b/lib/account_manager/repository/repository.dart new file mode 100644 index 0000000..9d1b9fe --- /dev/null +++ b/lib/account_manager/repository/repository.dart @@ -0,0 +1 @@ +export 'account_repository/account_repository.dart'; diff --git a/lib/pages/new_account.dart b/lib/account_manager/views/new_account_page/new_account_page.dart similarity index 66% rename from lib/pages/new_account.dart rename to lib/account_manager/views/new_account_page/new_account_page.dart index 3ef1b6d..f81f30d 100644 --- a/lib/pages/new_account.dart +++ b/lib/account_manager/views/new_account_page/new_account_page.dart @@ -2,28 +2,23 @@ import 'package:awesome_extensions/awesome_extensions.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_form_builder/flutter_form_builder.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_translate/flutter_translate.dart'; import 'package:form_builder_validators/form_builder_validators.dart'; import 'package:go_router/go_router.dart'; -import '../components/default_app_bar.dart'; -import '../components/signal_strength_meter.dart'; -import '../entities/entities.dart'; -import '../providers/local_accounts.dart'; -import '../providers/logins.dart'; -import '../providers/window_control.dart'; -import '../tools/tools.dart'; -import '../veilid_support/veilid_support.dart'; +import '../../../layout/default_app_bar.dart'; +import '../../../tools/tools.dart'; +import '../../../veilid_processor/veilid_processor.dart'; +import '../../account_manager.dart'; -class NewAccountPage extends ConsumerStatefulWidget { +class NewAccountPage extends StatefulWidget { const NewAccountPage({super.key}); @override NewAccountPageState createState() => NewAccountPageState(); } -class NewAccountPageState extends ConsumerState { +class NewAccountPageState extends State { final _formKey = GlobalKey(); late bool isInAsyncCall = false; static const String formFieldName = 'name'; @@ -34,42 +29,11 @@ class NewAccountPageState extends ConsumerState { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) async { - setState(() {}); - await ref.read(windowControlProvider.notifier).changeWindowSetup( + await changeWindowSetup( TitleBarStyle.normal, OrientationCapability.portraitOnly); }); } - /// Creates a new master identity, an account associated with the master - /// identity, stores the account in the identity key and then logs into - /// that account with no password set at this time - Future createAccount() async { - final localAccounts = ref.read(localAccountsProvider.notifier); - final logins = ref.read(loginsProvider.notifier); - - final name = _formKey.currentState!.fields[formFieldName]!.value as String; - final pronouns = - _formKey.currentState!.fields[formFieldPronouns]!.value as String? ?? - ''; - - final imws = await IdentityMasterWithSecrets.create(); - try { - final localAccount = await localAccounts.newLocalAccount( - identityMaster: imws.identityMaster, - identitySecret: imws.identitySecret, - name: name, - pronouns: pronouns); - - // Log in the new account by default with no pin - final ok = await logins.login(localAccount.identityMaster.masterRecordKey, - EncryptionKeyType.none, ''); - assert(ok, 'login with none should never fail'); - } on Exception catch (_) { - await imws.delete(); - rethrow; - } - } - Widget _newAccountForm(BuildContext context, {required Future Function(GlobalKey) onSubmit}) => @@ -90,12 +54,14 @@ class NewAccountPageState extends ConsumerState { validator: FormBuilderValidators.compose([ FormBuilderValidators.required(), ]), + textInputAction: TextInputAction.next, ), FormBuilderTextField( name: formFieldPronouns, maxLength: 64, decoration: InputDecoration( labelText: translate('account.form_pronouns')), + textInputAction: TextInputAction.next, ), Row(children: [ const Spacer(), @@ -129,13 +95,7 @@ class NewAccountPageState extends ConsumerState { @override Widget build(BuildContext context) { - ref.watch(windowControlProvider); - - final localAccounts = ref.watch(localAccountsProvider); - final logins = ref.watch(loginsProvider); - - final displayModalHUD = - isInAsyncCall || !localAccounts.hasValue || !logins.hasValue; + final displayModalHUD = isInAsyncCall; return Scaffold( // resizeToAvoidBottomInset: false, @@ -147,7 +107,7 @@ class NewAccountPageState extends ConsumerState { icon: const Icon(Icons.settings), tooltip: translate('app_bar.settings_tooltip'), onPressed: () async { - context.go('/new_account/settings'); + await GoRouterHelper(context).push('/settings'); }) ]), body: _newAccountForm( @@ -155,7 +115,16 @@ class NewAccountPageState extends ConsumerState { onSubmit: (formKey) async { FocusScope.of(context).unfocus(); try { - await createAccount(); + final name = + _formKey.currentState!.fields[formFieldName]!.value as String; + final pronouns = _formKey.currentState!.fields[formFieldPronouns]! + .value as String? ?? + ''; + final newProfileSpec = + NewProfileSpec(name: name, pronouns: pronouns); + + await AccountRepository.instance + .createWithNewMasterIdentity(newProfileSpec); } on Exception catch (e) { if (context.mounted) { await showErrorModal(context, translate('new_account_page.error'), diff --git a/lib/account_manager/views/profile_widget.dart b/lib/account_manager/views/profile_widget.dart new file mode 100644 index 0000000..5d85014 --- /dev/null +++ b/lib/account_manager/views/profile_widget.dart @@ -0,0 +1,43 @@ +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:flutter/material.dart'; + +import '../../proto/proto.dart' as proto; +import '../../theme/theme.dart'; + +class ProfileWidget extends StatelessWidget { + const ProfileWidget({ + required proto.Profile profile, + super.key, + }) : _profile = profile; + + // + + final proto.Profile _profile; + + // + + @override + // ignore: prefer_expression_function_bodies + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scale = theme.extension()!; + final textTheme = theme.textTheme; + + return DecoratedBox( + decoration: ShapeDecoration( + color: scale.primaryScale.border, + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))), + child: Column(children: [ + Text( + _profile.name, + style: textTheme.headlineSmall, + textAlign: TextAlign.left, + ).paddingAll(4), + if (_profile.pronouns.isNotEmpty) + Text(_profile.pronouns, style: textTheme.bodyMedium) + .paddingLTRB(4, 0, 4, 4), + ]), + ); + } +} diff --git a/lib/account_manager/views/views.dart b/lib/account_manager/views/views.dart new file mode 100644 index 0000000..a10db1b --- /dev/null +++ b/lib/account_manager/views/views.dart @@ -0,0 +1,2 @@ +export 'new_account_page/new_account_page.dart'; +export 'profile_widget.dart'; diff --git a/lib/app.dart b/lib/app.dart index 957f46f..6624602 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -1,52 +1,84 @@ import 'package:animated_theme_switcher/animated_theme_switcher.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_translate/flutter_translate.dart'; import 'package:form_builder_validators/form_builder_validators.dart'; +import 'account_manager/account_manager.dart'; import 'router/router.dart'; +import 'settings/settings.dart'; import 'tick.dart'; +import 'veilid_processor/veilid_processor.dart'; -class VeilidChatApp extends ConsumerWidget { +class VeilidChatApp extends StatelessWidget { const VeilidChatApp({ - required this.theme, + required this.initialThemeData, super.key, }); - final ThemeData theme; + static const String name = 'VeilidChat'; + + final ThemeData initialThemeData; @override - Widget build(BuildContext context, WidgetRef ref) { - final router = ref.watch(routerProvider); + Widget build(BuildContext context) { final localizationDelegate = LocalizedApp.of(context).delegate; return ThemeProvider( - initTheme: theme, + initTheme: initialThemeData, builder: (_, theme) => LocalizationProvider( state: LocalizationProvider.of(context).state, - child: BackgroundTicker( - builder: (context) => MaterialApp.router( - debugShowCheckedModeBanner: false, - routerConfig: router, - title: translate('app.title'), - theme: theme, - localizationsDelegates: [ - GlobalMaterialLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, - FormBuilderLocalizations.delegate, - localizationDelegate - ], - supportedLocales: localizationDelegate.supportedLocales, - locale: localizationDelegate.currentLocale, - )), + child: MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => + ConnectionStateCubit(ProcessorRepository.instance)), + BlocProvider( + create: (context) => + RouterCubit(AccountRepository.instance), + ), + BlocProvider( + create: (context) => + LocalAccountsCubit(AccountRepository.instance), + ), + BlocProvider( + create: (context) => + UserLoginsCubit(AccountRepository.instance), + ), + BlocProvider( + create: (context) => + ActiveLocalAccountCubit(AccountRepository.instance), + ), + BlocProvider( + create: (context) => + PreferencesCubit(PreferencesRepository.instance), + ) + ], + child: BackgroundTicker( + builder: (context) => MaterialApp.router( + debugShowCheckedModeBanner: false, + routerConfig: context.watch().router(), + title: translate('app.title'), + theme: theme, + localizationsDelegates: [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + FormBuilderLocalizations.delegate, + localizationDelegate + ], + supportedLocales: localizationDelegate.supportedLocales, + locale: localizationDelegate.currentLocale, + ), + )), )); } @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); - properties.add(DiagnosticsProperty('theme', theme)); + properties + .add(DiagnosticsProperty('themeData', initialThemeData)); } } diff --git a/lib/chat/chat.dart b/lib/chat/chat.dart new file mode 100644 index 0000000..6acdd43 --- /dev/null +++ b/lib/chat/chat.dart @@ -0,0 +1,2 @@ +export 'cubits/cubits.dart'; +export 'views/views.dart'; diff --git a/lib/chat/cubits/active_chat_cubit.dart b/lib/chat/cubits/active_chat_cubit.dart new file mode 100644 index 0000000..b57076e --- /dev/null +++ b/lib/chat/cubits/active_chat_cubit.dart @@ -0,0 +1,11 @@ +import 'package:bloc_tools/bloc_tools.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:veilid_support/veilid_support.dart'; + +class ActiveChatCubit extends Cubit with BlocTools { + ActiveChatCubit(super.initialState); + + void setActiveChat(TypedKey? activeChatRemoteConversationRecordKey) { + emit(activeChatRemoteConversationRecordKey); + } +} diff --git a/lib/chat/cubits/cubits.dart b/lib/chat/cubits/cubits.dart new file mode 100644 index 0000000..b80767f --- /dev/null +++ b/lib/chat/cubits/cubits.dart @@ -0,0 +1,2 @@ +export 'active_chat_cubit.dart'; +export 'single_contact_messages_cubit.dart'; diff --git a/lib/chat/cubits/single_contact_messages_cubit.dart b/lib/chat/cubits/single_contact_messages_cubit.dart new file mode 100644 index 0000000..4427b89 --- /dev/null +++ b/lib/chat/cubits/single_contact_messages_cubit.dart @@ -0,0 +1,278 @@ +import 'dart:async'; + +import 'package:async_tools/async_tools.dart'; +import 'package:bloc_tools/bloc_tools.dart'; +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../account_manager/account_manager.dart'; +import '../../proto/proto.dart' as proto; + +class _SingleContactMessageQueueEntry { + _SingleContactMessageQueueEntry({this.localMessages, this.remoteMessages}); + IList? localMessages; + IList? remoteMessages; +} + +typedef SingleContactMessagesState = AsyncValue>; + +// Cubit that processes single-contact chats +// Builds the reconciled chat record from the local and remote conversation keys +class SingleContactMessagesCubit extends Cubit { + SingleContactMessagesCubit({ + required ActiveAccountInfo activeAccountInfo, + required TypedKey remoteIdentityPublicKey, + required TypedKey localConversationRecordKey, + required TypedKey localMessagesRecordKey, + required TypedKey remoteConversationRecordKey, + required TypedKey remoteMessagesRecordKey, + required OwnedDHTRecordPointer reconciledChatRecord, + }) : _activeAccountInfo = activeAccountInfo, + _remoteIdentityPublicKey = remoteIdentityPublicKey, + _localConversationRecordKey = localConversationRecordKey, + _localMessagesRecordKey = localMessagesRecordKey, + _remoteConversationRecordKey = remoteConversationRecordKey, + _remoteMessagesRecordKey = remoteMessagesRecordKey, + _reconciledChatRecord = reconciledChatRecord, + _messagesUpdateQueue = StreamController(), + super(const AsyncValue.loading()) { + // Async Init + Future.delayed(Duration.zero, _init); + } + + @override + Future close() async { + await _messagesUpdateQueue.close(); + await _localSubscription?.cancel(); + await _remoteSubscription?.cancel(); + await _reconciledChatSubscription?.cancel(); + await _localMessagesCubit?.close(); + await _remoteMessagesCubit?.close(); + await _reconciledChatMessagesCubit?.close(); + await super.close(); + } + + // Initialize everything + Future _init() async { + // Make crypto + await _initMessagesCrypto(); + + // Reconciled messages key + await _initReconciledChatMessages(); + + // Local messages key + await _initLocalMessages(); + + // Remote messages key + await _initRemoteMessages(); + + // Messages listener + Future.delayed(Duration.zero, () async { + await for (final entry in _messagesUpdateQueue.stream) { + await _updateMessagesStateAsync(entry); + } + }); + } + + // Make crypto + + Future _initMessagesCrypto() async { + _messagesCrypto = await _activeAccountInfo + .makeConversationCrypto(_remoteIdentityPublicKey); + } + + // Open local messages key + Future _initLocalMessages() async { + final writer = _activeAccountInfo.conversationWriter; + + _localMessagesCubit = DHTShortArrayCubit( + open: () async => DHTShortArray.openWrite( + _localMessagesRecordKey, writer, + parent: _localConversationRecordKey, crypto: _messagesCrypto), + decodeElement: proto.Message.fromBuffer); + _localSubscription = + _localMessagesCubit!.stream.listen(_updateLocalMessagesState); + _updateLocalMessagesState(_localMessagesCubit!.state); + } + + // Open remote messages key + Future _initRemoteMessages() async { + _remoteMessagesCubit = DHTShortArrayCubit( + open: () async => DHTShortArray.openRead(_remoteMessagesRecordKey, + parent: _remoteConversationRecordKey, crypto: _messagesCrypto), + decodeElement: proto.Message.fromBuffer); + _remoteSubscription = + _remoteMessagesCubit!.stream.listen(_updateRemoteMessagesState); + _updateRemoteMessagesState(_remoteMessagesCubit!.state); + } + + // Open reconciled chat record key + Future _initReconciledChatMessages() async { + final accountRecordKey = + _activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey; + + _reconciledChatMessagesCubit = DHTShortArrayCubit( + open: () async => DHTShortArray.openOwned(_reconciledChatRecord, + parent: accountRecordKey), + decodeElement: proto.Message.fromBuffer); + _reconciledChatSubscription = + _reconciledChatMessagesCubit!.stream.listen(_updateReconciledChatState); + _updateReconciledChatState(_reconciledChatMessagesCubit!.state); + } + + // Called when the local messages list gets a change + void _updateLocalMessagesState( + BlocBusyState>> avmessages) { + final localMessages = avmessages.state.data?.value; + if (localMessages == null) { + return; + } + // Add local messages updates to queue to process asynchronously + _messagesUpdateQueue + .add(_SingleContactMessageQueueEntry(localMessages: localMessages)); + } + + // Called when the remote messages list gets a change + void _updateRemoteMessagesState( + BlocBusyState>> avmessages) { + final remoteMessages = avmessages.state.data?.value; + if (remoteMessages == null) { + return; + } + // Add remote messages updates to queue to process asynchronously + _messagesUpdateQueue + .add(_SingleContactMessageQueueEntry(remoteMessages: remoteMessages)); + } + + // Called when the reconciled messages list gets a change + void _updateReconciledChatState( + BlocBusyState>> avmessages) { + // When reconciled messages are updated, pass this + // directly to the messages cubit state + emit(avmessages.state); + } + + Future _mergeMessagesInner( + {required DHTShortArrayWrite reconciledMessagesWriter, + required IList messages}) async { + // Ensure remoteMessages is sorted by timestamp + final newMessages = messages + .sort((a, b) => a.timestamp.compareTo(b.timestamp)) + .removeDuplicates(); + + // Existing messages will always be sorted by timestamp so merging is easy + final existingMessages = await reconciledMessagesWriter + .getAllItemsProtobuf(proto.Message.fromBuffer); + if (existingMessages == null) { + throw Exception( + 'Could not load existing reconciled messages at this time'); + } + + var ePos = 0; + var nPos = 0; + while (ePos < existingMessages.length && nPos < newMessages.length) { + final existingMessage = existingMessages[ePos]; + final newMessage = newMessages[nPos]; + + // If timestamp to insert is less than + // the current position, insert it here + final newTs = Timestamp.fromInt64(newMessage.timestamp); + final existingTs = Timestamp.fromInt64(existingMessage.timestamp); + final cmp = newTs.compareTo(existingTs); + if (cmp < 0) { + // New message belongs here + + // Insert into dht backing array + await reconciledMessagesWriter.tryInsertItem( + ePos, newMessage.writeToBuffer()); + // Insert into local copy as well for this operation + existingMessages.insert(ePos, newMessage); + + // Next message + nPos++; + ePos++; + } else if (cmp == 0) { + // Duplicate, skip + nPos++; + ePos++; + } else if (cmp > 0) { + // New message belongs later + ePos++; + } + } + // If there are any new messages left, append them all + while (nPos < newMessages.length) { + final newMessage = newMessages[nPos]; + + // Append to dht backing array + await reconciledMessagesWriter.tryAddItem(newMessage.writeToBuffer()); + // Insert into local copy as well for this operation + existingMessages.add(newMessage); + + nPos++; + } + } + + Future _updateMessagesStateAsync( + _SingleContactMessageQueueEntry entry) async { + final reconciledChatMessagesCubit = _reconciledChatMessagesCubit!; + + // Merge remote and local messages into the reconciled chat log + await reconciledChatMessagesCubit + .operateWrite((reconciledMessagesWriter) async { + // xxx for now, keep two lists, but can probable simplify this out soon + if (entry.localMessages != null) { + await _mergeMessagesInner( + reconciledMessagesWriter: reconciledMessagesWriter, + messages: entry.localMessages!); + } + if (entry.remoteMessages != null) { + await _mergeMessagesInner( + reconciledMessagesWriter: reconciledMessagesWriter, + messages: entry.remoteMessages!); + } + }); + } + + // Force refresh of messages + Future refresh() async { + final lcc = _localMessagesCubit; + final rcc = _remoteMessagesCubit; + + if (lcc != null) { + await lcc.refresh(); + } + if (rcc != null) { + await rcc.refresh(); + } + } + + Future addMessage({required proto.Message message}) async { + await _localMessagesCubit! + .operateWrite((writer) => writer.tryAddItem(message.writeToBuffer())); + } + + final ActiveAccountInfo _activeAccountInfo; + final TypedKey _remoteIdentityPublicKey; + final TypedKey _localConversationRecordKey; + final TypedKey _localMessagesRecordKey; + final TypedKey _remoteConversationRecordKey; + final TypedKey _remoteMessagesRecordKey; + final OwnedDHTRecordPointer _reconciledChatRecord; + + late final DHTRecordCrypto _messagesCrypto; + + DHTShortArrayCubit? _localMessagesCubit; + DHTShortArrayCubit? _remoteMessagesCubit; + DHTShortArrayCubit? _reconciledChatMessagesCubit; + + final StreamController<_SingleContactMessageQueueEntry> _messagesUpdateQueue; + + StreamSubscription>>>? + _localSubscription; + StreamSubscription>>>? + _remoteSubscription; + StreamSubscription>>>? + _reconciledChatSubscription; +} diff --git a/lib/chat/views/chat_component.dart b/lib/chat/views/chat_component.dart new file mode 100644 index 0000000..146185b --- /dev/null +++ b/lib/chat/views/chat_component.dart @@ -0,0 +1,211 @@ +import 'dart:async'; + +import 'package:async_tools/async_tools.dart'; +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_chat_types/flutter_chat_types.dart' as types; +import 'package:flutter_chat_ui/flutter_chat_ui.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../account_manager/account_manager.dart'; +import '../../chat_list/chat_list.dart'; +import '../../contacts/contacts.dart'; +import '../../proto/proto.dart' as proto; +import '../../theme/theme.dart'; +import '../../tools/tools.dart'; +import '../chat.dart'; + +class ChatComponent extends StatelessWidget { + const ChatComponent._( + {required TypedKey localUserIdentityKey, + required SingleContactMessagesCubit messagesCubit, + required SingleContactMessagesState messagesState, + required types.User localUser, + required types.User remoteUser, + super.key}) + : _localUserIdentityKey = localUserIdentityKey, + _messagesCubit = messagesCubit, + _messagesState = messagesState, + _localUser = localUser, + _remoteUser = remoteUser; + + final TypedKey _localUserIdentityKey; + final SingleContactMessagesCubit _messagesCubit; + final SingleContactMessagesState _messagesState; + final types.User _localUser; + final types.User _remoteUser; + + // Builder wrapper function that takes care of state management requirements + static Widget builder( + {required TypedKey remoteConversationRecordKey, Key? key}) => + Builder(builder: (context) { + // Get all watched dependendies + final activeAccountInfo = context.watch(); + final accountRecordInfo = + context.watch().state.data?.value; + if (accountRecordInfo == null) { + return debugPage('should always have an account record here'); + } + final contactList = + context.watch().state.state.data?.value; + if (contactList == null) { + return debugPage('should always have a contact list here'); + } + final avconversation = context.select?>( + (x) => x.state[remoteConversationRecordKey]); + if (avconversation == null) { + return waitingPage(); + } + final conversation = avconversation.data?.value; + if (conversation == null) { + return avconversation.buildNotData(); + } + + // Make flutter_chat_ui 'User's + final localUserIdentityKey = activeAccountInfo + .localAccount.identityMaster + .identityPublicTypedKey(); + + final localUser = types.User( + id: localUserIdentityKey.toString(), + firstName: accountRecordInfo.profile.name, + ); + final editedName = conversation.contact.editedProfile.name; + final remoteUser = types.User( + id: conversation.contact.identityPublicKey.toVeilid().toString(), + firstName: editedName); + + // Get the messages cubit + final messages = context.select( + (x) => x.tryOperate(remoteConversationRecordKey, + closure: (cubit) => (cubit, cubit.state))); + + // Get the messages to display + // and ensure it is safe to operate() on the MessageCubit for this chat + if (messages == null) { + return waitingPage(); + } + + return ChatComponent._( + localUserIdentityKey: localUserIdentityKey, + messagesCubit: messages.$1, + messagesState: messages.$2, + localUser: localUser, + remoteUser: remoteUser, + key: key); + }); + + ///////////////////////////////////////////////////////////////////// + + types.Message messageToChatMessage(proto.Message message) { + final isLocal = message.author == _localUserIdentityKey.toProto(); + + final textMessage = types.TextMessage( + author: isLocal ? _localUser : _remoteUser, + createdAt: (message.timestamp ~/ 1000).toInt(), + id: message.timestamp.toString(), + text: message.text, + ); + return textMessage; + } + + Future _addMessage(proto.Message message) async { + if (message.text.isEmpty) { + return; + } + await _messagesCubit.addMessage(message: message); + } + + Future _handleSendPressed(types.PartialText message) async { + final protoMessage = proto.Message() + ..author = _localUserIdentityKey.toProto() + ..timestamp = Veilid.instance.now().toInt64() + ..text = message.text; + //..signature = signature; + + await _addMessage(protoMessage); + } + + Future _handleAttachmentPressed() async { + // + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scale = theme.extension()!; + final textTheme = Theme.of(context).textTheme; + final chatTheme = makeChatTheme(scale, textTheme); + + final messages = _messagesState.data?.value; + if (messages == null) { + return _messagesState.buildNotData(); + } + + // Convert protobuf messages to chat messages + final chatMessages = []; + for (final message in messages) { + final chatMessage = messageToChatMessage(message); + chatMessages.insert(0, chatMessage); + } + + return DefaultTextStyle( + style: textTheme.bodySmall!, + child: Align( + alignment: AlignmentDirectional.centerEnd, + child: Stack( + children: [ + Column( + children: [ + Container( + height: 48, + decoration: BoxDecoration( + color: scale.primaryScale.subtleBorder, + ), + child: Row(children: [ + Align( + alignment: AlignmentDirectional.centerStart, + child: Padding( + padding: const EdgeInsetsDirectional.fromSTEB( + 16, 0, 16, 0), + child: Text(_remoteUser.firstName!, + textAlign: TextAlign.start, + style: textTheme.titleMedium), + )), + const Spacer(), + IconButton( + icon: const Icon(Icons.close), + onPressed: () async { + context.read().setActiveChat(null); + }).paddingLTRB(16, 0, 16, 0) + ]), + ), + Expanded( + child: DecoratedBox( + decoration: const BoxDecoration(), + child: Chat( + theme: chatTheme, + messages: chatMessages, + //onAttachmentPressed: _handleAttachmentPressed, + //onMessageTap: _handleMessageTap, + //onPreviewDataFetched: _handlePreviewDataFetched, + onSendPressed: (message) { + singleFuture( + this, () async => _handleSendPressed(message)); + }, + //showUserAvatars: false, + //showUserNames: true, + user: _localUser, + ), + ), + ), + ], + ), + ], + ), + )); + } +} diff --git a/lib/chat/views/empty_chat_widget.dart b/lib/chat/views/empty_chat_widget.dart new file mode 100644 index 0000000..a9072cd --- /dev/null +++ b/lib/chat/views/empty_chat_widget.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; + +class EmptyChatWidget extends StatelessWidget { + const EmptyChatWidget({super.key}); + + @override + // ignore: prefer_expression_function_bodies + Widget build( + BuildContext context, + ) => + Container( + width: double.infinity, + height: double.infinity, + decoration: BoxDecoration( + color: Theme.of(context).scaffoldBackgroundColor, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.chat, + color: Theme.of(context).disabledColor, + size: 48, + ), + Text( + 'Say Something', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).disabledColor, + ), + ), + ], + ), + ); +} diff --git a/lib/chat/views/no_conversation_widget.dart b/lib/chat/views/no_conversation_widget.dart new file mode 100644 index 0000000..1b8545f --- /dev/null +++ b/lib/chat/views/no_conversation_widget.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; + +class NoConversationWidget extends StatelessWidget { + const NoConversationWidget({super.key}); + + @override + // ignore: prefer_expression_function_bodies + Widget build( + BuildContext context, + ) => + Container( + width: double.infinity, + height: double.infinity, + decoration: BoxDecoration( + color: Theme.of(context).primaryColor, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.emoji_people_outlined, + color: Theme.of(context).disabledColor, + size: 48, + ), + Text( + 'Choose A Conversation To Chat', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).disabledColor, + ), + ), + ], + ), + ); +} diff --git a/lib/chat/views/views.dart b/lib/chat/views/views.dart new file mode 100644 index 0000000..6230e65 --- /dev/null +++ b/lib/chat/views/views.dart @@ -0,0 +1,3 @@ +export 'chat_component.dart'; +export 'empty_chat_widget.dart'; +export 'no_conversation_widget.dart'; diff --git a/lib/chat_list/chat_list.dart b/lib/chat_list/chat_list.dart new file mode 100644 index 0000000..6acdd43 --- /dev/null +++ b/lib/chat_list/chat_list.dart @@ -0,0 +1,2 @@ +export 'cubits/cubits.dart'; +export 'views/views.dart'; diff --git a/lib/chat_list/cubits/active_conversations_bloc_map_cubit.dart b/lib/chat_list/cubits/active_conversations_bloc_map_cubit.dart new file mode 100644 index 0000000..8ce919a --- /dev/null +++ b/lib/chat_list/cubits/active_conversations_bloc_map_cubit.dart @@ -0,0 +1,116 @@ +import 'package:async_tools/async_tools.dart'; +import 'package:bloc_tools/bloc_tools.dart'; +import 'package:equatable/equatable.dart'; +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:meta/meta.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../account_manager/account_manager.dart'; +import '../../contacts/contacts.dart'; +import '../../proto/proto.dart' as proto; + +@immutable +class ActiveConversationState extends Equatable { + const ActiveConversationState({ + required this.contact, + required this.localConversation, + required this.remoteConversation, + }); + + final proto.Contact contact; + final proto.Conversation localConversation; + final proto.Conversation remoteConversation; + + @override + List get props => [contact, localConversation, remoteConversation]; +} + +typedef ActiveConversationCubit = TransformerCubit< + AsyncValue, AsyncValue>; + +typedef ActiveConversationsBlocMapState + = BlocMapState>; + +// Map of remoteConversationRecordKey to ActiveConversationCubit +// Wraps a conversation cubit to only expose completely built conversations +// Automatically follows the state of a ChatListCubit. +// Even though 'conversations' are per-contact and not per-chat +// We currently only build the cubits for the chats that are active, not +// archived chats or contacts that are not actively in a chat. +class ActiveConversationsBlocMapCubit extends BlocMapCubit, ActiveConversationCubit> + with + StateFollower>>, TypedKey, + proto.Chat> { + ActiveConversationsBlocMapCubit( + {required ActiveAccountInfo activeAccountInfo, + required ContactListCubit contactListCubit}) + : _activeAccountInfo = activeAccountInfo, + _contactListCubit = contactListCubit; + + // Add an active conversation to be tracked for changes + Future _addConversation({required proto.Contact contact}) async => + add(() => MapEntry( + contact.remoteConversationRecordKey.toVeilid(), + TransformerCubit( + ConversationCubit( + activeAccountInfo: _activeAccountInfo, + remoteIdentityPublicKey: contact.identityPublicKey.toVeilid(), + localConversationRecordKey: + contact.localConversationRecordKey.toVeilid(), + remoteConversationRecordKey: + contact.remoteConversationRecordKey.toVeilid(), + ), + // Transformer that only passes through completed conversations + // along with the contact that corresponds to the completed + // conversation + transform: (avstate) => avstate.when( + data: (data) => (data.localConversation == null || + data.remoteConversation == null) + ? const AsyncValue.loading() + : AsyncValue.data(ActiveConversationState( + contact: contact, + localConversation: data.localConversation!, + remoteConversation: data.remoteConversation!)), + loading: AsyncValue.loading, + error: AsyncValue.error)))); + + /// StateFollower ///////////////////////// + + @override + IMap getStateMap( + BlocBusyState>> state) { + final stateValue = state.state.data?.value; + if (stateValue == null) { + return IMap(); + } + return IMap.fromIterable(stateValue, + keyMapper: (e) => e.remoteConversationRecordKey.toVeilid(), + valueMapper: (e) => e); + } + + @override + Future removeFromState(TypedKey key) => remove(key); + + @override + Future updateState(TypedKey key, proto.Chat value) async { + final contactList = _contactListCubit.state.state.data?.value; + if (contactList == null) { + await addState(key, const AsyncValue.loading()); + return; + } + final contactIndex = contactList + .indexWhere((c) => c.remoteConversationRecordKey.toVeilid() == key); + if (contactIndex == -1) { + await addState(key, AsyncValue.error('Contact not found')); + return; + } + final contact = contactList[contactIndex]; + await _addConversation(contact: contact); + } + + //// + + final ActiveAccountInfo _activeAccountInfo; + final ContactListCubit _contactListCubit; +} diff --git a/lib/chat_list/cubits/active_single_contact_chat_bloc_map_cubit.dart b/lib/chat_list/cubits/active_single_contact_chat_bloc_map_cubit.dart new file mode 100644 index 0000000..c6827f3 --- /dev/null +++ b/lib/chat_list/cubits/active_single_contact_chat_bloc_map_cubit.dart @@ -0,0 +1,108 @@ +import 'dart:async'; + +import 'package:async_tools/async_tools.dart'; +import 'package:bloc_tools/bloc_tools.dart'; +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../account_manager/account_manager.dart'; +import '../../chat/chat.dart'; +import '../../contacts/contacts.dart'; +import '../../proto/proto.dart' as proto; +import 'active_conversations_bloc_map_cubit.dart'; +import 'chat_list_cubit.dart'; + +// Map of remoteConversationRecordKey to MessagesCubit +// Wraps a MessagesCubit to stream the latest messages to the state +// Automatically follows the state of a ActiveConversationsBlocMapCubit. +class ActiveSingleContactChatBlocMapCubit extends BlocMapCubit>, SingleContactMessagesCubit> + with + StateFollower> { + ActiveSingleContactChatBlocMapCubit( + {required ActiveAccountInfo activeAccountInfo, + required ContactListCubit contactListCubit, + required ChatListCubit chatListCubit}) + : _activeAccountInfo = activeAccountInfo, + _contactListCubit = contactListCubit, + _chatListCubit = chatListCubit; + + Future _addConversationMessages( + {required proto.Contact contact, + required proto.Chat chat, + required proto.Conversation localConversation, + required proto.Conversation remoteConversation}) async => + add(() => MapEntry( + contact.remoteConversationRecordKey.toVeilid(), + SingleContactMessagesCubit( + activeAccountInfo: _activeAccountInfo, + remoteIdentityPublicKey: contact.identityPublicKey.toVeilid(), + localConversationRecordKey: + contact.localConversationRecordKey.toVeilid(), + remoteConversationRecordKey: + contact.remoteConversationRecordKey.toVeilid(), + localMessagesRecordKey: localConversation.messages.toVeilid(), + remoteMessagesRecordKey: remoteConversation.messages.toVeilid(), + reconciledChatRecord: chat.reconciledChatRecord.toVeilid(), + ))); + + /// StateFollower ///////////////////////// + + @override + IMap> getStateMap( + ActiveConversationsBlocMapState state) => + state; + + @override + Future removeFromState(TypedKey key) => remove(key); + + @override + Future updateState( + TypedKey key, AsyncValue value) async { + // Get the contact object for this single contact chat + final contactList = _contactListCubit.state.state.data?.value; + if (contactList == null) { + await addState(key, const AsyncValue.loading()); + return; + } + final contactIndex = contactList + .indexWhere((c) => c.remoteConversationRecordKey.toVeilid() == key); + if (contactIndex == -1) { + await addState( + key, AsyncValue.error('Contact not found for conversation')); + return; + } + final contact = contactList[contactIndex]; + + // Get the chat object for this single contact chat + final chatList = _chatListCubit.state.state.data?.value; + if (chatList == null) { + await addState(key, const AsyncValue.loading()); + return; + } + final chatIndex = chatList + .indexWhere((c) => c.remoteConversationRecordKey.toVeilid() == key); + if (contactIndex == -1) { + await addState(key, AsyncValue.error('Chat not found for conversation')); + return; + } + final chat = chatList[chatIndex]; + + await value.when( + data: (state) => _addConversationMessages( + contact: contact, + chat: chat, + localConversation: state.localConversation, + remoteConversation: state.remoteConversation), + loading: () => addState(key, const AsyncValue.loading()), + error: (error, stackTrace) => + addState(key, AsyncValue.error(error, stackTrace))); + } + + //// + + final ActiveAccountInfo _activeAccountInfo; + final ContactListCubit _contactListCubit; + final ChatListCubit _chatListCubit; +} diff --git a/lib/chat_list/cubits/chat_list_cubit.dart b/lib/chat_list/cubits/chat_list_cubit.dart new file mode 100644 index 0000000..ea6261b --- /dev/null +++ b/lib/chat_list/cubits/chat_list_cubit.dart @@ -0,0 +1,121 @@ +import 'dart:async'; + +import 'package:veilid_support/veilid_support.dart'; + +import '../../account_manager/account_manager.dart'; +import '../../chat/chat.dart'; +import '../../proto/proto.dart' as proto; +import '../../tools/tools.dart'; + +////////////////////////////////////////////////// + +////////////////////////////////////////////////// +// Mutable state for per-account chat list + +class ChatListCubit extends DHTShortArrayCubit { + ChatListCubit({ + required ActiveAccountInfo activeAccountInfo, + required proto.Account account, + required this.activeChatCubit, + }) : _activeAccountInfo = activeAccountInfo, + super( + open: () => _open(activeAccountInfo, account), + decodeElement: proto.Chat.fromBuffer); + + static Future _open( + ActiveAccountInfo activeAccountInfo, proto.Account account) async { + final accountRecordKey = + activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey; + + final chatListRecordKey = account.chatList.toVeilid(); + + final dhtRecord = await DHTShortArray.openOwned(chatListRecordKey, + parent: accountRecordKey); + + return dhtRecord; + } + + /// Create a new chat (singleton for single contact chats) + Future getOrCreateChatSingleContact({ + required TypedKey remoteConversationRecordKey, + }) async { + // Add Chat to account's list + // if this fails, don't keep retrying, user can try again later + await operateWrite((writer) async { + final remoteConversationRecordKeyProto = + remoteConversationRecordKey.toProto(); + + // See if we have added this chat already + for (var i = 0; i < writer.length; i++) { + final cbuf = await writer.getItem(i); + if (cbuf == null) { + throw Exception('Failed to get chat'); + } + final c = proto.Chat.fromBuffer(cbuf); + if (c.remoteConversationRecordKey == remoteConversationRecordKeyProto) { + // Nothing to do here + return; + } + } + final accountRecordKey = _activeAccountInfo + .userLogin.accountRecordInfo.accountRecord.recordKey; + + // Make a record that can store the reconciled version of the chat + final reconciledChatRecord = + await (await DHTShortArray.create(parent: accountRecordKey)) + .scope((r) async => r.recordPointer); + + // Create conversation type Chat + final chat = proto.Chat() + ..type = proto.ChatType.SINGLE_CONTACT + ..remoteConversationRecordKey = remoteConversationRecordKeyProto + ..reconciledChatRecord = reconciledChatRecord.toProto(); + + // Add chat + final added = await writer.tryAddItem(chat.writeToBuffer()); + if (!added) { + throw Exception('Failed to add chat'); + } + }); + } + + /// Delete a chat + Future deleteChat( + {required TypedKey remoteConversationRecordKey}) async { + final remoteConversationKey = remoteConversationRecordKey.toProto(); + + // Remove Chat from account's list + // if this fails, don't keep retrying, user can try again later + final (deletedItem, success) = await operateWrite((writer) async { + if (activeChatCubit.state == remoteConversationRecordKey) { + activeChatCubit.setActiveChat(null); + } + for (var i = 0; i < writer.length; i++) { + final cbuf = await writer.getItem(i); + if (cbuf == null) { + throw Exception('Failed to get chat'); + } + final c = proto.Chat.fromBuffer(cbuf); + if (c.remoteConversationRecordKey == remoteConversationKey) { + // Found the right chat + if (await writer.tryRemoveItem(i) != null) { + return c; + } + return null; + } + } + return null; + }); + if (success && deletedItem != null) { + try { + await DHTRecordPool.instance + .delete(deletedItem.reconciledChatRecord.toVeilid().recordKey); + } on Exception catch (e) { + log.debug('error removing reconciled chat record: $e', e); + } + } + } + + final ActiveChatCubit activeChatCubit; + final ActiveAccountInfo _activeAccountInfo; +} diff --git a/lib/chat_list/cubits/cubits.dart b/lib/chat_list/cubits/cubits.dart new file mode 100644 index 0000000..35595db --- /dev/null +++ b/lib/chat_list/cubits/cubits.dart @@ -0,0 +1,3 @@ +export 'active_single_contact_chat_bloc_map_cubit.dart'; +export 'active_conversations_bloc_map_cubit.dart'; +export 'chat_list_cubit.dart'; diff --git a/lib/components/chat_single_contact_item_widget.dart b/lib/chat_list/views/chat_single_contact_item_widget.dart similarity index 56% rename from lib/components/chat_single_contact_item_widget.dart rename to lib/chat_list/views/chat_single_contact_item_widget.dart index f9cc102..7daa99c 100644 --- a/lib/components/chat_single_contact_item_widget.dart +++ b/lib/chat_list/views/chat_single_contact_item_widget.dart @@ -1,29 +1,38 @@ +import 'package:async_tools/async_tools.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; import 'package:flutter_translate/flutter_translate.dart'; -import '../proto/proto.dart' as proto; -import '../providers/account.dart'; -import '../providers/chat.dart'; -import '../tools/theme_service.dart'; +import '../../chat/cubits/active_chat_cubit.dart'; +import '../../proto/proto.dart' as proto; +import '../../theme/theme.dart'; +import '../chat_list.dart'; -class ChatSingleContactItemWidget extends ConsumerWidget { - const ChatSingleContactItemWidget({required this.contact, super.key}); +class ChatSingleContactItemWidget extends StatelessWidget { + const ChatSingleContactItemWidget({ + required proto.Contact contact, + required bool disabled, + super.key, + }) : _contact = contact, + _disabled = disabled; - final proto.Contact contact; + final proto.Contact _contact; + final bool _disabled; @override // ignore: prefer_expression_function_bodies - Widget build(BuildContext context, WidgetRef ref) { + Widget build( + BuildContext context, + ) { final theme = Theme.of(context); //final textTheme = theme.textTheme; final scale = theme.extension()!; - final activeChat = ref.watch(activeChatStateProvider); + final activeChatCubit = context.watch(); final remoteConversationRecordKey = - proto.TypedKeyProto.fromProto(contact.remoteConversationRecordKey); - final selected = activeChat == remoteConversationRecordKey; + _contact.remoteConversationRecordKey.toVeilid(); + final selected = activeChatCubit.state == remoteConversationRecordKey; return Container( margin: const EdgeInsets.fromLTRB(0, 4, 0, 0), @@ -34,22 +43,19 @@ class ChatSingleContactItemWidget extends ConsumerWidget { borderRadius: BorderRadius.circular(8), )), child: Slidable( - key: ObjectKey(contact), + key: ObjectKey(_contact), endActionPane: ActionPane( motion: const DrawerMotion(), children: [ SlidableAction( - onPressed: (context) async { - final activeAccountInfo = - await ref.read(fetchActiveAccountProvider.future); - if (activeAccountInfo != null) { - await deleteChat( - activeAccountInfo: activeAccountInfo, - remoteConversationRecordKey: - remoteConversationRecordKey); - ref.invalidate(fetchChatListProvider); - } - }, + onPressed: _disabled + ? null + : (context) async { + final chatListCubit = context.read(); + await chatListCubit.deleteChat( + remoteConversationRecordKey: + remoteConversationRecordKey); + }, backgroundColor: scale.tertiaryScale.background, foregroundColor: scale.tertiaryScale.text, icon: Icons.delete, @@ -68,16 +74,19 @@ class ChatSingleContactItemWidget extends ConsumerWidget { // The child of the Slidable is what the user sees when the // component is not dragged. child: ListTile( - onTap: () async { - ref.read(activeChatStateProvider.notifier).state = - remoteConversationRecordKey; - ref.invalidate(fetchChatListProvider); - }, - title: Text(contact.editedProfile.name), + onTap: _disabled + ? null + : () { + singleFuture(activeChatCubit, () async { + activeChatCubit + .setActiveChat(remoteConversationRecordKey); + }); + }, + title: Text(_contact.editedProfile.name), /// xxx show last message here - subtitle: (contact.editedProfile.pronouns.isNotEmpty) - ? Text(contact.editedProfile.pronouns) + subtitle: (_contact.editedProfile.pronouns.isNotEmpty) + ? Text(_contact.editedProfile.pronouns) : null, iconColor: scale.tertiaryScale.background, textColor: scale.tertiaryScale.text, @@ -89,6 +98,6 @@ class ChatSingleContactItemWidget extends ConsumerWidget { @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); - properties.add(DiagnosticsProperty('contact', contact)); + properties.add(DiagnosticsProperty('contact', _contact)); } } diff --git a/lib/chat_list/views/chat_single_contact_list_widget.dart b/lib/chat_list/views/chat_single_contact_list_widget.dart new file mode 100644 index 0000000..04092b4 --- /dev/null +++ b/lib/chat_list/views/chat_single_contact_list_widget.dart @@ -0,0 +1,83 @@ +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_translate/flutter_translate.dart'; +import 'package:searchable_listview/searchable_listview.dart'; + +import '../../contacts/contacts.dart'; +import '../../proto/proto.dart' as proto; +import '../../theme/theme.dart'; +import '../../tools/tools.dart'; +import '../chat_list.dart'; + +class ChatSingleContactListWidget extends StatelessWidget { + const ChatSingleContactListWidget({super.key}); + + @override + // ignore: prefer_expression_function_bodies + Widget build(BuildContext context) { + final theme = Theme.of(context); + //final textTheme = theme.textTheme; + final scale = theme.extension()!; + + final contactListV = context.watch().state; + + return contactListV.builder((context, contactList) { + final contactMap = IMap.fromIterable(contactList, + keyMapper: (c) => c.remoteConversationRecordKey, + valueMapper: (c) => c); + + final chatListV = context.watch().state; + return chatListV.builder((context, chatList) => SizedBox.expand( + child: styledTitleContainer( + context: context, + title: translate('chat_list.chats'), + child: SizedBox.expand( + child: (chatList.isEmpty) + ? const EmptyChatListWidget() + : SearchableList( + initialList: chatList.toList(), + builder: (l, i, c) { + final contact = + contactMap[c.remoteConversationRecordKey]; + if (contact == null) { + return const Text('...'); + } + return ChatSingleContactItemWidget( + contact: contact, + disabled: contactListV.busy); + }, + filter: (value) { + final lowerValue = value.toLowerCase(); + return chatList.where((c) { + final contact = + contactMap[c.remoteConversationRecordKey]; + if (contact == null) { + return false; + } + return contact.editedProfile.name + .toLowerCase() + .contains(lowerValue) || + contact.editedProfile.pronouns + .toLowerCase() + .contains(lowerValue); + }).toList(); + }, + spaceBetweenSearchAndList: 4, + inputDecoration: InputDecoration( + labelText: translate('chat_list.search'), + contentPadding: const EdgeInsets.all(2), + fillColor: scale.primaryScale.text, + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: scale.primaryScale.hoverBorder, + ), + borderRadius: BorderRadius.circular(8), + ), + ), + ).paddingAll(8)))) + .paddingLTRB(8, 8, 8, 65)); + }); + } +} diff --git a/lib/components/empty_chat_list_widget.dart b/lib/chat_list/views/empty_chat_list_widget.dart similarity index 79% rename from lib/components/empty_chat_list_widget.dart rename to lib/chat_list/views/empty_chat_list_widget.dart index 3ef0f97..024cbf0 100644 --- a/lib/components/empty_chat_list_widget.dart +++ b/lib/chat_list/views/empty_chat_list_widget.dart @@ -1,15 +1,14 @@ import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_translate/flutter_translate.dart'; -import '../tools/tools.dart'; +import '../../theme/theme.dart'; -class EmptyChatListWidget extends ConsumerWidget { +class EmptyChatListWidget extends StatelessWidget { const EmptyChatListWidget({super.key}); @override // ignore: prefer_expression_function_bodies - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { final theme = Theme.of(context); final textTheme = theme.textTheme; final scale = theme.extension()!; diff --git a/lib/chat_list/views/views.dart b/lib/chat_list/views/views.dart new file mode 100644 index 0000000..311d02e --- /dev/null +++ b/lib/chat_list/views/views.dart @@ -0,0 +1,3 @@ +export 'chat_single_contact_item_widget.dart'; +export 'chat_single_contact_list_widget.dart'; +export 'empty_chat_list_widget.dart'; diff --git a/lib/components/account_bubble.dart b/lib/components/account_bubble.dart deleted file mode 100644 index 57424ae..0000000 --- a/lib/components/account_bubble.dart +++ /dev/null @@ -1,55 +0,0 @@ -import 'package:awesome_extensions/awesome_extensions.dart'; -import 'package:circular_profile_avatar/circular_profile_avatar.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:window_manager/window_manager.dart'; - -import '../entities/local_account.dart'; -import '../providers/logins.dart'; - -class AccountBubble extends ConsumerWidget { - const AccountBubble({required this.account, super.key}); - final LocalAccount account; - - @override - Widget build(BuildContext context, WidgetRef ref) { - windowManager.setTitleBarStyle(TitleBarStyle.normal); - final logins = ref.watch(loginsProvider); - - return ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 300), - child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ - Expanded( - flex: 4, - child: CircularProfileAvatar('', - child: Container(color: Theme.of(context).disabledColor))), - const Expanded(child: Text('Placeholder')) - ])); - } - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties.add(DiagnosticsProperty('account', account)); - } -} - -class AddAccountBubble extends ConsumerWidget { - const AddAccountBubble({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - windowManager.setTitleBarStyle(TitleBarStyle.normal); - final logins = ref.watch(loginsProvider); - - return Column(mainAxisAlignment: MainAxisAlignment.center, children: [ - CircularProfileAvatar('', - borderWidth: 4, - borderColor: Theme.of(context).unselectedWidgetColor, - child: Container( - color: Colors.blue, child: const Icon(Icons.add, size: 50))), - const Text('Add Account').paddingLTRB(0, 4, 0, 0) - ]); - } -} diff --git a/lib/components/chat_component.dart b/lib/components/chat_component.dart deleted file mode 100644 index ff1b642..0000000 --- a/lib/components/chat_component.dart +++ /dev/null @@ -1,205 +0,0 @@ -import 'dart:async'; - -import 'package:awesome_extensions/awesome_extensions.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_chat_types/flutter_chat_types.dart' as types; -import 'package:flutter_chat_ui/flutter_chat_ui.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../proto/proto.dart' as proto; -import '../providers/account.dart'; -import '../providers/chat.dart'; -import '../providers/conversation.dart'; -import '../tools/tools.dart'; -import '../veilid_init.dart'; -import '../veilid_support/veilid_support.dart'; - -class ChatComponent extends ConsumerStatefulWidget { - const ChatComponent( - {required this.activeAccountInfo, - required this.activeChat, - required this.activeChatContact, - super.key}); - - final ActiveAccountInfo activeAccountInfo; - final TypedKey activeChat; - final proto.Contact activeChatContact; - - @override - ChatComponentState createState() => ChatComponentState(); - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties - ..add(DiagnosticsProperty( - 'activeAccountInfo', activeAccountInfo)) - ..add(DiagnosticsProperty('activeChat', activeChat)) - ..add(DiagnosticsProperty( - 'activeChatContact', activeChatContact)); - } -} - -class ChatComponentState extends ConsumerState { - final _unfocusNode = FocusNode(); - late final types.User _localUser; - late final types.User _remoteUser; - - @override - void initState() { - super.initState(); - - _localUser = types.User( - id: widget.activeAccountInfo.localAccount.identityMaster - .identityPublicTypedKey() - .toString(), - firstName: widget.activeAccountInfo.account.profile.name, - ); - _remoteUser = types.User( - id: proto.TypedKeyProto.fromProto( - widget.activeChatContact.identityPublicKey) - .toString(), - firstName: widget.activeChatContact.remoteProfile.name); - } - - @override - void dispose() { - _unfocusNode.dispose(); - super.dispose(); - } - - types.Message protoMessageToMessage(proto.Message message) { - final isLocal = message.author == - widget.activeAccountInfo.localAccount.identityMaster - .identityPublicTypedKey() - .toProto(); - - final textMessage = types.TextMessage( - author: isLocal ? _localUser : _remoteUser, - createdAt: (message.timestamp ~/ 1000).toInt(), - id: message.timestamp.toString(), - text: message.text, - ); - return textMessage; - } - - Future _addMessage(proto.Message protoMessage) async { - if (protoMessage.text.isEmpty) { - return; - } - - final message = protoMessageToMessage(protoMessage); - - // setState(() { - // _messages.insert(0, message); - // }); - - // Now add the message to the conversation messages - final localConversationRecordKey = proto.TypedKeyProto.fromProto( - widget.activeChatContact.localConversationRecordKey); - final remoteIdentityPublicKey = proto.TypedKeyProto.fromProto( - widget.activeChatContact.identityPublicKey); - - await addLocalConversationMessage( - activeAccountInfo: widget.activeAccountInfo, - localConversationRecordKey: localConversationRecordKey, - remoteIdentityPublicKey: remoteIdentityPublicKey, - message: protoMessage); - - ref.invalidate(activeConversationMessagesProvider); - } - - Future _handleSendPressed(types.PartialText message) async { - final protoMessage = proto.Message() - ..author = widget.activeAccountInfo.localAccount.identityMaster - .identityPublicTypedKey() - .toProto() - ..timestamp = (await eventualVeilid.future).now().toInt64() - ..text = message.text; - //..signature = signature; - - await _addMessage(protoMessage); - } - - void _handleAttachmentPressed() { - // - } - - @override - // ignore: prefer_expression_function_bodies - Widget build(BuildContext context) { - final theme = Theme.of(context); - final scale = theme.extension()!; - final textTheme = Theme.of(context).textTheme; - final chatTheme = makeChatTheme(scale, textTheme); - final contactName = widget.activeChatContact.editedProfile.name; - - final protoMessages = - ref.watch(activeConversationMessagesProvider).asData?.value; - if (protoMessages == null) { - return waitingPage(context); - } - final messages = []; - for (final protoMessage in protoMessages) { - final message = protoMessageToMessage(protoMessage); - messages.insert(0, message); - } - - return DefaultTextStyle( - style: textTheme.bodySmall!, - child: Align( - alignment: AlignmentDirectional.centerEnd, - child: Stack( - children: [ - Column( - children: [ - Container( - height: 48, - decoration: BoxDecoration( - color: scale.primaryScale.subtleBorder, - ), - child: Row(children: [ - Align( - alignment: AlignmentDirectional.centerStart, - child: Padding( - padding: const EdgeInsetsDirectional.fromSTEB( - 16, 0, 16, 0), - child: Text(contactName, - textAlign: TextAlign.start, - style: textTheme.titleMedium), - )), - const Spacer(), - IconButton( - icon: const Icon(Icons.close), - onPressed: () async { - ref.read(activeChatStateProvider.notifier).state = - null; - }).paddingLTRB(16, 0, 16, 0) - ]), - ), - Expanded( - child: DecoratedBox( - decoration: const BoxDecoration(), - child: Chat( - theme: chatTheme, - messages: messages, - //onAttachmentPressed: _handleAttachmentPressed, - //onMessageTap: _handleMessageTap, - //onPreviewDataFetched: _handlePreviewDataFetched, - - onSendPressed: (message) { - unawaited(_handleSendPressed(message)); - }, - //showUserAvatars: false, - //showUserNames: true, - user: _localUser, - ), - ), - ), - ], - ), - ], - ), - )); - } -} diff --git a/lib/components/chat_single_contact_list_widget.dart b/lib/components/chat_single_contact_list_widget.dart deleted file mode 100644 index 48db337..0000000 --- a/lib/components/chat_single_contact_list_widget.dart +++ /dev/null @@ -1,92 +0,0 @@ -import 'package:awesome_extensions/awesome_extensions.dart'; -import 'package:fast_immutable_collections/fast_immutable_collections.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_translate/flutter_translate.dart'; -import 'package:searchable_listview/searchable_listview.dart'; - -import '../proto/proto.dart' as proto; -import '../tools/tools.dart'; -import 'chat_single_contact_item_widget.dart'; -import 'empty_chat_list_widget.dart'; - -class ChatSingleContactListWidget extends ConsumerWidget { - ChatSingleContactListWidget( - {required IList contactList, - required this.chatList, - super.key}) - : contactMap = IMap.fromIterable(contactList, - keyMapper: (c) => c.remoteConversationRecordKey, - valueMapper: (c) => c); - - final IMap contactMap; - final IList chatList; - - @override - // ignore: prefer_expression_function_bodies - Widget build(BuildContext context, WidgetRef ref) { - final theme = Theme.of(context); - //final textTheme = theme.textTheme; - final scale = theme.extension()!; - - return SizedBox.expand( - child: styledTitleContainer( - context: context, - title: translate('chat_list.chats'), - child: SizedBox.expand( - child: (chatList.isEmpty) - ? const EmptyChatListWidget() - : SearchableList( - autoFocusOnSearch: false, - initialList: chatList.toList(), - builder: (l, i, c) { - final contact = - contactMap[c.remoteConversationKey]; - if (contact == null) { - return const Text('...'); - } - return ChatSingleContactItemWidget( - contact: contact); - }, - filter: (value) { - final lowerValue = value.toLowerCase(); - return chatList.where((c) { - final contact = - contactMap[c.remoteConversationKey]; - if (contact == null) { - return false; - } - return contact.editedProfile.name - .toLowerCase() - .contains(lowerValue) || - contact.editedProfile.pronouns - .toLowerCase() - .contains(lowerValue); - }).toList(); - }, - spaceBetweenSearchAndList: 4, - inputDecoration: InputDecoration( - labelText: translate('chat_list.search'), - contentPadding: const EdgeInsets.all(2), - fillColor: scale.primaryScale.text, - focusedBorder: OutlineInputBorder( - borderSide: BorderSide( - color: scale.primaryScale.hoverBorder, - ), - borderRadius: BorderRadius.circular(8), - ), - ), - ).paddingAll(8)))) - .paddingLTRB(8, 8, 8, 65); - } - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties - ..add(DiagnosticsProperty>( - 'contactMap', contactMap)) - ..add(IterableProperty('chatList', chatList)); - } -} diff --git a/lib/components/contact_invitation_display.dart b/lib/components/contact_invitation_display.dart deleted file mode 100644 index 5f32ca8..0000000 --- a/lib/components/contact_invitation_display.dart +++ /dev/null @@ -1,152 +0,0 @@ -import 'dart:async'; -import 'dart:math'; - -import 'package:awesome_extensions/awesome_extensions.dart'; -import 'package:basic_utils/basic_utils.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_translate/flutter_translate.dart'; -import 'package:qr_flutter/qr_flutter.dart'; - -import '../tools/tools.dart'; -import '../veilid_support/veilid_support.dart'; - -class ContactInvitationDisplayDialog extends ConsumerStatefulWidget { - const ContactInvitationDisplayDialog({ - required this.name, - required this.message, - required this.generator, - super.key, - }); - - final String name; - final String message; - final FutureOr generator; - - @override - ContactInvitationDisplayDialogState createState() => - ContactInvitationDisplayDialogState(); - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties - ..add(StringProperty('name', name)) - ..add(StringProperty('message', message)) - ..add(DiagnosticsProperty?>('generator', generator)); - } -} - -class ContactInvitationDisplayDialogState - extends ConsumerState { - final focusNode = FocusNode(); - final formKey = GlobalKey(); - late final AutoDisposeFutureProvider _generateFutureProvider; - - @override - void initState() { - super.initState(); - - _generateFutureProvider = - AutoDisposeFutureProvider((ref) async => widget.generator); - } - - @override - void dispose() { - focusNode.dispose(); - super.dispose(); - } - - String makeTextInvite(String message, Uint8List data) { - final invite = StringUtils.addCharAtPosition( - base64UrlNoPadEncode(data), '\n', 40, - repeat: true); - final msg = message.isNotEmpty ? '$message\n' : ''; - return '$msg' - '--- BEGIN VEILIDCHAT CONTACT INVITE ----\n' - '$invite\n' - '---- END VEILIDCHAT CONTACT INVITE -----\n'; - } - - @override - // ignore: prefer_expression_function_bodies - Widget build(BuildContext context) { - final theme = Theme.of(context); - //final scale = theme.extension()!; - final textTheme = theme.textTheme; - - final signedContactInvitationBytesV = ref.watch(_generateFutureProvider); - final cardsize = - min(MediaQuery.of(context).size.shortestSide - 48.0, 400); - - return Dialog( - backgroundColor: Colors.white, - child: ConstrainedBox( - constraints: BoxConstraints( - minWidth: cardsize, - maxWidth: cardsize, - minHeight: cardsize, - maxHeight: cardsize), - child: signedContactInvitationBytesV.when( - loading: () => buildProgressIndicator(context), - data: (data) { - if (data == null) { - Navigator.of(context).pop(); - return const Text(''); - } - return Form( - key: formKey, - child: Column(children: [ - FittedBox( - child: Text( - translate( - 'send_invite_dialog.contact_invitation'), - style: textTheme.headlineSmall! - .copyWith(color: Colors.black))) - .paddingAll(8), - FittedBox( - child: QrImageView.withQr( - size: 300, - qr: QrCode.fromUint8List( - data: data, - errorCorrectLevel: - QrErrorCorrectLevel.L))) - .expanded(), - Text(widget.message, - softWrap: true, - style: textTheme.labelLarge! - .copyWith(color: Colors.black)) - .paddingAll(8), - ElevatedButton.icon( - icon: const Icon(Icons.copy), - label: Text( - translate('send_invite_dialog.copy_invitation')), - onPressed: () async { - showInfoToast( - context, - translate( - 'send_invite_dialog.invitation_copied')); - await Clipboard.setData(ClipboardData( - text: makeTextInvite(widget.message, data))); - }, - ).paddingAll(16), - ])); - }, - error: (e, s) { - Navigator.of(context).pop(); - showErrorToast(context, - translate('send_invite_dialog.failed_to_generate')); - return const Text(''); - }))); - } - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties - ..add(DiagnosticsProperty('focusNode', focusNode)) - ..add(DiagnosticsProperty>('formKey', formKey)); - } -} diff --git a/lib/components/contact_item_widget.dart b/lib/components/contact_item_widget.dart deleted file mode 100644 index 1a4cb46..0000000 --- a/lib/components/contact_item_widget.dart +++ /dev/null @@ -1,122 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_animate/flutter_animate.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_slidable/flutter_slidable.dart'; -import 'package:flutter_translate/flutter_translate.dart'; -import '../proto/proto.dart' as proto; -import '../pages/main_pager/main_pager.dart'; -import '../providers/account.dart'; -import '../providers/chat.dart'; -import '../providers/contact.dart'; -import '../tools/theme_service.dart'; - -class ContactItemWidget extends ConsumerWidget { - const ContactItemWidget({required this.contact, super.key}); - - final proto.Contact contact; - - @override - // ignore: prefer_expression_function_bodies - Widget build(BuildContext context, WidgetRef ref) { - final theme = Theme.of(context); - //final textTheme = theme.textTheme; - final scale = theme.extension()!; - - final remoteConversationKey = - proto.TypedKeyProto.fromProto(contact.remoteConversationRecordKey); - - return Container( - margin: const EdgeInsets.fromLTRB(0, 4, 0, 0), - clipBehavior: Clip.antiAlias, - decoration: ShapeDecoration( - color: scale.tertiaryScale.subtleBorder, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - )), - child: Slidable( - key: ObjectKey(contact), - endActionPane: ActionPane( - motion: const DrawerMotion(), - children: [ - SlidableAction( - onPressed: (context) async { - final activeAccountInfo = - await ref.read(fetchActiveAccountProvider.future); - if (activeAccountInfo != null) { - await deleteContact( - activeAccountInfo: activeAccountInfo, - contact: contact); - ref - ..invalidate(fetchContactListProvider) - ..invalidate(fetchChatListProvider); - } - }, - backgroundColor: scale.tertiaryScale.background, - foregroundColor: scale.tertiaryScale.text, - icon: Icons.delete, - label: translate('button.delete'), - padding: const EdgeInsets.all(2)), - // SlidableAction( - // onPressed: (context) => (), - // backgroundColor: scale.secondaryScale.background, - // foregroundColor: scale.secondaryScale.text, - // icon: Icons.edit, - // label: 'Edit', - // ), - ], - ), - - // The child of the Slidable is what the user sees when the - // component is not dragged. - child: ListTile( - onTap: () async { - final activeAccountInfo = - await ref.read(fetchActiveAccountProvider.future); - if (activeAccountInfo != null) { - // Start a chat - await getOrCreateChatSingleContact( - activeAccountInfo: activeAccountInfo, - remoteConversationRecordKey: remoteConversationKey); - ref - ..invalidate(fetchContactListProvider) - ..invalidate(fetchChatListProvider); - // Click over to chats - if (context.mounted) { - await MainPager.of(context)?.pageController.animateToPage( - 1, - duration: 250.ms, - curve: Curves.easeInOut); - } - } - - // // ignore: use_build_context_synchronously - // if (!context.mounted) { - // return; - // } - // await showDialog( - // context: context, - // builder: (context) => ContactInvitationDisplayDialog( - // name: activeAccountInfo.localAccount.name, - // message: contactInvitationRecord.message, - // generator: Uint8List.fromList( - // contactInvitationRecord.invitation), - // )); - // } - }, - title: Text(contact.editedProfile.name), - subtitle: (contact.editedProfile.pronouns.isNotEmpty) - ? Text(contact.editedProfile.pronouns) - : null, - iconColor: scale.tertiaryScale.background, - textColor: scale.tertiaryScale.text, - //Text(Timestamp.fromInt64(contactInvitationRecord.expiration) / ), - leading: const Icon(Icons.person)))); - } - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties.add(DiagnosticsProperty('contact', contact)); - } -} diff --git a/lib/components/empty_chat_widget.dart b/lib/components/empty_chat_widget.dart deleted file mode 100644 index dbe184d..0000000 --- a/lib/components/empty_chat_widget.dart +++ /dev/null @@ -1,36 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -class EmptyChatWidget extends ConsumerWidget { - const EmptyChatWidget({super.key}); - - @override - // ignore: prefer_expression_function_bodies - Widget build(BuildContext context, WidgetRef ref) { - // - - return Container( - width: double.infinity, - height: double.infinity, - decoration: BoxDecoration( - color: Theme.of(context).scaffoldBackgroundColor, - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.chat, - color: Theme.of(context).disabledColor, - size: 48, - ), - Text( - 'Say Something', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).disabledColor, - ), - ), - ], - ), - ); - } -} diff --git a/lib/components/no_conversation_widget.dart b/lib/components/no_conversation_widget.dart deleted file mode 100644 index faf820f..0000000 --- a/lib/components/no_conversation_widget.dart +++ /dev/null @@ -1,35 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -class NoContactWidget extends ConsumerWidget { - const NoContactWidget({super.key}); - - @override - // ignore: prefer_expression_function_bodies - Widget build(BuildContext context, WidgetRef ref) { - // - return Container( - width: double.infinity, - height: double.infinity, - decoration: BoxDecoration( - color: Theme.of(context).primaryColor, - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.emoji_people_outlined, - color: Theme.of(context).disabledColor, - size: 48, - ), - Text( - 'Choose A Conversation To Chat', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).disabledColor, - ), - ), - ], - ), - ); - } -} diff --git a/lib/components/profile_widget.dart b/lib/components/profile_widget.dart deleted file mode 100644 index a4a7090..0000000 --- a/lib/components/profile_widget.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'package:awesome_extensions/awesome_extensions.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../tools/tools.dart'; - -class ProfileWidget extends ConsumerWidget { - const ProfileWidget({ - required this.name, - this.pronouns, - super.key, - }); - - final String name; - final String? pronouns; - - @override - // ignore: prefer_expression_function_bodies - Widget build(BuildContext context, WidgetRef ref) { - final theme = Theme.of(context); - final scale = theme.extension()!; - final textTheme = theme.textTheme; - - return DecoratedBox( - decoration: ShapeDecoration( - color: scale.primaryScale.border, - shape: - RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))), - child: Column(children: [ - Text( - name, - style: textTheme.headlineSmall, - textAlign: TextAlign.left, - ).paddingAll(4), - if (pronouns != null && pronouns!.isNotEmpty) - Text(pronouns!, style: textTheme.bodyMedium).paddingLTRB(4, 0, 4, 4), - ]), - ); - } - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties - ..add(StringProperty('name', name)) - ..add(StringProperty('pronouns', pronouns)); - } -} diff --git a/lib/components/signal_strength_meter.dart b/lib/components/signal_strength_meter.dart deleted file mode 100644 index c093529..0000000 --- a/lib/components/signal_strength_meter.dart +++ /dev/null @@ -1,66 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:signal_strength_indicator/signal_strength_indicator.dart'; -import 'package:go_router/go_router.dart'; - -import '../providers/connection_state.dart'; -import '../tools/tools.dart'; -import '../veilid_support/veilid_support.dart'; - -class SignalStrengthMeterWidget extends ConsumerWidget { - const SignalStrengthMeterWidget({super.key}); - - @override - // ignore: prefer_expression_function_bodies - Widget build(BuildContext context, WidgetRef ref) { - final theme = Theme.of(context); - final scale = theme.extension()!; - - const iconSize = 16.0; - final connState = ref.watch(connectionStateProvider); - - late final double value; - late final Color color; - late final Color inactiveColor; - switch (connState.attachment.state) { - case AttachmentState.detached: - return Icon(Icons.signal_cellular_nodata, - size: iconSize, color: scale.grayScale.text); - case AttachmentState.detaching: - return Icon(Icons.signal_cellular_off, - size: iconSize, color: scale.grayScale.text); - case AttachmentState.attaching: - value = 0; - color = scale.primaryScale.text; - case AttachmentState.attachedWeak: - value = 1; - color = scale.primaryScale.text; - case AttachmentState.attachedStrong: - value = 2; - color = scale.primaryScale.text; - case AttachmentState.attachedGood: - value = 3; - color = scale.primaryScale.text; - case AttachmentState.fullyAttached: - value = 4; - color = scale.primaryScale.text; - case AttachmentState.overAttached: - value = 4; - color = scale.secondaryScale.subtleText; - } - inactiveColor = scale.grayScale.subtleText; - - return GestureDetector( - onLongPress: () async { - await context.push('/developer'); - }, - child: SignalStrengthIndicator.bars( - value: value, - activeColor: color, - inactiveColor: inactiveColor, - size: iconSize, - barCount: 4, - spacing: 1, - )); - } -} diff --git a/lib/contact_invitation/contact_invitation.dart b/lib/contact_invitation/contact_invitation.dart new file mode 100644 index 0000000..08ae2e7 --- /dev/null +++ b/lib/contact_invitation/contact_invitation.dart @@ -0,0 +1,3 @@ +export 'cubits/cubits.dart'; +export 'models/models.dart'; +export 'views/views.dart'; diff --git a/lib/contact_invitation/cubits/contact_invitation_list_cubit.dart b/lib/contact_invitation/cubits/contact_invitation_list_cubit.dart new file mode 100644 index 0000000..1c3f148 --- /dev/null +++ b/lib/contact_invitation/cubits/contact_invitation_list_cubit.dart @@ -0,0 +1,288 @@ +import 'dart:async'; + +import 'package:fixnum/fixnum.dart'; +import 'package:flutter/foundation.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../account_manager/account_manager.dart'; +import '../../proto/proto.dart' as proto; +import '../../tools/tools.dart'; +import '../models/models.dart'; + +////////////////////////////////////////////////// + +class ContactInviteInvalidKeyException implements Exception { + const ContactInviteInvalidKeyException(this.type) : super(); + final EncryptionKeyType type; +} + +typedef GetEncryptionKeyCallback = Future Function( + VeilidCryptoSystem cs, + EncryptionKeyType encryptionKeyType, + Uint8List encryptedSecret); + +////////////////////////////////////////////////// + +////////////////////////////////////////////////// +// Mutable state for per-account contact invitations + +class ContactInvitationListCubit + extends DHTShortArrayCubit { + ContactInvitationListCubit({ + required ActiveAccountInfo activeAccountInfo, + required proto.Account account, + }) : _activeAccountInfo = activeAccountInfo, + _account = account, + super( + open: () => _open(activeAccountInfo, account), + decodeElement: proto.ContactInvitationRecord.fromBuffer); + + static Future _open( + ActiveAccountInfo activeAccountInfo, proto.Account account) async { + final accountRecordKey = + activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey; + + final contactInvitationListRecordPointer = + account.contactInvitationRecords.toVeilid(); + + final dhtRecord = await DHTShortArray.openOwned( + contactInvitationListRecordPointer, + parent: accountRecordKey); + + return dhtRecord; + } + + Future createInvitation( + {required EncryptionKeyType encryptionKeyType, + required String encryptionKey, + required String message, + required Timestamp? expiration}) async { + final pool = DHTRecordPool.instance; + + // Generate writer keypair to share with new contact + final cs = await pool.veilid.bestCryptoSystem(); + final contactRequestWriter = await cs.generateKeyPair(); + final conversationWriter = _activeAccountInfo.conversationWriter; + + // Encrypt the writer secret with the encryption key + final encryptedSecret = await encryptionKeyType.encryptSecretToBytes( + secret: contactRequestWriter.secret, + cryptoKind: cs.kind(), + encryptionKey: encryptionKey, + ); + + // Create local conversation DHT record with the account record key as its + // parent. + // Do not set the encryption of this key yet as it will not yet be written + // to and it will be eventually encrypted with the DH of the contact's + // identity key + late final Uint8List signedContactInvitationBytes; + await (await pool.create( + parent: _activeAccountInfo.accountRecordKey, + schema: DHTSchema.smpl(oCnt: 0, members: [ + DHTSchemaMember(mKey: conversationWriter.key, mCnt: 1) + ]))) + .deleteScope((localConversation) async { + // dont bother reopening localConversation with writer + // Make ContactRequestPrivate and encrypt with the writer secret + final crpriv = proto.ContactRequestPrivate() + ..writerKey = contactRequestWriter.key.toProto() + ..profile = _account.profile + ..identityMasterRecordKey = + _activeAccountInfo.userLogin.accountMasterRecordKey.toProto() + ..chatRecordKey = localConversation.key.toProto() + ..expiration = expiration?.toInt64() ?? Int64.ZERO; + final crprivbytes = crpriv.writeToBuffer(); + final encryptedContactRequestPrivate = await cs.encryptAeadWithNonce( + crprivbytes, contactRequestWriter.secret); + + // Create ContactRequest and embed contactrequestprivate + final creq = proto.ContactRequest() + ..encryptionKeyType = encryptionKeyType.toProto() + ..private = encryptedContactRequestPrivate; + + // Create DHT unicast inbox for ContactRequest + // Subkey 0 is the ContactRequest from the initiator + // Subkey 1 will contain the invitation response accept/reject eventually + await (await pool.create( + parent: _activeAccountInfo.accountRecordKey, + schema: DHTSchema.smpl(oCnt: 1, members: [ + DHTSchemaMember(mCnt: 1, mKey: contactRequestWriter.key) + ]), + crypto: const DHTRecordCryptoPublic())) + .deleteScope((contactRequestInbox) async { + // Store ContactRequest in owner subkey + await contactRequestInbox.eventualWriteProtobuf(creq); + + // Create ContactInvitation and SignedContactInvitation + final cinv = proto.ContactInvitation() + ..contactRequestInboxKey = contactRequestInbox.key.toProto() + ..writerSecret = encryptedSecret; + final cinvbytes = cinv.writeToBuffer(); + final scinv = proto.SignedContactInvitation() + ..contactInvitation = cinvbytes + ..identitySignature = (await cs.sign( + conversationWriter.key, conversationWriter.secret, cinvbytes)) + .toProto(); + signedContactInvitationBytes = scinv.writeToBuffer(); + + // Create ContactInvitationRecord + final cinvrec = proto.ContactInvitationRecord() + ..contactRequestInbox = + contactRequestInbox.ownedDHTRecordPointer.toProto() + ..writerKey = contactRequestWriter.key.toProto() + ..writerSecret = contactRequestWriter.secret.toProto() + ..localConversationRecordKey = localConversation.key.toProto() + ..expiration = expiration?.toInt64() ?? Int64.ZERO + ..invitation = signedContactInvitationBytes + ..message = message; + + // Add ContactInvitationRecord to account's list + // if this fails, don't keep retrying, user can try again later + await operateWrite((writer) async { + if (await writer.tryAddItem(cinvrec.writeToBuffer()) == false) { + throw Exception('Failed to add contact invitation record'); + } + }); + }); + }); + + return signedContactInvitationBytes; + } + + Future deleteInvitation( + {required bool accepted, + required TypedKey contactRequestInboxRecordKey}) async { + final pool = DHTRecordPool.instance; + final accountRecordKey = + _activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey; + + // Remove ContactInvitationRecord from account's list + final (deletedItem, success) = await operateWrite((writer) async { + for (var i = 0; i < writer.length; i++) { + final item = await writer.getItemProtobuf( + proto.ContactInvitationRecord.fromBuffer, i); + if (item == null) { + throw Exception('Failed to get contact invitation record'); + } + if (item.contactRequestInbox.recordKey.toVeilid() == + contactRequestInboxRecordKey) { + if (await writer.tryRemoveItem(i) != null) { + return item; + } + return null; + } + } + return null; + }); + + if (success && deletedItem != null) { + // Delete the contact request inbox + final contactRequestInbox = deletedItem.contactRequestInbox.toVeilid(); + await (await pool.openOwned(contactRequestInbox, + parent: accountRecordKey)) + .scope((contactRequestInbox) async { + // Wipe out old invitation so it shows up as invalid + await contactRequestInbox.tryWriteBytes(Uint8List(0)); + }); + try { + await pool.delete(contactRequestInbox.recordKey); + } on Exception catch (e) { + log.debug('error removing contact request inbox: $e', e); + } + if (!accepted) { + try { + await pool.delete(deletedItem.localConversationRecordKey.toVeilid()); + } on Exception catch (e) { + log.debug('error removing local conversation record: $e', e); + } + } + } + } + + Future validateInvitation( + {required Uint8List inviteData, + required GetEncryptionKeyCallback getEncryptionKeyCallback}) async { + final pool = DHTRecordPool.instance; + + // Get contact request inbox from invitation + final signedContactInvitation = + proto.SignedContactInvitation.fromBuffer(inviteData); + final contactInvitationBytes = + Uint8List.fromList(signedContactInvitation.contactInvitation); + final contactInvitation = + proto.ContactInvitation.fromBuffer(contactInvitationBytes); + final contactRequestInboxKey = + contactInvitation.contactRequestInboxKey.toVeilid(); + + ValidContactInvitation? out; + + final cs = await pool.veilid.getCryptoSystem(contactRequestInboxKey.kind); + + // Compare the invitation's contact request + // inbox with our list of extant invitations + // If we're chatting to ourselves, + // we are validating an invitation we have created + final isSelf = state.state.data!.value.indexWhere((cir) => + cir.contactRequestInbox.recordKey.toVeilid() == + contactRequestInboxKey) != + -1; + + await (await pool.openRead(contactRequestInboxKey, + parent: _activeAccountInfo.accountRecordKey)) + .maybeDeleteScope(!isSelf, (contactRequestInbox) async { + // + final contactRequest = await contactRequestInbox + .getProtobuf(proto.ContactRequest.fromBuffer); + + // Decrypt contact request private + final encryptionKeyType = + EncryptionKeyType.fromProto(contactRequest!.encryptionKeyType); + late final SharedSecret? writerSecret; + try { + writerSecret = await getEncryptionKeyCallback(cs, encryptionKeyType, + Uint8List.fromList(contactInvitation.writerSecret)); + } on Exception catch (_) { + throw ContactInviteInvalidKeyException(encryptionKeyType); + } + if (writerSecret == null) { + return null; + } + + final contactRequestPrivateBytes = await cs.decryptAeadWithNonce( + Uint8List.fromList(contactRequest.private), writerSecret); + + final contactRequestPrivate = + proto.ContactRequestPrivate.fromBuffer(contactRequestPrivateBytes); + final contactIdentityMasterRecordKey = + contactRequestPrivate.identityMasterRecordKey.toVeilid(); + + // Fetch the account master + final contactIdentityMaster = await openIdentityMaster( + identityMasterRecordKey: contactIdentityMasterRecordKey); + + // Verify + final signature = signedContactInvitation.identitySignature.toVeilid(); + await cs.verify(contactIdentityMaster.identityPublicKey, + contactInvitationBytes, signature); + + final writer = KeyPair( + key: contactRequestPrivate.writerKey.toVeilid(), + secret: writerSecret); + + out = ValidContactInvitation( + activeAccountInfo: _activeAccountInfo, + account: _account, + contactRequestInboxKey: contactRequestInboxKey, + contactRequestPrivate: contactRequestPrivate, + contactIdentityMaster: contactIdentityMaster, + writer: writer); + }); + + return out; + } + + // + final ActiveAccountInfo _activeAccountInfo; + final proto.Account _account; +} diff --git a/lib/contact_invitation/cubits/contact_request_inbox_cubit.dart b/lib/contact_invitation/cubits/contact_request_inbox_cubit.dart new file mode 100644 index 0000000..80c18ae --- /dev/null +++ b/lib/contact_invitation/cubits/contact_request_inbox_cubit.dart @@ -0,0 +1,43 @@ +import 'package:veilid_support/veilid_support.dart'; + +import '../../account_manager/account_manager.dart'; +import '../../proto/proto.dart' as proto; + +// Watch subkey #1 of the ContactRequest record for accept/reject +class ContactRequestInboxCubit + extends DefaultDHTRecordCubit { + ContactRequestInboxCubit( + {required this.activeAccountInfo, required this.contactInvitationRecord}) + : super( + open: () => _open( + activeAccountInfo: activeAccountInfo, + contactInvitationRecord: contactInvitationRecord), + decodeState: proto.SignedContactResponse.fromBuffer); + + // ContactRequestInboxCubit.value( + // {required super.record, + // required this.activeAccountInfo, + // required this.contactInvitationRecord}) + // : super.value(decodeState: proto.SignedContactResponse.fromBuffer); + + static Future _open( + {required ActiveAccountInfo activeAccountInfo, + required proto.ContactInvitationRecord contactInvitationRecord}) async { + final pool = DHTRecordPool.instance; + final accountRecordKey = + activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey; + final writerKey = contactInvitationRecord.writerKey.toVeilid(); + final writerSecret = contactInvitationRecord.writerSecret.toVeilid(); + final recordKey = + contactInvitationRecord.contactRequestInbox.recordKey.toVeilid(); + final writer = TypedKeyPair( + kind: recordKey.kind, key: writerKey, secret: writerSecret); + return pool.openRead(recordKey, + crypto: await DHTRecordCryptoPrivate.fromTypedKeyPair(writer), + parent: accountRecordKey, + defaultSubkey: 1); + } + + final ActiveAccountInfo activeAccountInfo; + final proto.ContactInvitationRecord contactInvitationRecord; +} diff --git a/lib/contact_invitation/cubits/cubits.dart b/lib/contact_invitation/cubits/cubits.dart new file mode 100644 index 0000000..fd2833f --- /dev/null +++ b/lib/contact_invitation/cubits/cubits.dart @@ -0,0 +1,5 @@ +export 'contact_invitation_list_cubit.dart'; +export 'contact_request_inbox_cubit.dart'; +export 'invitation_generator_cubit.dart'; +export 'waiting_invitation_cubit.dart'; +export 'waiting_invitations_bloc_map_cubit.dart'; diff --git a/lib/contact_invitation/cubits/invitation_generator_cubit.dart b/lib/contact_invitation/cubits/invitation_generator_cubit.dart new file mode 100644 index 0000000..c6f7258 --- /dev/null +++ b/lib/contact_invitation/cubits/invitation_generator_cubit.dart @@ -0,0 +1,8 @@ +import 'dart:typed_data'; + +import 'package:bloc_tools/bloc_tools.dart'; + +class InvitationGeneratorCubit extends FutureCubit { + InvitationGeneratorCubit(super.fut); + InvitationGeneratorCubit.value(super.v) : super.value(); +} diff --git a/lib/contact_invitation/cubits/waiting_invitation_cubit.dart b/lib/contact_invitation/cubits/waiting_invitation_cubit.dart new file mode 100644 index 0000000..04342e0 --- /dev/null +++ b/lib/contact_invitation/cubits/waiting_invitation_cubit.dart @@ -0,0 +1,111 @@ +import 'dart:typed_data'; + +import 'package:async_tools/async_tools.dart'; +import 'package:bloc_tools/bloc_tools.dart'; +import 'package:equatable/equatable.dart'; +import 'package:meta/meta.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../account_manager/account_manager.dart'; +import '../../contacts/contacts.dart'; +import '../../proto/proto.dart' as proto; +import '../../tools/tools.dart'; +import '../models/accepted_contact.dart'; +import 'contact_request_inbox_cubit.dart'; + +@immutable +class InvitationStatus extends Equatable { + const InvitationStatus({required this.acceptedContact}); + final AcceptedContact? acceptedContact; + + @override + List get props => [acceptedContact]; +} + +class WaitingInvitationCubit extends AsyncTransformerCubit { + WaitingInvitationCubit(ContactRequestInboxCubit super.input, + {required ActiveAccountInfo activeAccountInfo, + required proto.Account account, + required proto.ContactInvitationRecord contactInvitationRecord}) + : super( + transform: (signedContactResponse) => _transform( + signedContactResponse, + activeAccountInfo: activeAccountInfo, + account: account, + contactInvitationRecord: contactInvitationRecord)); + + static Future> _transform( + proto.SignedContactResponse signedContactResponse, + {required ActiveAccountInfo activeAccountInfo, + required proto.Account account, + required proto.ContactInvitationRecord contactInvitationRecord}) async { + final pool = DHTRecordPool.instance; + final contactResponseBytes = + Uint8List.fromList(signedContactResponse.contactResponse); + final contactResponse = + proto.ContactResponse.fromBuffer(contactResponseBytes); + final contactIdentityMasterRecordKey = + contactResponse.identityMasterRecordKey.toVeilid(); + final cs = + await pool.veilid.getCryptoSystem(contactIdentityMasterRecordKey.kind); + + // Fetch the remote contact's account master + final contactIdentityMaster = await openIdentityMaster( + identityMasterRecordKey: contactIdentityMasterRecordKey); + + // Verify + final signature = signedContactResponse.identitySignature.toVeilid(); + await cs.verify(contactIdentityMaster.identityPublicKey, + contactResponseBytes, signature); + + // Check for rejection + if (!contactResponse.accept) { + // Rejection + return const AsyncValue.data(InvitationStatus(acceptedContact: null)); + } + + // Pull profile from remote conversation key + final remoteConversationRecordKey = + contactResponse.remoteConversationRecordKey.toVeilid(); + + final conversation = ConversationCubit( + activeAccountInfo: activeAccountInfo, + remoteIdentityPublicKey: contactIdentityMaster.identityPublicTypedKey(), + remoteConversationRecordKey: remoteConversationRecordKey); + + // wait for remote conversation for up to 20 seconds + proto.Conversation? remoteConversation; + var retryCount = 20; + do { + await conversation.refresh(); + remoteConversation = conversation.state.data?.value.remoteConversation; + if (remoteConversation != null) { + break; + } + log.info('Remote conversation could not be read. Waiting...'); + await Future.delayed(const Duration(seconds: 1)); + retryCount--; + } while (retryCount > 0); + if (remoteConversation == null) { + return AsyncValue.error('Invitation accept timed out.'); + } + + // Complete the local conversation now that we have the remote profile + final remoteProfile = remoteConversation.profile; + final localConversationRecordKey = + contactInvitationRecord.localConversationRecordKey.toVeilid(); + return conversation.initLocalConversation( + existingConversationRecordKey: localConversationRecordKey, + profile: account.profile, + // ignore: prefer_expression_function_bodies + callback: (localConversation) async { + return AsyncValue.data(InvitationStatus( + acceptedContact: AcceptedContact( + remoteProfile: remoteProfile, + remoteIdentity: contactIdentityMaster, + remoteConversationRecordKey: remoteConversationRecordKey, + localConversationRecordKey: localConversationRecordKey))); + }); + } +} diff --git a/lib/contact_invitation/cubits/waiting_invitations_bloc_map_cubit.dart b/lib/contact_invitation/cubits/waiting_invitations_bloc_map_cubit.dart new file mode 100644 index 0000000..c674a14 --- /dev/null +++ b/lib/contact_invitation/cubits/waiting_invitations_bloc_map_cubit.dart @@ -0,0 +1,62 @@ +import 'package:async_tools/async_tools.dart'; +import 'package:bloc_tools/bloc_tools.dart'; +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../account_manager/account_manager.dart'; +import '../../proto/proto.dart' as proto; +import 'cubits.dart'; + +typedef WaitingInvitationsBlocMapState + = BlocMapState>; + +// Map of contactRequestInboxRecordKey to WaitingInvitationCubit +// Wraps a contact invitation cubit to watch for accept/reject +// Automatically follows the state of a ContactInvitationListCubit. +class WaitingInvitationsBlocMapCubit extends BlocMapCubit, WaitingInvitationCubit> + with + StateFollower< + BlocBusyState>>, + TypedKey, + proto.ContactInvitationRecord> { + WaitingInvitationsBlocMapCubit( + {required this.activeAccountInfo, required this.account}); + + Future _addWaitingInvitation( + {required proto.ContactInvitationRecord + contactInvitationRecord}) async => + add(() => MapEntry( + contactInvitationRecord.contactRequestInbox.recordKey.toVeilid(), + WaitingInvitationCubit( + ContactRequestInboxCubit( + activeAccountInfo: activeAccountInfo, + contactInvitationRecord: contactInvitationRecord), + activeAccountInfo: activeAccountInfo, + account: account, + contactInvitationRecord: contactInvitationRecord))); + + /// StateFollower ///////////////////////// + @override + IMap getStateMap( + BlocBusyState>> state) { + final stateValue = state.state.data?.value; + if (stateValue == null) { + return IMap(); + } + return IMap.fromIterable(stateValue, + keyMapper: (e) => e.contactRequestInbox.recordKey.toVeilid(), + valueMapper: (e) => e); + } + + @override + Future removeFromState(TypedKey key) => remove(key); + + @override + Future updateState(TypedKey key, proto.ContactInvitationRecord value) => + _addWaitingInvitation(contactInvitationRecord: value); + + //// + final ActiveAccountInfo activeAccountInfo; + final proto.Account account; +} diff --git a/lib/contact_invitation/models/accepted_contact.dart b/lib/contact_invitation/models/accepted_contact.dart new file mode 100644 index 0000000..ac8edc2 --- /dev/null +++ b/lib/contact_invitation/models/accepted_contact.dart @@ -0,0 +1,28 @@ +import 'package:equatable/equatable.dart'; +import 'package:meta/meta.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../proto/proto.dart' as proto; + +@immutable +class AcceptedContact extends Equatable { + const AcceptedContact({ + required this.remoteProfile, + required this.remoteIdentity, + required this.remoteConversationRecordKey, + required this.localConversationRecordKey, + }); + + final proto.Profile remoteProfile; + final IdentityMaster remoteIdentity; + final TypedKey remoteConversationRecordKey; + final TypedKey localConversationRecordKey; + + @override + List get props => [ + remoteProfile, + remoteIdentity, + remoteConversationRecordKey, + localConversationRecordKey + ]; +} diff --git a/lib/contact_invitation/models/models.dart b/lib/contact_invitation/models/models.dart new file mode 100644 index 0000000..0936f63 --- /dev/null +++ b/lib/contact_invitation/models/models.dart @@ -0,0 +1,2 @@ +export 'accepted_contact.dart'; +export 'valid_contact_invitation.dart'; diff --git a/lib/contact_invitation/models/valid_contact_invitation.dart b/lib/contact_invitation/models/valid_contact_invitation.dart new file mode 100644 index 0000000..88f43d9 --- /dev/null +++ b/lib/contact_invitation/models/valid_contact_invitation.dart @@ -0,0 +1,146 @@ +import 'package:meta/meta.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../account_manager/account_manager.dart'; +import '../../contacts/contacts.dart'; +import '../../proto/proto.dart' as proto; +import '../../tools/tools.dart'; +import 'models.dart'; + +////////////////////////////////////////////////// +/// + +class ValidContactInvitation { + @internal + ValidContactInvitation( + {required ActiveAccountInfo activeAccountInfo, + required proto.Account account, + required TypedKey contactRequestInboxKey, + required proto.ContactRequestPrivate contactRequestPrivate, + required IdentityMaster contactIdentityMaster, + required KeyPair writer}) + : _activeAccountInfo = activeAccountInfo, + _account = account, + _contactRequestInboxKey = contactRequestInboxKey, + _contactRequestPrivate = contactRequestPrivate, + _contactIdentityMaster = contactIdentityMaster, + _writer = writer; + + proto.Profile get remoteProfile => _contactRequestPrivate.profile; + + Future accept() async { + final pool = DHTRecordPool.instance; + try { + // Ensure we don't delete this if we're trying to chat to self + // The initiating side will delete the records in deleteInvitation() + final isSelf = _contactIdentityMaster.identityPublicKey == + _activeAccountInfo.localAccount.identityMaster.identityPublicKey; + final accountRecordKey = _activeAccountInfo.accountRecordKey; + + return (await pool.openWrite(_contactRequestInboxKey, _writer, + parent: accountRecordKey)) + // ignore: prefer_expression_function_bodies + .maybeDeleteScope(!isSelf, (contactRequestInbox) async { + // Create local conversation key for this + // contact and send via contact response + final conversation = ConversationCubit( + activeAccountInfo: _activeAccountInfo, + remoteIdentityPublicKey: + _contactIdentityMaster.identityPublicTypedKey()); + return conversation.initLocalConversation( + profile: _account.profile, + callback: (localConversation) async { + final contactResponse = proto.ContactResponse() + ..accept = true + ..remoteConversationRecordKey = localConversation.key.toProto() + ..identityMasterRecordKey = _activeAccountInfo + .localAccount.identityMaster.masterRecordKey + .toProto(); + final contactResponseBytes = contactResponse.writeToBuffer(); + + final cs = await pool.veilid + .getCryptoSystem(_contactRequestInboxKey.kind); + + final identitySignature = await cs.sign( + _activeAccountInfo.conversationWriter.key, + _activeAccountInfo.conversationWriter.secret, + contactResponseBytes); + + final signedContactResponse = proto.SignedContactResponse() + ..contactResponse = contactResponseBytes + ..identitySignature = identitySignature.toProto(); + + // Write the acceptance to the inbox + if (await contactRequestInbox.tryWriteProtobuf( + proto.SignedContactResponse.fromBuffer, + signedContactResponse, + subkey: 1) != + null) { + throw Exception('failed to accept contact invitation'); + } + return AcceptedContact( + remoteProfile: _contactRequestPrivate.profile, + remoteIdentity: _contactIdentityMaster, + remoteConversationRecordKey: + _contactRequestPrivate.chatRecordKey.toVeilid(), + localConversationRecordKey: localConversation.key, + ); + }); + }); + } on Exception catch (e) { + log.debug('exception: $e', e); + return null; + } + } + + Future reject() async { + final pool = DHTRecordPool.instance; + + // Ensure we don't delete this if we're trying to chat to self + final isSelf = _contactIdentityMaster.identityPublicKey == + _activeAccountInfo.localAccount.identityMaster.identityPublicKey; + final accountRecordKey = + _activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey; + + return (await pool.openWrite(_contactRequestInboxKey, _writer, + parent: accountRecordKey)) + .maybeDeleteScope(!isSelf, (contactRequestInbox) async { + final cs = + await pool.veilid.getCryptoSystem(_contactRequestInboxKey.kind); + + final contactResponse = proto.ContactResponse() + ..accept = false + ..identityMasterRecordKey = _activeAccountInfo + .localAccount.identityMaster.masterRecordKey + .toProto(); + final contactResponseBytes = contactResponse.writeToBuffer(); + + final identitySignature = await cs.sign( + _activeAccountInfo.conversationWriter.key, + _activeAccountInfo.conversationWriter.secret, + contactResponseBytes); + + final signedContactResponse = proto.SignedContactResponse() + ..contactResponse = contactResponseBytes + ..identitySignature = identitySignature.toProto(); + + // Write the rejection to the inbox + if (await contactRequestInbox.tryWriteProtobuf( + proto.SignedContactResponse.fromBuffer, signedContactResponse, + subkey: 1) != + null) { + log.error('failed to reject contact invitation'); + return false; + } + return true; + }); + } + + // + final ActiveAccountInfo _activeAccountInfo; + final proto.Account _account; + final TypedKey _contactRequestInboxKey; + final IdentityMaster _contactIdentityMaster; + final KeyPair _writer; + final proto.ContactRequestPrivate _contactRequestPrivate; +} diff --git a/lib/contact_invitation/views/contact_invitation_display.dart b/lib/contact_invitation/views/contact_invitation_display.dart new file mode 100644 index 0000000..4291541 --- /dev/null +++ b/lib/contact_invitation/views/contact_invitation_display.dart @@ -0,0 +1,132 @@ +import 'dart:math'; + +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:basic_utils/basic_utils.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_translate/flutter_translate.dart'; +import 'package:provider/provider.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../tools/tools.dart'; +import '../contact_invitation.dart'; + +class ContactInvitationDisplayDialog extends StatefulWidget { + const ContactInvitationDisplayDialog({ + required this.message, + super.key, + }); + + final String message; + + @override + State createState() => + _ContactInvitationDisplayDialogState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(StringProperty('message', message)); + } +} + +class _ContactInvitationDisplayDialogState + extends State { + final focusNode = FocusNode(); + final formKey = GlobalKey(); + + @override + void initState() { + super.initState(); + } + + @override + void dispose() { + focusNode.dispose(); + super.dispose(); + } + + String makeTextInvite(String message, Uint8List data) { + final invite = StringUtils.addCharAtPosition( + base64UrlNoPadEncode(data), '\n', 40, + repeat: true); + final msg = message.isNotEmpty ? '$message\n' : ''; + return '$msg' + '--- BEGIN VEILIDCHAT CONTACT INVITE ----\n' + '$invite\n' + '---- END VEILIDCHAT CONTACT INVITE -----\n'; + } + + @override + // ignore: prefer_expression_function_bodies + Widget build(BuildContext context) { + final theme = Theme.of(context); + //final scale = theme.extension()!; + final textTheme = theme.textTheme; + + final signedContactInvitationBytesV = + context.watch().state; + + final cardsize = + min(MediaQuery.of(context).size.shortestSide - 48.0, 400); + + return Dialog( + backgroundColor: Colors.white, + child: ConstrainedBox( + constraints: BoxConstraints( + minWidth: cardsize, + maxWidth: cardsize, + minHeight: cardsize, + maxHeight: cardsize), + child: signedContactInvitationBytesV.when( + loading: buildProgressIndicator, + data: (data) => Form( + key: formKey, + child: Column(children: [ + FittedBox( + child: Text( + translate( + 'send_invite_dialog.contact_invitation'), + style: textTheme.headlineSmall! + .copyWith(color: Colors.black))) + .paddingAll(8), + FittedBox( + child: QrImageView.withQr( + size: 300, + qr: QrCode.fromUint8List( + data: data, + errorCorrectLevel: + QrErrorCorrectLevel.L))) + .expanded(), + Text(widget.message, + softWrap: true, + style: textTheme.labelLarge! + .copyWith(color: Colors.black)) + .paddingAll(8), + ElevatedButton.icon( + icon: const Icon(Icons.copy), + label: Text( + translate('send_invite_dialog.copy_invitation')), + onPressed: () async { + showInfoToast( + context, + translate( + 'send_invite_dialog.invitation_copied')); + await Clipboard.setData(ClipboardData( + text: makeTextInvite(widget.message, data))); + }, + ).paddingAll(16), + ])), + error: errorPage))); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('focusNode', focusNode)) + ..add(DiagnosticsProperty>('formKey', formKey)); + } +} diff --git a/lib/components/contact_invitation_item_widget.dart b/lib/contact_invitation/views/contact_invitation_item_widget.dart similarity index 62% rename from lib/components/contact_invitation_item_widget.dart rename to lib/contact_invitation/views/contact_invitation_item_widget.dart index 1a967ba..e633390 100644 --- a/lib/components/contact_invitation_item_widget.dart +++ b/lib/contact_invitation/views/contact_invitation_item_widget.dart @@ -1,30 +1,33 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; import 'package:flutter_translate/flutter_translate.dart'; -import '../proto/proto.dart' as proto; -import '../providers/account.dart'; -import '../providers/contact_invite.dart'; -import '../tools/tools.dart'; -import 'contact_invitation_display.dart'; +import '../../proto/proto.dart' as proto; +import '../../theme/theme.dart'; +import '../contact_invitation.dart'; -class ContactInvitationItemWidget extends ConsumerWidget { +class ContactInvitationItemWidget extends StatelessWidget { const ContactInvitationItemWidget( - {required this.contactInvitationRecord, super.key}); + {required this.contactInvitationRecord, + required this.disabled, + super.key}); final proto.ContactInvitationRecord contactInvitationRecord; + final bool disabled; @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); - properties.add(DiagnosticsProperty( - 'contactInvitationRecord', contactInvitationRecord)); + properties + ..add(DiagnosticsProperty( + 'contactInvitationRecord', contactInvitationRecord)) + ..add(DiagnosticsProperty('disabled', disabled)); } @override // ignore: prefer_expression_function_bodies - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { final theme = Theme.of(context); //final textTheme = theme.textTheme; final scale = theme.extension()!; @@ -50,17 +53,18 @@ class ContactInvitationItemWidget extends ConsumerWidget { children: [ // A SlidableAction can have an icon and/or a label. SlidableAction( - onPressed: (context) async { - final activeAccountInfo = - await ref.read(fetchActiveAccountProvider.future); - if (activeAccountInfo != null) { - await deleteContactInvitation( - accepted: false, - activeAccountInfo: activeAccountInfo, - contactInvitationRecord: contactInvitationRecord); - ref.invalidate(fetchContactInvitationRecordsProvider); - } - }, + onPressed: disabled + ? null + : (context) async { + final contactInvitationListCubit = + context.read(); + await contactInvitationListCubit.deleteInvitation( + accepted: false, + contactRequestInboxRecordKey: + contactInvitationRecord + .contactRequestInbox.recordKey + .toVeilid()); + }, backgroundColor: scale.tertiaryScale.background, foregroundColor: scale.tertiaryScale.text, icon: Icons.delete, @@ -95,24 +99,22 @@ class ContactInvitationItemWidget extends ConsumerWidget { // component is not dragged. child: ListTile( //title: Text(translate('contact_list.invitation')), - onTap: () async { - final activeAccountInfo = - await ref.read(fetchActiveAccountProvider.future); - if (activeAccountInfo != null) { - // ignore: use_build_context_synchronously - if (!context.mounted) { - return; - } - await showDialog( - context: context, - builder: (context) => ContactInvitationDisplayDialog( - name: activeAccountInfo.localAccount.name, - message: contactInvitationRecord.message, - generator: Uint8List.fromList( - contactInvitationRecord.invitation), - )); - } - }, + onTap: disabled + ? null + : () async { + if (!context.mounted) { + return; + } + await showDialog( + context: context, + builder: (context) => BlocProvider( + create: (context) => InvitationGeneratorCubit + .value(Uint8List.fromList( + contactInvitationRecord.invitation)), + child: ContactInvitationDisplayDialog( + message: contactInvitationRecord.message, + ))); + }, title: Text( contactInvitationRecord.message.isEmpty ? translate('contact_list.invitation') diff --git a/lib/components/contact_invitation_list_widget.dart b/lib/contact_invitation/views/contact_invitation_list_widget.dart similarity index 84% rename from lib/components/contact_invitation_list_widget.dart rename to lib/contact_invitation/views/contact_invitation_list_widget.dart index 372a1cc..19243b7 100644 --- a/lib/components/contact_invitation_list_widget.dart +++ b/lib/contact_invitation/views/contact_invitation_list_widget.dart @@ -2,19 +2,20 @@ import 'package:awesome_extensions/awesome_extensions.dart'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../proto/proto.dart' as proto; -import '../tools/tools.dart'; +import '../../proto/proto.dart' as proto; +import '../../theme/theme.dart'; import 'contact_invitation_item_widget.dart'; -class ContactInvitationListWidget extends ConsumerStatefulWidget { +class ContactInvitationListWidget extends StatefulWidget { const ContactInvitationListWidget({ required this.contactInvitationRecordList, + required this.disabled, super.key, }); final IList contactInvitationRecordList; + final bool disabled; @override ContactInvitationListWidgetState createState() => @@ -22,13 +23,15 @@ class ContactInvitationListWidget extends ConsumerStatefulWidget { @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); - properties.add(IterableProperty( - 'contactInvitationRecordList', contactInvitationRecordList)); + properties + ..add(IterableProperty( + 'contactInvitationRecordList', contactInvitationRecordList)) + ..add(DiagnosticsProperty('disabled', disabled)); } } class ContactInvitationListWidgetState - extends ConsumerState { + extends State { final ScrollController _scrollController = ScrollController(); @override @@ -64,6 +67,7 @@ class ContactInvitationListWidgetState return ContactInvitationItemWidget( contactInvitationRecord: widget.contactInvitationRecordList[index], + disabled: widget.disabled, key: ObjectKey(widget.contactInvitationRecordList[index])) .paddingLTRB(4, 2, 4, 2); }, diff --git a/lib/components/invite_dialog.dart b/lib/contact_invitation/views/invite_dialog.dart similarity index 64% rename from lib/components/invite_dialog.dart rename to lib/contact_invitation/views/invite_dialog.dart index 870c6fe..6e4580b 100644 --- a/lib/components/invite_dialog.dart +++ b/lib/contact_invitation/views/invite_dialog.dart @@ -3,21 +3,18 @@ import 'dart:async'; import 'package:awesome_extensions/awesome_extensions.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_translate/flutter_translate.dart'; -import '../entities/local_account.dart'; -import '../providers/account.dart'; -import '../providers/contact.dart'; -import '../providers/contact_invite.dart'; -import '../tools/tools.dart'; -import 'enter_password.dart'; -import 'enter_pin.dart'; -import 'profile_widget.dart'; +import '../../account_manager/account_manager.dart'; +import '../../contacts/contacts.dart'; +import '../../tools/tools.dart'; +import '../contact_invitation.dart'; -class InviteDialog extends ConsumerStatefulWidget { +class InviteDialog extends StatefulWidget { const InviteDialog( - {required this.onValidationCancelled, + {required this.modalContext, + required this.onValidationCancelled, required this.onValidationSuccess, required this.onValidationFailed, required this.inviteControlIsValid, @@ -33,6 +30,7 @@ class InviteDialog extends ConsumerStatefulWidget { InviteDialogState dialogState, Future Function({required Uint8List inviteData}) validateInviteData) buildInviteControl; + final BuildContext modalContext; @override InviteDialogState createState() => InviteDialogState(); @@ -54,11 +52,12 @@ class InviteDialog extends ConsumerStatefulWidget { InviteDialogState dialogState, Future Function({required Uint8List inviteData}) validateInviteData)>.has( - 'buildInviteControl', buildInviteControl)); + 'buildInviteControl', buildInviteControl)) + ..add(DiagnosticsProperty('modalContext', modalContext)); } } -class InviteDialogState extends ConsumerState { +class InviteDialogState extends State { ValidContactInvitation? _validInvitation; bool _isValidating = false; bool _isAccepting = false; @@ -73,22 +72,15 @@ class InviteDialogState extends ConsumerState { Future _onAccept() async { final navigator = Navigator.of(context); + final activeAccountInfo = widget.modalContext.read(); + final contactList = widget.modalContext.read(); setState(() { _isAccepting = true; }); - final activeAccountInfo = await ref.read(fetchActiveAccountProvider.future); - if (activeAccountInfo == null) { - setState(() { - _isAccepting = false; - }); - navigator.pop(); - return; - } final validInvitation = _validInvitation; if (validInvitation != null) { - final acceptedContact = - await acceptContactInvitation(activeAccountInfo, validInvitation); + final acceptedContact = await validInvitation.accept(); if (acceptedContact != null) { // initiator when accept is received will create // contact in the case of a 'note to self' @@ -96,9 +88,8 @@ class InviteDialogState extends ConsumerState { activeAccountInfo.localAccount.identityMaster.identityPublicKey == acceptedContact.remoteIdentity.identityPublicKey; if (!isSelf) { - await createContact( - activeAccountInfo: activeAccountInfo, - profile: acceptedContact.profile, + await contactList.createContact( + remoteProfile: acceptedContact.remoteProfile, remoteIdentity: acceptedContact.remoteIdentity, remoteConversationRecordKey: acceptedContact.remoteConversationRecordKey, @@ -106,9 +97,6 @@ class InviteDialogState extends ConsumerState { acceptedContact.localConversationRecordKey, ); } - ref - ..invalidate(fetchContactInvitationRecordsProvider) - ..invalidate(fetchContactListProvider); } else { if (context.mounted) { showErrorToast(context, 'invite_dialog.failed_to_accept'); @@ -127,17 +115,9 @@ class InviteDialogState extends ConsumerState { setState(() { _isAccepting = true; }); - final activeAccountInfo = await ref.read(fetchActiveAccountProvider.future); - if (activeAccountInfo == null) { - setState(() { - _isAccepting = false; - }); - navigator.pop(); - return; - } final validInvitation = _validInvitation; if (validInvitation != null) { - if (await rejectContactInvitation(activeAccountInfo, validInvitation)) { + if (await validInvitation.reject()) { // do nothing right now } else { if (context.mounted) { @@ -155,67 +135,56 @@ class InviteDialogState extends ConsumerState { required Uint8List inviteData, }) async { try { - final activeAccountInfo = - await ref.read(fetchActiveAccountProvider.future); - if (activeAccountInfo == null) { - setState(() { - _isValidating = false; - _validInvitation = null; - }); - return; - } - final contactInvitationRecords = - await ref.read(fetchContactInvitationRecordsProvider.future); + final contactInvitationListCubit = + widget.modalContext.read(); setState(() { _isValidating = true; _validInvitation = null; }); - final validatedContactInvitation = await validateContactInvitation( - activeAccountInfo: activeAccountInfo, - contactInvitationRecords: contactInvitationRecords, - inviteData: inviteData, - getEncryptionKeyCallback: - (cs, encryptionKeyType, encryptedSecret) async { - String encryptionKey; - switch (encryptionKeyType) { - case EncryptionKeyType.none: - encryptionKey = ''; - case EncryptionKeyType.pin: - final description = - translate('invite_dialog.protected_with_pin'); - if (!context.mounted) { - return null; + final validatedContactInvitation = + await contactInvitationListCubit.validateInvitation( + inviteData: inviteData, + getEncryptionKeyCallback: + (cs, encryptionKeyType, encryptedSecret) async { + String encryptionKey; + switch (encryptionKeyType) { + case EncryptionKeyType.none: + encryptionKey = ''; + case EncryptionKeyType.pin: + final description = + translate('invite_dialog.protected_with_pin'); + if (!context.mounted) { + return null; + } + final pin = await showDialog( + context: context, + builder: (context) => EnterPinDialog( + reenter: false, description: description)); + if (pin == null) { + return null; + } + encryptionKey = pin; + case EncryptionKeyType.password: + final description = + translate('invite_dialog.protected_with_password'); + if (!context.mounted) { + return null; + } + final password = await showDialog( + context: context, + builder: (context) => + EnterPasswordDialog(description: description)); + if (password == null) { + return null; + } + encryptionKey = password; } - final pin = await showDialog( - context: context, - builder: (context) => EnterPinDialog( - reenter: false, description: description)); - if (pin == null) { - return null; - } - encryptionKey = pin; - case EncryptionKeyType.password: - final description = - translate('invite_dialog.protected_with_password'); - if (!context.mounted) { - return null; - } - final password = await showDialog( - context: context, - builder: (context) => - EnterPasswordDialog(description: description)); - if (password == null) { - return null; - } - encryptionKey = password; - } - return decryptSecretFromBytes( - secretBytes: encryptedSecret, - cryptoKind: cs.kind(), - encryptionKeyType: encryptionKeyType, - encryptionKey: encryptionKey); - }); + return encryptionKeyType.decryptSecretFromBytes( + secretBytes: encryptedSecret, + cryptoKind: cs.kind(), + encryptionKey: encryptionKey); + }); // Check if validation was cancelled if (validatedContactInvitation == null) { @@ -276,7 +245,7 @@ class InviteDialogState extends ConsumerState { return SizedBox( height: 300, width: 300, - child: buildProgressIndicator(context).toCenter()) + child: buildProgressIndicator().toCenter()) .paddingAll(16); } return ConstrainedBox( @@ -292,7 +261,7 @@ class InviteDialogState extends ConsumerState { Column(children: [ Text(translate('invite_dialog.validating')) .paddingLTRB(0, 0, 0, 16), - buildProgressIndicator(context).paddingAll(16), + buildProgressIndicator().paddingAll(16), ]).toCenter(), if (_validInvitation == null && !_isValidating && @@ -304,14 +273,11 @@ class InviteDialogState extends ConsumerState { if (_validInvitation != null && !_isValidating) Column(children: [ Container( - constraints: const BoxConstraints(maxHeight: 64), - width: double.infinity, - child: ProfileWidget( - name: _validInvitation! - .contactRequestPrivate.profile.name, - pronouns: _validInvitation! - .contactRequestPrivate.profile.pronouns, - )).paddingLTRB(0, 0, 0, 8), + constraints: const BoxConstraints(maxHeight: 64), + width: double.infinity, + child: ProfileWidget( + profile: _validInvitation!.remoteProfile)) + .paddingLTRB(0, 0, 0, 8), Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ diff --git a/lib/contact_invitation/views/new_contact_invitation_bottom_sheet.dart b/lib/contact_invitation/views/new_contact_invitation_bottom_sheet.dart new file mode 100644 index 0000000..b0ba5c3 --- /dev/null +++ b/lib/contact_invitation/views/new_contact_invitation_bottom_sheet.dart @@ -0,0 +1,66 @@ +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_translate/flutter_translate.dart'; + +import '../../theme/theme.dart'; +import 'paste_invite_dialog.dart'; +import 'scan_invite_dialog.dart'; +import 'send_invite_dialog.dart'; + +Widget newContactInvitationBottomSheetBuilder( + BuildContext sheetContext, BuildContext context) { + final theme = Theme.of(sheetContext); + final textTheme = theme.textTheme; + final scale = theme.extension()!; + + return KeyboardListener( + focusNode: FocusNode(), + onKeyEvent: (ke) { + if (ke.logicalKey == LogicalKeyboardKey.escape) { + Navigator.pop(sheetContext); + } + }, + child: SizedBox( + height: 200, + child: Column(children: [ + Text(translate('accounts_menu.invite_contact'), + style: textTheme.titleMedium) + .paddingAll(8), + Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ + Column(children: [ + IconButton( + onPressed: () async { + Navigator.pop(sheetContext); + await SendInviteDialog.show(context); + }, + iconSize: 64, + icon: const Icon(Icons.contact_page), + color: scale.primaryScale.background), + Text(translate('accounts_menu.create_invite')) + ]), + Column(children: [ + IconButton( + onPressed: () async { + Navigator.pop(sheetContext); + await ScanInviteDialog.show(context); + }, + iconSize: 64, + icon: const Icon(Icons.qr_code_scanner), + color: scale.primaryScale.background), + Text(translate('accounts_menu.scan_invite')) + ]), + Column(children: [ + IconButton( + onPressed: () async { + Navigator.pop(sheetContext); + await PasteInviteDialog.show(context); + }, + iconSize: 64, + icon: const Icon(Icons.paste), + color: scale.primaryScale.background), + Text(translate('accounts_menu.paste_invite')) + ]) + ]).expanded() + ]))); +} diff --git a/lib/components/paste_invite_dialog.dart b/lib/contact_invitation/views/paste_invite_dialog.dart similarity index 83% rename from lib/components/paste_invite_dialog.dart rename to lib/contact_invitation/views/paste_invite_dialog.dart index b7e545c..bfd3fcd 100644 --- a/lib/components/paste_invite_dialog.dart +++ b/lib/contact_invitation/views/paste_invite_dialog.dart @@ -2,16 +2,17 @@ import 'dart:async'; import 'dart:typed_data'; import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_translate/flutter_translate.dart'; +import 'package:veilid_support/veilid_support.dart'; -import '../tools/tools.dart'; -import '../veilid_support/veilid_support.dart'; +import '../../theme/theme.dart'; +import '../../tools/tools.dart'; import 'invite_dialog.dart'; -class PasteInviteDialog extends ConsumerStatefulWidget { - const PasteInviteDialog({super.key}); +class PasteInviteDialog extends StatefulWidget { + const PasteInviteDialog({required this.modalContext, super.key}); @override PasteInviteDialogState createState() => PasteInviteDialogState(); @@ -20,11 +21,20 @@ class PasteInviteDialog extends ConsumerStatefulWidget { await showStyledDialog( context: context, title: translate('paste_invite_dialog.title'), - child: const PasteInviteDialog()); + child: PasteInviteDialog(modalContext: context)); + } + + final BuildContext modalContext; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + .add(DiagnosticsProperty('modalContext', modalContext)); } } -class PasteInviteDialogState extends ConsumerState { +class PasteInviteDialogState extends State { final _pasteTextController = TextEditingController(); @override @@ -122,6 +132,7 @@ class PasteInviteDialogState extends ConsumerState { // ignore: prefer_expression_function_bodies Widget build(BuildContext context) { return InviteDialog( + modalContext: widget.modalContext, onValidationCancelled: onValidationCancelled, onValidationSuccess: onValidationSuccess, onValidationFailed: onValidationFailed, diff --git a/lib/components/scan_invite_dialog.dart b/lib/contact_invitation/views/scan_invite_dialog.dart similarity index 94% rename from lib/components/scan_invite_dialog.dart rename to lib/contact_invitation/views/scan_invite_dialog.dart index a506bcf..70f5b3b 100644 --- a/lib/components/scan_invite_dialog.dart +++ b/lib/contact_invitation/views/scan_invite_dialog.dart @@ -6,14 +6,14 @@ import 'package:awesome_extensions/awesome_extensions.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_translate/flutter_translate.dart'; import 'package:image/image.dart' as img; import 'package:mobile_scanner/mobile_scanner.dart'; import 'package:pasteboard/pasteboard.dart'; import 'package:zxing2/qrcode.dart'; -import '../tools/tools.dart'; +import '../../theme/theme.dart'; +import '../../tools/tools.dart'; import 'invite_dialog.dart'; class BarcodeOverlay extends CustomPainter { @@ -31,9 +31,6 @@ class BarcodeOverlay extends CustomPainter { @override void paint(Canvas canvas, Size size) { - if (barcode.corners == null) { - return; - } final adjustedSize = applyBoxFit(boxFit, arguments.size, size); var verticalPadding = size.height - adjustedSize.destination.height; @@ -50,15 +47,14 @@ class BarcodeOverlay extends CustomPainter { horizontalPadding = 0; } - final ratioWidth = - (Platform.isIOS ? capture.width! : arguments.size.width) / - adjustedSize.destination.width; + final ratioWidth = (Platform.isIOS ? capture.width : arguments.size.width) / + adjustedSize.destination.width; final ratioHeight = - (Platform.isIOS ? capture.height! : arguments.size.height) / + (Platform.isIOS ? capture.height : arguments.size.height) / adjustedSize.destination.height; final adjustedOffset = []; - for (final offset in barcode.corners!) { + for (final offset in barcode.corners) { adjustedOffset.add( Offset( offset.dx / ratioWidth + horizontalPadding, @@ -107,8 +103,8 @@ class ScannerOverlay extends CustomPainter { bool shouldRepaint(covariant CustomPainter oldDelegate) => false; } -class ScanInviteDialog extends ConsumerStatefulWidget { - const ScanInviteDialog({super.key}); +class ScanInviteDialog extends StatefulWidget { + const ScanInviteDialog({required this.modalContext, super.key}); @override ScanInviteDialogState createState() => ScanInviteDialogState(); @@ -117,11 +113,20 @@ class ScanInviteDialog extends ConsumerStatefulWidget { await showStyledDialog( context: context, title: translate('scan_invite_dialog.title'), - child: const ScanInviteDialog()); + child: ScanInviteDialog(modalContext: context)); + } + + final BuildContext modalContext; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + .add(DiagnosticsProperty('modalContext', modalContext)); } } -class ScanInviteDialogState extends ConsumerState { +class ScanInviteDialogState extends State { bool scanned = false; @override @@ -384,6 +389,7 @@ class ScanInviteDialogState extends ConsumerState { // ignore: prefer_expression_function_bodies Widget build(BuildContext context) { return InviteDialog( + modalContext: widget.modalContext, onValidationCancelled: onValidationCancelled, onValidationSuccess: onValidationSuccess, onValidationFailed: onValidationFailed, diff --git a/lib/components/send_invite_dialog.dart b/lib/contact_invitation/views/send_invite_dialog.dart similarity index 81% rename from lib/components/send_invite_dialog.dart rename to lib/contact_invitation/views/send_invite_dialog.dart index 49adb68..fb83254 100644 --- a/lib/components/send_invite_dialog.dart +++ b/lib/contact_invitation/views/send_invite_dialog.dart @@ -5,20 +5,16 @@ import 'package:awesome_extensions/awesome_extensions.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_translate/flutter_translate.dart'; +import 'package:veilid_support/veilid_support.dart'; -import '../entities/local_account.dart'; -import '../providers/account.dart'; -import '../providers/contact_invite.dart'; -import '../tools/tools.dart'; -import '../veilid_support/veilid_support.dart'; -import 'contact_invitation_display.dart'; -import 'enter_password.dart'; -import 'enter_pin.dart'; +import '../../account_manager/account_manager.dart'; +import '../../tools/tools.dart'; +import '../contact_invitation.dart'; -class SendInviteDialog extends ConsumerStatefulWidget { - const SendInviteDialog({super.key}); +class SendInviteDialog extends StatefulWidget { + const SendInviteDialog({required this.modalContext, super.key}); @override SendInviteDialogState createState() => SendInviteDialogState(); @@ -27,11 +23,20 @@ class SendInviteDialog extends ConsumerStatefulWidget { await showStyledDialog( context: context, title: translate('send_invite_dialog.title'), - child: const SendInviteDialog()); + child: SendInviteDialog(modalContext: context)); + } + + final BuildContext modalContext; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + .add(DiagnosticsProperty('modalContext', modalContext)); } } -class SendInviteDialogState extends ConsumerState { +class SendInviteDialogState extends State { final _messageTextController = TextEditingController( text: translate('send_invite_dialog.connect_with_me')); @@ -61,8 +66,7 @@ class SendInviteDialogState extends ConsumerState { if (pin == null) { return; } - // ignore: use_build_context_synchronously - if (!context.mounted) { + if (!mounted) { return; } final matchpin = await showDialog( @@ -79,8 +83,7 @@ class SendInviteDialogState extends ConsumerState { _encryptionKey = pin; }); } else { - // ignore: use_build_context_synchronously - if (!context.mounted) { + if (!mounted) { return; } showErrorToast( @@ -100,8 +103,7 @@ class SendInviteDialogState extends ConsumerState { if (password == null) { return; } - // ignore: use_build_context_synchronously - if (!context.mounted) { + if (!mounted) { return; } final matchpass = await showDialog( @@ -118,8 +120,7 @@ class SendInviteDialogState extends ConsumerState { _encryptionKey = password; }); } else { - // ignore: use_build_context_synchronously - if (!context.mounted) { + if (!mounted) { return; } showErrorToast( @@ -135,32 +136,23 @@ class SendInviteDialogState extends ConsumerState { final navigator = Navigator.of(context); // Start generation - final activeAccountInfo = await ref.read(fetchActiveAccountProvider.future); - if (activeAccountInfo == null) { - navigator.pop(); - return; - } - final generator = createContactInvitation( - activeAccountInfo: activeAccountInfo, + final contactInvitationListCubit = + widget.modalContext.read(); + + final generator = contactInvitationListCubit.createInvitation( encryptionKeyType: _encryptionKeyType, encryptionKey: _encryptionKey, message: _messageTextController.text, expiration: _expiration); - // ignore: use_build_context_synchronously - if (!context.mounted) { - return; - } + await showDialog( context: context, - builder: (context) => ContactInvitationDisplayDialog( - name: activeAccountInfo.localAccount.name, + builder: (context) => BlocProvider( + create: (context) => InvitationGeneratorCubit(generator), + child: ContactInvitationDisplayDialog( message: _messageTextController.text, - generator: generator, - )); - // if (ret == null) { - // return; - // } - ref.invalidate(fetchContactInvitationRecordsProvider); + ))); + navigator.pop(); } diff --git a/lib/contact_invitation/views/views.dart b/lib/contact_invitation/views/views.dart new file mode 100644 index 0000000..d9f599b --- /dev/null +++ b/lib/contact_invitation/views/views.dart @@ -0,0 +1,8 @@ +export 'contact_invitation_display.dart'; +export 'contact_invitation_item_widget.dart'; +export 'contact_invitation_list_widget.dart'; +export 'invite_dialog.dart'; +export 'new_contact_invitation_bottom_sheet.dart'; +export 'paste_invite_dialog.dart'; +export 'scan_invite_dialog.dart'; +export 'send_invite_dialog.dart'; diff --git a/lib/contacts/contacts.dart b/lib/contacts/contacts.dart new file mode 100644 index 0000000..6acdd43 --- /dev/null +++ b/lib/contacts/contacts.dart @@ -0,0 +1,2 @@ +export 'cubits/cubits.dart'; +export 'views/views.dart'; diff --git a/lib/contacts/cubits/contact_list_cubit.dart b/lib/contacts/cubits/contact_list_cubit.dart new file mode 100644 index 0000000..99f13bf --- /dev/null +++ b/lib/contacts/cubits/contact_list_cubit.dart @@ -0,0 +1,101 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:veilid_support/veilid_support.dart'; + +import '../../account_manager/account_manager.dart'; +import '../../proto/proto.dart' as proto; +import '../../tools/tools.dart'; + +////////////////////////////////////////////////// +// Mutable state for per-account contacts + +class ContactListCubit extends DHTShortArrayCubit { + ContactListCubit({ + required ActiveAccountInfo activeAccountInfo, + required proto.Account account, + }) : super( + open: () => _open(activeAccountInfo, account), + decodeElement: proto.Contact.fromBuffer); + + static Future _open( + ActiveAccountInfo activeAccountInfo, proto.Account account) async { + final accountRecordKey = + activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey; + + final contactListRecordKey = account.contactList.toVeilid(); + + final dhtRecord = await DHTShortArray.openOwned(contactListRecordKey, + parent: accountRecordKey); + + return dhtRecord; + } + + Future createContact({ + required proto.Profile remoteProfile, + required IdentityMaster remoteIdentity, + required TypedKey remoteConversationRecordKey, + required TypedKey localConversationRecordKey, + }) async { + // Create Contact + final contact = proto.Contact() + ..editedProfile = remoteProfile + ..remoteProfile = remoteProfile + ..identityMasterJson = jsonEncode(remoteIdentity.toJson()) + ..identityPublicKey = TypedKey( + kind: remoteIdentity.identityRecordKey.kind, + value: remoteIdentity.identityPublicKey) + .toProto() + ..remoteConversationRecordKey = remoteConversationRecordKey.toProto() + ..localConversationRecordKey = localConversationRecordKey.toProto() + ..showAvailability = false; + + // Add Contact to account's list + // if this fails, don't keep retrying, user can try again later + await operateWrite((writer) async { + if (!await writer.tryAddItem(contact.writeToBuffer())) { + throw Exception('Failed to add contact record'); + } + }); + } + + Future deleteContact({required proto.Contact contact}) async { + final pool = DHTRecordPool.instance; + final localConversationKey = contact.localConversationRecordKey.toVeilid(); + final remoteConversationKey = + contact.remoteConversationRecordKey.toVeilid(); + + // Remove Contact from account's list + final (deletedItem, success) = await operateWrite((writer) async { + for (var i = 0; i < writer.length; i++) { + final item = await writer.getItemProtobuf(proto.Contact.fromBuffer, i); + if (item == null) { + throw Exception('Failed to get contact'); + } + if (item.remoteConversationRecordKey == + contact.remoteConversationRecordKey) { + if (await writer.tryRemoveItem(i) != null) { + return item; + } + return null; + } + } + return null; + }); + + if (success && deletedItem != null) { + try { + await pool.delete(localConversationKey); + } on Exception catch (e) { + log.debug('error removing local conversation record key: $e', e); + } + try { + if (localConversationKey != remoteConversationKey) { + await pool.delete(remoteConversationKey); + } + } on Exception catch (e) { + log.debug('error removing remote conversation record key: $e', e); + } + } + } +} diff --git a/lib/contacts/cubits/conversation_cubit.dart b/lib/contacts/cubits/conversation_cubit.dart new file mode 100644 index 0000000..34c609d --- /dev/null +++ b/lib/contacts/cubits/conversation_cubit.dart @@ -0,0 +1,292 @@ +// A Conversation is a type of Chat that is 1:1 between two Contacts only +// Each Contact in the ContactList has at most one Conversation between the +// remote contact and the local account + +import 'dart:async'; +import 'dart:convert'; + +import 'package:async_tools/async_tools.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:meta/meta.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../account_manager/account_manager.dart'; +import '../../proto/proto.dart' as proto; + +@immutable +class ConversationState extends Equatable { + const ConversationState( + {required this.localConversation, required this.remoteConversation}); + + final proto.Conversation? localConversation; + final proto.Conversation? remoteConversation; + + @override + List get props => [localConversation, remoteConversation]; +} + +class ConversationCubit extends Cubit> { + ConversationCubit( + {required ActiveAccountInfo activeAccountInfo, + required TypedKey remoteIdentityPublicKey, + TypedKey? localConversationRecordKey, + TypedKey? remoteConversationRecordKey}) + : _activeAccountInfo = activeAccountInfo, + _localConversationRecordKey = localConversationRecordKey, + _remoteIdentityPublicKey = remoteIdentityPublicKey, + _remoteConversationRecordKey = remoteConversationRecordKey, + _incrementalState = const ConversationState( + localConversation: null, remoteConversation: null), + super(const AsyncValue.loading()) { + if (_localConversationRecordKey != null) { + Future.delayed(Duration.zero, () async { + await _setLocalConversation(() async { + final accountRecordKey = _activeAccountInfo + .userLogin.accountRecordInfo.accountRecord.recordKey; + + // Open local record key if it is specified + final pool = DHTRecordPool.instance; + final crypto = await _cachedConversationCrypto(); + final writer = _activeAccountInfo.conversationWriter; + final record = await pool.openWrite( + _localConversationRecordKey!, writer, + parent: accountRecordKey, crypto: crypto); + return record; + }); + }); + } + + if (_remoteConversationRecordKey != null) { + Future.delayed(Duration.zero, () async { + await _setRemoteConversation(() async { + final accountRecordKey = _activeAccountInfo + .userLogin.accountRecordInfo.accountRecord.recordKey; + + // Open remote record key if it is specified + final pool = DHTRecordPool.instance; + final crypto = await _cachedConversationCrypto(); + final record = await pool.openRead(_remoteConversationRecordKey, + parent: accountRecordKey, crypto: crypto); + return record; + }); + }); + } + } + + @override + Future close() async { + await _localSubscription?.cancel(); + await _remoteSubscription?.cancel(); + await _localConversationCubit?.close(); + await _remoteConversationCubit?.close(); + + await super.close(); + } + + void updateLocalConversationState(AsyncValue avconv) { + final newState = avconv.when( + data: (conv) { + _incrementalState = ConversationState( + localConversation: conv, + remoteConversation: _incrementalState.remoteConversation); + // return loading still if state isn't complete + if ((_localConversationRecordKey != null && + _incrementalState.localConversation == null) || + (_remoteConversationRecordKey != null && + _incrementalState.remoteConversation == null)) { + return const AsyncValue.loading(); + } + // state is complete, all required keys are open + return AsyncValue.data(_incrementalState); + }, + loading: AsyncValue.loading, + error: AsyncValue.error, + ); + emit(newState); + } + + void updateRemoteConversationState(AsyncValue avconv) { + final newState = avconv.when( + data: (conv) { + _incrementalState = ConversationState( + localConversation: _incrementalState.localConversation, + remoteConversation: conv); + // return loading still if state isn't complete + if ((_localConversationRecordKey != null && + _incrementalState.localConversation == null) || + (_remoteConversationRecordKey != null && + _incrementalState.remoteConversation == null)) { + return const AsyncValue.loading(); + } + // state is complete, all required keys are open + return AsyncValue.data(_incrementalState); + }, + loading: AsyncValue.loading, + error: AsyncValue.error, + ); + emit(newState); + } + + // Open local converation key + Future _setLocalConversation(Future Function() open) async { + assert(_localConversationCubit == null, + 'shoud not set local conversation twice'); + _localConversationCubit = DefaultDHTRecordCubit( + open: open, decodeState: proto.Conversation.fromBuffer); + _localSubscription = + _localConversationCubit!.stream.listen(updateLocalConversationState); + } + + // Open remote converation key + Future _setRemoteConversation(Future Function() open) async { + assert(_remoteConversationCubit == null, + 'shoud not set remote conversation twice'); + _remoteConversationCubit = DefaultDHTRecordCubit( + open: open, decodeState: proto.Conversation.fromBuffer); + _remoteSubscription = + _remoteConversationCubit!.stream.listen(updateRemoteConversationState); + } + + // Initialize a local conversation + // If we were the initiator of the conversation there may be an + // incomplete 'existingConversationRecord' that we need to fill + // in now that we have the remote identity key + // The ConversationCubit must not already have a local conversation + // The callback allows for more initialization to occur and for + // cleanup to delete records upon failure of the callback + Future initLocalConversation( + {required proto.Profile profile, + required FutureOr Function(DHTRecord) callback, + TypedKey? existingConversationRecordKey}) async { + assert(_localConversationRecordKey == null, + 'must not have a local conversation yet'); + + final pool = DHTRecordPool.instance; + final accountRecordKey = + _activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey; + + final crypto = await _cachedConversationCrypto(); + final writer = _activeAccountInfo.conversationWriter; + + // Open with SMPL scheme for identity writer + late final DHTRecord localConversationRecord; + if (existingConversationRecordKey != null) { + localConversationRecord = await pool.openWrite( + existingConversationRecordKey, writer, + parent: accountRecordKey, crypto: crypto); + } else { + final localConversationRecordCreate = await pool.create( + parent: accountRecordKey, + crypto: crypto, + schema: DHTSchema.smpl( + oCnt: 0, members: [DHTSchemaMember(mKey: writer.key, mCnt: 1)])); + await localConversationRecordCreate.close(); + localConversationRecord = await pool.openWrite( + localConversationRecordCreate.key, writer, + parent: accountRecordKey, crypto: crypto); + } + final out = localConversationRecord + // ignore: prefer_expression_function_bodies + .deleteScope((localConversation) async { + // Make messages log + return initLocalMessages( + activeAccountInfo: _activeAccountInfo, + remoteIdentityPublicKey: _remoteIdentityPublicKey, + localConversationKey: localConversation.key, + callback: (messages) async { + // Create initial local conversation key contents + final conversation = proto.Conversation() + ..profile = profile + ..identityMasterJson = jsonEncode( + _activeAccountInfo.localAccount.identityMaster.toJson()) + ..messages = messages.recordKey.toProto(); + + // Write initial conversation to record + final update = await localConversation.tryWriteProtobuf( + proto.Conversation.fromBuffer, conversation); + if (update != null) { + throw Exception('Failed to write local conversation'); + } + final out = await callback(localConversation); + + // Upon success emit the local conversation record to the state + updateLocalConversationState(AsyncValue.data(conversation)); + + return out; + }); + }); + + // If success, save the new local conversation record key in this object + _localConversationRecordKey = localConversationRecord.key; + await _setLocalConversation(() async => localConversationRecord); + + return out; + } + + // Initialize local messages + Future initLocalMessages({ + required ActiveAccountInfo activeAccountInfo, + required TypedKey remoteIdentityPublicKey, + required TypedKey localConversationKey, + required FutureOr Function(DHTShortArray) callback, + }) async { + final crypto = + await activeAccountInfo.makeConversationCrypto(remoteIdentityPublicKey); + final writer = activeAccountInfo.conversationWriter; + + return (await DHTShortArray.create( + parent: localConversationKey, crypto: crypto, smplWriter: writer)) + .deleteScope((messages) async => await callback(messages)); + } + + // Force refresh of conversation keys + Future refresh() async { + final lcc = _localConversationCubit; + final rcc = _remoteConversationCubit; + + if (lcc != null) { + await lcc.refreshDefault(); + } + if (rcc != null) { + await rcc.refreshDefault(); + } + } + + Future writeLocalConversation({ + required proto.Conversation conversation, + }) async { + final update = await _localConversationCubit!.record + .tryWriteProtobuf(proto.Conversation.fromBuffer, conversation); + + if (update != null) { + updateLocalConversationState(AsyncValue.data(conversation)); + } + + return update; + } + + Future _cachedConversationCrypto() async { + var conversationCrypto = _conversationCrypto; + if (conversationCrypto != null) { + return conversationCrypto; + } + conversationCrypto = await _activeAccountInfo + .makeConversationCrypto(_remoteIdentityPublicKey); + + _conversationCrypto = conversationCrypto; + return conversationCrypto; + } + + final ActiveAccountInfo _activeAccountInfo; + final TypedKey _remoteIdentityPublicKey; + TypedKey? _localConversationRecordKey; + final TypedKey? _remoteConversationRecordKey; + DefaultDHTRecordCubit? _localConversationCubit; + DefaultDHTRecordCubit? _remoteConversationCubit; + StreamSubscription>? _localSubscription; + StreamSubscription>? _remoteSubscription; + ConversationState _incrementalState; + // + DHTRecordCrypto? _conversationCrypto; +} diff --git a/lib/contacts/cubits/cubits.dart b/lib/contacts/cubits/cubits.dart new file mode 100644 index 0000000..3d16d52 --- /dev/null +++ b/lib/contacts/cubits/cubits.dart @@ -0,0 +1,2 @@ +export 'contact_list_cubit.dart'; +export 'conversation_cubit.dart'; diff --git a/lib/contacts/views/contact_item_widget.dart b/lib/contacts/views/contact_item_widget.dart new file mode 100644 index 0000000..49d6bb1 --- /dev/null +++ b/lib/contacts/views/contact_item_widget.dart @@ -0,0 +1,109 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_slidable/flutter_slidable.dart'; +import 'package:flutter_translate/flutter_translate.dart'; +import '../../chat_list/chat_list.dart'; +import '../../layout/layout.dart'; +import '../../proto/proto.dart' as proto; +import '../../theme/theme.dart'; +import '../contacts.dart'; + +class ContactItemWidget extends StatelessWidget { + const ContactItemWidget( + {required this.contact, required this.disabled, super.key}); + + final proto.Contact contact; + final bool disabled; + + @override + // ignore: prefer_expression_function_bodies + Widget build( + BuildContext context, + ) { + final theme = Theme.of(context); + //final textTheme = theme.textTheme; + final scale = theme.extension()!; + + final remoteConversationKey = + contact.remoteConversationRecordKey.toVeilid(); + + return Container( + margin: const EdgeInsets.fromLTRB(0, 4, 0, 0), + clipBehavior: Clip.antiAlias, + decoration: ShapeDecoration( + color: scale.tertiaryScale.subtleBorder, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + )), + child: Slidable( + key: ObjectKey(contact), + endActionPane: ActionPane( + motion: const DrawerMotion(), + children: [ + SlidableAction( + onPressed: disabled || context.watch().isBusy + ? null + : (context) async { + final contactListCubit = + context.read(); + final chatListCubit = context.read(); + + // Remove any chats for this contact + await chatListCubit.deleteChat( + remoteConversationRecordKey: + remoteConversationKey); + + // Delete the contact itself + await contactListCubit.deleteContact( + contact: contact); + }, + backgroundColor: scale.tertiaryScale.background, + foregroundColor: scale.tertiaryScale.text, + icon: Icons.delete, + label: translate('button.delete'), + padding: const EdgeInsets.all(2)), + // SlidableAction( + // onPressed: (context) => (), + // backgroundColor: scale.secondaryScale.background, + // foregroundColor: scale.secondaryScale.text, + // icon: Icons.edit, + // label: 'Edit', + // ), + ], + ), + + // The child of the Slidable is what the user sees when the + // component is not dragged. + child: ListTile( + onTap: disabled || context.watch().isBusy + ? null + : () async { + // Start a chat + final chatListCubit = context.read(); + await chatListCubit.getOrCreateChatSingleContact( + remoteConversationRecordKey: remoteConversationKey); + // Click over to chats + if (context.mounted) { + await MainPager.of(context) + ?.pageController + .animateToPage(1, + duration: 250.ms, curve: Curves.easeInOut); + } + }, + title: Text(contact.editedProfile.name), + subtitle: (contact.editedProfile.pronouns.isNotEmpty) + ? Text(contact.editedProfile.pronouns) + : null, + iconColor: scale.tertiaryScale.background, + textColor: scale.tertiaryScale.text, + leading: const Icon(Icons.person)))); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('contact', contact)); + } +} diff --git a/lib/components/contact_list_widget.dart b/lib/contacts/views/contact_list_widget.dart similarity index 80% rename from lib/components/contact_list_widget.dart rename to lib/contacts/views/contact_list_widget.dart index 1a8c87c..df8cf79 100644 --- a/lib/components/contact_list_widget.dart +++ b/lib/contacts/views/contact_list_widget.dart @@ -2,27 +2,31 @@ import 'package:awesome_extensions/awesome_extensions.dart'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_translate/flutter_translate.dart'; import 'package:searchable_listview/searchable_listview.dart'; -import '../proto/proto.dart' as proto; -import '../tools/tools.dart'; +import '../../proto/proto.dart' as proto; +import '../../theme/theme.dart'; +import '../../tools/tools.dart'; import 'contact_item_widget.dart'; import 'empty_contact_list_widget.dart'; -class ContactListWidget extends ConsumerWidget { - const ContactListWidget({required this.contactList, super.key}); +class ContactListWidget extends StatelessWidget { + const ContactListWidget( + {required this.contactList, required this.disabled, super.key}); final IList contactList; + final bool disabled; @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); - properties.add(IterableProperty('contactList', contactList)); + properties + ..add(IterableProperty('contactList', contactList)) + ..add(DiagnosticsProperty('disabled', disabled)); } @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { final theme = Theme.of(context); //final textTheme = theme.textTheme; final scale = theme.extension()!; @@ -35,9 +39,9 @@ class ContactListWidget extends ConsumerWidget { child: (contactList.isEmpty) ? const EmptyContactListWidget() : SearchableList( - autoFocusOnSearch: false, initialList: contactList.toList(), - builder: (l, i, c) => ContactItemWidget(contact: c), + builder: (l, i, c) => + ContactItemWidget(contact: c, disabled: disabled), filter: (value) { final lowerValue = value.toLowerCase(); return contactList diff --git a/lib/components/empty_contact_list_widget.dart b/lib/contacts/views/empty_contact_list_widget.dart similarity index 80% rename from lib/components/empty_contact_list_widget.dart rename to lib/contacts/views/empty_contact_list_widget.dart index bcd832b..db07b4a 100644 --- a/lib/components/empty_contact_list_widget.dart +++ b/lib/contacts/views/empty_contact_list_widget.dart @@ -1,15 +1,16 @@ import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_translate/flutter_translate.dart'; -import '../tools/tools.dart'; +import '../../theme/theme.dart'; -class EmptyContactListWidget extends ConsumerWidget { +class EmptyContactListWidget extends StatelessWidget { const EmptyContactListWidget({super.key}); @override // ignore: prefer_expression_function_bodies - Widget build(BuildContext context, WidgetRef ref) { + Widget build( + BuildContext context, + ) { final theme = Theme.of(context); final textTheme = theme.textTheme; final scale = theme.extension()!; diff --git a/lib/contacts/views/views.dart b/lib/contacts/views/views.dart new file mode 100644 index 0000000..8c98b0f --- /dev/null +++ b/lib/contacts/views/views.dart @@ -0,0 +1,3 @@ +export 'contact_item_widget.dart'; +export 'contact_list_widget.dart'; +export 'empty_contact_list_widget.dart'; diff --git a/lib/entities/entities.dart b/lib/entities/entities.dart deleted file mode 100644 index 8a24422..0000000 --- a/lib/entities/entities.dart +++ /dev/null @@ -1,3 +0,0 @@ -export 'local_account.dart'; -export 'preferences.dart'; -export 'user_login.dart'; diff --git a/lib/entities/local_account.dart b/lib/entities/local_account.dart deleted file mode 100644 index 68c5ca2..0000000 --- a/lib/entities/local_account.dart +++ /dev/null @@ -1,76 +0,0 @@ -import 'dart:typed_data'; - -import 'package:change_case/change_case.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; - -import '../proto/proto.dart' as proto; -import '../veilid_support/veilid_support.dart'; - -part 'local_account.freezed.dart'; -part 'local_account.g.dart'; - -// Local account identitySecretKey is potentially encrypted with a key -// using the following mechanisms -// * None : no key, bytes are unencrypted -// * Pin : Code is a numeric pin (4-256 numeric digits) hashed with Argon2 -// * Password: Code is a UTF-8 string that is hashed with Argon2 -enum EncryptionKeyType { - none, - pin, - password; - - factory EncryptionKeyType.fromJson(dynamic j) => - EncryptionKeyType.values.byName((j as String).toCamelCase()); - - factory EncryptionKeyType.fromProto(proto.EncryptionKeyType p) { - // ignore: exhaustive_cases - switch (p) { - case proto.EncryptionKeyType.ENCRYPTION_KEY_TYPE_NONE: - return EncryptionKeyType.none; - case proto.EncryptionKeyType.ENCRYPTION_KEY_TYPE_PIN: - return EncryptionKeyType.pin; - case proto.EncryptionKeyType.ENCRYPTION_KEY_TYPE_PASSWORD: - return EncryptionKeyType.password; - } - throw StateError('unknown EncryptionKeyType enum value'); - } - String toJson() => name.toPascalCase(); - proto.EncryptionKeyType toProto() => switch (this) { - EncryptionKeyType.none => - proto.EncryptionKeyType.ENCRYPTION_KEY_TYPE_NONE, - EncryptionKeyType.pin => - proto.EncryptionKeyType.ENCRYPTION_KEY_TYPE_PIN, - EncryptionKeyType.password => - proto.EncryptionKeyType.ENCRYPTION_KEY_TYPE_PASSWORD, - }; -} - -// Local Accounts are stored in a table locally and not backed by a DHT key -// and represents the accounts that have been added/imported -// on the current device. -// Stores a copy of the IdentityMaster associated with the account -// and the identitySecretKey optionally encrypted by an unlock code -// This is the root of the account information tree for VeilidChat -// -@freezed -class LocalAccount with _$LocalAccount { - const factory LocalAccount({ - // The master key record for the account, containing the identityPublicKey - required IdentityMaster identityMaster, - // The encrypted identity secret that goes with - // the identityPublicKey with appended salt - @Uint8ListJsonConverter() required Uint8List identitySecretBytes, - // The kind of encryption input used on the account - required EncryptionKeyType encryptionKeyType, - // If account is not hidden, password can be retrieved via - required bool biometricsEnabled, - // Keep account hidden unless account password is entered - // (tries all hidden accounts with auth method (no biometrics)) - required bool hiddenAccount, - // Display name for account until it is unlocked - required String name, - }) = _LocalAccount; - - factory LocalAccount.fromJson(dynamic json) => - _$LocalAccountFromJson(json as Map); -} diff --git a/lib/init.dart b/lib/init.dart new file mode 100644 index 0000000..edc954b --- /dev/null +++ b/lib/init.dart @@ -0,0 +1,41 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import 'account_manager/account_manager.dart'; +import 'app.dart'; +import 'tools/tools.dart'; +import 'veilid_processor/veilid_processor.dart'; + +final Completer eventualInitialized = Completer(); + +// Initialize Veilid +Future initializeVeilid() async { + // Init Veilid + Veilid.instance.initializeVeilidCore( + getDefaultVeilidPlatformConfig(kIsWeb, VeilidChatApp.name)); + + // Veilid logging + initVeilidLog(kDebugMode); + + // Startup Veilid + await ProcessorRepository.instance.startup(); + + // DHT Record Pool + await DHTRecordPool.init(); +} + +// Initialize repositories +Future initializeRepositories() async { + await AccountRepository.instance.init(); +} + +Future initializeVeilidChat() async { + log.info('Initializing Veilid'); + await initializeVeilid(); + log.info('Initializing Repositories'); + await initializeRepositories(); + + eventualInitialized.complete(); +} diff --git a/lib/components/default_app_bar.dart b/lib/layout/default_app_bar.dart similarity index 100% rename from lib/components/default_app_bar.dart rename to lib/layout/default_app_bar.dart diff --git a/lib/layout/home/home.dart b/lib/layout/home/home.dart new file mode 100644 index 0000000..5b1b3d1 --- /dev/null +++ b/lib/layout/home/home.dart @@ -0,0 +1,6 @@ +export 'home_account_invalid.dart'; +export 'home_account_locked.dart'; +export 'home_account_missing.dart'; +export 'home_account_ready/home_account_ready.dart'; +export 'home_no_active.dart'; +export 'home_shell.dart'; diff --git a/lib/layout/home/home_account_invalid.dart b/lib/layout/home/home_account_invalid.dart new file mode 100644 index 0000000..bf11735 --- /dev/null +++ b/lib/layout/home/home_account_invalid.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; + +class HomeAccountInvalid extends StatefulWidget { + const HomeAccountInvalid({super.key}); + + @override + HomeAccountInvalidState createState() => HomeAccountInvalidState(); +} + +class HomeAccountInvalidState extends State { + @override + void initState() { + super.initState(); + } + + @override + void dispose() { + super.dispose(); + } + + @override + Widget build(BuildContext context) => const Text('Account invalid'); +} +// xxx: delete invalid account + // Future.delayed(0.ms, () async { + // await showErrorModal(context, translate('home.invalid_account_title'), + // translate('home.invalid_account_text')); + // // Delete account + // await AccountRepository.instance.deleteLocalAccount(activeUserLogin); + // // Switch to no active user login + // await AccountRepository.instance.switchToAccount(null); + // }); diff --git a/lib/layout/home/home_account_locked.dart b/lib/layout/home/home_account_locked.dart new file mode 100644 index 0000000..0b8a4f7 --- /dev/null +++ b/lib/layout/home/home_account_locked.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; + +class HomeAccountLocked extends StatefulWidget { + const HomeAccountLocked({super.key}); + + @override + HomeAccountLockedState createState() => HomeAccountLockedState(); +} + +class HomeAccountLockedState extends State { + @override + void initState() { + super.initState(); + } + + @override + void dispose() { + super.dispose(); + } + + @override + Widget build(BuildContext context) => const Text('Account locked'); +} diff --git a/lib/layout/home/home_account_missing.dart b/lib/layout/home/home_account_missing.dart new file mode 100644 index 0000000..d9c0aad --- /dev/null +++ b/lib/layout/home/home_account_missing.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; + +class HomeAccountMissing extends StatefulWidget { + const HomeAccountMissing({super.key}); + + @override + HomeAccountMissingState createState() => HomeAccountMissingState(); +} + +class HomeAccountMissingState extends State { + @override + void initState() { + super.initState(); + } + + @override + void dispose() { + super.dispose(); + } + + @override + Widget build(BuildContext context) => const Text('Account missing'); +} + +// xxx click to delete missing account or add to postframecallback + // Future.delayed(0.ms, () async { + // await showErrorModal(context, translate('home.missing_account_title'), + // translate('home.missing_account_text')); + // // Delete account + // await AccountRepository.instance.deleteLocalAccount(activeUserLogin); + // // Switch to no active user login + // await AccountRepository.instance.switchToAccount(null); + // }); \ No newline at end of file diff --git a/lib/layout/home/home_account_ready/home_account_ready.dart b/lib/layout/home/home_account_ready/home_account_ready.dart new file mode 100644 index 0000000..b198f0b --- /dev/null +++ b/lib/layout/home/home_account_ready/home_account_ready.dart @@ -0,0 +1,3 @@ +export 'home_account_ready_chat.dart'; +export 'home_account_ready_main.dart'; +export 'home_account_ready_shell.dart'; diff --git a/lib/layout/home/home_account_ready/home_account_ready_chat.dart b/lib/layout/home/home_account_ready/home_account_ready_chat.dart new file mode 100644 index 0000000..ffeaa05 --- /dev/null +++ b/lib/layout/home/home_account_ready/home_account_ready_chat.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../chat/chat.dart'; +import '../../../tools/tools.dart'; + +class HomeAccountReadyChat extends StatefulWidget { + const HomeAccountReadyChat({super.key}); + + @override + HomeAccountReadyChatState createState() => HomeAccountReadyChatState(); +} + +class HomeAccountReadyChatState extends State { + final _unfocusNode = FocusNode(); + + @override + void initState() { + super.initState(); + + WidgetsBinding.instance.addPostFrameCallback((_) async { + await changeWindowSetup( + TitleBarStyle.normal, OrientationCapability.normal); + }); + } + + @override + void dispose() { + _unfocusNode.dispose(); + super.dispose(); + } + + Widget buildChatComponent(BuildContext context) { + final activeChatRemoteConversationKey = + context.watch().state; + if (activeChatRemoteConversationKey == null) { + return const EmptyChatWidget(); + } + return ChatComponent.builder( + remoteConversationRecordKey: activeChatRemoteConversationKey); + } + + @override + Widget build(BuildContext context) => SafeArea( + child: GestureDetector( + onTap: () => FocusScope.of(context).requestFocus(_unfocusNode), + child: buildChatComponent(context), + )); +} diff --git a/lib/layout/home/home_account_ready/home_account_ready_main.dart b/lib/layout/home/home_account_ready/home_account_ready_main.dart new file mode 100644 index 0000000..d02cfaf --- /dev/null +++ b/lib/layout/home/home_account_ready/home_account_ready_main.dart @@ -0,0 +1,109 @@ +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_translate/flutter_translate.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../account_manager/account_manager.dart'; +import '../../../chat/chat.dart'; +import '../../../theme/theme.dart'; +import '../../../tools/tools.dart'; +import 'main_pager/main_pager.dart'; + +class HomeAccountReadyMain extends StatefulWidget { + const HomeAccountReadyMain({super.key}); + + @override + State createState() => _HomeAccountReadyMainState(); +} + +class _HomeAccountReadyMainState extends State { + @override + void initState() { + super.initState(); + + WidgetsBinding.instance.addPostFrameCallback((_) async { + await changeWindowSetup( + TitleBarStyle.normal, OrientationCapability.normal); + }); + } + + Widget buildUserPanel() => Builder(builder: (context) { + final account = context.watch().state; + final theme = Theme.of(context); + final scale = theme.extension()!; + + return Column(children: [ + Row(children: [ + IconButton( + icon: const Icon(Icons.settings), + color: scale.secondaryScale.text, + constraints: const BoxConstraints.expand(height: 64, width: 64), + style: ButtonStyle( + backgroundColor: + MaterialStateProperty.all(scale.secondaryScale.border), + shape: MaterialStateProperty.all( + const RoundedRectangleBorder( + borderRadius: + BorderRadius.all(Radius.circular(16))))), + tooltip: translate('app_bar.settings_tooltip'), + onPressed: () async { + await GoRouterHelper(context).push('/settings'); + }).paddingLTRB(0, 0, 8, 0), + asyncValueBuilder(account, + (_, account) => ProfileWidget(profile: account.profile)) + .expanded(), + ]).paddingAll(8), + const MainPager().expanded() + ]); + }); + + Widget buildPhone(BuildContext context) => + Material(color: Colors.transparent, child: buildUserPanel()); + + Widget buildTabletLeftPane(BuildContext context) => Builder( + builder: (context) => + Material(color: Colors.transparent, child: buildUserPanel())); + + Widget buildTabletRightPane(BuildContext context) { + final activeChatRemoteConversationKey = + context.watch().state; + if (activeChatRemoteConversationKey == null) { + return const EmptyChatWidget(); + } + return ChatComponent.builder( + remoteConversationRecordKey: activeChatRemoteConversationKey); + } + + // ignore: prefer_expression_function_bodies + Widget buildTablet(BuildContext context) { + final w = MediaQuery.of(context).size.width; + final theme = Theme.of(context); + final scale = theme.extension()!; + + final children = [ + ConstrainedBox( + constraints: const BoxConstraints(minWidth: 300, maxWidth: 300), + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: w / 2), + child: buildTabletLeftPane(context))), + SizedBox( + width: 2, + height: double.infinity, + child: ColoredBox(color: scale.primaryScale.hoverBorder)), + Expanded(child: buildTabletRightPane(context)), + ]; + + return Row( + children: children, + ); + } + + @override + Widget build(BuildContext context) => responsiveVisibility( + context: context, + phone: false, + ) + ? buildTablet(context) + : buildPhone(context); +} diff --git a/lib/layout/home/home_account_ready/home_account_ready_shell.dart b/lib/layout/home/home_account_ready/home_account_ready_shell.dart new file mode 100644 index 0000000..2cdce7e --- /dev/null +++ b/lib/layout/home/home_account_ready/home_account_ready_shell.dart @@ -0,0 +1,159 @@ +import 'package:async_tools/async_tools.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:provider/provider.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../../account_manager/account_manager.dart'; +import '../../../chat/chat.dart'; +import '../../../chat_list/chat_list.dart'; +import '../../../contact_invitation/contact_invitation.dart'; +import '../../../contacts/contacts.dart'; +import '../../../router/router.dart'; +import '../../../tools/tools.dart'; + +class HomeAccountReadyShell extends StatefulWidget { + factory HomeAccountReadyShell( + {required BuildContext context, required Widget child, Key? key}) { + // These must exist in order for the account to + // be considered 'ready' for this widget subtree + final activeLocalAccount = context.read().state!; + final activeAccountInfo = context.read(); + final routerCubit = context.read(); + + return HomeAccountReadyShell._( + activeLocalAccount: activeLocalAccount, + activeAccountInfo: activeAccountInfo, + routerCubit: routerCubit, + key: key, + child: child); + } + const HomeAccountReadyShell._( + {required this.activeLocalAccount, + required this.activeAccountInfo, + required this.routerCubit, + required this.child, + super.key}); + + @override + HomeAccountReadyShellState createState() => HomeAccountReadyShellState(); + + final Widget child; + final TypedKey activeLocalAccount; + final ActiveAccountInfo activeAccountInfo; + final RouterCubit routerCubit; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty( + 'activeLocalAccount', activeLocalAccount)) + ..add(DiagnosticsProperty( + 'activeAccountInfo', activeAccountInfo)) + ..add(DiagnosticsProperty('routerCubit', routerCubit)); + } +} + +class HomeAccountReadyShellState extends State { + final SingleStateProcessor + _singleInvitationStatusProcessor = SingleStateProcessor(); + + @override + void initState() { + super.initState(); + } + + // Process all accepted or rejected invitations + void _invitationStatusListener( + BuildContext context, WaitingInvitationsBlocMapState state) { + _singleInvitationStatusProcessor.updateState(state, (newState) async { + final contactListCubit = context.read(); + final contactInvitationListCubit = + context.read(); + + for (final entry in newState.entries) { + final contactRequestInboxRecordKey = entry.key; + final invStatus = entry.value.data?.value; + // Skip invitations that have not yet been accepted or rejected + if (invStatus == null) { + continue; + } + + // Delete invitation and process the accepted or rejected contact + final acceptedContact = invStatus.acceptedContact; + if (acceptedContact != null) { + await contactInvitationListCubit.deleteInvitation( + accepted: true, + contactRequestInboxRecordKey: contactRequestInboxRecordKey); + + // Accept + await contactListCubit.createContact( + remoteProfile: acceptedContact.remoteProfile, + remoteIdentity: acceptedContact.remoteIdentity, + remoteConversationRecordKey: + acceptedContact.remoteConversationRecordKey, + localConversationRecordKey: + acceptedContact.localConversationRecordKey, + ); + } else { + // Reject + await contactInvitationListCubit.deleteInvitation( + accepted: false, + contactRequestInboxRecordKey: contactRequestInboxRecordKey); + } + } + }); + } + + @override + Widget build(BuildContext context) { + final account = context.watch().state.data?.value; + if (account == null) { + return waitingPage(); + } + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => ContactInvitationListCubit( + activeAccountInfo: widget.activeAccountInfo, + account: account)), + BlocProvider( + create: (context) => ContactListCubit( + activeAccountInfo: widget.activeAccountInfo, + account: account)), + BlocProvider( + create: (context) => ActiveChatCubit(null) + ..withStateListen((event) { + widget.routerCubit.setHasActiveChat(event != null); + })), + BlocProvider( + create: (context) => ChatListCubit( + activeAccountInfo: widget.activeAccountInfo, + activeChatCubit: context.read(), + account: account)), + BlocProvider( + create: (context) => ActiveConversationsBlocMapCubit( + activeAccountInfo: widget.activeAccountInfo, + contactListCubit: context.read()) + ..followBloc(context.read())), + BlocProvider( + create: (context) => ActiveSingleContactChatBlocMapCubit( + activeAccountInfo: widget.activeAccountInfo, + contactListCubit: context.read(), + chatListCubit: context.read()) + ..followBloc(context.read())), + BlocProvider( + create: (context) => WaitingInvitationsBlocMapCubit( + activeAccountInfo: widget.activeAccountInfo, account: account) + ..followBloc(context.read())) + ], + child: MultiBlocListener(listeners: [ + BlocListener( + listener: _invitationStatusListener, + ) + ], child: widget.child)); + } +} diff --git a/lib/pages/main_pager/account.dart b/lib/layout/home/home_account_ready/main_pager/account_page.dart similarity index 52% rename from lib/pages/main_pager/account.dart rename to lib/layout/home/home_account_ready/main_pager/account_page.dart index 4c3e56d..b2c8384 100644 --- a/lib/pages/main_pager/account.dart +++ b/lib/layout/home/home_account_ready/main_pager/account_page.dart @@ -1,47 +1,23 @@ -// ignore_for_file: prefer_const_constructors - import 'package:awesome_extensions/awesome_extensions.dart'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_translate/flutter_translate.dart'; -import '../../components/contact_invitation_list_widget.dart'; -import '../../components/contact_list_widget.dart'; -import '../../entities/local_account.dart'; -import '../../proto/proto.dart' as proto; -import '../../providers/contact.dart'; -import '../../providers/contact_invite.dart'; -import '../../tools/theme_service.dart'; -import '../../tools/tools.dart'; -import '../../veilid_support/veilid_support.dart'; +import '../../../../contact_invitation/contact_invitation.dart'; +import '../../../../contacts/contacts.dart'; +import '../../../../theme/theme.dart'; -class AccountPage extends ConsumerStatefulWidget { +class AccountPage extends StatefulWidget { const AccountPage({ - required this.localAccounts, - required this.activeUserLogin, - required this.account, super.key, }); - final IList localAccounts; - final TypedKey activeUserLogin; - final proto.Account account; - @override AccountPageState createState() => AccountPageState(); - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties - ..add(IterableProperty('localAccounts', localAccounts)) - ..add(DiagnosticsProperty('activeUserLogin', activeUserLogin)) - ..add(DiagnosticsProperty('account', account)); - } } -class AccountPageState extends ConsumerState { +class AccountPageState extends State { final _unfocusNode = FocusNode(); @override @@ -62,17 +38,20 @@ class AccountPageState extends ConsumerState { final textTheme = theme.textTheme; final scale = theme.extension()!; + final cilState = context.watch().state; + final cilBusy = cilState.busy; final contactInvitationRecordList = - ref.watch(fetchContactInvitationRecordsProvider).asData?.value ?? - const IListConst([]); - final contactList = ref.watch(fetchContactListProvider).asData?.value ?? - const IListConst([]); + cilState.state.data?.value ?? const IListConst([]); + + final ciState = context.watch().state; + final ciBusy = ciState.busy; + final contactList = ciState.state.data?.value ?? const IListConst([]); return SizedBox( child: Column(children: [ if (contactInvitationRecordList.isNotEmpty) ExpansionTile( - tilePadding: EdgeInsets.fromLTRB(8, 0, 8, 0), + tilePadding: const EdgeInsets.fromLTRB(8, 0, 8, 0), backgroundColor: scale.primaryScale.border, collapsedBackgroundColor: scale.primaryScale.border, shape: RoundedRectangleBorder( @@ -90,10 +69,11 @@ class AccountPageState extends ConsumerState { initiallyExpanded: true, children: [ ContactInvitationListWidget( - contactInvitationRecordList: contactInvitationRecordList) + contactInvitationRecordList: contactInvitationRecordList, + disabled: cilBusy) ], ).paddingLTRB(8, 0, 8, 8), - ContactListWidget(contactList: contactList).expanded(), + ContactListWidget(contactList: contactList, disabled: ciBusy).expanded(), ])); } } diff --git a/lib/components/bottom_sheet_action_button.dart b/lib/layout/home/home_account_ready/main_pager/bottom_sheet_action_button.dart similarity index 90% rename from lib/components/bottom_sheet_action_button.dart rename to lib/layout/home/home_account_ready/main_pager/bottom_sheet_action_button.dart index 4330d33..c34e478 100644 --- a/lib/components/bottom_sheet_action_button.dart +++ b/lib/layout/home/home_account_ready/main_pager/bottom_sheet_action_button.dart @@ -1,8 +1,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -class BottomSheetActionButton extends ConsumerStatefulWidget { +class BottomSheetActionButton extends StatefulWidget { const BottomSheetActionButton( {required this.bottomSheetBuilder, required this.builder, @@ -32,8 +31,7 @@ class BottomSheetActionButton extends ConsumerStatefulWidget { } } -class BottomSheetActionButtonState - extends ConsumerState { +class BottomSheetActionButtonState extends State { bool _showFab = true; @override diff --git a/lib/layout/home/home_account_ready/main_pager/chats_page.dart b/lib/layout/home/home_account_ready/main_pager/chats_page.dart new file mode 100644 index 0000000..1c7e7fe --- /dev/null +++ b/lib/layout/home/home_account_ready/main_pager/chats_page.dart @@ -0,0 +1,34 @@ +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:flutter/material.dart'; + +import '../../../../chat_list/chat_list.dart'; + +class ChatsPage extends StatefulWidget { + const ChatsPage({super.key}); + + @override + ChatsPageState createState() => ChatsPageState(); +} + +class ChatsPageState extends State { + final _unfocusNode = FocusNode(); + + @override + void initState() { + super.initState(); + } + + @override + void dispose() { + _unfocusNode.dispose(); + super.dispose(); + } + + @override + // ignore: prefer_expression_function_bodies + Widget build(BuildContext context) { + return Column(children: [ + const ChatSingleContactListWidget().expanded(), + ]); + } +} diff --git a/lib/pages/main_pager/main_pager.dart b/lib/layout/home/home_account_ready/main_pager/main_pager.dart similarity index 56% rename from lib/pages/main_pager/main_pager.dart rename to lib/layout/home/home_account_ready/main_pager/main_pager.dart index 7265285..54f794c 100644 --- a/lib/pages/main_pager/main_pager.dart +++ b/lib/layout/home/home_account_ready/main_pager/main_pager.dart @@ -1,57 +1,31 @@ import 'dart:async'; -import 'package:awesome_extensions/awesome_extensions.dart'; -import 'package:fast_immutable_collections/fast_immutable_collections.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_animate/flutter_animate.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_translate/flutter_translate.dart'; import 'package:preload_page_view/preload_page_view.dart'; import 'package:stylish_bottom_bar/model/bar_items.dart'; import 'package:stylish_bottom_bar/stylish_bottom_bar.dart'; -import '../../components/bottom_sheet_action_button.dart'; -import '../../components/paste_invite_dialog.dart'; -import '../../components/scan_invite_dialog.dart'; -import '../../components/send_invite_dialog.dart'; -import '../../entities/local_account.dart'; -import '../../proto/proto.dart' as proto; -import '../../tools/tools.dart'; -import '../../veilid_support/veilid_support.dart'; -import 'account.dart'; -import 'chats.dart'; +import '../../../../contact_invitation/contact_invitation.dart'; +import '../../../../theme/theme.dart'; +import '../../../../tools/tools.dart'; +import 'account_page.dart'; +import 'bottom_sheet_action_button.dart'; +import 'chats_page.dart'; -class MainPager extends ConsumerStatefulWidget { - const MainPager( - {required this.localAccounts, - required this.activeUserLogin, - required this.account, - super.key}); - - final IList localAccounts; - final TypedKey activeUserLogin; - final proto.Account account; +class MainPager extends StatefulWidget { + const MainPager({super.key}); @override MainPagerState createState() => MainPagerState(); static MainPagerState? of(BuildContext context) => context.findAncestorStateOfType(); - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties - ..add(IterableProperty('localAccounts', localAccounts)) - ..add(DiagnosticsProperty('activeUserLogin', activeUserLogin)) - ..add(DiagnosticsProperty('account', account)); - } } -class MainPagerState extends ConsumerState - with TickerProviderStateMixin { +class MainPagerState extends State with TickerProviderStateMixin { ////////////////////////////////////////////////////////////////// final _unfocusNode = FocusNode(); @@ -136,98 +110,41 @@ class MainPagerState extends ConsumerState context: context, // ignore: prefer_expression_function_bodies builder: (context) { - return const AlertDialog( - shape: RoundedRectangleBorder( + return AlertDialog( + shape: const RoundedRectangleBorder( borderRadius: BorderRadius.all(Radius.circular(20)), ), - contentPadding: EdgeInsets.only( + contentPadding: const EdgeInsets.only( top: 10, ), - title: Text( + title: const Text( 'Scan Contact Invite', style: TextStyle(fontSize: 24), ), - content: ScanInviteDialog()); + content: ScanInviteDialog( + modalContext: context, + )); }); } - Widget _newContactInvitationBottomSheetBuilder( - // ignore: prefer_expression_function_bodies - BuildContext context) { - final theme = Theme.of(context); - final textTheme = theme.textTheme; - final scale = theme.extension()!; + Widget _onNewChatBottomSheetBuilder( + BuildContext sheetContext, BuildContext context) => + const SizedBox( + height: 200, + child: Center( + child: Text( + 'Group and custom chat functionality is not available yet'))); - return KeyboardListener( - focusNode: FocusNode(), - onKeyEvent: (ke) { - if (ke.logicalKey == LogicalKeyboardKey.escape) { - Navigator.pop(context); - } - }, - child: SizedBox( - height: 200, - child: Column(children: [ - Text(translate('accounts_menu.invite_contact'), - style: textTheme.titleMedium) - .paddingAll(8), - Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - Column(children: [ - IconButton( - onPressed: () async { - Navigator.pop(context); - await SendInviteDialog.show(context); - }, - iconSize: 64, - icon: const Icon(Icons.contact_page), - color: scale.primaryScale.background), - Text(translate('accounts_menu.create_invite')) - ]), - Column(children: [ - IconButton( - onPressed: () async { - Navigator.pop(context); - await ScanInviteDialog.show(context); - }, - iconSize: 64, - icon: const Icon(Icons.qr_code_scanner), - color: scale.primaryScale.background), - Text(translate('accounts_menu.scan_invite')) - ]), - Column(children: [ - IconButton( - onPressed: () async { - Navigator.pop(context); - await PasteInviteDialog.show(context); - }, - iconSize: 64, - icon: const Icon(Icons.paste), - color: scale.primaryScale.background), - Text(translate('accounts_menu.paste_invite')) - ]) - ]).expanded() - ]))); - } - - // ignore: prefer_expression_function_bodies - Widget _onNewChatBottomSheetBuilder(BuildContext context) { - return const SizedBox( - height: 200, - child: Center( - child: Text( - 'Group and custom chat functionality is not available yet'))); - } - - Widget _bottomSheetBuilder(BuildContext context) { + Widget _bottomSheetBuilder(BuildContext sheetContext, BuildContext context) { if (_currentPage == 0) { // New contact invitation - return _newContactInvitationBottomSheetBuilder(context); + return newContactInvitationBottomSheetBuilder(sheetContext, context); } else if (_currentPage == 1) { // New chat - return _onNewChatBottomSheetBuilder(context); + return _onNewChatBottomSheetBuilder(sheetContext, context); } else { // Unknown error - return waitingPage(context); + return debugPage('unknown page'); } } @@ -250,12 +167,9 @@ class MainPagerState extends ConsumerState _currentPage = index; }); }, - children: [ - AccountPage( - localAccounts: widget.localAccounts, - activeUserLogin: widget.activeUserLogin, - account: widget.account), - const ChatsPage(), + children: const [ + AccountPage(), + ChatsPage(), ])), // appBar: AppBar( // toolbarHeight: 24, @@ -301,7 +215,8 @@ class MainPagerState extends ConsumerState _fabIconList[_currentPage], color: scale.secondaryScale.text, ), - bottomSheetBuilder: _bottomSheetBuilder), + bottomSheetBuilder: (sheetContext) => + _bottomSheetBuilder(sheetContext, context)), floatingActionButtonLocation: FloatingActionButtonLocation.endDocked, ); } diff --git a/lib/layout/home/home_no_active.dart b/lib/layout/home/home_no_active.dart new file mode 100644 index 0000000..e61fe0e --- /dev/null +++ b/lib/layout/home/home_no_active.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; + +import '../../tools/tools.dart'; + +class HomeNoActive extends StatefulWidget { + const HomeNoActive({super.key}); + + @override + HomeNoActiveState createState() => HomeNoActiveState(); +} + +class HomeNoActiveState extends State { + @override + void initState() { + super.initState(); + } + + @override + void dispose() { + super.dispose(); + } + + @override + Widget build(BuildContext context) => waitingPage(); +} diff --git a/lib/layout/home/home_shell.dart b/lib/layout/home/home_shell.dart new file mode 100644 index 0000000..bd1949c --- /dev/null +++ b/lib/layout/home/home_shell.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:provider/provider.dart'; + +import '../../account_manager/account_manager.dart'; +import '../../theme/theme.dart'; +import 'home_account_invalid.dart'; +import 'home_account_locked.dart'; +import 'home_account_missing.dart'; +import 'home_no_active.dart'; + +class HomeShell extends StatefulWidget { + const HomeShell({required this.accountReadyBuilder, super.key}); + + @override + HomeShellState createState() => HomeShellState(); + + final Builder accountReadyBuilder; +} + +class HomeShellState extends State { + final _unfocusNode = FocusNode(); + + @override + void initState() { + super.initState(); + } + + @override + void dispose() { + _unfocusNode.dispose(); + super.dispose(); + } + + Widget buildWithLogin(BuildContext context) { + final activeLocalAccount = context.watch().state; + + if (activeLocalAccount == null) { + // If no logged in user is active, show the loading panel + return const HomeNoActive(); + } + + final accountInfo = + AccountRepository.instance.getAccountInfo(activeLocalAccount); + + switch (accountInfo.status) { + case AccountInfoStatus.noAccount: + return const HomeAccountMissing(); + case AccountInfoStatus.accountInvalid: + return const HomeAccountInvalid(); + case AccountInfoStatus.accountLocked: + return const HomeAccountLocked(); + case AccountInfoStatus.accountReady: + return Provider.value( + value: accountInfo.activeAccountInfo!, + child: BlocProvider( + create: (context) => AccountRecordCubit( + open: () async => AccountRepository.instance + .openAccountRecord( + accountInfo.activeAccountInfo!.userLogin)), + child: widget.accountReadyBuilder)); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scale = theme.extension()!; + + // XXX: eventually write account switcher here + return SafeArea( + child: GestureDetector( + onTap: () => FocusScope.of(context).requestFocus(_unfocusNode), + child: DecoratedBox( + decoration: BoxDecoration( + color: scale.primaryScale.activeElementBackground), + child: buildWithLogin(context)))); + } +} diff --git a/lib/pages/index.dart b/lib/layout/index.dart similarity index 79% rename from lib/pages/index.dart rename to lib/layout/index.dart index 8a53316..958b909 100644 --- a/lib/pages/index.dart +++ b/lib/layout/index.dart @@ -1,17 +1,29 @@ import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:radix_colors/radix_colors.dart'; -import '../providers/window_control.dart'; +import '../tools/tools.dart'; -class IndexPage extends ConsumerWidget { +class IndexPage extends StatefulWidget { const IndexPage({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { - ref.watch(windowControlProvider); + State createState() => _IndexPageState(); +} +class _IndexPageState extends State { + @override + void initState() { + super.initState(); + + WidgetsBinding.instance.addPostFrameCallback((_) async { + await changeWindowSetup( + TitleBarStyle.hidden, OrientationCapability.normal); + }); + } + + @override + Widget build(BuildContext context) { final theme = Theme.of(context); final textTheme = theme.textTheme; final monoTextStyle = textTheme.labelSmall! diff --git a/lib/layout/layout.dart b/lib/layout/layout.dart new file mode 100644 index 0000000..003d97a --- /dev/null +++ b/lib/layout/layout.dart @@ -0,0 +1,4 @@ +export 'default_app_bar.dart'; +export 'home/home.dart'; +export 'home/home_account_ready/main_pager/main_pager.dart'; +export 'index.dart'; diff --git a/lib/main.dart b/lib/main.dart index 3644eab..64ee506 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,14 +4,14 @@ import 'dart:io'; import 'package:ansicolor/ansicolor.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_translate/flutter_translate.dart'; import 'package:intl/date_symbol_data_local.dart'; import 'app.dart'; -import 'providers/window_control.dart'; +import 'init.dart'; +import 'settings/preferences_repository.dart'; +import 'theme/theme.dart'; import 'tools/tools.dart'; -import 'veilid_init.dart'; void main() async { // Disable all debugprints in release mode @@ -27,33 +27,40 @@ void main() async { // Ansi colors ansiColorDisabled = false; - // Catch errors - await runZonedGuarded(() async { + Future mainFunc() async { // Logs initLoggy(); - // Prepare theme + // Prepare preferences from SharedPreferences and theme WidgetsFlutterBinding.ensureInitialized(); - final themeService = await ThemeService.instance; - final initTheme = themeService.initial; + await PreferencesRepository.instance.init(); + final initialThemeData = + PreferencesRepository.instance.value.themePreferences.themeData(); // Manage window on desktop platforms - await WindowControl.initialize(); + await initializeWindowControl(); // Make localization delegate - final delegate = await LocalizationDelegate.create( + final localizationDelegate = await LocalizationDelegate.create( fallbackLocale: 'en_US', supportedLocales: ['en_US']); await initializeDateFormatting(); // Start up Veilid and Veilid processor in the background - unawaited(initializeVeilid()); + unawaited(initializeVeilidChat()); // Run the app // Hot reloads will only restart this part, not Veilid - runApp(ProviderScope( - observers: const [StateLogger()], - child: LocalizedApp(delegate, VeilidChatApp(theme: initTheme)))); - }, (error, stackTrace) { - log.error('Dart Runtime: {$error}\n{$stackTrace}'); - }); + runApp(LocalizedApp(localizationDelegate, + VeilidChatApp(initialThemeData: initialThemeData))); + } + + if (kDebugMode) { + // In debug mode, run the app without catching exceptions for debugging + await mainFunc(); + } else { + // Catch errors in production without killing the app + await runZonedGuarded(mainFunc, (error, stackTrace) { + log.error('Dart Runtime: {$error}\n{$stackTrace}'); + }); + } } diff --git a/lib/pages/chat_only.dart b/lib/pages/chat_only.dart deleted file mode 100644 index 2dc57d2..0000000 --- a/lib/pages/chat_only.dart +++ /dev/null @@ -1,45 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../providers/window_control.dart'; -import 'home.dart'; - -class ChatOnlyPage extends ConsumerStatefulWidget { - const ChatOnlyPage({super.key}); - - @override - ChatOnlyPageState createState() => ChatOnlyPageState(); -} - -class ChatOnlyPageState extends ConsumerState - with TickerProviderStateMixin { - final _unfocusNode = FocusNode(); - - @override - void initState() { - super.initState(); - - WidgetsBinding.instance.addPostFrameCallback((_) async { - setState(() {}); - await ref.read(windowControlProvider.notifier).changeWindowSetup( - TitleBarStyle.normal, OrientationCapability.normal); - }); - } - - @override - void dispose() { - _unfocusNode.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - ref.watch(windowControlProvider); - - return SafeArea( - child: GestureDetector( - onTap: () => FocusScope.of(context).requestFocus(_unfocusNode), - child: HomePage.buildChatComponent(context, ref), - )); - } -} diff --git a/lib/pages/edit_account.dart b/lib/pages/edit_account.dart deleted file mode 100644 index 8b13789..0000000 --- a/lib/pages/edit_account.dart +++ /dev/null @@ -1 +0,0 @@ - diff --git a/lib/pages/edit_contact.dart b/lib/pages/edit_contact.dart deleted file mode 100644 index 169874f..0000000 --- a/lib/pages/edit_contact.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -class ContactsPage extends ConsumerWidget { - const ContactsPage({super.key}); - static const path = '/contacts'; - - @override - Widget build(BuildContext context, WidgetRef ref) => const Scaffold( - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text('Contacts Page'), - // ElevatedButton( - // onPressed: () async { - // ref.watch(authNotifierProvider.notifier).login( - // "myEmail", - // "myPassword", - // ); - // }, - // child: const Text("Login"), - // ), - ], - ), - ), - ); -} diff --git a/lib/pages/home.dart b/lib/pages/home.dart deleted file mode 100644 index 408aa86..0000000 --- a/lib/pages/home.dart +++ /dev/null @@ -1,269 +0,0 @@ -import 'package:awesome_extensions/awesome_extensions.dart'; -import 'package:fast_immutable_collections/fast_immutable_collections.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_animate/flutter_animate.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_translate/flutter_translate.dart'; -import 'package:go_router/go_router.dart'; - -import '../proto/proto.dart' as proto; -import '../components/chat_component.dart'; -import '../components/empty_chat_widget.dart'; -import '../components/profile_widget.dart'; -import '../entities/local_account.dart'; -import '../providers/account.dart'; -import '../providers/chat.dart'; -import '../providers/contact.dart'; -import '../providers/local_accounts.dart'; -import '../providers/logins.dart'; -import '../providers/window_control.dart'; -import '../tools/tools.dart'; -import '../veilid_support/veilid_support.dart'; -import 'main_pager/main_pager.dart'; - -class HomePage extends ConsumerStatefulWidget { - const HomePage({super.key}); - - @override - HomePageState createState() => HomePageState(); - - static Widget buildChatComponent(BuildContext context, WidgetRef ref) { - final contactList = ref.watch(fetchContactListProvider).asData?.value ?? - const IListConst([]); - - final activeChat = ref.watch(activeChatStateProvider); - if (activeChat == null) { - return const EmptyChatWidget(); - } - - final activeAccountInfo = - ref.watch(fetchActiveAccountProvider).asData?.value; - if (activeAccountInfo == null) { - return const EmptyChatWidget(); - } - - final activeChatContactIdx = contactList.indexWhere( - (c) => - proto.TypedKeyProto.fromProto(c.remoteConversationRecordKey) == - activeChat, - ); - if (activeChatContactIdx == -1) { - ref.read(activeChatStateProvider.notifier).state = null; - return const EmptyChatWidget(); - } - final activeChatContact = contactList[activeChatContactIdx]; - - return ChatComponent( - activeAccountInfo: activeAccountInfo, - activeChat: activeChat, - activeChatContact: activeChatContact); - } -} - -class HomePageState extends ConsumerState - with TickerProviderStateMixin { - final _unfocusNode = FocusNode(); - - @override - void initState() { - super.initState(); - - WidgetsBinding.instance.addPostFrameCallback((_) async { - setState(() {}); - await ref.read(windowControlProvider.notifier).changeWindowSetup( - TitleBarStyle.normal, OrientationCapability.normal); - }); - } - - @override - void dispose() { - _unfocusNode.dispose(); - super.dispose(); - } - - // ignore: prefer_expression_function_bodies - Widget buildAccountList() { - return const Column(children: [ - Center(child: Text('Small Profile')), - Center(child: Text('Contact invitations')), - Center(child: Text('Contacts')) - ]); - } - - Widget buildUnlockAccount( - BuildContext context, - IList localAccounts, - // ignore: prefer_expression_function_bodies - ) { - return const Center(child: Text('unlock account')); - } - - /// We have an active, unlocked, user login - Widget buildReadyAccount( - BuildContext context, - IList localAccounts, - TypedKey activeUserLogin, - proto.Account account) { - final theme = Theme.of(context); - final scale = theme.extension()!; - - return Column(children: [ - Row(children: [ - IconButton( - icon: const Icon(Icons.settings), - color: scale.secondaryScale.text, - constraints: const BoxConstraints.expand(height: 64, width: 64), - style: ButtonStyle( - backgroundColor: - MaterialStateProperty.all(scale.secondaryScale.border), - shape: MaterialStateProperty.all(const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(16))))), - tooltip: translate('app_bar.settings_tooltip'), - onPressed: () async { - context.go('/home/settings'); - }).paddingLTRB(0, 0, 8, 0), - ProfileWidget( - name: account.profile.name, - pronouns: account.profile.pronouns, - ).expanded(), - ]).paddingAll(8), - MainPager( - localAccounts: localAccounts, - activeUserLogin: activeUserLogin, - account: account) - .expanded() - ]); - } - - Widget buildUserPanel() { - final localAccountsV = ref.watch(localAccountsProvider); - final loginsV = ref.watch(loginsProvider); - - if (!localAccountsV.hasValue || !loginsV.hasValue) { - return waitingPage(context); - } - final localAccounts = localAccountsV.requireValue; - final logins = loginsV.requireValue; - - final activeUserLogin = logins.activeUserLogin; - if (activeUserLogin == null) { - // If no logged in user is active, show the list of account - return buildAccountList(); - } - final accountV = ref - .watch(fetchAccountProvider(accountMasterRecordKey: activeUserLogin)); - if (!accountV.hasValue) { - return waitingPage(context); - } - final account = accountV.requireValue; - switch (account.status) { - case AccountInfoStatus.noAccount: - Future.delayed(0.ms, () async { - await showErrorModal(context, translate('home.missing_account_title'), - translate('home.missing_account_text')); - // Delete account - await ref - .read(localAccountsProvider.notifier) - .deleteLocalAccount(activeUserLogin); - // Switch to no active user login - await ref.read(loginsProvider.notifier).switchToAccount(null); - }); - return waitingPage(context); - case AccountInfoStatus.accountInvalid: - Future.delayed(0.ms, () async { - await showErrorModal(context, translate('home.invalid_account_title'), - translate('home.invalid_account_text')); - // Delete account - await ref - .read(localAccountsProvider.notifier) - .deleteLocalAccount(activeUserLogin); - // Switch to no active user login - await ref.read(loginsProvider.notifier).switchToAccount(null); - }); - return waitingPage(context); - case AccountInfoStatus.accountLocked: - // Show unlock widget - return buildUnlockAccount(context, localAccounts); - case AccountInfoStatus.accountReady: - return buildReadyAccount( - context, - localAccounts, - activeUserLogin, - account.account!, - ); - } - } - - // ignore: prefer_expression_function_bodies - Widget buildPhone(BuildContext context) { - return Material(color: Colors.transparent, child: buildUserPanel()); - } - - // ignore: prefer_expression_function_bodies - Widget buildTabletLeftPane(BuildContext context) { - // - return Material(color: Colors.transparent, child: buildUserPanel()); - } - - // ignore: prefer_expression_function_bodies - Widget buildTabletRightPane(BuildContext context) { - // - return HomePage.buildChatComponent(context, ref); - } - - // ignore: prefer_expression_function_bodies - Widget buildTablet(BuildContext context) { - final w = MediaQuery.of(context).size.width; - final theme = Theme.of(context); - final scale = theme.extension()!; - - final children = [ - ConstrainedBox( - constraints: const BoxConstraints(minWidth: 300, maxWidth: 300), - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: w / 2), - child: buildTabletLeftPane(context))), - SizedBox( - width: 2, - height: double.infinity, - child: ColoredBox(color: scale.primaryScale.hoverBorder)), - Expanded(child: buildTabletRightPane(context)), - ]; - - return Row( - children: children, - ); - - // final theme = MultiSplitViewTheme( - // data: isDesktop - // ? MultiSplitViewThemeData( - // dividerThickness: 1, - // dividerPainter: DividerPainters.grooved2(thickness: 1)) - // : MultiSplitViewThemeData( - // dividerThickness: 3, - // dividerPainter: DividerPainters.grooved2(thickness: 1)), - // child: multiSplitView); - } - - @override - Widget build(BuildContext context) { - ref.watch(windowControlProvider); - - final theme = Theme.of(context); - final scale = theme.extension()!; - - return SafeArea( - child: GestureDetector( - onTap: () => FocusScope.of(context).requestFocus(_unfocusNode), - child: DecoratedBox( - decoration: BoxDecoration( - color: scale.primaryScale.activeElementBackground), - child: responsiveVisibility( - context: context, - phone: false, - ) - ? buildTablet(context) - : buildPhone(context), - ))); - } -} diff --git a/lib/pages/main_pager/chats.dart b/lib/pages/main_pager/chats.dart deleted file mode 100644 index e823dfd..0000000 --- a/lib/pages/main_pager/chats.dart +++ /dev/null @@ -1,100 +0,0 @@ -import 'package:awesome_extensions/awesome_extensions.dart'; -import 'package:fast_immutable_collections/fast_immutable_collections.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../../components/chat_single_contact_list_widget.dart'; -import '../../components/empty_chat_list_widget.dart'; -import '../../entities/local_account.dart'; -import '../../proto/proto.dart' as proto; -import '../../providers/account.dart'; -import '../../providers/chat.dart'; -import '../../providers/contact.dart'; -import '../../providers/local_accounts.dart'; -import '../../providers/logins.dart'; -import '../../tools/tools.dart'; -import '../../veilid_support/veilid_support.dart'; - -class ChatsPage extends ConsumerStatefulWidget { - const ChatsPage({super.key}); - - @override - ChatsPageState createState() => ChatsPageState(); -} - -class ChatsPageState extends ConsumerState { - final _unfocusNode = FocusNode(); - - @override - void initState() { - super.initState(); - } - - @override - void dispose() { - _unfocusNode.dispose(); - super.dispose(); - } - - /// We have an active, unlocked, user login - Widget buildChatList( - BuildContext context, - IList localAccounts, - TypedKey activeUserLogin, - proto.Account account, - // ignore: prefer_expression_function_bodies - ) { - final contactList = ref.watch(fetchContactListProvider).asData?.value ?? - const IListConst([]); - final chatList = - ref.watch(fetchChatListProvider).asData?.value ?? const IListConst([]); - - return Column(children: [ - if (chatList.isNotEmpty) - ChatSingleContactListWidget( - contactList: contactList, chatList: chatList) - .expanded(), - if (chatList.isEmpty) const EmptyChatListWidget().expanded(), - ]); - } - - @override - // ignore: prefer_expression_function_bodies - Widget build(BuildContext context) { - final localAccountsV = ref.watch(localAccountsProvider); - final loginsV = ref.watch(loginsProvider); - - if (!localAccountsV.hasValue || !loginsV.hasValue) { - return waitingPage(context); - } - final localAccounts = localAccountsV.requireValue; - final logins = loginsV.requireValue; - - final activeUserLogin = logins.activeUserLogin; - if (activeUserLogin == null) { - // If no logged in user is active show a placeholder - return waitingPage(context); - } - final accountV = ref - .watch(fetchAccountProvider(accountMasterRecordKey: activeUserLogin)); - if (!accountV.hasValue) { - return waitingPage(context); - } - final account = accountV.requireValue; - switch (account.status) { - case AccountInfoStatus.noAccount: - return waitingPage(context); - case AccountInfoStatus.accountInvalid: - return waitingPage(context); - case AccountInfoStatus.accountLocked: - return waitingPage(context); - case AccountInfoStatus.accountReady: - return buildChatList( - context, - localAccounts, - activeUserLogin, - account.account!, - ); - } - } -} diff --git a/lib/pages/settings.dart b/lib/pages/settings.dart deleted file mode 100644 index dfbb816..0000000 --- a/lib/pages/settings.dart +++ /dev/null @@ -1,139 +0,0 @@ -import 'package:animated_theme_switcher/animated_theme_switcher.dart'; -import 'package:awesome_extensions/awesome_extensions.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_form_builder/flutter_form_builder.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_translate/flutter_translate.dart'; - -import '../components/default_app_bar.dart'; -import '../components/signal_strength_meter.dart'; -import '../entities/preferences.dart'; -import '../providers/window_control.dart'; -import '../tools/tools.dart'; - -class SettingsPage extends ConsumerStatefulWidget { - const SettingsPage({super.key}); - - @override - SettingsPageState createState() => SettingsPageState(); -} - -class SettingsPageState extends ConsumerState { - final _formKey = GlobalKey(); - late bool isInAsyncCall = false; -// ThemePreferences? themePreferences; - static const String formFieldTheme = 'theme'; - static const String formFieldBrightness = 'brightness'; - // static const String formFieldTitle = 'title'; - - @override - void initState() { - super.initState(); - } - - List> _getThemeDropdownItems() { - const colorPrefs = ColorPreference.values; - final colorNames = { - ColorPreference.scarlet: translate('themes.scarlet'), - ColorPreference.vapor: translate('themes.vapor'), - ColorPreference.babydoll: translate('themes.babydoll'), - ColorPreference.gold: translate('themes.gold'), - ColorPreference.garden: translate('themes.garden'), - ColorPreference.forest: translate('themes.forest'), - ColorPreference.arctic: translate('themes.arctic'), - ColorPreference.lapis: translate('themes.lapis'), - ColorPreference.eggplant: translate('themes.eggplant'), - ColorPreference.lime: translate('themes.lime'), - ColorPreference.grim: translate('themes.grim'), - ColorPreference.contrast: translate('themes.contrast') - }; - - return colorPrefs - .map((e) => DropdownMenuItem(value: e, child: Text(colorNames[e]!))) - .toList(); - } - - List> _getBrightnessDropdownItems() { - const brightnessPrefs = BrightnessPreference.values; - final brightnessNames = { - BrightnessPreference.system: translate('brightness.system'), - BrightnessPreference.light: translate('brightness.light'), - BrightnessPreference.dark: translate('brightness.dark') - }; - - return brightnessPrefs - .map( - (e) => DropdownMenuItem(value: e, child: Text(brightnessNames[e]!))) - .toList(); - } - - @override - Widget build(BuildContext context) { - ref.watch(windowControlProvider); - final themeService = ref.watch(themeServiceProvider).valueOrNull; - if (themeService == null) { - return waitingPage(context); - } - final themePreferences = themeService.load(); - - return ThemeSwitchingArea( - child: Scaffold( - // resizeToAvoidBottomInset: false, - appBar: DefaultAppBar( - title: Text(translate('settings_page.titlebar')), - leading: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () => context.pop(), - ), - actions: [ - const SignalStrengthMeterWidget().paddingLTRB(16, 0, 16, 0), - ]), - - body: FormBuilder( - key: _formKey, - child: ListView( - children: [ - ThemeSwitcher.withTheme( - builder: (_, switcher, theme) => FormBuilderDropdown( - name: formFieldTheme, - decoration: InputDecoration( - label: Text(translate('settings_page.color_theme'))), - items: _getThemeDropdownItems(), - initialValue: themePreferences.colorPreference, - onChanged: (value) async { - final newPrefs = themePreferences.copyWith( - colorPreference: value as ColorPreference); - await themeService.save(newPrefs); - switcher.changeTheme(theme: themeService.get(newPrefs)); - ref.invalidate(themeServiceProvider); - setState(() {}); - })), - ThemeSwitcher.withTheme( - builder: (_, switcher, theme) => FormBuilderDropdown( - name: formFieldBrightness, - decoration: InputDecoration( - label: - Text(translate('settings_page.brightness_mode'))), - items: _getBrightnessDropdownItems(), - initialValue: themePreferences.brightnessPreference, - onChanged: (value) async { - final newPrefs = themePreferences.copyWith( - brightnessPreference: value as BrightnessPreference); - await themeService.save(newPrefs); - switcher.changeTheme(theme: themeService.get(newPrefs)); - ref.invalidate(themeServiceProvider); - setState(() {}); - })), - ], - ), - ).paddingSymmetric(horizontal: 24, vertical: 8), - )); - } - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties.add(DiagnosticsProperty('isInAsyncCall', isInAsyncCall)); - } -} diff --git a/lib/processor.dart b/lib/processor.dart deleted file mode 100644 index be414b1..0000000 --- a/lib/processor.dart +++ /dev/null @@ -1,100 +0,0 @@ -import 'dart:async'; - -import 'package:veilid/veilid.dart'; - -import 'providers/connection_state.dart'; -import 'tools/tools.dart'; -import 'veilid_support/src/config.dart'; -import 'veilid_support/src/veilid_log.dart'; - -class Processor { - Processor(); - String _veilidVersion = ''; - bool _startedUp = false; - Stream? _updateStream; - Future? _updateProcessor; - - Future startup() async { - if (_startedUp) { - return; - } - - try { - _veilidVersion = Veilid.instance.veilidVersionString(); - } on Exception { - _veilidVersion = 'Failed to get veilid version.'; - } - - log.info('Veilid version: $_veilidVersion'); - - // In case of hot restart shut down first - try { - await Veilid.instance.shutdownVeilidCore(); - } on Exception {} - - final updateStream = - await Veilid.instance.startupVeilidCore(await getVeilidChatConfig()); - _updateStream = updateStream; - _updateProcessor = processUpdates(); - _startedUp = true; - - await Veilid.instance.attach(); - } - - Future shutdown() async { - if (!_startedUp) { - return; - } - await Veilid.instance.shutdownVeilidCore(); - if (_updateProcessor != null) { - await _updateProcessor; - } - _updateProcessor = null; - _updateStream = null; - _startedUp = false; - } - - Future processUpdateAttachment( - VeilidUpdateAttachment updateAttachment) async { - //loggy.info("Attachment: ${updateAttachment.json}"); - - // // Set connection meter and ui state for connection state - - connectionState.state = ConnectionState( - attachment: VeilidStateAttachment( - state: updateAttachment.state, - publicInternetReady: updateAttachment.publicInternetReady, - localNetworkReady: updateAttachment.localNetworkReady)); - } - - Future processUpdateConfig(VeilidUpdateConfig updateConfig) async { - //loggy.info("Config: ${updateConfig.json}"); - } - - Future processUpdateNetwork(VeilidUpdateNetwork updateNetwork) async { - //loggy.info("Network: ${updateNetwork.json}"); - } - - Future processUpdates() async { - final stream = _updateStream; - if (stream != null) { - await for (final update in stream) { - if (update is VeilidLog) { - await processLog(update); - } else if (update is VeilidUpdateAttachment) { - await processUpdateAttachment(update); - } else if (update is VeilidUpdateConfig) { - await processUpdateConfig(update); - } else if (update is VeilidUpdateNetwork) { - await processUpdateNetwork(update); - } else if (update is VeilidAppMessage) { - log.info('AppMessage: ${update.toJson()}'); - } else if (update is VeilidAppCall) { - log.info('AppCall: ${update.toJson()}'); - } else { - log.trace('Update: ${update.toJson()}'); - } - } - } - } -} diff --git a/lib/proto/proto.dart b/lib/proto/proto.dart index 9d1aeb6..cfccda3 100644 --- a/lib/proto/proto.dart +++ b/lib/proto/proto.dart @@ -1,5 +1,5 @@ -export '../veilid_support/dht_support/proto/proto.dart'; -export '../veilid_support/proto/proto.dart'; +export 'package:veilid_support/dht_support/proto/proto.dart'; +export 'package:veilid_support/proto/proto.dart'; export 'veilidchat.pb.dart'; export 'veilidchat.pbenum.dart'; export 'veilidchat.pbjson.dart'; diff --git a/lib/proto/veilidchat.pb.dart b/lib/proto/veilidchat.pb.dart index 6628b46..1503c57 100644 --- a/lib/proto/veilidchat.pb.dart +++ b/lib/proto/veilidchat.pb.dart @@ -14,8 +14,8 @@ import 'dart:core' as $core; import 'package:fixnum/fixnum.dart' as $fixnum; import 'package:protobuf/protobuf.dart' as $pb; -import 'dht.pb.dart' as $0; -import 'veilid.pb.dart' as $1; +import 'package:veilid_support/proto/dht.pb.dart' as $0; +import 'package:veilid_support/proto/veilid.pb.dart' as $1; import 'veilidchat.pbenum.dart'; export 'veilidchat.pbenum.dart'; @@ -456,7 +456,8 @@ class Chat extends $pb.GeneratedMessage { static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'Chat', package: const $pb.PackageName(_omitMessageNames ? '' : 'veilidchat'), createEmptyInstance: create) ..e(1, _omitFieldNames ? '' : 'type', $pb.PbFieldType.OE, defaultOrMaker: ChatType.CHAT_TYPE_UNSPECIFIED, valueOf: ChatType.valueOf, enumValues: ChatType.values) - ..aOM<$1.TypedKey>(2, _omitFieldNames ? '' : 'remoteConversationKey', subBuilder: $1.TypedKey.create) + ..aOM<$1.TypedKey>(2, _omitFieldNames ? '' : 'remoteConversationRecordKey', subBuilder: $1.TypedKey.create) + ..aOM<$0.OwnedDHTRecordPointer>(3, _omitFieldNames ? '' : 'reconciledChatRecord', subBuilder: $0.OwnedDHTRecordPointer.create) ..hasRequiredFields = false ; @@ -491,15 +492,26 @@ class Chat extends $pb.GeneratedMessage { void clearType() => clearField(1); @$pb.TagNumber(2) - $1.TypedKey get remoteConversationKey => $_getN(1); + $1.TypedKey get remoteConversationRecordKey => $_getN(1); @$pb.TagNumber(2) - set remoteConversationKey($1.TypedKey v) { setField(2, v); } + set remoteConversationRecordKey($1.TypedKey v) { setField(2, v); } @$pb.TagNumber(2) - $core.bool hasRemoteConversationKey() => $_has(1); + $core.bool hasRemoteConversationRecordKey() => $_has(1); @$pb.TagNumber(2) - void clearRemoteConversationKey() => clearField(2); + void clearRemoteConversationRecordKey() => clearField(2); @$pb.TagNumber(2) - $1.TypedKey ensureRemoteConversationKey() => $_ensure(1); + $1.TypedKey ensureRemoteConversationRecordKey() => $_ensure(1); + + @$pb.TagNumber(3) + $0.OwnedDHTRecordPointer get reconciledChatRecord => $_getN(2); + @$pb.TagNumber(3) + set reconciledChatRecord($0.OwnedDHTRecordPointer v) { setField(3, v); } + @$pb.TagNumber(3) + $core.bool hasReconciledChatRecord() => $_has(2); + @$pb.TagNumber(3) + void clearReconciledChatRecord() => clearField(3); + @$pb.TagNumber(3) + $0.OwnedDHTRecordPointer ensureReconciledChatRecord() => $_ensure(2); } class Account extends $pb.GeneratedMessage { diff --git a/lib/proto/veilidchat.pbjson.dart b/lib/proto/veilidchat.pbjson.dart index c8eb2c8..7aea1fb 100644 --- a/lib/proto/veilidchat.pbjson.dart +++ b/lib/proto/veilidchat.pbjson.dart @@ -185,15 +185,17 @@ const Chat$json = { '1': 'Chat', '2': [ {'1': 'type', '3': 1, '4': 1, '5': 14, '6': '.veilidchat.ChatType', '10': 'type'}, - {'1': 'remote_conversation_key', '3': 2, '4': 1, '5': 11, '6': '.veilid.TypedKey', '10': 'remoteConversationKey'}, + {'1': 'remote_conversation_record_key', '3': 2, '4': 1, '5': 11, '6': '.veilid.TypedKey', '10': 'remoteConversationRecordKey'}, + {'1': 'reconciled_chat_record', '3': 3, '4': 1, '5': 11, '6': '.dht.OwnedDHTRecordPointer', '10': 'reconciledChatRecord'}, ], }; /// Descriptor for `Chat`. Decode as a `google.protobuf.DescriptorProto`. final $typed_data.Uint8List chatDescriptor = $convert.base64Decode( - 'CgRDaGF0EigKBHR5cGUYASABKA4yFC52ZWlsaWRjaGF0LkNoYXRUeXBlUgR0eXBlEkgKF3JlbW' - '90ZV9jb252ZXJzYXRpb25fa2V5GAIgASgLMhAudmVpbGlkLlR5cGVkS2V5UhVyZW1vdGVDb252' - 'ZXJzYXRpb25LZXk='); + 'CgRDaGF0EigKBHR5cGUYASABKA4yFC52ZWlsaWRjaGF0LkNoYXRUeXBlUgR0eXBlElUKHnJlbW' + '90ZV9jb252ZXJzYXRpb25fcmVjb3JkX2tleRgCIAEoCzIQLnZlaWxpZC5UeXBlZEtleVIbcmVt' + 'b3RlQ29udmVyc2F0aW9uUmVjb3JkS2V5ElAKFnJlY29uY2lsZWRfY2hhdF9yZWNvcmQYAyABKA' + 'syGi5kaHQuT3duZWRESFRSZWNvcmRQb2ludGVyUhRyZWNvbmNpbGVkQ2hhdFJlY29yZA=='); @$core.Deprecated('Use accountDescriptor instead') const Account$json = { diff --git a/lib/proto/veilidchat.proto b/lib/proto/veilidchat.proto index 8e5f231..1727ee1 100644 --- a/lib/proto/veilidchat.proto +++ b/lib/proto/veilidchat.proto @@ -42,8 +42,12 @@ message Message { repeated Attachment attachments = 5; } -// A record of a 1-1 chat that is synchronized between -// two users. Visible and encrypted for the other party +// The means of direct communications that is synchronized between +// two users. Visible and encrypted for the other party. +// Includes communications for: +// * Profile changes +// * Identity changes +// * 1-1 chat messages // // DHT Schema: SMPL(0,1,[identityPublicKey]) // DHT Key (UnicastOutbox): localConversation @@ -117,12 +121,15 @@ enum ChatType { GROUP = 2; } -// Either a 1-1 converation or a group chat (eventually) +// Either a 1-1 conversation or a group chat (eventually) +// Privately encrypted, this is the local user's copy of the chat message Chat { // What kind of chat is this ChatType type = 1; - // 1-1 Chat key - veilid.TypedKey remote_conversation_key = 2; + // Conversation key for the other party + veilid.TypedKey remote_conversation_record_key = 2; + // Reconciled chat record DHTLog (xxx for now DHTShortArray) + dht.OwnedDHTRecordPointer reconciled_chat_record = 3; } // A record of an individual account diff --git a/lib/providers/account.dart b/lib/providers/account.dart deleted file mode 100644 index 22e532f..0000000 --- a/lib/providers/account.dart +++ /dev/null @@ -1,133 +0,0 @@ -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -import '../entities/local_account.dart'; -import '../proto/proto.dart' as proto; -import '../entities/user_login.dart'; -import '../veilid_support/veilid_support.dart'; - -import 'local_accounts.dart'; -import 'logins.dart'; - -part 'account.g.dart'; - -enum AccountInfoStatus { - noAccount, - accountInvalid, - accountLocked, - accountReady, -} - -class AccountInfo { - AccountInfo({ - required this.status, - required this.active, - this.account, - }); - - AccountInfoStatus status; - bool active; - proto.Account? account; -} - -/// Get an account from the identity key and if it is logged in and we -/// have its secret available, return the account record contents -@riverpod -Future fetchAccount(FetchAccountRef ref, - {required TypedKey accountMasterRecordKey}) async { - // Get which local account we want to fetch the profile for - final localAccount = await ref.watch( - fetchLocalAccountProvider(accountMasterRecordKey: accountMasterRecordKey) - .future); - if (localAccount == null) { - // Local account does not exist - return AccountInfo(status: AccountInfoStatus.noAccount, active: false); - } - - // See if we've logged into this account or if it is locked - final activeUserLogin = await ref.watch(loginsProvider.future - .select((value) async => (await value).activeUserLogin)); - final active = activeUserLogin == accountMasterRecordKey; - - final login = await ref.watch( - fetchLoginProvider(accountMasterRecordKey: accountMasterRecordKey) - .future); - if (login == null) { - // Account was locked - return AccountInfo(status: AccountInfoStatus.accountLocked, active: active); - } - - // Pull the account DHT key, decode it and return it - final pool = await DHTRecordPool.instance(); - final account = await (await pool.openOwned( - login.accountRecordInfo.accountRecord, - parent: localAccount.identityMaster.identityRecordKey)) - .scope((accountRec) => accountRec.getProtobuf(proto.Account.fromBuffer)); - if (account == null) { - // Account could not be read or decrypted from DHT - ref.invalidateSelf(); - return AccountInfo( - status: AccountInfoStatus.accountInvalid, active: active); - } - - // Got account, decrypted and decoded - return AccountInfo( - status: AccountInfoStatus.accountReady, active: active, account: account); -} - -class ActiveAccountInfo { - ActiveAccountInfo({ - required this.localAccount, - required this.userLogin, - required this.account, - }); - - LocalAccount localAccount; - UserLogin userLogin; - proto.Account account; -} - -/// Get the active account info -@riverpod -Future fetchActiveAccount(FetchActiveAccountRef ref) async { - // See if we've logged into this account or if it is locked - final activeUserLogin = await ref.watch(loginsProvider.future - .select((value) async => (await value).activeUserLogin)); - if (activeUserLogin == null) { - return null; - } - - // Get the user login - final userLogin = await ref.watch( - fetchLoginProvider(accountMasterRecordKey: activeUserLogin).future); - if (userLogin == null) { - // Account was locked - return null; - } - - // Get which local account we want to fetch the profile for - final localAccount = await ref.watch( - fetchLocalAccountProvider(accountMasterRecordKey: activeUserLogin) - .future); - if (localAccount == null) { - // Local account does not exist - return null; - } - - // Pull the account DHT key, decode it and return it - final pool = await DHTRecordPool.instance(); - final account = await (await pool.openOwned( - userLogin.accountRecordInfo.accountRecord, - parent: localAccount.identityMaster.identityRecordKey)) - .scope((accountRec) => accountRec.getProtobuf(proto.Account.fromBuffer)); - if (account == null) { - ref.invalidateSelf(); - return null; - } - - // Got account, decrypted and decoded - return ActiveAccountInfo( - localAccount: localAccount, - userLogin: userLogin, - account: account, - ); -} diff --git a/lib/providers/account.g.dart b/lib/providers/account.g.dart deleted file mode 100644 index 6fba2b3..0000000 --- a/lib/providers/account.g.dart +++ /dev/null @@ -1,199 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'account.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -String _$fetchAccountHash() => r'f3072fdd89611b53cd9821613acab450b3c08820'; - -/// Copied from Dart SDK -class _SystemHash { - _SystemHash._(); - - static int combine(int hash, int value) { - // ignore: parameter_assignments - hash = 0x1fffffff & (hash + value); - // ignore: parameter_assignments - hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); - return hash ^ (hash >> 6); - } - - static int finish(int hash) { - // ignore: parameter_assignments - hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); - // ignore: parameter_assignments - hash = hash ^ (hash >> 11); - return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); - } -} - -/// Get an account from the identity key and if it is logged in and we -/// have its secret available, return the account record contents -/// -/// Copied from [fetchAccount]. -@ProviderFor(fetchAccount) -const fetchAccountProvider = FetchAccountFamily(); - -/// Get an account from the identity key and if it is logged in and we -/// have its secret available, return the account record contents -/// -/// Copied from [fetchAccount]. -class FetchAccountFamily extends Family> { - /// Get an account from the identity key and if it is logged in and we - /// have its secret available, return the account record contents - /// - /// Copied from [fetchAccount]. - const FetchAccountFamily(); - - /// Get an account from the identity key and if it is logged in and we - /// have its secret available, return the account record contents - /// - /// Copied from [fetchAccount]. - FetchAccountProvider call({ - required Typed accountMasterRecordKey, - }) { - return FetchAccountProvider( - accountMasterRecordKey: accountMasterRecordKey, - ); - } - - @override - FetchAccountProvider getProviderOverride( - covariant FetchAccountProvider provider, - ) { - return call( - accountMasterRecordKey: provider.accountMasterRecordKey, - ); - } - - static const Iterable? _dependencies = null; - - @override - Iterable? get dependencies => _dependencies; - - static const Iterable? _allTransitiveDependencies = null; - - @override - Iterable? get allTransitiveDependencies => - _allTransitiveDependencies; - - @override - String? get name => r'fetchAccountProvider'; -} - -/// Get an account from the identity key and if it is logged in and we -/// have its secret available, return the account record contents -/// -/// Copied from [fetchAccount]. -class FetchAccountProvider extends AutoDisposeFutureProvider { - /// Get an account from the identity key and if it is logged in and we - /// have its secret available, return the account record contents - /// - /// Copied from [fetchAccount]. - FetchAccountProvider({ - required Typed accountMasterRecordKey, - }) : this._internal( - (ref) => fetchAccount( - ref as FetchAccountRef, - accountMasterRecordKey: accountMasterRecordKey, - ), - from: fetchAccountProvider, - name: r'fetchAccountProvider', - debugGetCreateSourceHash: - const bool.fromEnvironment('dart.vm.product') - ? null - : _$fetchAccountHash, - dependencies: FetchAccountFamily._dependencies, - allTransitiveDependencies: - FetchAccountFamily._allTransitiveDependencies, - accountMasterRecordKey: accountMasterRecordKey, - ); - - FetchAccountProvider._internal( - super._createNotifier, { - required super.name, - required super.dependencies, - required super.allTransitiveDependencies, - required super.debugGetCreateSourceHash, - required super.from, - required this.accountMasterRecordKey, - }) : super.internal(); - - final Typed accountMasterRecordKey; - - @override - Override overrideWith( - FutureOr Function(FetchAccountRef provider) create, - ) { - return ProviderOverride( - origin: this, - override: FetchAccountProvider._internal( - (ref) => create(ref as FetchAccountRef), - from: from, - name: null, - dependencies: null, - allTransitiveDependencies: null, - debugGetCreateSourceHash: null, - accountMasterRecordKey: accountMasterRecordKey, - ), - ); - } - - @override - AutoDisposeFutureProviderElement createElement() { - return _FetchAccountProviderElement(this); - } - - @override - bool operator ==(Object other) { - return other is FetchAccountProvider && - other.accountMasterRecordKey == accountMasterRecordKey; - } - - @override - int get hashCode { - var hash = _SystemHash.combine(0, runtimeType.hashCode); - hash = _SystemHash.combine(hash, accountMasterRecordKey.hashCode); - - return _SystemHash.finish(hash); - } -} - -mixin FetchAccountRef on AutoDisposeFutureProviderRef { - /// The parameter `accountMasterRecordKey` of this provider. - Typed get accountMasterRecordKey; -} - -class _FetchAccountProviderElement - extends AutoDisposeFutureProviderElement with FetchAccountRef { - _FetchAccountProviderElement(super.provider); - - @override - Typed get accountMasterRecordKey => - (origin as FetchAccountProvider).accountMasterRecordKey; -} - -String _$fetchActiveAccountHash() => - r'197e5dd793563ff1d9927309a5ec9db1c9f67f07'; - -/// Get the active account info -/// -/// Copied from [fetchActiveAccount]. -@ProviderFor(fetchActiveAccount) -final fetchActiveAccountProvider = - AutoDisposeFutureProvider.internal( - fetchActiveAccount, - name: r'fetchActiveAccountProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$fetchActiveAccountHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef FetchActiveAccountRef - = AutoDisposeFutureProviderRef; -// ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/lib/providers/chat.dart b/lib/providers/chat.dart deleted file mode 100644 index 7ddbe1d..0000000 --- a/lib/providers/chat.dart +++ /dev/null @@ -1,119 +0,0 @@ -import 'package:fast_immutable_collections/fast_immutable_collections.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -import '../proto/proto.dart' as proto; -import '../proto/proto.dart' show Chat, ChatType; - -import '../veilid_support/veilid_support.dart'; -import 'account.dart'; - -part 'chat.g.dart'; - -/// Create a new chat (singleton for single contact chats) -Future getOrCreateChatSingleContact({ - required ActiveAccountInfo activeAccountInfo, - required TypedKey remoteConversationRecordKey, -}) async { - final accountRecordKey = - activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey; - - // Create conversation type Chat - final chat = Chat() - ..type = ChatType.SINGLE_CONTACT - ..remoteConversationKey = remoteConversationRecordKey.toProto(); - - // Add Chat to account's list - // if this fails, don't keep retrying, user can try again later - await (await DHTShortArray.openOwned( - proto.OwnedDHTRecordPointerProto.fromProto( - activeAccountInfo.account.chatList), - parent: accountRecordKey)) - .scope((chatList) async { - for (var i = 0; i < chatList.length; i++) { - final cbuf = await chatList.getItem(i); - if (cbuf == null) { - throw Exception('Failed to get chat'); - } - final c = Chat.fromBuffer(cbuf); - if (c == chat) { - return; - } - } - if (await chatList.tryAddItem(chat.writeToBuffer()) == false) { - throw Exception('Failed to add chat'); - } - }); -} - -/// Delete a chat -Future deleteChat( - {required ActiveAccountInfo activeAccountInfo, - required TypedKey remoteConversationRecordKey}) async { - final accountRecordKey = - activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey; - - // Create conversation type Chat - final remoteConversationKey = remoteConversationRecordKey.toProto(); - - // Add Chat to account's list - // if this fails, don't keep retrying, user can try again later - await (await DHTShortArray.openOwned( - proto.OwnedDHTRecordPointerProto.fromProto( - activeAccountInfo.account.chatList), - parent: accountRecordKey)) - .scope((chatList) async { - for (var i = 0; i < chatList.length; i++) { - final cbuf = await chatList.getItem(i); - if (cbuf == null) { - throw Exception('Failed to get chat'); - } - final c = Chat.fromBuffer(cbuf); - if (c.remoteConversationKey == remoteConversationKey) { - await chatList.tryRemoveItem(i); - - if (activeChatState.state == remoteConversationRecordKey) { - activeChatState.state = null; - } - - return; - } - } - }); -} - -/// Get the active account contact list -@riverpod -Future?> fetchChatList(FetchChatListRef ref) async { - // See if we've logged into this account or if it is locked - final activeAccountInfo = await ref.watch(fetchActiveAccountProvider.future); - if (activeAccountInfo == null) { - return null; - } - final accountRecordKey = - activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey; - - // Decode the chat list from the DHT - IList out = const IListConst([]); - await (await DHTShortArray.openOwned( - proto.OwnedDHTRecordPointerProto.fromProto( - activeAccountInfo.account.chatList), - parent: accountRecordKey)) - .scope((cList) async { - for (var i = 0; i < cList.length; i++) { - final cir = await cList.getItem(i); - if (cir == null) { - throw Exception('Failed to get chat'); - } - out = out.add(Chat.fromBuffer(cir)); - } - }); - - return out; -} - -// The selected chat -final activeChatState = StateController(null); -final activeChatStateProvider = - StateNotifierProvider, TypedKey?>( - (ref) => activeChatState); diff --git a/lib/providers/chat.g.dart b/lib/providers/chat.g.dart deleted file mode 100644 index 411eae1..0000000 --- a/lib/providers/chat.g.dart +++ /dev/null @@ -1,27 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'chat.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -String _$fetchChatListHash() => r'407692f9d6794a5a2b356d7a34240624b211daa8'; - -/// Get the active account contact list -/// -/// Copied from [fetchChatList]. -@ProviderFor(fetchChatList) -final fetchChatListProvider = AutoDisposeFutureProvider?>.internal( - fetchChatList, - name: r'fetchChatListProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$fetchChatListHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef FetchChatListRef = AutoDisposeFutureProviderRef?>; -// ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/lib/providers/connection_state.dart b/lib/providers/connection_state.dart deleted file mode 100644 index c7360bc..0000000 --- a/lib/providers/connection_state.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; - -import '../veilid_support/veilid_support.dart'; - -part 'connection_state.freezed.dart'; - -@freezed -class ConnectionState with _$ConnectionState { - const factory ConnectionState({ - required VeilidStateAttachment attachment, - }) = _ConnectionState; - const ConnectionState._(); - - bool get isAttached => !(attachment.state == AttachmentState.detached || - attachment.state == AttachmentState.detaching || - attachment.state == AttachmentState.attaching); - - bool get isPublicInternetReady => attachment.publicInternetReady; -} - -final connectionState = StateController(const ConnectionState( - attachment: VeilidStateAttachment( - state: AttachmentState.detached, - publicInternetReady: false, - localNetworkReady: false))); -final connectionStateProvider = - StateNotifierProvider, ConnectionState>( - (ref) => connectionState); diff --git a/lib/providers/connection_state.freezed.dart b/lib/providers/connection_state.freezed.dart deleted file mode 100644 index 350a1af..0000000 --- a/lib/providers/connection_state.freezed.dart +++ /dev/null @@ -1,150 +0,0 @@ -// coverage:ignore-file -// GENERATED CODE - DO NOT MODIFY BY HAND -// ignore_for_file: type=lint -// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark - -part of 'connection_state.dart'; - -// ************************************************************************** -// FreezedGenerator -// ************************************************************************** - -T _$identity(T value) => value; - -final _privateConstructorUsedError = UnsupportedError( - 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods'); - -/// @nodoc -mixin _$ConnectionState { - VeilidStateAttachment get attachment => throw _privateConstructorUsedError; - - @JsonKey(ignore: true) - $ConnectionStateCopyWith get copyWith => - throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $ConnectionStateCopyWith<$Res> { - factory $ConnectionStateCopyWith( - ConnectionState value, $Res Function(ConnectionState) then) = - _$ConnectionStateCopyWithImpl<$Res, ConnectionState>; - @useResult - $Res call({VeilidStateAttachment attachment}); - - $VeilidStateAttachmentCopyWith<$Res> get attachment; -} - -/// @nodoc -class _$ConnectionStateCopyWithImpl<$Res, $Val extends ConnectionState> - implements $ConnectionStateCopyWith<$Res> { - _$ConnectionStateCopyWithImpl(this._value, this._then); - - // ignore: unused_field - final $Val _value; - // ignore: unused_field - final $Res Function($Val) _then; - - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? attachment = null, - }) { - return _then(_value.copyWith( - attachment: null == attachment - ? _value.attachment - : attachment // ignore: cast_nullable_to_non_nullable - as VeilidStateAttachment, - ) as $Val); - } - - @override - @pragma('vm:prefer-inline') - $VeilidStateAttachmentCopyWith<$Res> get attachment { - return $VeilidStateAttachmentCopyWith<$Res>(_value.attachment, (value) { - return _then(_value.copyWith(attachment: value) as $Val); - }); - } -} - -/// @nodoc -abstract class _$$ConnectionStateImplCopyWith<$Res> - implements $ConnectionStateCopyWith<$Res> { - factory _$$ConnectionStateImplCopyWith(_$ConnectionStateImpl value, - $Res Function(_$ConnectionStateImpl) then) = - __$$ConnectionStateImplCopyWithImpl<$Res>; - @override - @useResult - $Res call({VeilidStateAttachment attachment}); - - @override - $VeilidStateAttachmentCopyWith<$Res> get attachment; -} - -/// @nodoc -class __$$ConnectionStateImplCopyWithImpl<$Res> - extends _$ConnectionStateCopyWithImpl<$Res, _$ConnectionStateImpl> - implements _$$ConnectionStateImplCopyWith<$Res> { - __$$ConnectionStateImplCopyWithImpl( - _$ConnectionStateImpl _value, $Res Function(_$ConnectionStateImpl) _then) - : super(_value, _then); - - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? attachment = null, - }) { - return _then(_$ConnectionStateImpl( - attachment: null == attachment - ? _value.attachment - : attachment // ignore: cast_nullable_to_non_nullable - as VeilidStateAttachment, - )); - } -} - -/// @nodoc - -class _$ConnectionStateImpl extends _ConnectionState { - const _$ConnectionStateImpl({required this.attachment}) : super._(); - - @override - final VeilidStateAttachment attachment; - - @override - String toString() { - return 'ConnectionState(attachment: $attachment)'; - } - - @override - bool operator ==(dynamic other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$ConnectionStateImpl && - (identical(other.attachment, attachment) || - other.attachment == attachment)); - } - - @override - int get hashCode => Object.hash(runtimeType, attachment); - - @JsonKey(ignore: true) - @override - @pragma('vm:prefer-inline') - _$$ConnectionStateImplCopyWith<_$ConnectionStateImpl> get copyWith => - __$$ConnectionStateImplCopyWithImpl<_$ConnectionStateImpl>( - this, _$identity); -} - -abstract class _ConnectionState extends ConnectionState { - const factory _ConnectionState( - {required final VeilidStateAttachment attachment}) = - _$ConnectionStateImpl; - const _ConnectionState._() : super._(); - - @override - VeilidStateAttachment get attachment; - @override - @JsonKey(ignore: true) - _$$ConnectionStateImplCopyWith<_$ConnectionStateImpl> get copyWith => - throw _privateConstructorUsedError; -} diff --git a/lib/providers/contact.dart b/lib/providers/contact.dart deleted file mode 100644 index d935e73..0000000 --- a/lib/providers/contact.dart +++ /dev/null @@ -1,133 +0,0 @@ -import 'dart:convert'; - -import 'package:fast_immutable_collections/fast_immutable_collections.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -import '../proto/proto.dart' as proto; -import '../proto/proto.dart' show Contact; - -import '../veilid_support/veilid_support.dart'; -import '../tools/tools.dart'; -import 'account.dart'; -import 'chat.dart'; - -part 'contact.g.dart'; - -Future createContact({ - required ActiveAccountInfo activeAccountInfo, - required proto.Profile profile, - required IdentityMaster remoteIdentity, - required TypedKey remoteConversationRecordKey, - required TypedKey localConversationRecordKey, -}) async { - final accountRecordKey = - activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey; - - // Create Contact - final contact = Contact() - ..editedProfile = profile - ..remoteProfile = profile - ..identityMasterJson = jsonEncode(remoteIdentity.toJson()) - ..identityPublicKey = TypedKey( - kind: remoteIdentity.identityRecordKey.kind, - value: remoteIdentity.identityPublicKey) - .toProto() - ..remoteConversationRecordKey = remoteConversationRecordKey.toProto() - ..localConversationRecordKey = localConversationRecordKey.toProto() - ..showAvailability = false; - - // Add Contact to account's list - // if this fails, don't keep retrying, user can try again later - await (await DHTShortArray.openOwned( - proto.OwnedDHTRecordPointerProto.fromProto( - activeAccountInfo.account.contactList), - parent: accountRecordKey)) - .scope((contactList) async { - if (await contactList.tryAddItem(contact.writeToBuffer()) == false) { - throw Exception('Failed to add contact'); - } - }); -} - -Future deleteContact( - {required ActiveAccountInfo activeAccountInfo, - required Contact contact}) async { - final pool = await DHTRecordPool.instance(); - final accountRecordKey = - activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey; - final localConversationKey = - proto.TypedKeyProto.fromProto(contact.localConversationRecordKey); - final remoteConversationKey = - proto.TypedKeyProto.fromProto(contact.remoteConversationRecordKey); - - // Remove any chats for this contact - await deleteChat( - activeAccountInfo: activeAccountInfo, - remoteConversationRecordKey: remoteConversationKey); - - // Remove Contact from account's list - await (await DHTShortArray.openOwned( - proto.OwnedDHTRecordPointerProto.fromProto( - activeAccountInfo.account.contactList), - parent: accountRecordKey)) - .scope((contactList) async { - for (var i = 0; i < contactList.length; i++) { - final item = - await contactList.getItemProtobuf(proto.Contact.fromBuffer, i); - if (item == null) { - throw Exception('Failed to get contact'); - } - if (item.remoteConversationRecordKey == - contact.remoteConversationRecordKey) { - await contactList.tryRemoveItem(i); - break; - } - } - try { - await (await pool.openRead(localConversationKey, - parent: accountRecordKey)) - .delete(); - } on Exception catch (e) { - log.debug('error removing local conversation record key: $e', e); - } - try { - if (localConversationKey != remoteConversationKey) { - await (await pool.openRead(remoteConversationKey, - parent: accountRecordKey)) - .delete(); - } - } on Exception catch (e) { - log.debug('error removing remote conversation record key: $e', e); - } - }); -} - -/// Get the active account contact list -@riverpod -Future?> fetchContactList(FetchContactListRef ref) async { - // See if we've logged into this account or if it is locked - final activeAccountInfo = await ref.watch(fetchActiveAccountProvider.future); - if (activeAccountInfo == null) { - return null; - } - final accountRecordKey = - activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey; - - // Decode the contact list from the DHT - IList out = const IListConst([]); - await (await DHTShortArray.openOwned( - proto.OwnedDHTRecordPointerProto.fromProto( - activeAccountInfo.account.contactList), - parent: accountRecordKey)) - .scope((cList) async { - for (var i = 0; i < cList.length; i++) { - final cir = await cList.getItem(i); - if (cir == null) { - throw Exception('Failed to get contact'); - } - out = out.add(Contact.fromBuffer(cir)); - } - }); - - return out; -} diff --git a/lib/providers/contact.g.dart b/lib/providers/contact.g.dart deleted file mode 100644 index 823f594..0000000 --- a/lib/providers/contact.g.dart +++ /dev/null @@ -1,28 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'contact.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -String _$fetchContactListHash() => r'f75cb33fbc664404bba122f1e128e437e0f0b2da'; - -/// Get the active account contact list -/// -/// Copied from [fetchContactList]. -@ProviderFor(fetchContactList) -final fetchContactListProvider = - AutoDisposeFutureProvider?>.internal( - fetchContactList, - name: r'fetchContactListProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$fetchContactListHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef FetchContactListRef = AutoDisposeFutureProviderRef?>; -// ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/lib/providers/contact_invite.dart b/lib/providers/contact_invite.dart deleted file mode 100644 index 3f7bd72..0000000 --- a/lib/providers/contact_invite.dart +++ /dev/null @@ -1,565 +0,0 @@ -import 'dart:typed_data'; - -import 'package:fast_immutable_collections/fast_immutable_collections.dart'; -import 'package:fixnum/fixnum.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -import '../entities/local_account.dart'; -import '../proto/proto.dart' as proto; -import '../proto/proto.dart' - show - ContactInvitation, - ContactInvitationRecord, - ContactRequest, - ContactRequestPrivate, - ContactResponse, - SignedContactInvitation, - SignedContactResponse; -import '../tools/tools.dart'; -import '../veilid_support/veilid_support.dart'; -import 'account.dart'; -import 'conversation.dart'; - -part 'contact_invite.g.dart'; - -class ContactInviteInvalidKeyException implements Exception { - const ContactInviteInvalidKeyException(this.type) : super(); - final EncryptionKeyType type; -} - -class AcceptedContact { - AcceptedContact({ - required this.profile, - required this.remoteIdentity, - required this.remoteConversationRecordKey, - required this.localConversationRecordKey, - }); - - proto.Profile profile; - IdentityMaster remoteIdentity; - TypedKey remoteConversationRecordKey; - TypedKey localConversationRecordKey; -} - -class AcceptedOrRejectedContact { - AcceptedOrRejectedContact({required this.acceptedContact}); - AcceptedContact? acceptedContact; -} - -Future checkAcceptRejectContact( - {required ActiveAccountInfo activeAccountInfo, - required ContactInvitationRecord contactInvitationRecord}) async { - // Open the contact request inbox - try { - final pool = await DHTRecordPool.instance(); - final accountRecordKey = - activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey; - final writerKey = - proto.CryptoKeyProto.fromProto(contactInvitationRecord.writerKey); - final writerSecret = - proto.CryptoKeyProto.fromProto(contactInvitationRecord.writerSecret); - final recordKey = proto.TypedKeyProto.fromProto( - contactInvitationRecord.contactRequestInbox.recordKey); - final writer = TypedKeyPair( - kind: recordKey.kind, key: writerKey, secret: writerSecret); - final acceptReject = await (await pool.openRead(recordKey, - crypto: await DHTRecordCryptoPrivate.fromTypedKeyPair(writer), - parent: accountRecordKey, - defaultSubkey: 1)) - .scope((contactRequestInbox) async { - // - final signedContactResponse = await contactRequestInbox - .getProtobuf(SignedContactResponse.fromBuffer, forceRefresh: true); - if (signedContactResponse == null) { - return null; - } - - final contactResponseBytes = - Uint8List.fromList(signedContactResponse.contactResponse); - final contactResponse = ContactResponse.fromBuffer(contactResponseBytes); - final contactIdentityMasterRecordKey = proto.TypedKeyProto.fromProto( - contactResponse.identityMasterRecordKey); - final cs = await pool.veilid.getCryptoSystem(recordKey.kind); - - // Fetch the remote contact's account master - final contactIdentityMaster = await openIdentityMaster( - identityMasterRecordKey: contactIdentityMasterRecordKey); - - // Verify - final signature = proto.SignatureProto.fromProto( - signedContactResponse.identitySignature); - await cs.verify(contactIdentityMaster.identityPublicKey, - contactResponseBytes, signature); - - // Check for rejection - if (!contactResponse.accept) { - return AcceptedOrRejectedContact(acceptedContact: null); - } - - // Pull profile from remote conversation key - final remoteConversationRecordKey = proto.TypedKeyProto.fromProto( - contactResponse.remoteConversationRecordKey); - final remoteConversation = await readRemoteConversation( - activeAccountInfo: activeAccountInfo, - remoteIdentityPublicKey: - contactIdentityMaster.identityPublicTypedKey(), - remoteConversationRecordKey: remoteConversationRecordKey); - if (remoteConversation == null) { - log.info('Remote conversation could not be read. Waiting...'); - return null; - } - // Complete the local conversation now that we have the remote profile - final localConversationRecordKey = proto.TypedKeyProto.fromProto( - contactInvitationRecord.localConversationRecordKey); - return createConversation( - activeAccountInfo: activeAccountInfo, - remoteIdentityPublicKey: - contactIdentityMaster.identityPublicTypedKey(), - existingConversationRecordKey: localConversationRecordKey, - // ignore: prefer_expression_function_bodies - callback: (localConversation) async { - return AcceptedOrRejectedContact( - acceptedContact: AcceptedContact( - profile: remoteConversation.profile, - remoteIdentity: contactIdentityMaster, - remoteConversationRecordKey: remoteConversationRecordKey, - localConversationRecordKey: localConversationRecordKey)); - }); - }); - - if (acceptReject == null) { - return null; - } - - // Delete invitation and return the accepted or rejected contact - await deleteContactInvitation( - accepted: acceptReject.acceptedContact != null, - activeAccountInfo: activeAccountInfo, - contactInvitationRecord: contactInvitationRecord); - - return acceptReject; - } on Exception catch (e) { - log.error('Exception in checkAcceptRejectContact: $e', e); - - // Attempt to clean up. All this needs better lifetime management - await deleteContactInvitation( - accepted: false, - activeAccountInfo: activeAccountInfo, - contactInvitationRecord: contactInvitationRecord); - - rethrow; - } -} - -Future deleteContactInvitation( - {required bool accepted, - required ActiveAccountInfo activeAccountInfo, - required ContactInvitationRecord contactInvitationRecord}) async { - final pool = await DHTRecordPool.instance(); - final accountRecordKey = - activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey; - - // Remove ContactInvitationRecord from account's list - await (await DHTShortArray.openOwned( - proto.OwnedDHTRecordPointerProto.fromProto( - activeAccountInfo.account.contactInvitationRecords), - parent: accountRecordKey)) - .scope((cirList) async { - for (var i = 0; i < cirList.length; i++) { - final item = await cirList.getItemProtobuf( - proto.ContactInvitationRecord.fromBuffer, i); - if (item == null) { - throw Exception('Failed to get contact invitation record'); - } - if (item.contactRequestInbox.recordKey == - contactInvitationRecord.contactRequestInbox.recordKey) { - await cirList.tryRemoveItem(i); - break; - } - } - await (await pool.openOwned( - proto.OwnedDHTRecordPointerProto.fromProto( - contactInvitationRecord.contactRequestInbox), - parent: accountRecordKey)) - .scope((contactRequestInbox) async { - // Wipe out old invitation so it shows up as invalid - await contactRequestInbox.tryWriteBytes(Uint8List(0)); - await contactRequestInbox.delete(); - }); - if (!accepted) { - await (await pool.openRead( - proto.TypedKeyProto.fromProto( - contactInvitationRecord.localConversationRecordKey), - parent: accountRecordKey)) - .delete(); - } - }); -} - -Future createContactInvitation( - {required ActiveAccountInfo activeAccountInfo, - required EncryptionKeyType encryptionKeyType, - required String encryptionKey, - required String message, - required Timestamp? expiration}) async { - final pool = await DHTRecordPool.instance(); - final accountRecordKey = - activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey; - final identityKey = - activeAccountInfo.localAccount.identityMaster.identityPublicKey; - final identitySecret = activeAccountInfo.userLogin.identitySecret.value; - - // Generate writer keypair to share with new contact - final cs = await pool.veilid.bestCryptoSystem(); - final contactRequestWriter = await cs.generateKeyPair(); - final conversationWriter = - getConversationWriter(activeAccountInfo: activeAccountInfo); - - // Encrypt the writer secret with the encryption key - final encryptedSecret = await encryptSecretToBytes( - secret: contactRequestWriter.secret, - cryptoKind: cs.kind(), - encryptionKey: encryptionKey, - encryptionKeyType: encryptionKeyType); - - // Create local chat DHT record with the account record key as its parent - // Do not set the encryption of this key yet as it will not yet be written - // to and it will be eventually encrypted with the DH of the contact's - // identity key - late final Uint8List signedContactInvitationBytes; - await (await pool.create( - parent: accountRecordKey, - schema: DHTSchema.smpl(oCnt: 0, members: [ - DHTSchemaMember(mKey: conversationWriter.key, mCnt: 1) - ]))) - .deleteScope((localConversation) async { - // dont bother reopening localConversation with writer - // Make ContactRequestPrivate and encrypt with the writer secret - final crpriv = ContactRequestPrivate() - ..writerKey = contactRequestWriter.key.toProto() - ..profile = activeAccountInfo.account.profile - ..identityMasterRecordKey = - activeAccountInfo.userLogin.accountMasterRecordKey.toProto() - ..chatRecordKey = localConversation.key.toProto() - ..expiration = expiration?.toInt64() ?? Int64.ZERO; - final crprivbytes = crpriv.writeToBuffer(); - final encryptedContactRequestPrivate = - await cs.encryptAeadWithNonce(crprivbytes, contactRequestWriter.secret); - - // Create ContactRequest and embed contactrequestprivate - final creq = ContactRequest() - ..encryptionKeyType = encryptionKeyType.toProto() - ..private = encryptedContactRequestPrivate; - - // Create DHT unicast inbox for ContactRequest - await (await pool.create( - parent: accountRecordKey, - schema: DHTSchema.smpl(oCnt: 1, members: [ - DHTSchemaMember(mCnt: 1, mKey: contactRequestWriter.key) - ]), - crypto: const DHTRecordCryptoPublic())) - .deleteScope((contactRequestInbox) async { - // Store ContactRequest in owner subkey - await contactRequestInbox.eventualWriteProtobuf(creq); - - // Create ContactInvitation and SignedContactInvitation - final cinv = ContactInvitation() - ..contactRequestInboxKey = contactRequestInbox.key.toProto() - ..writerSecret = encryptedSecret; - final cinvbytes = cinv.writeToBuffer(); - final scinv = SignedContactInvitation() - ..contactInvitation = cinvbytes - ..identitySignature = - (await cs.sign(identityKey, identitySecret, cinvbytes)).toProto(); - signedContactInvitationBytes = scinv.writeToBuffer(); - - // Create ContactInvitationRecord - final cinvrec = ContactInvitationRecord() - ..contactRequestInbox = - contactRequestInbox.ownedDHTRecordPointer.toProto() - ..writerKey = contactRequestWriter.key.toProto() - ..writerSecret = contactRequestWriter.secret.toProto() - ..localConversationRecordKey = localConversation.key.toProto() - ..expiration = expiration?.toInt64() ?? Int64.ZERO - ..invitation = signedContactInvitationBytes - ..message = message; - - // Add ContactInvitationRecord to account's list - // if this fails, don't keep retrying, user can try again later - await (await DHTShortArray.openOwned( - proto.OwnedDHTRecordPointerProto.fromProto( - activeAccountInfo.account.contactInvitationRecords), - parent: accountRecordKey)) - .scope((cirList) async { - if (await cirList.tryAddItem(cinvrec.writeToBuffer()) == false) { - throw Exception('Failed to add contact invitation record'); - } - }); - }); - }); - - return signedContactInvitationBytes; -} - -class ValidContactInvitation { - ValidContactInvitation( - {required this.signedContactInvitation, - required this.contactInvitation, - required this.contactRequestInboxKey, - required this.contactRequest, - required this.contactRequestPrivate, - required this.contactIdentityMaster, - required this.writer}); - - SignedContactInvitation signedContactInvitation; - ContactInvitation contactInvitation; - TypedKey contactRequestInboxKey; - ContactRequest contactRequest; - ContactRequestPrivate contactRequestPrivate; - IdentityMaster contactIdentityMaster; - KeyPair writer; -} - -typedef GetEncryptionKeyCallback = Future Function( - VeilidCryptoSystem cs, - EncryptionKeyType encryptionKeyType, - Uint8List encryptedSecret); - -Future validateContactInvitation( - {required ActiveAccountInfo activeAccountInfo, - required IList? contactInvitationRecords, - required Uint8List inviteData, - required GetEncryptionKeyCallback getEncryptionKeyCallback}) async { - final accountRecordKey = - activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey; - - final signedContactInvitation = - proto.SignedContactInvitation.fromBuffer(inviteData); - - final contactInvitationBytes = - Uint8List.fromList(signedContactInvitation.contactInvitation); - final contactInvitation = - proto.ContactInvitation.fromBuffer(contactInvitationBytes); - - final contactRequestInboxKey = - proto.TypedKeyProto.fromProto(contactInvitation.contactRequestInboxKey); - - ValidContactInvitation? out; - - final pool = await DHTRecordPool.instance(); - final cs = await pool.veilid.getCryptoSystem(contactRequestInboxKey.kind); - - // See if we're chatting to ourselves, if so, don't delete it here - final isSelf = contactInvitationRecords?.indexWhere((cir) => - proto.TypedKeyProto.fromProto(cir.contactRequestInbox.recordKey) == - contactRequestInboxKey) != - -1; - - await (await pool.openRead(contactRequestInboxKey, parent: accountRecordKey)) - .maybeDeleteScope(!isSelf, (contactRequestInbox) async { - // - final contactRequest = - await contactRequestInbox.getProtobuf(proto.ContactRequest.fromBuffer); - - // Decrypt contact request private - final encryptionKeyType = - EncryptionKeyType.fromProto(contactRequest!.encryptionKeyType); - late final SharedSecret? writerSecret; - try { - writerSecret = await getEncryptionKeyCallback(cs, encryptionKeyType, - Uint8List.fromList(contactInvitation.writerSecret)); - } on Exception catch (_) { - throw ContactInviteInvalidKeyException(encryptionKeyType); - } - if (writerSecret == null) { - return null; - } - - final contactRequestPrivateBytes = await cs.decryptAeadWithNonce( - Uint8List.fromList(contactRequest.private), writerSecret); - - final contactRequestPrivate = - proto.ContactRequestPrivate.fromBuffer(contactRequestPrivateBytes); - final contactIdentityMasterRecordKey = proto.TypedKeyProto.fromProto( - contactRequestPrivate.identityMasterRecordKey); - - // Fetch the account master - final contactIdentityMaster = await openIdentityMaster( - identityMasterRecordKey: contactIdentityMasterRecordKey); - - // Verify - final signature = proto.SignatureProto.fromProto( - signedContactInvitation.identitySignature); - await cs.verify(contactIdentityMaster.identityPublicKey, - contactInvitationBytes, signature); - - final writer = KeyPair( - key: proto.CryptoKeyProto.fromProto(contactRequestPrivate.writerKey), - secret: writerSecret); - - out = ValidContactInvitation( - signedContactInvitation: signedContactInvitation, - contactInvitation: contactInvitation, - contactRequestInboxKey: contactRequestInboxKey, - contactRequest: contactRequest, - contactRequestPrivate: contactRequestPrivate, - contactIdentityMaster: contactIdentityMaster, - writer: writer); - }); - - return out; -} - -Future acceptContactInvitation( - ActiveAccountInfo activeAccountInfo, - ValidContactInvitation validContactInvitation) async { - final pool = await DHTRecordPool.instance(); - try { - // Ensure we don't delete this if we're trying to chat to self - final isSelf = - validContactInvitation.contactIdentityMaster.identityPublicKey == - activeAccountInfo.localAccount.identityMaster.identityPublicKey; - final accountRecordKey = - activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey; - - return (await pool.openWrite(validContactInvitation.contactRequestInboxKey, - validContactInvitation.writer, - parent: accountRecordKey)) - // ignore: prefer_expression_function_bodies - .maybeDeleteScope(!isSelf, (contactRequestInbox) async { - // Create local conversation key for this - // contact and send via contact response - return createConversation( - activeAccountInfo: activeAccountInfo, - remoteIdentityPublicKey: validContactInvitation.contactIdentityMaster - .identityPublicTypedKey(), - callback: (localConversation) async { - final contactResponse = ContactResponse() - ..accept = true - ..remoteConversationRecordKey = localConversation.key.toProto() - ..identityMasterRecordKey = activeAccountInfo - .localAccount.identityMaster.masterRecordKey - .toProto(); - final contactResponseBytes = contactResponse.writeToBuffer(); - - final cs = await pool.veilid.getCryptoSystem( - validContactInvitation.contactRequestInboxKey.kind); - - final identitySignature = await cs.sign( - activeAccountInfo.localAccount.identityMaster.identityPublicKey, - activeAccountInfo.userLogin.identitySecret.value, - contactResponseBytes); - - final signedContactResponse = SignedContactResponse() - ..contactResponse = contactResponseBytes - ..identitySignature = identitySignature.toProto(); - - // Write the acceptance to the inbox - if (await contactRequestInbox.tryWriteProtobuf( - SignedContactResponse.fromBuffer, signedContactResponse, - subkey: 1) != - null) { - throw Exception('failed to accept contact invitation'); - } - return AcceptedContact( - profile: validContactInvitation.contactRequestPrivate.profile, - remoteIdentity: validContactInvitation.contactIdentityMaster, - remoteConversationRecordKey: proto.TypedKeyProto.fromProto( - validContactInvitation.contactRequestPrivate.chatRecordKey), - localConversationRecordKey: localConversation.key, - ); - }); - }); - } on Exception catch (e) { - log.debug('exception: $e', e); - return null; - } -} - -Future rejectContactInvitation(ActiveAccountInfo activeAccountInfo, - ValidContactInvitation validContactInvitation) async { - final pool = await DHTRecordPool.instance(); - - // Ensure we don't delete this if we're trying to chat to self - final isSelf = - validContactInvitation.contactIdentityMaster.identityPublicKey == - activeAccountInfo.localAccount.identityMaster.identityPublicKey; - final accountRecordKey = - activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey; - - return (await pool.openWrite(validContactInvitation.contactRequestInboxKey, - validContactInvitation.writer, - parent: accountRecordKey)) - .maybeDeleteScope(!isSelf, (contactRequestInbox) async { - final cs = await pool.veilid - .getCryptoSystem(validContactInvitation.contactRequestInboxKey.kind); - - final contactResponse = ContactResponse() - ..accept = false - ..identityMasterRecordKey = activeAccountInfo - .localAccount.identityMaster.masterRecordKey - .toProto(); - final contactResponseBytes = contactResponse.writeToBuffer(); - - final identitySignature = await cs.sign( - activeAccountInfo.localAccount.identityMaster.identityPublicKey, - activeAccountInfo.userLogin.identitySecret.value, - contactResponseBytes); - - final signedContactResponse = SignedContactResponse() - ..contactResponse = contactResponseBytes - ..identitySignature = identitySignature.toProto(); - - // Write the rejection to the inbox - if (await contactRequestInbox.tryWriteProtobuf( - SignedContactResponse.fromBuffer, signedContactResponse, - subkey: 1) != - null) { - log.error('failed to reject contact invitation'); - return false; - } - return true; - }); -} - -/// Get the active account contact invitation list -@riverpod -Future?> fetchContactInvitationRecords( - FetchContactInvitationRecordsRef ref) async { - // See if we've logged into this account or if it is locked - final activeAccountInfo = await ref.watch(fetchActiveAccountProvider.future); - if (activeAccountInfo == null) { - return null; - } - final accountRecordKey = - activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey; - - // Decode the contact invitation list from the DHT - IList out = const IListConst([]); - - try { - await (await DHTShortArray.openOwned( - proto.OwnedDHTRecordPointerProto.fromProto( - activeAccountInfo.account.contactInvitationRecords), - parent: accountRecordKey)) - .scope((cirList) async { - for (var i = 0; i < cirList.length; i++) { - final cir = await cirList.getItem(i); - if (cir == null) { - throw Exception('Failed to get contact invitation record'); - } - out = out.add(ContactInvitationRecord.fromBuffer(cir)); - } - }); - } on VeilidAPIExceptionTryAgain catch (_) { - // Try again later - ref.invalidateSelf(); - return null; - } on Exception catch (_) { - // Try again later - ref.invalidateSelf(); - rethrow; - } - - return out; -} diff --git a/lib/providers/contact_invite.g.dart b/lib/providers/contact_invite.g.dart deleted file mode 100644 index b6cf257..0000000 --- a/lib/providers/contact_invite.g.dart +++ /dev/null @@ -1,30 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'contact_invite.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -String _$fetchContactInvitationRecordsHash() => - r'365d563c5e66f45679f597502ea9e4b8296ff8af'; - -/// Get the active account contact invitation list -/// -/// Copied from [fetchContactInvitationRecords]. -@ProviderFor(fetchContactInvitationRecords) -final fetchContactInvitationRecordsProvider = - AutoDisposeFutureProvider?>.internal( - fetchContactInvitationRecords, - name: r'fetchContactInvitationRecordsProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$fetchContactInvitationRecordsHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef FetchContactInvitationRecordsRef - = AutoDisposeFutureProviderRef?>; -// ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/lib/providers/conversation.dart b/lib/providers/conversation.dart deleted file mode 100644 index 7e1c56b..0000000 --- a/lib/providers/conversation.dart +++ /dev/null @@ -1,374 +0,0 @@ -import 'dart:convert'; - -import 'package:fast_immutable_collections/fast_immutable_collections.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -import '../proto/proto.dart' as proto; -import '../proto/proto.dart' show Conversation, Message; - -import '../tools/tools.dart'; -import '../veilid_init.dart'; -import '../veilid_support/veilid_support.dart'; -import 'account.dart'; -import 'chat.dart'; -import 'contact.dart'; - -part 'conversation.g.dart'; - -Future getConversationCrypto({ - required ActiveAccountInfo activeAccountInfo, - required TypedKey remoteIdentityPublicKey, -}) async { - final veilid = await eventualVeilid.future; - final identitySecret = activeAccountInfo.userLogin.identitySecret; - final cs = await veilid.getCryptoSystem(identitySecret.kind); - final sharedSecret = - await cs.cachedDH(remoteIdentityPublicKey.value, identitySecret.value); - return DHTRecordCryptoPrivate.fromSecret(identitySecret.kind, sharedSecret); -} - -KeyPair getConversationWriter({ - required ActiveAccountInfo activeAccountInfo, -}) { - final identityKey = - activeAccountInfo.localAccount.identityMaster.identityPublicKey; - final identitySecret = activeAccountInfo.userLogin.identitySecret; - return KeyPair(key: identityKey, secret: identitySecret.value); -} - -// Create a conversation -// If we were the initiator of the conversation there may be an -// incomplete 'existingConversationRecord' that we need to fill -// in now that we have the remote identity key -Future createConversation( - {required ActiveAccountInfo activeAccountInfo, - required TypedKey remoteIdentityPublicKey, - required FutureOr Function(DHTRecord) callback, - TypedKey? existingConversationRecordKey}) async { - final pool = await DHTRecordPool.instance(); - final accountRecordKey = - activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey; - - final crypto = await getConversationCrypto( - activeAccountInfo: activeAccountInfo, - remoteIdentityPublicKey: remoteIdentityPublicKey); - final writer = getConversationWriter(activeAccountInfo: activeAccountInfo); - - // Open with SMPL scheme for identity writer - late final DHTRecord localConversationRecord; - if (existingConversationRecordKey != null) { - localConversationRecord = await pool.openWrite( - existingConversationRecordKey, writer, - parent: accountRecordKey, crypto: crypto); - } else { - final localConversationRecordCreate = await pool.create( - parent: accountRecordKey, - crypto: crypto, - schema: DHTSchema.smpl( - oCnt: 0, members: [DHTSchemaMember(mKey: writer.key, mCnt: 1)])); - await localConversationRecordCreate.close(); - localConversationRecord = await pool.openWrite( - localConversationRecordCreate.key, writer, - parent: accountRecordKey, crypto: crypto); - } - return localConversationRecord - // ignore: prefer_expression_function_bodies - .deleteScope((localConversation) async { - // Make messages log - return (await DHTShortArray.create( - parent: localConversation.key, crypto: crypto, smplWriter: writer)) - .deleteScope((messages) async { - // Write local conversation key - final conversation = Conversation() - ..profile = activeAccountInfo.account.profile - ..identityMasterJson = - jsonEncode(activeAccountInfo.localAccount.identityMaster.toJson()) - ..messages = messages.record.key.toProto(); - - // - final update = await localConversation.tryWriteProtobuf( - Conversation.fromBuffer, conversation); - if (update != null) { - throw Exception('Failed to write local conversation'); - } - return await callback(localConversation); - }); - }); -} - -Future readRemoteConversation({ - required ActiveAccountInfo activeAccountInfo, - required TypedKey remoteConversationRecordKey, - required TypedKey remoteIdentityPublicKey, -}) async { - final accountRecordKey = - activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey; - final pool = await DHTRecordPool.instance(); - - final crypto = await getConversationCrypto( - activeAccountInfo: activeAccountInfo, - remoteIdentityPublicKey: remoteIdentityPublicKey); - return (await pool.openRead(remoteConversationRecordKey, - parent: accountRecordKey, crypto: crypto)) - .scope((remoteConversation) async { - // - final conversation = - await remoteConversation.getProtobuf(Conversation.fromBuffer); - return conversation; - }); -} - -Future writeLocalConversation({ - required ActiveAccountInfo activeAccountInfo, - required TypedKey localConversationRecordKey, - required TypedKey remoteIdentityPublicKey, - required Conversation conversation, -}) async { - final accountRecordKey = - activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey; - final pool = await DHTRecordPool.instance(); - - final crypto = await getConversationCrypto( - activeAccountInfo: activeAccountInfo, - remoteIdentityPublicKey: remoteIdentityPublicKey); - final writer = getConversationWriter(activeAccountInfo: activeAccountInfo); - - return (await pool.openWrite(localConversationRecordKey, writer, - parent: accountRecordKey, crypto: crypto)) - .scope((localConversation) async { - // - final update = await localConversation.tryWriteProtobuf( - Conversation.fromBuffer, conversation); - if (update != null) { - return update; - } - return null; - }); -} - -Future readLocalConversation({ - required ActiveAccountInfo activeAccountInfo, - required TypedKey localConversationRecordKey, - required TypedKey remoteIdentityPublicKey, -}) async { - final accountRecordKey = - activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey; - final pool = await DHTRecordPool.instance(); - - final crypto = await getConversationCrypto( - activeAccountInfo: activeAccountInfo, - remoteIdentityPublicKey: remoteIdentityPublicKey); - - return (await pool.openRead(localConversationRecordKey, - parent: accountRecordKey, crypto: crypto)) - .scope((localConversation) async { - // - final update = await localConversation.getProtobuf(Conversation.fromBuffer); - if (update != null) { - return update; - } - return null; - }); -} - -Future addLocalConversationMessage( - {required ActiveAccountInfo activeAccountInfo, - required TypedKey localConversationRecordKey, - required TypedKey remoteIdentityPublicKey, - required Message message}) async { - final conversation = await readLocalConversation( - activeAccountInfo: activeAccountInfo, - localConversationRecordKey: localConversationRecordKey, - remoteIdentityPublicKey: remoteIdentityPublicKey); - if (conversation == null) { - return; - } - final messagesRecordKey = - proto.TypedKeyProto.fromProto(conversation.messages); - final crypto = await getConversationCrypto( - activeAccountInfo: activeAccountInfo, - remoteIdentityPublicKey: remoteIdentityPublicKey); - final writer = getConversationWriter(activeAccountInfo: activeAccountInfo); - - await (await DHTShortArray.openWrite(messagesRecordKey, writer, - parent: localConversationRecordKey, crypto: crypto)) - .scope((messages) async { - await messages.tryAddItem(message.writeToBuffer()); - }); -} - -Future mergeLocalConversationMessages( - {required ActiveAccountInfo activeAccountInfo, - required TypedKey localConversationRecordKey, - required TypedKey remoteIdentityPublicKey, - required IList newMessages}) async { - final conversation = await readLocalConversation( - activeAccountInfo: activeAccountInfo, - localConversationRecordKey: localConversationRecordKey, - remoteIdentityPublicKey: remoteIdentityPublicKey); - if (conversation == null) { - return false; - } - var changed = false; - final messagesRecordKey = - proto.TypedKeyProto.fromProto(conversation.messages); - final crypto = await getConversationCrypto( - activeAccountInfo: activeAccountInfo, - remoteIdentityPublicKey: remoteIdentityPublicKey); - final writer = getConversationWriter(activeAccountInfo: activeAccountInfo); - - newMessages = newMessages.sort((a, b) => Timestamp.fromInt64(a.timestamp) - .compareTo(Timestamp.fromInt64(b.timestamp))); - - await (await DHTShortArray.openWrite(messagesRecordKey, writer, - parent: localConversationRecordKey, crypto: crypto)) - .scope((messages) async { - // Ensure newMessages is sorted by timestamp - newMessages = - newMessages.sort((a, b) => a.timestamp.compareTo(b.timestamp)); - - // Existing messages will always be sorted by timestamp so merging is easy - var pos = 0; - outer: - for (final newMessage in newMessages) { - var skip = false; - while (pos < messages.length) { - final m = await messages.getItemProtobuf(proto.Message.fromBuffer, pos); - if (m == null) { - log.error('unable to get message #$pos'); - break outer; - } - - // If timestamp to insert is less than - // the current position, insert it here - final newTs = Timestamp.fromInt64(newMessage.timestamp); - final curTs = Timestamp.fromInt64(m.timestamp); - final cmp = newTs.compareTo(curTs); - if (cmp < 0) { - break; - } else if (cmp == 0) { - skip = true; - break; - } - pos++; - } - // Insert at this position - if (!skip) { - await messages.tryInsertItem(pos, newMessage.writeToBuffer()); - changed = true; - } - } - }); - return changed; -} - -Future?> getLocalConversationMessages({ - required ActiveAccountInfo activeAccountInfo, - required TypedKey localConversationRecordKey, - required TypedKey remoteIdentityPublicKey, -}) async { - final conversation = await readLocalConversation( - activeAccountInfo: activeAccountInfo, - localConversationRecordKey: localConversationRecordKey, - remoteIdentityPublicKey: remoteIdentityPublicKey); - if (conversation == null) { - return null; - } - final messagesRecordKey = - proto.TypedKeyProto.fromProto(conversation.messages); - final crypto = await getConversationCrypto( - activeAccountInfo: activeAccountInfo, - remoteIdentityPublicKey: remoteIdentityPublicKey); - - return (await DHTShortArray.openRead(messagesRecordKey, - parent: localConversationRecordKey, crypto: crypto)) - .scope((messages) async { - var out = IList(); - for (var i = 0; i < messages.length; i++) { - final msg = await messages.getItemProtobuf(Message.fromBuffer, i); - if (msg == null) { - throw Exception('Failed to get message'); - } - out = out.add(msg); - } - return out; - }); -} - -Future?> getRemoteConversationMessages({ - required ActiveAccountInfo activeAccountInfo, - required TypedKey remoteConversationRecordKey, - required TypedKey remoteIdentityPublicKey, -}) async { - final conversation = await readRemoteConversation( - activeAccountInfo: activeAccountInfo, - remoteConversationRecordKey: remoteConversationRecordKey, - remoteIdentityPublicKey: remoteIdentityPublicKey); - if (conversation == null) { - return null; - } - final messagesRecordKey = - proto.TypedKeyProto.fromProto(conversation.messages); - final crypto = await getConversationCrypto( - activeAccountInfo: activeAccountInfo, - remoteIdentityPublicKey: remoteIdentityPublicKey); - - return (await DHTShortArray.openRead(messagesRecordKey, - parent: remoteConversationRecordKey, crypto: crypto)) - .scope((messages) async { - var out = IList(); - for (var i = 0; i < messages.length; i++) { - final msg = await messages.getItemProtobuf(Message.fromBuffer, i); - if (msg == null) { - throw Exception('Failed to get message'); - } - out = out.add(msg); - } - return out; - }); -} - -@riverpod -class ActiveConversationMessages extends _$ActiveConversationMessages { - /// Get message for active conversation - @override - FutureOr?> build() async { - await eventualVeilid.future; - - final activeChat = ref.watch(activeChatStateProvider); - if (activeChat == null) { - return null; - } - - final activeAccountInfo = - await ref.watch(fetchActiveAccountProvider.future); - if (activeAccountInfo == null) { - return null; - } - - final contactList = ref.watch(fetchContactListProvider).asData?.value ?? - const IListConst([]); - - final activeChatContactIdx = contactList.indexWhere( - (c) => - proto.TypedKeyProto.fromProto(c.remoteConversationRecordKey) == - activeChat, - ); - if (activeChatContactIdx == -1) { - return null; - } - final activeChatContact = contactList[activeChatContactIdx]; - final remoteIdentityPublicKey = - proto.TypedKeyProto.fromProto(activeChatContact.identityPublicKey); - // final remoteConversationRecordKey = proto.TypedKeyProto.fromProto( - // activeChatContact.remoteConversationRecordKey); - final localConversationRecordKey = proto.TypedKeyProto.fromProto( - activeChatContact.localConversationRecordKey); - - return await getLocalConversationMessages( - activeAccountInfo: activeAccountInfo, - localConversationRecordKey: localConversationRecordKey, - remoteIdentityPublicKey: remoteIdentityPublicKey, - ); - } -} diff --git a/lib/providers/conversation.g.dart b/lib/providers/conversation.g.dart deleted file mode 100644 index fcf007c..0000000 --- a/lib/providers/conversation.g.dart +++ /dev/null @@ -1,28 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'conversation.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -String _$activeConversationMessagesHash() => - r'61c9e16f1304c7929a971ec7711d2b6c7cadc5ea'; - -/// See also [ActiveConversationMessages]. -@ProviderFor(ActiveConversationMessages) -final activeConversationMessagesProvider = AutoDisposeAsyncNotifierProvider< - ActiveConversationMessages, IList?>.internal( - ActiveConversationMessages.new, - name: r'activeConversationMessagesProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$activeConversationMessagesHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef _$ActiveConversationMessages - = AutoDisposeAsyncNotifier?>; -// ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/lib/providers/local_accounts.dart b/lib/providers/local_accounts.dart deleted file mode 100644 index c7c793d..0000000 --- a/lib/providers/local_accounts.dart +++ /dev/null @@ -1,167 +0,0 @@ -import 'dart:async'; - -import 'package:fast_immutable_collections/fast_immutable_collections.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -import '../entities/entities.dart'; -import '../proto/proto.dart' as proto; -import '../tools/tools.dart'; -import '../veilid_init.dart'; -import '../veilid_support/veilid_support.dart'; -import 'logins.dart'; - -part 'local_accounts.g.dart'; - -const String veilidChatAccountKey = 'com.veilid.veilidchat'; - -// Local account manager -@riverpod -class LocalAccounts extends _$LocalAccounts - with AsyncTableDBBacked> { - ////////////////////////////////////////////////////////////// - /// AsyncTableDBBacked - @override - String tableName() => 'local_account_manager'; - @override - String tableKeyName() => 'local_accounts'; - @override - IList valueFromJson(Object? obj) => obj != null - ? IList.fromJson( - obj, genericFromJson(LocalAccount.fromJson)) - : IList(); - @override - Object? valueToJson(IList val) => - val.toJson((la) => la.toJson()); - - /// Get all local account information - @override - FutureOr> build() async { - try { - await eventualVeilid.future; - return await load(); - } on Exception catch (e) { - log.error('Failed to load LocalAccounts table: $e', e); - return const IListConst([]); - } - } - - ////////////////////////////////////////////////////////////// - /// Mutators and Selectors - - /// Reorder accounts - Future reorderAccount(int oldIndex, int newIndex) async { - final localAccounts = state.requireValue; - final removedItem = Output(); - final updated = localAccounts - .removeAt(oldIndex, removedItem) - .insert(newIndex, removedItem.value!); - await store(updated); - state = AsyncValue.data(updated); - } - - /// Creates a new Account associated with master identity - /// Adds a logged-out LocalAccount to track its existence on this device - Future newLocalAccount( - {required IdentityMaster identityMaster, - required SecretKey identitySecret, - required String name, - required String pronouns, - EncryptionKeyType encryptionKeyType = EncryptionKeyType.none, - String encryptionKey = ''}) async { - final localAccounts = state.requireValue; - - // Add account with profile to DHT - await identityMaster.addAccountToIdentity( - identitySecret: identitySecret, - accountKey: veilidChatAccountKey, - createAccountCallback: (parent) async { - // Make empty contact list - final contactList = await (await DHTShortArray.create(parent: parent)) - .scope((r) async => r.record.ownedDHTRecordPointer); - - // Make empty contact invitation record list - final contactInvitationRecords = - await (await DHTShortArray.create(parent: parent)) - .scope((r) async => r.record.ownedDHTRecordPointer); - - // Make empty chat record list - final chatRecords = await (await DHTShortArray.create(parent: parent)) - .scope((r) async => r.record.ownedDHTRecordPointer); - - // Make account object - final account = proto.Account() - ..profile = (proto.Profile() - ..name = name - ..pronouns = pronouns) - ..contactList = contactList.toProto() - ..contactInvitationRecords = contactInvitationRecords.toProto() - ..chatList = chatRecords.toProto(); - return account; - }); - - // Encrypt identitySecret with key - final identitySecretBytes = await encryptSecretToBytes( - secret: identitySecret, - cryptoKind: identityMaster.identityRecordKey.kind, - encryptionKey: encryptionKey, - encryptionKeyType: encryptionKeyType); - - // Create local account object - // Does not contain the account key or its secret - // as that is not to be persisted, and only pulled from the identity key - // and optionally decrypted with the unlock password - final localAccount = LocalAccount( - identityMaster: identityMaster, - identitySecretBytes: identitySecretBytes, - encryptionKeyType: encryptionKeyType, - biometricsEnabled: false, - hiddenAccount: false, - name: name, - ); - - // Add local account object to internal store - final newLocalAccounts = localAccounts.add(localAccount); - await store(newLocalAccounts); - state = AsyncValue.data(newLocalAccounts); - - // Return local account object - return localAccount; - } - - /// Remove an account and wipe the messages for this account from this device - Future deleteLocalAccount(TypedKey accountMasterRecordKey) async { - final logins = ref.read(loginsProvider.notifier); - await logins.logout(accountMasterRecordKey); - - final localAccounts = state.requireValue; - final updated = localAccounts.removeWhere( - (la) => la.identityMaster.masterRecordKey == accountMasterRecordKey); - await store(updated); - state = AsyncValue.data(updated); - - // TO DO: wipe messages - - return true; - } - - /// Import an account from another VeilidChat instance - - /// Recover an account with the master identity secret - - /// Delete an account from all devices -} - -@riverpod -Future fetchLocalAccount(FetchLocalAccountRef ref, - {required TypedKey accountMasterRecordKey}) async { - final localAccounts = await ref.watch(localAccountsProvider.future); - try { - return localAccounts.firstWhere( - (e) => e.identityMaster.masterRecordKey == accountMasterRecordKey); - } on Exception catch (e) { - if (e is StateError) { - return null; - } - rethrow; - } -} diff --git a/lib/providers/local_accounts.g.dart b/lib/providers/local_accounts.g.dart deleted file mode 100644 index 026ddcc..0000000 --- a/lib/providers/local_accounts.g.dart +++ /dev/null @@ -1,179 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'local_accounts.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -String _$fetchLocalAccountHash() => r'e9f8ea0dd15031cc8145532e9cac73ab7f0f81be'; - -/// Copied from Dart SDK -class _SystemHash { - _SystemHash._(); - - static int combine(int hash, int value) { - // ignore: parameter_assignments - hash = 0x1fffffff & (hash + value); - // ignore: parameter_assignments - hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); - return hash ^ (hash >> 6); - } - - static int finish(int hash) { - // ignore: parameter_assignments - hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); - // ignore: parameter_assignments - hash = hash ^ (hash >> 11); - return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); - } -} - -/// See also [fetchLocalAccount]. -@ProviderFor(fetchLocalAccount) -const fetchLocalAccountProvider = FetchLocalAccountFamily(); - -/// See also [fetchLocalAccount]. -class FetchLocalAccountFamily extends Family> { - /// See also [fetchLocalAccount]. - const FetchLocalAccountFamily(); - - /// See also [fetchLocalAccount]. - FetchLocalAccountProvider call({ - required Typed accountMasterRecordKey, - }) { - return FetchLocalAccountProvider( - accountMasterRecordKey: accountMasterRecordKey, - ); - } - - @override - FetchLocalAccountProvider getProviderOverride( - covariant FetchLocalAccountProvider provider, - ) { - return call( - accountMasterRecordKey: provider.accountMasterRecordKey, - ); - } - - static const Iterable? _dependencies = null; - - @override - Iterable? get dependencies => _dependencies; - - static const Iterable? _allTransitiveDependencies = null; - - @override - Iterable? get allTransitiveDependencies => - _allTransitiveDependencies; - - @override - String? get name => r'fetchLocalAccountProvider'; -} - -/// See also [fetchLocalAccount]. -class FetchLocalAccountProvider - extends AutoDisposeFutureProvider { - /// See also [fetchLocalAccount]. - FetchLocalAccountProvider({ - required Typed accountMasterRecordKey, - }) : this._internal( - (ref) => fetchLocalAccount( - ref as FetchLocalAccountRef, - accountMasterRecordKey: accountMasterRecordKey, - ), - from: fetchLocalAccountProvider, - name: r'fetchLocalAccountProvider', - debugGetCreateSourceHash: - const bool.fromEnvironment('dart.vm.product') - ? null - : _$fetchLocalAccountHash, - dependencies: FetchLocalAccountFamily._dependencies, - allTransitiveDependencies: - FetchLocalAccountFamily._allTransitiveDependencies, - accountMasterRecordKey: accountMasterRecordKey, - ); - - FetchLocalAccountProvider._internal( - super._createNotifier, { - required super.name, - required super.dependencies, - required super.allTransitiveDependencies, - required super.debugGetCreateSourceHash, - required super.from, - required this.accountMasterRecordKey, - }) : super.internal(); - - final Typed accountMasterRecordKey; - - @override - Override overrideWith( - FutureOr Function(FetchLocalAccountRef provider) create, - ) { - return ProviderOverride( - origin: this, - override: FetchLocalAccountProvider._internal( - (ref) => create(ref as FetchLocalAccountRef), - from: from, - name: null, - dependencies: null, - allTransitiveDependencies: null, - debugGetCreateSourceHash: null, - accountMasterRecordKey: accountMasterRecordKey, - ), - ); - } - - @override - AutoDisposeFutureProviderElement createElement() { - return _FetchLocalAccountProviderElement(this); - } - - @override - bool operator ==(Object other) { - return other is FetchLocalAccountProvider && - other.accountMasterRecordKey == accountMasterRecordKey; - } - - @override - int get hashCode { - var hash = _SystemHash.combine(0, runtimeType.hashCode); - hash = _SystemHash.combine(hash, accountMasterRecordKey.hashCode); - - return _SystemHash.finish(hash); - } -} - -mixin FetchLocalAccountRef on AutoDisposeFutureProviderRef { - /// The parameter `accountMasterRecordKey` of this provider. - Typed get accountMasterRecordKey; -} - -class _FetchLocalAccountProviderElement - extends AutoDisposeFutureProviderElement - with FetchLocalAccountRef { - _FetchLocalAccountProviderElement(super.provider); - - @override - Typed get accountMasterRecordKey => - (origin as FetchLocalAccountProvider).accountMasterRecordKey; -} - -String _$localAccountsHash() => r'f19ec560b585d353219be82bc383b2c091660c53'; - -/// See also [LocalAccounts]. -@ProviderFor(LocalAccounts) -final localAccountsProvider = AutoDisposeAsyncNotifierProvider>.internal( - LocalAccounts.new, - name: r'localAccountsProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$localAccountsHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef _$LocalAccounts = AutoDisposeAsyncNotifier>; -// ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/lib/providers/logins.dart b/lib/providers/logins.dart deleted file mode 100644 index 2617d04..0000000 --- a/lib/providers/logins.dart +++ /dev/null @@ -1,150 +0,0 @@ -import 'dart:async'; - -import 'package:fast_immutable_collections/fast_immutable_collections.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -import '../entities/entities.dart'; -import '../tools/tools.dart'; -import '../veilid_init.dart'; -import '../veilid_support/veilid_support.dart'; -import 'local_accounts.dart'; - -part 'logins.g.dart'; - -// Local account manager -@riverpod -class Logins extends _$Logins with AsyncTableDBBacked { - ////////////////////////////////////////////////////////////// - /// AsyncTableDBBacked - @override - String tableName() => 'local_account_manager'; - @override - String tableKeyName() => 'active_logins'; - @override - ActiveLogins valueFromJson(Object? obj) => obj != null - ? ActiveLogins.fromJson(obj as Map) - : ActiveLogins.empty(); - @override - Object? valueToJson(ActiveLogins val) => val.toJson(); - - /// Get all local account information - @override - FutureOr build() async { - try { - await eventualVeilid.future; - return await load(); - } on Exception catch (e) { - log.error('Failed to load ActiveLogins table: $e', e); - return const ActiveLogins(userLogins: IListConst([])); - } - } - - ////////////////////////////////////////////////////////////// - /// Mutators and Selectors - - Future switchToAccount(TypedKey? accountMasterRecordKey) async { - final current = state.requireValue; - if (accountMasterRecordKey != null) { - // Assert the specified record key can be found, will throw if not - final _ = current.userLogins.firstWhere( - (ul) => ul.accountMasterRecordKey == accountMasterRecordKey); - } - final updated = current.copyWith(activeUserLogin: accountMasterRecordKey); - await store(updated); - state = AsyncValue.data(updated); - } - - Future _decryptedLogin( - IdentityMaster identityMaster, SecretKey identitySecret) async { - final veilid = await eventualVeilid.future; - final cs = - await veilid.getCryptoSystem(identityMaster.identityRecordKey.kind); - final keyOk = await cs.validateKeyPair( - identityMaster.identityPublicKey, identitySecret); - if (!keyOk) { - throw Exception('Identity is corrupted'); - } - - // Read the identity key to get the account keys - final accountRecordInfo = await identityMaster.readAccountFromIdentity( - identitySecret: identitySecret, accountKey: veilidChatAccountKey); - - // Add to user logins and select it - final current = state.requireValue; - final now = veilid.now(); - final updated = current.copyWith( - userLogins: current.userLogins.replaceFirstWhere( - (ul) => ul.accountMasterRecordKey == identityMaster.masterRecordKey, - (ul) => ul != null - ? ul.copyWith(lastActive: now) - : UserLogin( - accountMasterRecordKey: identityMaster.masterRecordKey, - identitySecret: - TypedSecret(kind: cs.kind(), value: identitySecret), - accountRecordInfo: accountRecordInfo, - lastActive: now), - addIfNotFound: true), - activeUserLogin: identityMaster.masterRecordKey); - await store(updated); - state = AsyncValue.data(updated); - - return true; - } - - Future login(TypedKey accountMasterRecordKey, - EncryptionKeyType encryptionKeyType, String encryptionKey) async { - final localAccounts = ref.read(localAccountsProvider).requireValue; - - // Get account, throws if not found - final localAccount = localAccounts.firstWhere( - (la) => la.identityMaster.masterRecordKey == accountMasterRecordKey); - - // Log in with this local account - - // Derive key from password - if (localAccount.encryptionKeyType != encryptionKeyType) { - throw Exception('Wrong authentication type'); - } - - final identitySecret = await decryptSecretFromBytes( - secretBytes: localAccount.identitySecretBytes, - cryptoKind: localAccount.identityMaster.identityRecordKey.kind, - encryptionKeyType: localAccount.encryptionKeyType, - encryptionKey: encryptionKey, - ); - - // Validate this secret with the identity public key and log in - return _decryptedLogin(localAccount.identityMaster, identitySecret); - } - - Future logout(TypedKey? accountMasterRecordKey) async { - final current = state.requireValue; - final logoutUser = accountMasterRecordKey ?? current.activeUserLogin; - if (logoutUser == null) { - return; - } - final updated = current.copyWith( - activeUserLogin: current.activeUserLogin == logoutUser - ? null - : current.activeUserLogin, - userLogins: current.userLogins - .removeWhere((ul) => ul.accountMasterRecordKey == logoutUser)); - await store(updated); - state = AsyncValue.data(updated); - } -} - -@riverpod -Future fetchLogin(FetchLoginRef ref, - {required TypedKey accountMasterRecordKey}) async { - final activeLogins = await ref.watch(loginsProvider.future); - try { - return activeLogins.userLogins - .firstWhere((e) => e.accountMasterRecordKey == accountMasterRecordKey); - } on Exception catch (e) { - if (e is StateError) { - return null; - } - rethrow; - } -} diff --git a/lib/providers/logins.g.dart b/lib/providers/logins.g.dart deleted file mode 100644 index e4eee2e..0000000 --- a/lib/providers/logins.g.dart +++ /dev/null @@ -1,176 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'logins.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -String _$fetchLoginHash() => r'cfe13f5152f1275e6eccc698142abfd98170d9b9'; - -/// Copied from Dart SDK -class _SystemHash { - _SystemHash._(); - - static int combine(int hash, int value) { - // ignore: parameter_assignments - hash = 0x1fffffff & (hash + value); - // ignore: parameter_assignments - hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); - return hash ^ (hash >> 6); - } - - static int finish(int hash) { - // ignore: parameter_assignments - hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); - // ignore: parameter_assignments - hash = hash ^ (hash >> 11); - return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); - } -} - -/// See also [fetchLogin]. -@ProviderFor(fetchLogin) -const fetchLoginProvider = FetchLoginFamily(); - -/// See also [fetchLogin]. -class FetchLoginFamily extends Family> { - /// See also [fetchLogin]. - const FetchLoginFamily(); - - /// See also [fetchLogin]. - FetchLoginProvider call({ - required Typed accountMasterRecordKey, - }) { - return FetchLoginProvider( - accountMasterRecordKey: accountMasterRecordKey, - ); - } - - @override - FetchLoginProvider getProviderOverride( - covariant FetchLoginProvider provider, - ) { - return call( - accountMasterRecordKey: provider.accountMasterRecordKey, - ); - } - - static const Iterable? _dependencies = null; - - @override - Iterable? get dependencies => _dependencies; - - static const Iterable? _allTransitiveDependencies = null; - - @override - Iterable? get allTransitiveDependencies => - _allTransitiveDependencies; - - @override - String? get name => r'fetchLoginProvider'; -} - -/// See also [fetchLogin]. -class FetchLoginProvider extends AutoDisposeFutureProvider { - /// See also [fetchLogin]. - FetchLoginProvider({ - required Typed accountMasterRecordKey, - }) : this._internal( - (ref) => fetchLogin( - ref as FetchLoginRef, - accountMasterRecordKey: accountMasterRecordKey, - ), - from: fetchLoginProvider, - name: r'fetchLoginProvider', - debugGetCreateSourceHash: - const bool.fromEnvironment('dart.vm.product') - ? null - : _$fetchLoginHash, - dependencies: FetchLoginFamily._dependencies, - allTransitiveDependencies: - FetchLoginFamily._allTransitiveDependencies, - accountMasterRecordKey: accountMasterRecordKey, - ); - - FetchLoginProvider._internal( - super._createNotifier, { - required super.name, - required super.dependencies, - required super.allTransitiveDependencies, - required super.debugGetCreateSourceHash, - required super.from, - required this.accountMasterRecordKey, - }) : super.internal(); - - final Typed accountMasterRecordKey; - - @override - Override overrideWith( - FutureOr Function(FetchLoginRef provider) create, - ) { - return ProviderOverride( - origin: this, - override: FetchLoginProvider._internal( - (ref) => create(ref as FetchLoginRef), - from: from, - name: null, - dependencies: null, - allTransitiveDependencies: null, - debugGetCreateSourceHash: null, - accountMasterRecordKey: accountMasterRecordKey, - ), - ); - } - - @override - AutoDisposeFutureProviderElement createElement() { - return _FetchLoginProviderElement(this); - } - - @override - bool operator ==(Object other) { - return other is FetchLoginProvider && - other.accountMasterRecordKey == accountMasterRecordKey; - } - - @override - int get hashCode { - var hash = _SystemHash.combine(0, runtimeType.hashCode); - hash = _SystemHash.combine(hash, accountMasterRecordKey.hashCode); - - return _SystemHash.finish(hash); - } -} - -mixin FetchLoginRef on AutoDisposeFutureProviderRef { - /// The parameter `accountMasterRecordKey` of this provider. - Typed get accountMasterRecordKey; -} - -class _FetchLoginProviderElement - extends AutoDisposeFutureProviderElement with FetchLoginRef { - _FetchLoginProviderElement(super.provider); - - @override - Typed get accountMasterRecordKey => - (origin as FetchLoginProvider).accountMasterRecordKey; -} - -String _$loginsHash() => r'2660f71bb7903464187a93fba5c07e22041e8c40'; - -/// See also [Logins]. -@ProviderFor(Logins) -final loginsProvider = - AutoDisposeAsyncNotifierProvider.internal( - Logins.new, - name: r'loginsProvider', - debugGetCreateSourceHash: - const bool.fromEnvironment('dart.vm.product') ? null : _$loginsHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef _$Logins = AutoDisposeAsyncNotifier; -// ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/lib/providers/window_control.dart b/lib/providers/window_control.dart deleted file mode 100644 index 56116f1..0000000 --- a/lib/providers/window_control.dart +++ /dev/null @@ -1,83 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; -import 'package:window_manager/window_manager.dart'; - -import '../tools/responsive.dart'; - -export 'package:window_manager/window_manager.dart' show TitleBarStyle; - -part 'window_control.g.dart'; - -enum OrientationCapability { - normal, - portraitOnly, - landscapeOnly, -} - -// Window Control -@riverpod -class WindowControl extends _$WindowControl { - /// Change window control - @override - FutureOr build() async { - await _doWindowSetup(TitleBarStyle.hidden, OrientationCapability.normal); - return true; - } - - static Future initialize() async { - if (isDesktop) { - await windowManager.ensureInitialized(); - - const windowOptions = WindowOptions( - size: Size(768, 1024), - //minimumSize: Size(480, 480), - center: true, - backgroundColor: Colors.transparent, - skipTaskbar: false, - ); - await windowManager.waitUntilReadyToShow(windowOptions, () async { - await windowManager.show(); - await windowManager.focus(); - }); - } - } - - Future _doWindowSetup(TitleBarStyle titleBarStyle, - OrientationCapability orientationCapability) async { - if (isDesktop) { - await windowManager.setTitleBarStyle(titleBarStyle); - } else { - switch (orientationCapability) { - case OrientationCapability.normal: - await SystemChrome.setPreferredOrientations([ - DeviceOrientation.portraitUp, - DeviceOrientation.landscapeLeft, - DeviceOrientation.landscapeRight, - ]); - case OrientationCapability.portraitOnly: - await SystemChrome.setPreferredOrientations([ - DeviceOrientation.portraitUp, - ]); - case OrientationCapability.landscapeOnly: - await SystemChrome.setPreferredOrientations([ - DeviceOrientation.landscapeLeft, - DeviceOrientation.landscapeRight, - ]); - } - } - } - - ////////////////////////////////////////////////////////////// - /// Mutators and Selectors - - /// Reorder accounts - Future changeWindowSetup(TitleBarStyle titleBarStyle, - OrientationCapability orientationCapability) async { - state = const AsyncValue.loading(); - await _doWindowSetup(titleBarStyle, orientationCapability); - state = const AsyncValue.data(true); - } -} diff --git a/lib/providers/window_control.g.dart b/lib/providers/window_control.g.dart deleted file mode 100644 index d093cf4..0000000 --- a/lib/providers/window_control.g.dart +++ /dev/null @@ -1,26 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'window_control.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -String _$windowControlHash() => r'c6afcbe1d4bfcfc580c30393aac60624c5ceabe0'; - -/// See also [WindowControl]. -@ProviderFor(WindowControl) -final windowControlProvider = - AutoDisposeAsyncNotifierProvider.internal( - WindowControl.new, - name: r'windowControlProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$windowControlHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef _$WindowControl = AutoDisposeAsyncNotifier; -// ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/lib/router/cubit/router_cubit.dart b/lib/router/cubit/router_cubit.dart new file mode 100644 index 0000000..296e448 --- /dev/null +++ b/lib/router/cubit/router_cubit.dart @@ -0,0 +1,176 @@ +import 'dart:async'; + +import 'package:bloc/bloc.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:go_router/go_router.dart'; +import 'package:stream_transform/stream_transform.dart'; + +import '../../../account_manager/account_manager.dart'; +import '../../init.dart'; +import '../../layout/layout.dart'; +import '../../settings/settings.dart'; +import '../../tools/tools.dart'; +import '../../veilid_processor/views/developer.dart'; + +part 'router_cubit.freezed.dart'; +part 'router_cubit.g.dart'; +part 'router_state.dart'; + +final _rootNavKey = GlobalKey(debugLabel: 'rootNavKey'); +final _homeNavKey = GlobalKey(debugLabel: 'homeNavKey'); + +class RouterCubit extends Cubit { + RouterCubit(AccountRepository accountRepository) + : super(const RouterState( + isInitialized: false, + hasAnyAccount: false, + hasActiveChat: false, + )) { + // Watch for changes that the router will care about + Future.delayed(Duration.zero, () async { + await eventualInitialized.future; + emit(state.copyWith( + isInitialized: true, + hasAnyAccount: accountRepository.getLocalAccounts().isNotEmpty)); + }); + + // Subscribe to repository streams + _accountRepositorySubscription = accountRepository.stream.listen((event) { + switch (event) { + case AccountRepositoryChange.localAccounts: + emit(state.copyWith( + hasAnyAccount: accountRepository.getLocalAccounts().isNotEmpty)); + break; + case AccountRepositoryChange.userLogins: + case AccountRepositoryChange.activeLocalAccount: + break; + } + }); + } + + void setHasActiveChat(bool active) { + emit(state.copyWith(hasActiveChat: active)); + } + + @override + Future close() async { + await _accountRepositorySubscription.cancel(); + await super.close(); + } + + /// Our application routes + List get routes => [ + GoRoute( + path: '/', + builder: (context, state) => const IndexPage(), + ), + ShellRoute( + navigatorKey: _homeNavKey, + builder: (context, state, child) => HomeShell( + accountReadyBuilder: Builder( + builder: (context) => + HomeAccountReadyShell(context: context, child: child))), + routes: [ + GoRoute( + path: '/home', + builder: (context, state) => const HomeAccountReadyMain(), + ), + GoRoute( + path: '/home/chat', + builder: (context, state) => const HomeAccountReadyChat(), + ), + ], + ), + GoRoute( + path: '/new_account', + builder: (context, state) => const NewAccountPage(), + ), + GoRoute( + path: '/settings', + builder: (context, state) => const SettingsPage(), + ), + GoRoute( + path: '/developer', + builder: (context, state) => const DeveloperPage(), + ) + ]; + + /// Redirects when our state changes + String? redirect(BuildContext context, GoRouterState goRouterState) { + // No matter where we are, if there's not + + switch (goRouterState.matchedLocation) { + case '/': + + // Wait for initialization to complete + if (!eventualInitialized.isCompleted) { + return null; + } + + return state.hasAnyAccount ? '/home' : '/new_account'; + case '/new_account': + return state.hasAnyAccount ? '/home' : null; + case '/home': + if (!state.hasAnyAccount) { + return '/new_account'; + } + if (responsiveVisibility( + context: context, + tablet: false, + tabletLandscape: false, + desktop: false)) { + if (state.hasActiveChat) { + return '/home/chat'; + } + } + return null; + case '/home/chat': + if (!state.hasAnyAccount) { + return '/new_account'; + } + if (responsiveVisibility( + context: context, + tablet: false, + tabletLandscape: false, + desktop: false)) { + if (!state.hasActiveChat) { + return '/home'; + } + } else { + return '/home'; + } + return null; + case '/settings': + return null; + case '/developer': + return null; + default: + return state.hasAnyAccount ? null : '/new_account'; + } + } + + /// Make a GoRouter instance that uses this cubit + GoRouter router() { + final r = _router; + if (r != null) { + return r; + } + return _router = GoRouter( + navigatorKey: _rootNavKey, + refreshListenable: StreamListenable(stream.startWith(state).distinct()), + debugLogDiagnostics: kDebugMode, + initialLocation: '/', + routes: routes, + redirect: redirect, + ); + } + + //////////////// + + late final StreamSubscription + _accountRepositorySubscription; + GoRouter? _router; +} diff --git a/lib/router/cubit/router_cubit.freezed.dart b/lib/router/cubit/router_cubit.freezed.dart new file mode 100644 index 0000000..bbe7a04 --- /dev/null +++ b/lib/router/cubit/router_cubit.freezed.dart @@ -0,0 +1,203 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'router_cubit.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); + +RouterState _$RouterStateFromJson(Map json) { + return _RouterState.fromJson(json); +} + +/// @nodoc +mixin _$RouterState { + bool get isInitialized => throw _privateConstructorUsedError; + bool get hasAnyAccount => throw _privateConstructorUsedError; + bool get hasActiveChat => throw _privateConstructorUsedError; + + Map toJson() => throw _privateConstructorUsedError; + @JsonKey(ignore: true) + $RouterStateCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $RouterStateCopyWith<$Res> { + factory $RouterStateCopyWith( + RouterState value, $Res Function(RouterState) then) = + _$RouterStateCopyWithImpl<$Res, RouterState>; + @useResult + $Res call({bool isInitialized, bool hasAnyAccount, bool hasActiveChat}); +} + +/// @nodoc +class _$RouterStateCopyWithImpl<$Res, $Val extends RouterState> + implements $RouterStateCopyWith<$Res> { + _$RouterStateCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? isInitialized = null, + Object? hasAnyAccount = null, + Object? hasActiveChat = null, + }) { + return _then(_value.copyWith( + isInitialized: null == isInitialized + ? _value.isInitialized + : isInitialized // ignore: cast_nullable_to_non_nullable + as bool, + hasAnyAccount: null == hasAnyAccount + ? _value.hasAnyAccount + : hasAnyAccount // ignore: cast_nullable_to_non_nullable + as bool, + hasActiveChat: null == hasActiveChat + ? _value.hasActiveChat + : hasActiveChat // ignore: cast_nullable_to_non_nullable + as bool, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$RouterStateImplCopyWith<$Res> + implements $RouterStateCopyWith<$Res> { + factory _$$RouterStateImplCopyWith( + _$RouterStateImpl value, $Res Function(_$RouterStateImpl) then) = + __$$RouterStateImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({bool isInitialized, bool hasAnyAccount, bool hasActiveChat}); +} + +/// @nodoc +class __$$RouterStateImplCopyWithImpl<$Res> + extends _$RouterStateCopyWithImpl<$Res, _$RouterStateImpl> + implements _$$RouterStateImplCopyWith<$Res> { + __$$RouterStateImplCopyWithImpl( + _$RouterStateImpl _value, $Res Function(_$RouterStateImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? isInitialized = null, + Object? hasAnyAccount = null, + Object? hasActiveChat = null, + }) { + return _then(_$RouterStateImpl( + isInitialized: null == isInitialized + ? _value.isInitialized + : isInitialized // ignore: cast_nullable_to_non_nullable + as bool, + hasAnyAccount: null == hasAnyAccount + ? _value.hasAnyAccount + : hasAnyAccount // ignore: cast_nullable_to_non_nullable + as bool, + hasActiveChat: null == hasActiveChat + ? _value.hasActiveChat + : hasActiveChat // ignore: cast_nullable_to_non_nullable + as bool, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$RouterStateImpl with DiagnosticableTreeMixin implements _RouterState { + const _$RouterStateImpl( + {required this.isInitialized, + required this.hasAnyAccount, + required this.hasActiveChat}); + + factory _$RouterStateImpl.fromJson(Map json) => + _$$RouterStateImplFromJson(json); + + @override + final bool isInitialized; + @override + final bool hasAnyAccount; + @override + final bool hasActiveChat; + + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { + return 'RouterState(isInitialized: $isInitialized, hasAnyAccount: $hasAnyAccount, hasActiveChat: $hasActiveChat)'; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('type', 'RouterState')) + ..add(DiagnosticsProperty('isInitialized', isInitialized)) + ..add(DiagnosticsProperty('hasAnyAccount', hasAnyAccount)) + ..add(DiagnosticsProperty('hasActiveChat', hasActiveChat)); + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$RouterStateImpl && + (identical(other.isInitialized, isInitialized) || + other.isInitialized == isInitialized) && + (identical(other.hasAnyAccount, hasAnyAccount) || + other.hasAnyAccount == hasAnyAccount) && + (identical(other.hasActiveChat, hasActiveChat) || + other.hasActiveChat == hasActiveChat)); + } + + @JsonKey(ignore: true) + @override + int get hashCode => + Object.hash(runtimeType, isInitialized, hasAnyAccount, hasActiveChat); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$RouterStateImplCopyWith<_$RouterStateImpl> get copyWith => + __$$RouterStateImplCopyWithImpl<_$RouterStateImpl>(this, _$identity); + + @override + Map toJson() { + return _$$RouterStateImplToJson( + this, + ); + } +} + +abstract class _RouterState implements RouterState { + const factory _RouterState( + {required final bool isInitialized, + required final bool hasAnyAccount, + required final bool hasActiveChat}) = _$RouterStateImpl; + + factory _RouterState.fromJson(Map json) = + _$RouterStateImpl.fromJson; + + @override + bool get isInitialized; + @override + bool get hasAnyAccount; + @override + bool get hasActiveChat; + @override + @JsonKey(ignore: true) + _$$RouterStateImplCopyWith<_$RouterStateImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/router/cubit/router_cubit.g.dart b/lib/router/cubit/router_cubit.g.dart new file mode 100644 index 0000000..f67c770 --- /dev/null +++ b/lib/router/cubit/router_cubit.g.dart @@ -0,0 +1,21 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'router_cubit.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$RouterStateImpl _$$RouterStateImplFromJson(Map json) => + _$RouterStateImpl( + isInitialized: json['is_initialized'] as bool, + hasAnyAccount: json['has_any_account'] as bool, + hasActiveChat: json['has_active_chat'] as bool, + ); + +Map _$$RouterStateImplToJson(_$RouterStateImpl instance) => + { + 'is_initialized': instance.isInitialized, + 'has_any_account': instance.hasAnyAccount, + 'has_active_chat': instance.hasActiveChat, + }; diff --git a/lib/router/cubit/router_state.dart b/lib/router/cubit/router_state.dart new file mode 100644 index 0000000..072f797 --- /dev/null +++ b/lib/router/cubit/router_state.dart @@ -0,0 +1,12 @@ +part of 'router_cubit.dart'; + +@freezed +class RouterState with _$RouterState { + const factory RouterState( + {required bool isInitialized, + required bool hasAnyAccount, + required bool hasActiveChat}) = _RouterState; + + factory RouterState.fromJson(dynamic json) => + _$RouterStateFromJson(json as Map); +} diff --git a/lib/router/router.dart b/lib/router/router.dart index d0f2cf5..1867a19 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -1,23 +1 @@ -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -import 'router_notifier.dart'; - -part 'router.g.dart'; - -final _key = GlobalKey(debugLabel: 'routerKey'); - -/// This simple provider caches our GoRouter. -@riverpod -GoRouter router(RouterRef ref) { - final notifier = ref.watch(routerNotifierProvider.notifier); - return GoRouter( - navigatorKey: _key, - refreshListenable: notifier, - debugLogDiagnostics: true, - initialLocation: '/', - routes: notifier.routes, - redirect: notifier.redirect, - ); -} +export 'cubit/router_cubit.dart'; diff --git a/lib/router/router.g.dart b/lib/router/router.g.dart deleted file mode 100644 index b015d4b..0000000 --- a/lib/router/router.g.dart +++ /dev/null @@ -1,26 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'router.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -String _$routerHash() => r'86eecb1955be62ef8e6f6efcec0fa615289cb823'; - -/// This simple provider caches our GoRouter. -/// -/// Copied from [router]. -@ProviderFor(router) -final routerProvider = AutoDisposeProvider.internal( - router, - name: r'routerProvider', - debugGetCreateSourceHash: - const bool.fromEnvironment('dart.vm.product') ? null : _$routerHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef RouterRef = AutoDisposeProviderRef; -// ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/lib/router/router_notifier.dart b/lib/router/router_notifier.dart deleted file mode 100644 index a4f7bb6..0000000 --- a/lib/router/router_notifier.dart +++ /dev/null @@ -1,160 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -import '../pages/chat_only.dart'; -import '../pages/developer.dart'; -import '../pages/home.dart'; -import '../pages/index.dart'; -import '../pages/new_account.dart'; -import '../pages/settings.dart'; -import '../providers/chat.dart'; -import '../providers/local_accounts.dart'; -import '../tools/responsive.dart'; -import '../veilid_init.dart'; - -part 'router_notifier.g.dart'; - -@riverpod -class RouterNotifier extends _$RouterNotifier implements Listenable { - /// GoRouter listener - VoidCallback? routerListener; - - /// Do we need to make or import an account immediately? - bool hasAnyAccount = false; - bool hasActiveChat = false; - - /// AsyncNotifier build - @override - Future build() async { - hasAnyAccount = await ref.watch( - localAccountsProvider.selectAsync((data) => data.isNotEmpty), - ); - hasActiveChat = ref.watch(activeChatStateProvider) != null; - - // When this notifier's state changes, inform GoRouter - ref.listenSelf((_, __) { - if (state.isLoading) { - return; - } - routerListener?.call(); - }); - } - - /// Redirects when our state changes - String? redirect(BuildContext context, GoRouterState state) { - if (this.state.isLoading || this.state.hasError) { - return null; - } - - // No matter where we are, if there's not - switch (state.matchedLocation) { - case '/': - - // Wait for veilid to be initialized - if (!eventualVeilid.isCompleted) { - return null; - } - - return hasAnyAccount ? '/home' : '/new_account'; - case '/new_account': - return hasAnyAccount ? '/home' : null; - case '/home': - if (!hasAnyAccount) { - return '/new_account'; - } - if (responsiveVisibility( - context: context, - tablet: false, - tabletLandscape: false, - desktop: false)) { - if (hasActiveChat) { - return '/home/chat'; - } - } - return null; - case '/home/chat': - if (!hasAnyAccount) { - return '/new_account'; - } - if (responsiveVisibility( - context: context, - tablet: false, - tabletLandscape: false, - desktop: false)) { - if (!hasActiveChat) { - return '/home'; - } - } else { - return '/home'; - } - return null; - case '/home/settings': - case '/new_account/settings': - return null; - case '/developer': - return null; - default: - return hasAnyAccount ? null : '/new_account'; - } - } - - /// Our application routes - List get routes => [ - GoRoute( - path: '/', - builder: (context, state) => const IndexPage(), - ), - GoRoute( - path: '/home', - builder: (context, state) => const HomePage(), - routes: [ - GoRoute( - path: 'settings', - builder: (context, state) => const SettingsPage(), - ), - GoRoute( - path: 'chat', - builder: (context, state) => const ChatOnlyPage(), - ), - ], - ), - GoRoute( - path: '/new_account', - builder: (context, state) => const NewAccountPage(), - routes: [ - GoRoute( - path: 'settings', - builder: (context, state) => const SettingsPage(), - ), - ], - ), - GoRoute( - path: '/developer', - builder: (context, state) => const DeveloperPage(), - ) - ]; - - /////////////////////////////////////////////////////////////////////////// - /// Listenable - - /// Adds [GoRouter]'s listener as specified by its [Listenable]. - /// [GoRouteInformationProvider] uses this method on creation to handle its - /// internal [ChangeNotifier]. - /// Check out the internal implementation of [GoRouter] and - /// [GoRouteInformationProvider] to see this in action. - @override - void addListener(VoidCallback listener) { - routerListener = listener; - } - - /// Removes [GoRouter]'s listener as specified by its [Listenable]. - /// [GoRouteInformationProvider] uses this method when disposing, - /// so that it removes its callback when destroyed. - /// Check out the internal implementation of [GoRouter] and - /// [GoRouteInformationProvider] to see this in action. - @override - void removeListener(VoidCallback listener) { - routerListener = null; - } -} diff --git a/lib/router/router_notifier.g.dart b/lib/router/router_notifier.g.dart deleted file mode 100644 index fe12322..0000000 --- a/lib/router/router_notifier.g.dart +++ /dev/null @@ -1,26 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'router_notifier.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -String _$routerNotifierHash() => r'6f52ed95f090f2d198d358e7526a91511c0a61e5'; - -/// See also [RouterNotifier]. -@ProviderFor(RouterNotifier) -final routerNotifierProvider = - AutoDisposeAsyncNotifierProvider.internal( - RouterNotifier.new, - name: r'routerNotifierProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$routerNotifierHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef _$RouterNotifier = AutoDisposeAsyncNotifier; -// ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/lib/settings/models/models.dart b/lib/settings/models/models.dart new file mode 100644 index 0000000..b7a1cbf --- /dev/null +++ b/lib/settings/models/models.dart @@ -0,0 +1 @@ +export 'preferences.dart'; diff --git a/lib/entities/preferences.dart b/lib/settings/models/preferences.dart similarity index 55% rename from lib/entities/preferences.dart rename to lib/settings/models/preferences.dart index c7d7a4f..8dfcb73 100644 --- a/lib/entities/preferences.dart +++ b/lib/settings/models/preferences.dart @@ -1,22 +1,11 @@ import 'package:change_case/change_case.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; +import '../../theme/theme.dart'; + part 'preferences.freezed.dart'; part 'preferences.g.dart'; -// Theme supports light and dark mode, optionally selected by the -// operating system -enum BrightnessPreference { - system, - light, - dark; - - factory BrightnessPreference.fromJson(dynamic j) => - BrightnessPreference.values.byName((j as String).toCamelCase()); - - String toJson() => name.toPascalCase(); -} - // Lock preference changes how frequently the messenger locks its // interface and requires the identitySecretKey to be entered (pin/password/etc) @freezed @@ -29,49 +18,23 @@ class LockPreference with _$LockPreference { factory LockPreference.fromJson(dynamic json) => _$LockPreferenceFromJson(json as Map); -} -// Theme supports multiple color variants based on 'Radix' -enum ColorPreference { - // Radix Colors - scarlet, - babydoll, - vapor, - gold, - garden, - forest, - arctic, - lapis, - eggplant, - lime, - grim, - // Accessible Colors - contrast; - - factory ColorPreference.fromJson(dynamic j) => - ColorPreference.values.byName((j as String).toCamelCase()); - String toJson() => name.toPascalCase(); + static const LockPreference defaults = LockPreference( + inactivityLockSecs: 0, + lockWhenSwitching: false, + lockWithSystemLock: false, + ); } // Theme supports multiple translations enum LanguagePreference { - englishUS; + englishUs; factory LanguagePreference.fromJson(dynamic j) => LanguagePreference.values.byName((j as String).toCamelCase()); String toJson() => name.toPascalCase(); -} -@freezed -class ThemePreferences with _$ThemePreferences { - const factory ThemePreferences({ - required BrightnessPreference brightnessPreference, - required ColorPreference colorPreference, - required double displayScale, - }) = _ThemePreferences; - - factory ThemePreferences.fromJson(dynamic json) => - _$ThemePreferencesFromJson(json as Map); + static const LanguagePreference defaults = LanguagePreference.englishUs; } // Preferences are stored in a table locally and globally affect all @@ -86,4 +49,9 @@ class Preferences with _$Preferences { factory Preferences.fromJson(dynamic json) => _$PreferencesFromJson(json as Map); + + static const Preferences defaults = Preferences( + themePreferences: ThemePreferences.defaults, + language: LanguagePreference.defaults, + locking: LockPreference.defaults); } diff --git a/lib/entities/preferences.freezed.dart b/lib/settings/models/preferences.freezed.dart similarity index 67% rename from lib/entities/preferences.freezed.dart rename to lib/settings/models/preferences.freezed.dart index 7020dcb..e9667c8 100644 --- a/lib/entities/preferences.freezed.dart +++ b/lib/settings/models/preferences.freezed.dart @@ -12,7 +12,7 @@ part of 'preferences.dart'; T _$identity(T value) => value; final _privateConstructorUsedError = UnsupportedError( - 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods'); + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); LockPreference _$LockPreferenceFromJson(Map json) { return _LockPreference.fromJson(json); @@ -146,7 +146,7 @@ class _$LockPreferenceImpl implements _LockPreference { } @override - bool operator ==(dynamic other) { + bool operator ==(Object other) { return identical(this, other) || (other.runtimeType == runtimeType && other is _$LockPreferenceImpl && @@ -199,192 +199,6 @@ abstract class _LockPreference implements LockPreference { throw _privateConstructorUsedError; } -ThemePreferences _$ThemePreferencesFromJson(Map json) { - return _ThemePreferences.fromJson(json); -} - -/// @nodoc -mixin _$ThemePreferences { - BrightnessPreference get brightnessPreference => - throw _privateConstructorUsedError; - ColorPreference get colorPreference => throw _privateConstructorUsedError; - double get displayScale => throw _privateConstructorUsedError; - - Map toJson() => throw _privateConstructorUsedError; - @JsonKey(ignore: true) - $ThemePreferencesCopyWith get copyWith => - throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $ThemePreferencesCopyWith<$Res> { - factory $ThemePreferencesCopyWith( - ThemePreferences value, $Res Function(ThemePreferences) then) = - _$ThemePreferencesCopyWithImpl<$Res, ThemePreferences>; - @useResult - $Res call( - {BrightnessPreference brightnessPreference, - ColorPreference colorPreference, - double displayScale}); -} - -/// @nodoc -class _$ThemePreferencesCopyWithImpl<$Res, $Val extends ThemePreferences> - implements $ThemePreferencesCopyWith<$Res> { - _$ThemePreferencesCopyWithImpl(this._value, this._then); - - // ignore: unused_field - final $Val _value; - // ignore: unused_field - final $Res Function($Val) _then; - - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? brightnessPreference = null, - Object? colorPreference = null, - Object? displayScale = null, - }) { - return _then(_value.copyWith( - brightnessPreference: null == brightnessPreference - ? _value.brightnessPreference - : brightnessPreference // ignore: cast_nullable_to_non_nullable - as BrightnessPreference, - colorPreference: null == colorPreference - ? _value.colorPreference - : colorPreference // ignore: cast_nullable_to_non_nullable - as ColorPreference, - displayScale: null == displayScale - ? _value.displayScale - : displayScale // ignore: cast_nullable_to_non_nullable - as double, - ) as $Val); - } -} - -/// @nodoc -abstract class _$$ThemePreferencesImplCopyWith<$Res> - implements $ThemePreferencesCopyWith<$Res> { - factory _$$ThemePreferencesImplCopyWith(_$ThemePreferencesImpl value, - $Res Function(_$ThemePreferencesImpl) then) = - __$$ThemePreferencesImplCopyWithImpl<$Res>; - @override - @useResult - $Res call( - {BrightnessPreference brightnessPreference, - ColorPreference colorPreference, - double displayScale}); -} - -/// @nodoc -class __$$ThemePreferencesImplCopyWithImpl<$Res> - extends _$ThemePreferencesCopyWithImpl<$Res, _$ThemePreferencesImpl> - implements _$$ThemePreferencesImplCopyWith<$Res> { - __$$ThemePreferencesImplCopyWithImpl(_$ThemePreferencesImpl _value, - $Res Function(_$ThemePreferencesImpl) _then) - : super(_value, _then); - - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? brightnessPreference = null, - Object? colorPreference = null, - Object? displayScale = null, - }) { - return _then(_$ThemePreferencesImpl( - brightnessPreference: null == brightnessPreference - ? _value.brightnessPreference - : brightnessPreference // ignore: cast_nullable_to_non_nullable - as BrightnessPreference, - colorPreference: null == colorPreference - ? _value.colorPreference - : colorPreference // ignore: cast_nullable_to_non_nullable - as ColorPreference, - displayScale: null == displayScale - ? _value.displayScale - : displayScale // ignore: cast_nullable_to_non_nullable - as double, - )); - } -} - -/// @nodoc -@JsonSerializable() -class _$ThemePreferencesImpl implements _ThemePreferences { - const _$ThemePreferencesImpl( - {required this.brightnessPreference, - required this.colorPreference, - required this.displayScale}); - - factory _$ThemePreferencesImpl.fromJson(Map json) => - _$$ThemePreferencesImplFromJson(json); - - @override - final BrightnessPreference brightnessPreference; - @override - final ColorPreference colorPreference; - @override - final double displayScale; - - @override - String toString() { - return 'ThemePreferences(brightnessPreference: $brightnessPreference, colorPreference: $colorPreference, displayScale: $displayScale)'; - } - - @override - bool operator ==(dynamic other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$ThemePreferencesImpl && - (identical(other.brightnessPreference, brightnessPreference) || - other.brightnessPreference == brightnessPreference) && - (identical(other.colorPreference, colorPreference) || - other.colorPreference == colorPreference) && - (identical(other.displayScale, displayScale) || - other.displayScale == displayScale)); - } - - @JsonKey(ignore: true) - @override - int get hashCode => Object.hash( - runtimeType, brightnessPreference, colorPreference, displayScale); - - @JsonKey(ignore: true) - @override - @pragma('vm:prefer-inline') - _$$ThemePreferencesImplCopyWith<_$ThemePreferencesImpl> get copyWith => - __$$ThemePreferencesImplCopyWithImpl<_$ThemePreferencesImpl>( - this, _$identity); - - @override - Map toJson() { - return _$$ThemePreferencesImplToJson( - this, - ); - } -} - -abstract class _ThemePreferences implements ThemePreferences { - const factory _ThemePreferences( - {required final BrightnessPreference brightnessPreference, - required final ColorPreference colorPreference, - required final double displayScale}) = _$ThemePreferencesImpl; - - factory _ThemePreferences.fromJson(Map json) = - _$ThemePreferencesImpl.fromJson; - - @override - BrightnessPreference get brightnessPreference; - @override - ColorPreference get colorPreference; - @override - double get displayScale; - @override - @JsonKey(ignore: true) - _$$ThemePreferencesImplCopyWith<_$ThemePreferencesImpl> get copyWith => - throw _privateConstructorUsedError; -} - Preferences _$PreferencesFromJson(Map json) { return _Preferences.fromJson(json); } @@ -541,7 +355,7 @@ class _$PreferencesImpl implements _Preferences { } @override - bool operator ==(dynamic other) { + bool operator ==(Object other) { return identical(this, other) || (other.runtimeType == runtimeType && other is _$PreferencesImpl && diff --git a/lib/entities/preferences.g.dart b/lib/settings/models/preferences.g.dart similarity index 67% rename from lib/entities/preferences.g.dart rename to lib/settings/models/preferences.g.dart index 0e6f96c..af010d6 100644 --- a/lib/entities/preferences.g.dart +++ b/lib/settings/models/preferences.g.dart @@ -21,23 +21,6 @@ Map _$$LockPreferenceImplToJson( 'lock_with_system_lock': instance.lockWithSystemLock, }; -_$ThemePreferencesImpl _$$ThemePreferencesImplFromJson( - Map json) => - _$ThemePreferencesImpl( - brightnessPreference: - BrightnessPreference.fromJson(json['brightness_preference']), - colorPreference: ColorPreference.fromJson(json['color_preference']), - displayScale: (json['display_scale'] as num).toDouble(), - ); - -Map _$$ThemePreferencesImplToJson( - _$ThemePreferencesImpl instance) => - { - 'brightness_preference': instance.brightnessPreference.toJson(), - 'color_preference': instance.colorPreference.toJson(), - 'display_scale': instance.displayScale, - }; - _$PreferencesImpl _$$PreferencesImplFromJson(Map json) => _$PreferencesImpl( themePreferences: ThemePreferences.fromJson(json['theme_preferences']), diff --git a/lib/settings/preferences_cubit.dart b/lib/settings/preferences_cubit.dart new file mode 100644 index 0000000..6cfd249 --- /dev/null +++ b/lib/settings/preferences_cubit.dart @@ -0,0 +1,8 @@ +import 'package:bloc_tools/bloc_tools.dart'; + +import 'settings.dart'; + +class PreferencesCubit extends StreamWrapperCubit { + PreferencesCubit(PreferencesRepository repository) + : super(repository.stream, defaultState: repository.value); +} diff --git a/lib/settings/preferences_repository.dart b/lib/settings/preferences_repository.dart new file mode 100644 index 0000000..03f73ba --- /dev/null +++ b/lib/settings/preferences_repository.dart @@ -0,0 +1,36 @@ +import 'dart:async'; + +import 'package:shared_preferences/shared_preferences.dart'; + +import '../tools/tools.dart'; +import 'models/models.dart'; + +class PreferencesRepository { + PreferencesRepository._(); + + late final SharedPreferencesValue _data; + + Preferences get value => _data.requireValue; + Stream get stream => _data.stream; + + ////////////////////////////////////////////////////////////// + /// Singleton initialization + + static PreferencesRepository instance = PreferencesRepository._(); + + Future init() async { + final sharedPreferences = await SharedPreferences.getInstance(); + // ignore: do_not_use_environment + const namespace = String.fromEnvironment('NAMESPACE'); + _data = SharedPreferencesValue( + sharedPreferences: sharedPreferences, + keyName: namespace.isEmpty ? 'preferences' : 'preferences_$namespace', + valueFromJson: (obj) => + obj != null ? Preferences.fromJson(obj) : Preferences.defaults, + valueToJson: (val) => val.toJson()); + await _data.get(); + } + + Future set(Preferences value) => _data.set(value); + Future get() => _data.get(); +} diff --git a/lib/settings/settings.dart b/lib/settings/settings.dart new file mode 100644 index 0000000..b56c1a4 --- /dev/null +++ b/lib/settings/settings.dart @@ -0,0 +1,4 @@ +export 'models/models.dart'; +export 'preferences_cubit.dart'; +export 'preferences_repository.dart'; +export 'settings_page.dart'; diff --git a/lib/settings/settings_page.dart b/lib/settings/settings_page.dart new file mode 100644 index 0000000..821f9c6 --- /dev/null +++ b/lib/settings/settings_page.dart @@ -0,0 +1,64 @@ +import 'package:animated_theme_switcher/animated_theme_switcher.dart'; +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:flutter_translate/flutter_translate.dart'; + +import '../layout/default_app_bar.dart'; +import '../theme/theme.dart'; +import '../tools/tools.dart'; +import '../veilid_processor/veilid_processor.dart'; +import 'settings.dart'; + +class SettingsPage extends StatefulWidget { + const SettingsPage({super.key}); + + @override + SettingsPageState createState() => SettingsPageState(); +} + +class SettingsPageState extends State { + final _formKey = GlobalKey(); + static const String formFieldTheme = 'theme'; + static const String formFieldBrightness = 'brightness'; + + @override + void initState() { + super.initState(); + + WidgetsBinding.instance.addPostFrameCallback((_) async { + await changeWindowSetup( + TitleBarStyle.normal, OrientationCapability.normal); + }); + } + + @override + Widget build(BuildContext context) => + AsyncBlocBuilder( + builder: (context, state) => ThemeSwitchingArea( + child: Scaffold( + // resizeToAvoidBottomInset: false, + appBar: DefaultAppBar( + title: Text(translate('settings_page.titlebar')), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => context.pop(), + ), + actions: [ + const SignalStrengthMeterWidget() + .paddingLTRB(16, 0, 16, 0), + ]), + + body: FormBuilder( + key: _formKey, + child: ListView( + children: [ + buildSettingsPageColorPreferences( + onChanged: () => setState(() {})), + buildSettingsPageBrightnessPreferences( + onChanged: () => setState(() {})), + ], + ), + ).paddingSymmetric(horizontal: 24, vertical: 8), + ))); +} diff --git a/lib/theme/models/models.dart b/lib/theme/models/models.dart new file mode 100644 index 0000000..c22e8ab --- /dev/null +++ b/lib/theme/models/models.dart @@ -0,0 +1,4 @@ +export 'radix_generator.dart'; +export 'scale_color.dart'; +export 'scale_scheme.dart'; +export 'theme_preference.dart'; diff --git a/lib/tools/radix_generator.dart b/lib/theme/models/radix_generator.dart similarity index 99% rename from lib/tools/radix_generator.dart rename to lib/theme/models/radix_generator.dart index b805374..609f923 100644 --- a/lib/tools/radix_generator.dart +++ b/lib/theme/models/radix_generator.dart @@ -2,7 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_chat_ui/flutter_chat_ui.dart'; import 'package:radix_colors/radix_colors.dart'; -import 'theme_service.dart'; +import 'scale_color.dart'; +import 'scale_scheme.dart'; enum RadixThemeColor { scarlet, // tomato + red + violet diff --git a/lib/theme/models/scale_color.dart b/lib/theme/models/scale_color.dart new file mode 100644 index 0000000..1b2f112 --- /dev/null +++ b/lib/theme/models/scale_color.dart @@ -0,0 +1,91 @@ +import 'dart:ui'; + +class ScaleColor { + ScaleColor({ + required this.appBackground, + required this.subtleBackground, + required this.elementBackground, + required this.hoverElementBackground, + required this.activeElementBackground, + required this.subtleBorder, + required this.border, + required this.hoverBorder, + required this.background, + required this.hoverBackground, + required this.subtleText, + required this.text, + }); + + Color appBackground; + Color subtleBackground; + Color elementBackground; + Color hoverElementBackground; + Color activeElementBackground; + Color subtleBorder; + Color border; + Color hoverBorder; + Color background; + Color hoverBackground; + Color subtleText; + Color text; + + ScaleColor copyWith( + {Color? appBackground, + Color? subtleBackground, + Color? elementBackground, + Color? hoverElementBackground, + Color? activeElementBackground, + Color? subtleBorder, + Color? border, + Color? hoverBorder, + Color? background, + Color? hoverBackground, + Color? subtleText, + Color? text}) => + ScaleColor( + appBackground: appBackground ?? this.appBackground, + subtleBackground: subtleBackground ?? this.subtleBackground, + elementBackground: elementBackground ?? this.elementBackground, + hoverElementBackground: + hoverElementBackground ?? this.hoverElementBackground, + activeElementBackground: + activeElementBackground ?? this.activeElementBackground, + subtleBorder: subtleBorder ?? this.subtleBorder, + border: border ?? this.border, + hoverBorder: hoverBorder ?? this.hoverBorder, + background: background ?? this.background, + hoverBackground: hoverBackground ?? this.hoverBackground, + subtleText: subtleText ?? this.subtleText, + text: text ?? this.text, + ); + + // ignore: prefer_constructors_over_static_methods + static ScaleColor lerp(ScaleColor a, ScaleColor b, double t) => ScaleColor( + appBackground: Color.lerp(a.appBackground, b.appBackground, t) ?? + const Color(0x00000000), + subtleBackground: + Color.lerp(a.subtleBackground, b.subtleBackground, t) ?? + const Color(0x00000000), + elementBackground: + Color.lerp(a.elementBackground, b.elementBackground, t) ?? + const Color(0x00000000), + hoverElementBackground: + Color.lerp(a.hoverElementBackground, b.hoverElementBackground, t) ?? + const Color(0x00000000), + activeElementBackground: Color.lerp( + a.activeElementBackground, b.activeElementBackground, t) ?? + const Color(0x00000000), + subtleBorder: Color.lerp(a.subtleBorder, b.subtleBorder, t) ?? + const Color(0x00000000), + border: Color.lerp(a.border, b.border, t) ?? const Color(0x00000000), + hoverBorder: Color.lerp(a.hoverBorder, b.hoverBorder, t) ?? + const Color(0x00000000), + background: Color.lerp(a.background, b.background, t) ?? + const Color(0x00000000), + hoverBackground: Color.lerp(a.hoverBackground, b.hoverBackground, t) ?? + const Color(0x00000000), + subtleText: Color.lerp(a.subtleText, b.subtleText, t) ?? + const Color(0x00000000), + text: Color.lerp(a.text, b.text, t) ?? const Color(0x00000000), + ); +} diff --git a/lib/theme/models/scale_scheme.dart b/lib/theme/models/scale_scheme.dart new file mode 100644 index 0000000..74e51bc --- /dev/null +++ b/lib/theme/models/scale_scheme.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; + +import 'scale_color.dart'; + +class ScaleScheme extends ThemeExtension { + ScaleScheme( + {required this.primaryScale, + required this.primaryAlphaScale, + required this.secondaryScale, + required this.tertiaryScale, + required this.grayScale, + required this.errorScale}); + + final ScaleColor primaryScale; + final ScaleColor primaryAlphaScale; + final ScaleColor secondaryScale; + final ScaleColor tertiaryScale; + final ScaleColor grayScale; + final ScaleColor errorScale; + + @override + ScaleScheme copyWith( + {ScaleColor? primaryScale, + ScaleColor? primaryAlphaScale, + ScaleColor? secondaryScale, + ScaleColor? tertiaryScale, + ScaleColor? grayScale, + ScaleColor? errorScale}) => + ScaleScheme( + primaryScale: primaryScale ?? this.primaryScale, + primaryAlphaScale: primaryAlphaScale ?? this.primaryAlphaScale, + secondaryScale: secondaryScale ?? this.secondaryScale, + tertiaryScale: tertiaryScale ?? this.tertiaryScale, + grayScale: grayScale ?? this.grayScale, + errorScale: errorScale ?? this.errorScale, + ); + + @override + ScaleScheme lerp(ScaleScheme? other, double t) { + if (other is! ScaleScheme) { + return this; + } + return ScaleScheme( + primaryScale: ScaleColor.lerp(primaryScale, other.primaryScale, t), + primaryAlphaScale: + ScaleColor.lerp(primaryAlphaScale, other.primaryAlphaScale, t), + secondaryScale: ScaleColor.lerp(secondaryScale, other.secondaryScale, t), + tertiaryScale: ScaleColor.lerp(tertiaryScale, other.tertiaryScale, t), + grayScale: ScaleColor.lerp(grayScale, other.grayScale, t), + errorScale: ScaleColor.lerp(errorScale, other.errorScale, t), + ); + } +} diff --git a/lib/theme/models/theme_preference.dart b/lib/theme/models/theme_preference.dart new file mode 100644 index 0000000..74c90d8 --- /dev/null +++ b/lib/theme/models/theme_preference.dart @@ -0,0 +1,114 @@ +import 'package:change_case/change_case.dart'; +import 'package:flutter/material.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../tools/tools.dart'; +import 'radix_generator.dart'; + +part 'theme_preference.freezed.dart'; +part 'theme_preference.g.dart'; + +// Theme supports light and dark mode, optionally selected by the +// operating system +enum BrightnessPreference { + system, + light, + dark; + + factory BrightnessPreference.fromJson(dynamic j) => + BrightnessPreference.values.byName((j as String).toCamelCase()); + + String toJson() => name.toPascalCase(); +} + +// Theme supports multiple color variants based on 'Radix' +enum ColorPreference { + // Radix Colors + scarlet, + babydoll, + vapor, + gold, + garden, + forest, + arctic, + lapis, + eggplant, + lime, + grim, + // Accessible Colors + contrast; + + factory ColorPreference.fromJson(dynamic j) => + ColorPreference.values.byName((j as String).toCamelCase()); + String toJson() => name.toPascalCase(); +} + +@freezed +class ThemePreferences with _$ThemePreferences { + const factory ThemePreferences({ + required BrightnessPreference brightnessPreference, + required ColorPreference colorPreference, + required double displayScale, + }) = _ThemePreferences; + + factory ThemePreferences.fromJson(dynamic json) => + _$ThemePreferencesFromJson(json as Map); + + static const ThemePreferences defaults = ThemePreferences( + colorPreference: ColorPreference.vapor, + brightnessPreference: BrightnessPreference.system, + displayScale: 1, + ); +} + +extension ThemePreferencesExt on ThemePreferences { + /// Get material 'ThemeData' for existinb + ThemeData themeData() { + late final Brightness brightness; + switch (brightnessPreference) { + case BrightnessPreference.system: + if (isPlatformDark) { + brightness = Brightness.dark; + } else { + brightness = Brightness.light; + } + case BrightnessPreference.light: + brightness = Brightness.light; + case BrightnessPreference.dark: + brightness = Brightness.dark; + } + + late final ThemeData themeData; + switch (colorPreference) { + // Special cases + case ColorPreference.contrast: + // xxx do contrastGenerator + themeData = radixGenerator(brightness, RadixThemeColor.grim); + // Generate from Radix + case ColorPreference.scarlet: + themeData = radixGenerator(brightness, RadixThemeColor.scarlet); + case ColorPreference.babydoll: + themeData = radixGenerator(brightness, RadixThemeColor.babydoll); + case ColorPreference.vapor: + themeData = radixGenerator(brightness, RadixThemeColor.vapor); + case ColorPreference.gold: + themeData = radixGenerator(brightness, RadixThemeColor.gold); + case ColorPreference.garden: + themeData = radixGenerator(brightness, RadixThemeColor.garden); + case ColorPreference.forest: + themeData = radixGenerator(brightness, RadixThemeColor.forest); + case ColorPreference.arctic: + themeData = radixGenerator(brightness, RadixThemeColor.arctic); + case ColorPreference.lapis: + themeData = radixGenerator(brightness, RadixThemeColor.lapis); + case ColorPreference.eggplant: + themeData = radixGenerator(brightness, RadixThemeColor.eggplant); + case ColorPreference.lime: + themeData = radixGenerator(brightness, RadixThemeColor.lime); + case ColorPreference.grim: + themeData = radixGenerator(brightness, RadixThemeColor.grim); + } + + return themeData; + } +} diff --git a/lib/theme/models/theme_preference.freezed.dart b/lib/theme/models/theme_preference.freezed.dart new file mode 100644 index 0000000..9f10955 --- /dev/null +++ b/lib/theme/models/theme_preference.freezed.dart @@ -0,0 +1,201 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'theme_preference.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); + +ThemePreferences _$ThemePreferencesFromJson(Map json) { + return _ThemePreferences.fromJson(json); +} + +/// @nodoc +mixin _$ThemePreferences { + BrightnessPreference get brightnessPreference => + throw _privateConstructorUsedError; + ColorPreference get colorPreference => throw _privateConstructorUsedError; + double get displayScale => throw _privateConstructorUsedError; + + Map toJson() => throw _privateConstructorUsedError; + @JsonKey(ignore: true) + $ThemePreferencesCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $ThemePreferencesCopyWith<$Res> { + factory $ThemePreferencesCopyWith( + ThemePreferences value, $Res Function(ThemePreferences) then) = + _$ThemePreferencesCopyWithImpl<$Res, ThemePreferences>; + @useResult + $Res call( + {BrightnessPreference brightnessPreference, + ColorPreference colorPreference, + double displayScale}); +} + +/// @nodoc +class _$ThemePreferencesCopyWithImpl<$Res, $Val extends ThemePreferences> + implements $ThemePreferencesCopyWith<$Res> { + _$ThemePreferencesCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? brightnessPreference = null, + Object? colorPreference = null, + Object? displayScale = null, + }) { + return _then(_value.copyWith( + brightnessPreference: null == brightnessPreference + ? _value.brightnessPreference + : brightnessPreference // ignore: cast_nullable_to_non_nullable + as BrightnessPreference, + colorPreference: null == colorPreference + ? _value.colorPreference + : colorPreference // ignore: cast_nullable_to_non_nullable + as ColorPreference, + displayScale: null == displayScale + ? _value.displayScale + : displayScale // ignore: cast_nullable_to_non_nullable + as double, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$ThemePreferencesImplCopyWith<$Res> + implements $ThemePreferencesCopyWith<$Res> { + factory _$$ThemePreferencesImplCopyWith(_$ThemePreferencesImpl value, + $Res Function(_$ThemePreferencesImpl) then) = + __$$ThemePreferencesImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {BrightnessPreference brightnessPreference, + ColorPreference colorPreference, + double displayScale}); +} + +/// @nodoc +class __$$ThemePreferencesImplCopyWithImpl<$Res> + extends _$ThemePreferencesCopyWithImpl<$Res, _$ThemePreferencesImpl> + implements _$$ThemePreferencesImplCopyWith<$Res> { + __$$ThemePreferencesImplCopyWithImpl(_$ThemePreferencesImpl _value, + $Res Function(_$ThemePreferencesImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? brightnessPreference = null, + Object? colorPreference = null, + Object? displayScale = null, + }) { + return _then(_$ThemePreferencesImpl( + brightnessPreference: null == brightnessPreference + ? _value.brightnessPreference + : brightnessPreference // ignore: cast_nullable_to_non_nullable + as BrightnessPreference, + colorPreference: null == colorPreference + ? _value.colorPreference + : colorPreference // ignore: cast_nullable_to_non_nullable + as ColorPreference, + displayScale: null == displayScale + ? _value.displayScale + : displayScale // ignore: cast_nullable_to_non_nullable + as double, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$ThemePreferencesImpl implements _ThemePreferences { + const _$ThemePreferencesImpl( + {required this.brightnessPreference, + required this.colorPreference, + required this.displayScale}); + + factory _$ThemePreferencesImpl.fromJson(Map json) => + _$$ThemePreferencesImplFromJson(json); + + @override + final BrightnessPreference brightnessPreference; + @override + final ColorPreference colorPreference; + @override + final double displayScale; + + @override + String toString() { + return 'ThemePreferences(brightnessPreference: $brightnessPreference, colorPreference: $colorPreference, displayScale: $displayScale)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$ThemePreferencesImpl && + (identical(other.brightnessPreference, brightnessPreference) || + other.brightnessPreference == brightnessPreference) && + (identical(other.colorPreference, colorPreference) || + other.colorPreference == colorPreference) && + (identical(other.displayScale, displayScale) || + other.displayScale == displayScale)); + } + + @JsonKey(ignore: true) + @override + int get hashCode => Object.hash( + runtimeType, brightnessPreference, colorPreference, displayScale); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$ThemePreferencesImplCopyWith<_$ThemePreferencesImpl> get copyWith => + __$$ThemePreferencesImplCopyWithImpl<_$ThemePreferencesImpl>( + this, _$identity); + + @override + Map toJson() { + return _$$ThemePreferencesImplToJson( + this, + ); + } +} + +abstract class _ThemePreferences implements ThemePreferences { + const factory _ThemePreferences( + {required final BrightnessPreference brightnessPreference, + required final ColorPreference colorPreference, + required final double displayScale}) = _$ThemePreferencesImpl; + + factory _ThemePreferences.fromJson(Map json) = + _$ThemePreferencesImpl.fromJson; + + @override + BrightnessPreference get brightnessPreference; + @override + ColorPreference get colorPreference; + @override + double get displayScale; + @override + @JsonKey(ignore: true) + _$$ThemePreferencesImplCopyWith<_$ThemePreferencesImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/theme/models/theme_preference.g.dart b/lib/theme/models/theme_preference.g.dart new file mode 100644 index 0000000..6f33c43 --- /dev/null +++ b/lib/theme/models/theme_preference.g.dart @@ -0,0 +1,24 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'theme_preference.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$ThemePreferencesImpl _$$ThemePreferencesImplFromJson( + Map json) => + _$ThemePreferencesImpl( + brightnessPreference: + BrightnessPreference.fromJson(json['brightness_preference']), + colorPreference: ColorPreference.fromJson(json['color_preference']), + displayScale: (json['display_scale'] as num).toDouble(), + ); + +Map _$$ThemePreferencesImplToJson( + _$ThemePreferencesImpl instance) => + { + 'brightness_preference': instance.brightnessPreference.toJson(), + 'color_preference': instance.colorPreference.toJson(), + 'display_scale': instance.displayScale, + }; diff --git a/lib/theme/theme.dart b/lib/theme/theme.dart new file mode 100644 index 0000000..3e9c176 --- /dev/null +++ b/lib/theme/theme.dart @@ -0,0 +1,2 @@ +export 'models/models.dart'; +export 'views/views.dart'; diff --git a/lib/theme/views/brightness_preferences.dart b/lib/theme/views/brightness_preferences.dart new file mode 100644 index 0000000..0c7ab10 --- /dev/null +++ b/lib/theme/views/brightness_preferences.dart @@ -0,0 +1,45 @@ +import 'package:animated_theme_switcher/animated_theme_switcher.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:flutter_translate/flutter_translate.dart'; + +import '../../settings/settings.dart'; +import '../models/models.dart'; + +const String formFieldBrightness = 'brightness'; + +List> _getBrightnessDropdownItems() { + const brightnessPrefs = BrightnessPreference.values; + final brightnessNames = { + BrightnessPreference.system: translate('brightness.system'), + BrightnessPreference.light: translate('brightness.light'), + BrightnessPreference.dark: translate('brightness.dark') + }; + + return brightnessPrefs + .map((e) => DropdownMenuItem(value: e, child: Text(brightnessNames[e]!))) + .toList(); +} + +Widget buildSettingsPageBrightnessPreferences( + {required void Function() onChanged}) { + final preferencesRepository = PreferencesRepository.instance; + final themePreferences = preferencesRepository.value.themePreferences; + return ThemeSwitcher.withTheme( + builder: (_, switcher, theme) => FormBuilderDropdown( + name: formFieldBrightness, + decoration: InputDecoration( + label: Text(translate('settings_page.brightness_mode'))), + items: _getBrightnessDropdownItems(), + initialValue: themePreferences.brightnessPreference, + onChanged: (value) async { + final newThemePrefs = themePreferences.copyWith( + brightnessPreference: value as BrightnessPreference); + final newPrefs = preferencesRepository.value + .copyWith(themePreferences: newThemePrefs); + + await preferencesRepository.set(newPrefs); + switcher.changeTheme(theme: newThemePrefs.themeData()); + onChanged(); + })); +} diff --git a/lib/theme/views/color_preferences.dart b/lib/theme/views/color_preferences.dart new file mode 100644 index 0000000..a364e00 --- /dev/null +++ b/lib/theme/views/color_preferences.dart @@ -0,0 +1,53 @@ +import 'package:animated_theme_switcher/animated_theme_switcher.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:flutter_translate/flutter_translate.dart'; + +import '../../settings/settings.dart'; +import '../models/models.dart'; + +const String formFieldTheme = 'theme'; + +List> _getThemeDropdownItems() { + const colorPrefs = ColorPreference.values; + final colorNames = { + ColorPreference.scarlet: translate('themes.scarlet'), + ColorPreference.vapor: translate('themes.vapor'), + ColorPreference.babydoll: translate('themes.babydoll'), + ColorPreference.gold: translate('themes.gold'), + ColorPreference.garden: translate('themes.garden'), + ColorPreference.forest: translate('themes.forest'), + ColorPreference.arctic: translate('themes.arctic'), + ColorPreference.lapis: translate('themes.lapis'), + ColorPreference.eggplant: translate('themes.eggplant'), + ColorPreference.lime: translate('themes.lime'), + ColorPreference.grim: translate('themes.grim'), + ColorPreference.contrast: translate('themes.contrast') + }; + + return colorPrefs + .map((e) => DropdownMenuItem(value: e, child: Text(colorNames[e]!))) + .toList(); +} + +Widget buildSettingsPageColorPreferences({required void Function() onChanged}) { + final preferencesRepository = PreferencesRepository.instance; + final themePreferences = preferencesRepository.value.themePreferences; + return ThemeSwitcher.withTheme( + builder: (_, switcher, theme) => FormBuilderDropdown( + name: formFieldTheme, + decoration: InputDecoration( + label: Text(translate('settings_page.color_theme'))), + items: _getThemeDropdownItems(), + initialValue: themePreferences.colorPreference, + onChanged: (value) async { + final newThemePrefs = themePreferences.copyWith( + colorPreference: value as ColorPreference); + final newPrefs = preferencesRepository.value + .copyWith(themePreferences: newThemePrefs); + + await preferencesRepository.set(newPrefs); + switcher.changeTheme(theme: newThemePrefs.themeData()); + onChanged(); + })); +} diff --git a/lib/theme/views/views.dart b/lib/theme/views/views.dart new file mode 100644 index 0000000..85d06c4 --- /dev/null +++ b/lib/theme/views/views.dart @@ -0,0 +1,2 @@ +export 'brightness_preferences.dart'; +export 'color_preferences.dart'; diff --git a/lib/tick.dart b/lib/tick.dart index 9f71a11..99007e7 100644 --- a/lib/tick.dart +++ b/lib/tick.dart @@ -1,24 +1,13 @@ -// XXX Eliminate this when we have ValueChanged import 'dart:async'; -import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:veilid_support/veilid_support.dart'; -import 'proto/proto.dart' as proto; -import 'providers/account.dart'; -import 'providers/chat.dart'; -import 'providers/connection_state.dart'; -import 'providers/contact.dart'; -import 'providers/contact_invite.dart'; -import 'providers/conversation.dart'; -import 'veilid_init.dart'; +import 'init.dart'; +import 'veilid_processor/veilid_processor.dart'; -const int ticksPerContactInvitationCheck = 5; -const int ticksPerNewMessageCheck = 5; - -class BackgroundTicker extends ConsumerStatefulWidget { +class BackgroundTicker extends StatefulWidget { const BackgroundTicker({required this.builder, super.key}); final Widget Function(BuildContext) builder; @@ -33,12 +22,9 @@ class BackgroundTicker extends ConsumerStatefulWidget { } } -class BackgroundTickerState extends ConsumerState { +class BackgroundTickerState extends State { Timer? _tickTimer; bool _inTick = false; - int _contactInvitationCheckTick = 0; - int _newMessageCheckTick = 0; - bool _hasRefreshedContactList = false; @override void initState() { @@ -67,156 +53,21 @@ class BackgroundTickerState extends ConsumerState { } Future _onTick() async { - // Don't tick until veilid is started and attached - if (!eventualVeilid.isCompleted) { + // Don't tick until we are initialized + if (!eventualInitialized.isCompleted) { return; } - if (!connectionState.state.isAttached) { + if (!ProcessorRepository + .instance.processorConnectionState.isPublicInternetReady) { return; } _inTick = true; try { - final unord = >[]; - // If our contact list hasn't been refreshed yet, we need to - // refresh it. This happens every tick until it's non-empty. - // It will not happen until we are attached to Veilid. - if (_hasRefreshedContactList == false) { - unord.add(_doContactListRefresh()); - } - - // Check extant contact invitations once every N seconds - _contactInvitationCheckTick += 1; - if (_contactInvitationCheckTick >= ticksPerContactInvitationCheck) { - _contactInvitationCheckTick = 0; - unord.add(_doContactInvitationCheck()); - } - - // Check new messages once every N seconds - _newMessageCheckTick += 1; - if (_newMessageCheckTick >= ticksPerNewMessageCheck) { - _newMessageCheckTick = 0; - unord.add(_doNewMessageCheck()); - } - if (unord.isNotEmpty) { - await Future.wait(unord); - } + // Tick DHT record pool + unawaited(DHTRecordPool.instance.tick()); } finally { _inTick = false; } } - - Future _doContactListRefresh() async { - // Don't refresh the contact list until we're connected to Veilid, because - // that's when we can actually communicate. - if (!connectionState.state.isAttached) { - return; - } - // Get the contact list, or an empty IList. - final contactList = ref.read(fetchContactListProvider).asData?.value ?? - const IListConst([]); - if (contactList.isEmpty) { - ref.invalidate(fetchContactListProvider); - } else { - // This happens on the tick after it refreshes, because invalidation - // and refresh happens only once per tick, and we won't know if it - // worked until it has. - _hasRefreshedContactList = true; - } - } - - Future _doContactInvitationCheck() async { - if (!connectionState.state.isPublicInternetReady) { - return; - } - final contactInvitationRecords = - await ref.read(fetchContactInvitationRecordsProvider.future); - if (contactInvitationRecords == null) { - return; - } - final activeAccountInfo = await ref.read(fetchActiveAccountProvider.future); - if (activeAccountInfo == null) { - return; - } - - final allChecks = >[]; - for (final contactInvitationRecord in contactInvitationRecords) { - allChecks.add(() async { - final acceptReject = await checkAcceptRejectContact( - activeAccountInfo: activeAccountInfo, - contactInvitationRecord: contactInvitationRecord); - if (acceptReject != null) { - final acceptedContact = acceptReject.acceptedContact; - if (acceptedContact != null) { - // Accept - await createContact( - activeAccountInfo: activeAccountInfo, - profile: acceptedContact.profile, - remoteIdentity: acceptedContact.remoteIdentity, - remoteConversationRecordKey: - acceptedContact.remoteConversationRecordKey, - localConversationRecordKey: - acceptedContact.localConversationRecordKey, - ); - ref - ..invalidate(fetchContactInvitationRecordsProvider) - ..invalidate(fetchContactListProvider); - } else { - // Reject - ref.invalidate(fetchContactInvitationRecordsProvider); - } - } - }()); - } - await Future.wait(allChecks); - } - - Future _doNewMessageCheck() async { - if (!connectionState.state.isPublicInternetReady) { - return; - } - - final activeChat = ref.read(activeChatStateProvider); - - if (activeChat == null) { - return; - } - final activeAccountInfo = await ref.read(fetchActiveAccountProvider.future); - if (activeAccountInfo == null) { - return; - } - - final contactList = ref.read(fetchContactListProvider).asData?.value ?? - const IListConst([]); - final activeChatContactIdx = contactList.indexWhere( - (c) => - proto.TypedKeyProto.fromProto(c.remoteConversationRecordKey) == - activeChat, - ); - if (activeChatContactIdx == -1) { - return; - } - final activeChatContact = contactList[activeChatContactIdx]; - final remoteIdentityPublicKey = - proto.TypedKeyProto.fromProto(activeChatContact.identityPublicKey); - final remoteConversationRecordKey = proto.TypedKeyProto.fromProto( - activeChatContact.remoteConversationRecordKey); - final localConversationRecordKey = proto.TypedKeyProto.fromProto( - activeChatContact.localConversationRecordKey); - - final newMessages = await getRemoteConversationMessages( - activeAccountInfo: activeAccountInfo, - remoteIdentityPublicKey: remoteIdentityPublicKey, - remoteConversationRecordKey: remoteConversationRecordKey); - if (newMessages != null && newMessages.isNotEmpty) { - final changed = await mergeLocalConversationMessages( - activeAccountInfo: activeAccountInfo, - localConversationRecordKey: localConversationRecordKey, - remoteIdentityPublicKey: remoteIdentityPublicKey, - newMessages: newMessages); - if (changed) { - ref.invalidate(activeConversationMessagesProvider); - } - } - } } diff --git a/lib/components/enter_password.dart b/lib/tools/enter_password.dart similarity index 94% rename from lib/components/enter_password.dart rename to lib/tools/enter_password.dart index a1b06ab..42880ee 100644 --- a/lib/components/enter_password.dart +++ b/lib/tools/enter_password.dart @@ -2,12 +2,11 @@ import 'package:awesome_extensions/awesome_extensions.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_translate/flutter_translate.dart'; -import '../tools/tools.dart'; +import '../theme/theme.dart'; -class EnterPasswordDialog extends ConsumerStatefulWidget { +class EnterPasswordDialog extends StatefulWidget { const EnterPasswordDialog({ this.matchPass, this.description, @@ -29,7 +28,7 @@ class EnterPasswordDialog extends ConsumerStatefulWidget { } } -class EnterPasswordDialogState extends ConsumerState { +class EnterPasswordDialogState extends State { final passwordController = TextEditingController(); final focusNode = FocusNode(); final formKey = GlobalKey(); diff --git a/lib/components/enter_pin.dart b/lib/tools/enter_pin.dart similarity index 95% rename from lib/components/enter_pin.dart rename to lib/tools/enter_pin.dart index a8def81..3128710 100644 --- a/lib/components/enter_pin.dart +++ b/lib/tools/enter_pin.dart @@ -2,13 +2,12 @@ import 'package:awesome_extensions/awesome_extensions.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_translate/flutter_translate.dart'; import 'package:pinput/pinput.dart'; -import '../tools/tools.dart'; +import '../theme/theme.dart'; -class EnterPinDialog extends ConsumerStatefulWidget { +class EnterPinDialog extends StatefulWidget { const EnterPinDialog({ required this.reenter, required this.description, @@ -30,7 +29,7 @@ class EnterPinDialog extends ConsumerStatefulWidget { } } -class EnterPinDialogState extends ConsumerState { +class EnterPinDialogState extends State { final pinController = TextEditingController(); final focusNode = FocusNode(); final formKey = GlobalKey(); diff --git a/lib/tools/loggy.dart b/lib/tools/loggy.dart index fc07d3f..9947308 100644 --- a/lib/tools/loggy.dart +++ b/lib/tools/loggy.dart @@ -1,13 +1,15 @@ import 'dart:io' show Platform; import 'package:ansicolor/ansicolor.dart'; +import 'package:bloc/bloc.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_translate/flutter_translate.dart'; import 'package:intl/intl.dart'; import 'package:loggy/loggy.dart'; +import 'package:veilid_support/veilid_support.dart'; -import '../pages/developer.dart'; -import '../veilid_support/veilid_support.dart'; +import '../veilid_processor/views/developer.dart'; +import 'state_logger.dart'; String wrapWithLogColor(LogLevel? level, String text) { // XXX: https://github.com/flutter/flutter/issues/64491 @@ -149,4 +151,7 @@ void initLoggy() { } Loggy('').level = getLogOptions(logLevel); + + // Create state logger + Bloc.observer = const StateLogger(); } diff --git a/lib/tools/secret_crypto.dart b/lib/tools/secret_crypto.dart deleted file mode 100644 index 873932d..0000000 --- a/lib/tools/secret_crypto.dart +++ /dev/null @@ -1,46 +0,0 @@ -import 'dart:typed_data'; -import '../entities/local_account.dart'; -import '../veilid_init.dart'; -import '../veilid_support/veilid_support.dart'; - -Future encryptSecretToBytes( - {required SecretKey secret, - required CryptoKind cryptoKind, - EncryptionKeyType encryptionKeyType = EncryptionKeyType.none, - String encryptionKey = ''}) async { - final veilid = await eventualVeilid.future; - - late final Uint8List secretBytes; - switch (encryptionKeyType) { - case EncryptionKeyType.none: - secretBytes = secret.decode(); - case EncryptionKeyType.pin: - case EncryptionKeyType.password: - final cs = await veilid.getCryptoSystem(cryptoKind); - - secretBytes = - await cs.encryptAeadWithPassword(secret.decode(), encryptionKey); - } - return secretBytes; -} - -Future decryptSecretFromBytes( - {required Uint8List secretBytes, - required CryptoKind cryptoKind, - EncryptionKeyType encryptionKeyType = EncryptionKeyType.none, - String encryptionKey = ''}) async { - final veilid = await eventualVeilid.future; - - late final SecretKey secret; - switch (encryptionKeyType) { - case EncryptionKeyType.none: - secret = SecretKey.fromBytes(secretBytes); - case EncryptionKeyType.pin: - case EncryptionKeyType.password: - final cs = await veilid.getCryptoSystem(cryptoKind); - - secret = SecretKey.fromBytes( - await cs.decryptAeadWithPassword(secretBytes, encryptionKey)); - } - return secret; -} diff --git a/lib/tools/shared_preferences.dart b/lib/tools/shared_preferences.dart new file mode 100644 index 0000000..ce1838d --- /dev/null +++ b/lib/tools/shared_preferences.dart @@ -0,0 +1,80 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:shared_preferences/shared_preferences.dart'; + +abstract mixin class SharedPreferencesBacked { + SharedPreferences get _sharedPreferences; + String keyName(); + T valueFromJson(Object? obj); + Object? valueToJson(T val); + + /// Load things from storage + Future load() async { + final valueJsonStr = _sharedPreferences.getString(keyName()); + final Object? valueJsonObj = + valueJsonStr != null ? jsonDecode(valueJsonStr) : null; + return valueFromJson(valueJsonObj); + } + + /// Store things to storage + Future store(T obj) async { + final valueJsonObj = valueToJson(obj); + if (valueJsonObj == null) { + await _sharedPreferences.remove(keyName()); + } else { + await _sharedPreferences.setString(keyName(), jsonEncode(valueJsonObj)); + } + return obj; + } +} + +class SharedPreferencesValue extends SharedPreferencesBacked { + SharedPreferencesValue({ + required SharedPreferences sharedPreferences, + required String keyName, + required T Function(Object? obj) valueFromJson, + required Object? Function(T obj) valueToJson, + }) : _sharedPreferencesInstance = sharedPreferences, + _valueFromJson = valueFromJson, + _valueToJson = valueToJson, + _keyName = keyName, + _streamController = StreamController.broadcast(); + + @override + SharedPreferences get _sharedPreferences => _sharedPreferencesInstance; + + T? get value => _value; + T get requireValue => _value!; + Stream get stream => _streamController.stream; + + Future get() async { + final val = _value; + if (val != null) { + return val; + } + final loadedValue = await load(); + return _value = loadedValue; + } + + Future set(T newVal) async { + _value = await store(newVal); + _streamController.add(newVal); + } + + T? _value; + final SharedPreferences _sharedPreferencesInstance; + final String _keyName; + final T Function(Object? obj) _valueFromJson; + final Object? Function(T obj) _valueToJson; + final StreamController _streamController; + + ////////////////////////////////////////////////////////////// + /// SharedPreferencesBacked + @override + String keyName() => _keyName; + @override + T valueFromJson(Object? obj) => _valueFromJson(obj); + @override + Object? valueToJson(T val) => _valueToJson(val); +} diff --git a/lib/tools/stack_trace.dart b/lib/tools/stack_trace.dart new file mode 100644 index 0000000..6cd7f55 --- /dev/null +++ b/lib/tools/stack_trace.dart @@ -0,0 +1,12 @@ +import 'package:stack_trace/stack_trace.dart'; + +/// Rethrows [error] with a stacktrace that is the combination of [stackTrace] +/// and [StackTrace.current]. +Never throwErrorWithCombinedStackTrace(Object error, StackTrace stackTrace) { + final chain = Chain([ + Trace.current(), + ...Chain.forTrace(stackTrace).traces, + ]); // .foldFrames((frame) => frame.package == 'xxx'); + + Error.throwWithStackTrace(error, chain.toTrace().vmTrace); +} diff --git a/lib/tools/state_logger.dart b/lib/tools/state_logger.dart index 973b5f9..60c274e 100644 --- a/lib/tools/state_logger.dart +++ b/lib/tools/state_logger.dart @@ -1,22 +1,61 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:bloc/bloc.dart'; +import 'package:loggy/loggy.dart'; import 'loggy.dart'; -class StateLogger extends ProviderObserver { +const Map _blocChangeLogLevels = { + 'ConnectionStateCubit': LogLevel.off, + 'ActiveConversationMessagesBlocMapCubit': LogLevel.off +}; +const Map _blocCreateCloseLogLevels = {}; +const Map _blocErrorLogLevels = {}; + +/// [BlocObserver] for the VeilidChat application that +/// observes all state changes. +class StateLogger extends BlocObserver { + /// {@macro counter_observer} const StateLogger(); + + void _checkLogLevel( + Map blocLogLevels, + LogLevel defaultLogLevel, + BlocBase bloc, + void Function(LogLevel) closure) { + final logLevel = + blocLogLevels[bloc.runtimeType.toString()] ?? defaultLogLevel; + if (logLevel != LogLevel.off) { + closure(logLevel); + } + } + @override - void didUpdateProvider( - ProviderBase provider, - Object? previousValue, - Object? newValue, - ProviderContainer container, - ) { - log.debug(''' -{ - provider: ${provider.name ?? provider.runtimeType}, - oldValue: $previousValue, - newValue: $newValue -} -'''); - super.didUpdateProvider(provider, previousValue, newValue, container); + void onChange(BlocBase bloc, Change change) { + super.onChange(bloc, change); + _checkLogLevel(_blocChangeLogLevels, LogLevel.debug, bloc, (logLevel) { + log.log(logLevel, 'Change: ${bloc.runtimeType} $change'); + }); + } + + @override + void onCreate(BlocBase bloc) { + super.onCreate(bloc); + _checkLogLevel(_blocCreateCloseLogLevels, LogLevel.debug, bloc, (logLevel) { + log.log(logLevel, 'Create: ${bloc.runtimeType}'); + }); + } + + @override + void onClose(BlocBase bloc) { + super.onClose(bloc); + _checkLogLevel(_blocCreateCloseLogLevels, LogLevel.debug, bloc, (logLevel) { + log.log(logLevel, 'Close: ${bloc.runtimeType}'); + }); + } + + @override + void onError(BlocBase bloc, Object error, StackTrace stackTrace) { + super.onError(bloc, error, stackTrace); + _checkLogLevel(_blocErrorLogLevels, LogLevel.error, bloc, (logLevel) { + log.log(logLevel, 'Error: ${bloc.runtimeType} $error\n$stackTrace'); + }); } } diff --git a/lib/tools/stream_listenable.dart b/lib/tools/stream_listenable.dart new file mode 100644 index 0000000..f01ee04 --- /dev/null +++ b/lib/tools/stream_listenable.dart @@ -0,0 +1,34 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import 'loggy.dart'; + +/// Converts a [Stream] into a [Listenable] +/// +/// {@tool snippet} +/// Typical usage is as follows: +/// +/// ```dart +/// StreamListenable(stream) +/// ``` +/// {@end-tool} +class StreamListenable extends ChangeNotifier { + /// Creates a [StreamListenable]. + /// + /// Every time the [Stream] receives an event this [ChangeNotifier] will + /// notify its listeners. + StreamListenable(Stream stream) { + notifyListeners(); + _subscription = stream.asBroadcastStream().listen((_) => notifyListeners()); + } + + late final StreamSubscription _subscription; + + @override + void dispose() { + unawaited(_subscription.cancel().onError((error, stackTrace) => + log.error('StreamListenable cancel error: $error\n$stackTrace'))); + super.dispose(); + } +} diff --git a/lib/tools/theme_service.dart b/lib/tools/theme_service.dart deleted file mode 100644 index 41b664e..0000000 --- a/lib/tools/theme_service.dart +++ /dev/null @@ -1,255 +0,0 @@ -// ignore_for_file: always_put_required_named_parameters_first - -import 'dart:convert'; - -import 'package:flutter/material.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -import '../entities/preferences.dart'; -import 'radix_generator.dart'; - -part 'theme_service.g.dart'; - -class ScaleColor { - ScaleColor({ - required this.appBackground, - required this.subtleBackground, - required this.elementBackground, - required this.hoverElementBackground, - required this.activeElementBackground, - required this.subtleBorder, - required this.border, - required this.hoverBorder, - required this.background, - required this.hoverBackground, - required this.subtleText, - required this.text, - }); - - Color appBackground; - Color subtleBackground; - Color elementBackground; - Color hoverElementBackground; - Color activeElementBackground; - Color subtleBorder; - Color border; - Color hoverBorder; - Color background; - Color hoverBackground; - Color subtleText; - Color text; - - ScaleColor copyWith( - {Color? appBackground, - Color? subtleBackground, - Color? elementBackground, - Color? hoverElementBackground, - Color? activeElementBackground, - Color? subtleBorder, - Color? border, - Color? hoverBorder, - Color? background, - Color? hoverBackground, - Color? subtleText, - Color? text}) => - ScaleColor( - appBackground: appBackground ?? this.appBackground, - subtleBackground: subtleBackground ?? this.subtleBackground, - elementBackground: elementBackground ?? this.elementBackground, - hoverElementBackground: - hoverElementBackground ?? this.hoverElementBackground, - activeElementBackground: - activeElementBackground ?? this.activeElementBackground, - subtleBorder: subtleBorder ?? this.subtleBorder, - border: border ?? this.border, - hoverBorder: hoverBorder ?? this.hoverBorder, - background: background ?? this.background, - hoverBackground: hoverBackground ?? this.hoverBackground, - subtleText: subtleText ?? this.subtleText, - text: text ?? this.text, - ); - - // ignore: prefer_constructors_over_static_methods - static ScaleColor lerp(ScaleColor a, ScaleColor b, double t) => ScaleColor( - appBackground: Color.lerp(a.appBackground, b.appBackground, t) ?? - const Color(0x00000000), - subtleBackground: - Color.lerp(a.subtleBackground, b.subtleBackground, t) ?? - const Color(0x00000000), - elementBackground: - Color.lerp(a.elementBackground, b.elementBackground, t) ?? - const Color(0x00000000), - hoverElementBackground: - Color.lerp(a.hoverElementBackground, b.hoverElementBackground, t) ?? - const Color(0x00000000), - activeElementBackground: Color.lerp( - a.activeElementBackground, b.activeElementBackground, t) ?? - const Color(0x00000000), - subtleBorder: Color.lerp(a.subtleBorder, b.subtleBorder, t) ?? - const Color(0x00000000), - border: Color.lerp(a.border, b.border, t) ?? const Color(0x00000000), - hoverBorder: Color.lerp(a.hoverBorder, b.hoverBorder, t) ?? - const Color(0x00000000), - background: Color.lerp(a.background, b.background, t) ?? - const Color(0x00000000), - hoverBackground: Color.lerp(a.hoverBackground, b.hoverBackground, t) ?? - const Color(0x00000000), - subtleText: Color.lerp(a.subtleText, b.subtleText, t) ?? - const Color(0x00000000), - text: Color.lerp(a.text, b.text, t) ?? const Color(0x00000000), - ); -} - -class ScaleScheme extends ThemeExtension { - ScaleScheme( - {required this.primaryScale, - required this.primaryAlphaScale, - required this.secondaryScale, - required this.tertiaryScale, - required this.grayScale, - required this.errorScale}); - - final ScaleColor primaryScale; - final ScaleColor primaryAlphaScale; - final ScaleColor secondaryScale; - final ScaleColor tertiaryScale; - final ScaleColor grayScale; - final ScaleColor errorScale; - - @override - ScaleScheme copyWith( - {ScaleColor? primaryScale, - ScaleColor? primaryAlphaScale, - ScaleColor? secondaryScale, - ScaleColor? tertiaryScale, - ScaleColor? grayScale, - ScaleColor? errorScale}) => - ScaleScheme( - primaryScale: primaryScale ?? this.primaryScale, - primaryAlphaScale: primaryAlphaScale ?? this.primaryAlphaScale, - secondaryScale: secondaryScale ?? this.secondaryScale, - tertiaryScale: tertiaryScale ?? this.tertiaryScale, - grayScale: grayScale ?? this.grayScale, - errorScale: errorScale ?? this.errorScale, - ); - - @override - ScaleScheme lerp(ScaleScheme? other, double t) { - if (other is! ScaleScheme) { - return this; - } - return ScaleScheme( - primaryScale: ScaleColor.lerp(primaryScale, other.primaryScale, t), - primaryAlphaScale: - ScaleColor.lerp(primaryAlphaScale, other.primaryAlphaScale, t), - secondaryScale: ScaleColor.lerp(secondaryScale, other.secondaryScale, t), - tertiaryScale: ScaleColor.lerp(tertiaryScale, other.tertiaryScale, t), - grayScale: ScaleColor.lerp(grayScale, other.grayScale, t), - errorScale: ScaleColor.lerp(errorScale, other.errorScale, t), - ); - } -} - -//////////////////////////////////////////////////////////////////////// - -class ThemeService { - ThemeService._(); - static late SharedPreferences prefs; - static ThemeService? _instance; - - static Future get instance async { - if (_instance == null) { - prefs = await SharedPreferences.getInstance(); - _instance = ThemeService._(); - } - return _instance!; - } - - static bool get isPlatformDark => - WidgetsBinding.instance.platformDispatcher.platformBrightness == - Brightness.dark; - - ThemeData get initial { - final themePreferences = load(); - return get(themePreferences); - } - - ThemePreferences load() { - final themePreferencesJson = prefs.getString('themePreferences'); - ThemePreferences? themePreferences; - if (themePreferencesJson != null) { - try { - themePreferences = - ThemePreferences.fromJson(jsonDecode(themePreferencesJson)); - // ignore: avoid_catches_without_on_clauses - } catch (_) { - // ignore - } - } - return themePreferences ?? - const ThemePreferences( - colorPreference: ColorPreference.vapor, - brightnessPreference: BrightnessPreference.system, - displayScale: 1, - ); - } - - Future save(ThemePreferences themePreferences) async { - await prefs.setString( - 'themePreferences', jsonEncode(themePreferences.toJson())); - } - - ThemeData get(ThemePreferences themePreferences) { - late final Brightness brightness; - switch (themePreferences.brightnessPreference) { - case BrightnessPreference.system: - if (isPlatformDark) { - brightness = Brightness.dark; - } else { - brightness = Brightness.light; - } - case BrightnessPreference.light: - brightness = Brightness.light; - case BrightnessPreference.dark: - brightness = Brightness.dark; - } - - late final ThemeData themeData; - switch (themePreferences.colorPreference) { - // Special cases - case ColorPreference.contrast: - // xxx do contrastGenerator - themeData = radixGenerator(brightness, RadixThemeColor.grim); - // Generate from Radix - case ColorPreference.scarlet: - themeData = radixGenerator(brightness, RadixThemeColor.scarlet); - case ColorPreference.babydoll: - themeData = radixGenerator(brightness, RadixThemeColor.babydoll); - case ColorPreference.vapor: - themeData = radixGenerator(brightness, RadixThemeColor.vapor); - case ColorPreference.gold: - themeData = radixGenerator(brightness, RadixThemeColor.gold); - case ColorPreference.garden: - themeData = radixGenerator(brightness, RadixThemeColor.garden); - case ColorPreference.forest: - themeData = radixGenerator(brightness, RadixThemeColor.forest); - case ColorPreference.arctic: - themeData = radixGenerator(brightness, RadixThemeColor.arctic); - case ColorPreference.lapis: - themeData = radixGenerator(brightness, RadixThemeColor.lapis); - case ColorPreference.eggplant: - themeData = radixGenerator(brightness, RadixThemeColor.eggplant); - case ColorPreference.lime: - themeData = radixGenerator(brightness, RadixThemeColor.lime); - case ColorPreference.grim: - themeData = radixGenerator(brightness, RadixThemeColor.grim); - } - - return themeData; - } -} - -@riverpod -FutureOr themeService(ThemeServiceRef ref) async => - await ThemeService.instance; diff --git a/lib/tools/theme_service.g.dart b/lib/tools/theme_service.g.dart deleted file mode 100644 index e146df9..0000000 --- a/lib/tools/theme_service.g.dart +++ /dev/null @@ -1,24 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'theme_service.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -String _$themeServiceHash() => r'87dbacb9df4923f507fb01e486b91d73a3fcef9c'; - -/// See also [themeService]. -@ProviderFor(themeService) -final themeServiceProvider = AutoDisposeFutureProvider.internal( - themeService, - name: r'themeServiceProvider', - debugGetCreateSourceHash: - const bool.fromEnvironment('dart.vm.product') ? null : _$themeServiceHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef ThemeServiceRef = AutoDisposeFutureProviderRef; -// ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/lib/tools/tools.dart b/lib/tools/tools.dart index 11cb944..0457d43 100644 --- a/lib/tools/tools.dart +++ b/lib/tools/tools.dart @@ -1,10 +1,12 @@ export 'animations.dart'; +export 'enter_password.dart'; +export 'enter_pin.dart'; export 'loggy.dart'; export 'phono_byte.dart'; -export 'radix_generator.dart'; export 'responsive.dart'; export 'scanner_error_widget.dart'; -export 'secret_crypto.dart'; +export 'shared_preferences.dart'; export 'state_logger.dart'; -export 'theme_service.dart'; +export 'stream_listenable.dart'; export 'widget_helpers.dart'; +export 'window_control.dart'; diff --git a/lib/tools/widget_helpers.dart b/lib/tools/widget_helpers.dart index 0d05d27..b3bddb7 100644 --- a/lib/tools/widget_helpers.dart +++ b/lib/tools/widget_helpers.dart @@ -1,12 +1,15 @@ +import 'package:async_tools/async_tools.dart'; import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:bloc_tools/bloc_tools.dart'; import 'package:blurry_modal_progress_hud/blurry_modal_progress_hud.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_spinkit/flutter_spinkit.dart'; import 'package:flutter_translate/flutter_translate.dart'; import 'package:motion_toast/motion_toast.dart'; import 'package:quickalert/quickalert.dart'; -import 'theme_service.dart'; +import '../theme/theme.dart'; extension BorderExt on Widget { DecoratedBox debugBorder() => DecoratedBox( @@ -22,24 +25,85 @@ extension ModalProgressExt on Widget { return BlurryModalProgressHUD( inAsyncCall: isLoading, blurEffectIntensity: 4, - progressIndicator: buildProgressIndicator(context), + progressIndicator: buildProgressIndicator(), color: scale.tertiaryScale.appBackground.withAlpha(64), child: this); } } -Widget buildProgressIndicator(BuildContext context) { - final theme = Theme.of(context); - final scale = theme.extension()!; - return SpinKitFoldingCube( - color: scale.tertiaryScale.background, - size: 80, - ); +Widget buildProgressIndicator() => Builder(builder: (context) { + final theme = Theme.of(context); + final scale = theme.extension()!; + return SpinKitFoldingCube( + color: scale.tertiaryScale.background, + size: 80, + ); + }); + +Widget waitingPage({String? text}) => Builder( + builder: (context) => ColoredBox( + color: Theme.of(context).scaffoldBackgroundColor, + child: Center( + child: Column(children: [ + buildProgressIndicator().expanded(), + if (text != null) Text(text) + ])))); + +Widget debugPage(String text) => Builder( + builder: (context) => ColoredBox( + color: Theme.of(context).colorScheme.error, + child: Center(child: Text(text)))); + +Widget errorPage(Object err, StackTrace? st) => Builder( + builder: (context) => ColoredBox( + color: Theme.of(context).colorScheme.error, + child: Center(child: ErrorWidget(err)))); + +Widget asyncValueBuilder( + AsyncValue av, Widget Function(BuildContext, T) builder) => + av.when( + loading: waitingPage, + error: errorPage, + data: (d) => Builder(builder: (context) => builder(context, d))); + +extension AsyncValueBuilderExt on AsyncValue { + Widget builder(Widget Function(BuildContext, T) builder) => + asyncValueBuilder(this, builder); + Widget buildNotData( + {Widget Function()? loading, + Widget Function(Object, StackTrace?)? error}) => + when( + loading: () => (loading ?? waitingPage)(), + error: (e, st) => (error ?? errorPage)(e, st), + data: (d) => debugPage('AsyncValue should not be data here')); } -Widget waitingPage(BuildContext context) => ColoredBox( - color: Theme.of(context).scaffoldBackgroundColor, - child: Center(child: buildProgressIndicator(context))); +extension BusyAsyncValueBuilderExt on BlocBusyState> { + Widget builder(Widget Function(BuildContext, T) builder) => + AbsorbPointer(absorbing: busy, child: state.builder(builder)); + Widget buildNotData( + {Widget Function()? loading, + Widget Function(Object, StackTrace?)? error}) => + AbsorbPointer( + absorbing: busy, + child: state.buildNotData(loading: loading, error: error)); +} + +class AsyncBlocBuilder>, S> + extends BlocBuilder> { + AsyncBlocBuilder({ + required BlocWidgetBuilder builder, + Widget Function()? loading, + Widget Function(Object, StackTrace?)? error, + super.key, + super.bloc, + super.buildWhen, + }) : super( + builder: (context, state) => state.when( + loading: () => (loading ?? waitingPage)(), + error: (e, st) => (error ?? errorPage)(e, st), + data: (d) => builder(context, d))); +} Future showErrorModal( BuildContext context, String title, String text) async { @@ -135,3 +199,7 @@ Future showStyledDialog( borderRadius: BorderRadius.circular(12))), child: child.paddingAll(0))))); } + +bool get isPlatformDark => + WidgetsBinding.instance.platformDispatcher.platformBrightness == + Brightness.dark; diff --git a/lib/tools/window_control.dart b/lib/tools/window_control.dart new file mode 100644 index 0000000..c6e33d3 --- /dev/null +++ b/lib/tools/window_control.dart @@ -0,0 +1,61 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:window_manager/window_manager.dart'; + +import '../../tools/responsive.dart'; + +export 'package:window_manager/window_manager.dart' show TitleBarStyle; + +enum OrientationCapability { + normal, + portraitOnly, + landscapeOnly, +} + +// Window Control +Future initializeWindowControl() async { + if (isDesktop) { + await windowManager.ensureInitialized(); + + const windowOptions = WindowOptions( + size: Size(768, 1024), + //minimumSize: Size(480, 480), + center: true, + backgroundColor: Colors.transparent, + skipTaskbar: false, + ); + await windowManager.waitUntilReadyToShow(windowOptions, () async { + await changeWindowSetup( + TitleBarStyle.hidden, OrientationCapability.normal); + await windowManager.show(); + await windowManager.focus(); + }); + } +} + +Future changeWindowSetup(TitleBarStyle titleBarStyle, + OrientationCapability orientationCapability) async { + if (isDesktop) { + await windowManager.setTitleBarStyle(titleBarStyle); + } else { + switch (orientationCapability) { + case OrientationCapability.normal: + await SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + ]); + case OrientationCapability.portraitOnly: + await SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + ]); + case OrientationCapability.landscapeOnly: + await SystemChrome.setPreferredOrientations([ + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + ]); + } + } +} diff --git a/lib/veilid_init.dart b/lib/veilid_init.dart deleted file mode 100644 index 86c0900..0000000 --- a/lib/veilid_init.dart +++ /dev/null @@ -1,78 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/foundation.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -import 'processor.dart'; -import 'veilid_support/veilid_support.dart'; - -part 'veilid_init.g.dart'; - -Future getVeilidVersion() async { - String veilidVersion; - try { - veilidVersion = Veilid.instance.veilidVersionString(); - } on Exception { - veilidVersion = 'Failed to get veilid version.'; - } - return veilidVersion; -} - -// Initialize Veilid -// Call only once. -void _initVeilid() { - if (kIsWeb) { - const platformConfig = VeilidWASMConfig( - logging: VeilidWASMConfigLogging( - performance: VeilidWASMConfigLoggingPerformance( - enabled: true, - level: VeilidConfigLogLevel.debug, - logsInTimings: true, - logsInConsole: false), - api: VeilidWASMConfigLoggingApi( - enabled: true, level: VeilidConfigLogLevel.info))); - Veilid.instance.initializeVeilidCore(platformConfig.toJson()); - } else { - const platformConfig = VeilidFFIConfig( - logging: VeilidFFIConfigLogging( - terminal: VeilidFFIConfigLoggingTerminal( - enabled: false, - level: VeilidConfigLogLevel.debug, - ), - otlp: VeilidFFIConfigLoggingOtlp( - enabled: false, - level: VeilidConfigLogLevel.trace, - grpcEndpoint: '127.0.0.1:4317', - serviceName: 'VeilidChat'), - api: VeilidFFIConfigLoggingApi( - enabled: true, level: VeilidConfigLogLevel.info))); - Veilid.instance.initializeVeilidCore(platformConfig.toJson()); - } -} - -Completer eventualVeilid = Completer(); -Processor processor = Processor(); - -Future initializeVeilid() async { - // Ensure this runs only once - if (eventualVeilid.isCompleted) { - return; - } - - // Init Veilid - _initVeilid(); - - // Veilid logging - initVeilidLog(); - - // Startup Veilid - await processor.startup(); - - // Share the initialized veilid instance to the rest of the app - eventualVeilid.complete(Veilid.instance); -} - -// Expose the Veilid instance as a FutureProvider -@riverpod -FutureOr veilidInstance(VeilidInstanceRef ref) async => - await eventualVeilid.future; diff --git a/lib/veilid_init.g.dart b/lib/veilid_init.g.dart deleted file mode 100644 index ab235f9..0000000 --- a/lib/veilid_init.g.dart +++ /dev/null @@ -1,25 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'veilid_init.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -String _$veilidInstanceHash() => r'cca5cf288bafc4a051a1713e285f4c1d3ef4b680'; - -/// See also [veilidInstance]. -@ProviderFor(veilidInstance) -final veilidInstanceProvider = AutoDisposeFutureProvider.internal( - veilidInstance, - name: r'veilidInstanceProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$veilidInstanceHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef VeilidInstanceRef = AutoDisposeFutureProviderRef; -// ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/lib/veilid_processor/cubit/connection_state_cubit.dart b/lib/veilid_processor/cubit/connection_state_cubit.dart new file mode 100644 index 0000000..8bf18f8 --- /dev/null +++ b/lib/veilid_processor/cubit/connection_state_cubit.dart @@ -0,0 +1,13 @@ +import 'package:bloc_tools/bloc_tools.dart'; + +import '../models/models.dart'; +import '../repository/processor_repository.dart'; + +export '../models/processor_connection_state.dart'; + +class ConnectionStateCubit + extends StreamWrapperCubit { + ConnectionStateCubit(ProcessorRepository processorRepository) + : super(processorRepository.streamProcessorConnectionState(), + defaultState: processorRepository.processorConnectionState); +} diff --git a/lib/veilid_processor/models/models.dart b/lib/veilid_processor/models/models.dart new file mode 100644 index 0000000..4dd8061 --- /dev/null +++ b/lib/veilid_processor/models/models.dart @@ -0,0 +1 @@ +export 'processor_connection_state.dart'; diff --git a/lib/veilid_processor/models/processor_connection_state.dart b/lib/veilid_processor/models/processor_connection_state.dart new file mode 100644 index 0000000..c5220fb --- /dev/null +++ b/lib/veilid_processor/models/processor_connection_state.dart @@ -0,0 +1,19 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:veilid_support/veilid_support.dart'; + +part 'processor_connection_state.freezed.dart'; + +@freezed +class ProcessorConnectionState with _$ProcessorConnectionState { + const factory ProcessorConnectionState({ + required VeilidStateAttachment attachment, + required VeilidStateNetwork network, + }) = _ProcessorConnectionState; + const ProcessorConnectionState._(); + + bool get isAttached => !(attachment.state == AttachmentState.detached || + attachment.state == AttachmentState.detaching || + attachment.state == AttachmentState.attaching); + + bool get isPublicInternetReady => attachment.publicInternetReady; +} diff --git a/lib/veilid_processor/models/processor_connection_state.freezed.dart b/lib/veilid_processor/models/processor_connection_state.freezed.dart new file mode 100644 index 0000000..d857318 --- /dev/null +++ b/lib/veilid_processor/models/processor_connection_state.freezed.dart @@ -0,0 +1,184 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'processor_connection_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); + +/// @nodoc +mixin _$ProcessorConnectionState { + VeilidStateAttachment get attachment => throw _privateConstructorUsedError; + VeilidStateNetwork get network => throw _privateConstructorUsedError; + + @JsonKey(ignore: true) + $ProcessorConnectionStateCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $ProcessorConnectionStateCopyWith<$Res> { + factory $ProcessorConnectionStateCopyWith(ProcessorConnectionState value, + $Res Function(ProcessorConnectionState) then) = + _$ProcessorConnectionStateCopyWithImpl<$Res, ProcessorConnectionState>; + @useResult + $Res call({VeilidStateAttachment attachment, VeilidStateNetwork network}); + + $VeilidStateAttachmentCopyWith<$Res> get attachment; + $VeilidStateNetworkCopyWith<$Res> get network; +} + +/// @nodoc +class _$ProcessorConnectionStateCopyWithImpl<$Res, + $Val extends ProcessorConnectionState> + implements $ProcessorConnectionStateCopyWith<$Res> { + _$ProcessorConnectionStateCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? attachment = null, + Object? network = null, + }) { + return _then(_value.copyWith( + attachment: null == attachment + ? _value.attachment + : attachment // ignore: cast_nullable_to_non_nullable + as VeilidStateAttachment, + network: null == network + ? _value.network + : network // ignore: cast_nullable_to_non_nullable + as VeilidStateNetwork, + ) as $Val); + } + + @override + @pragma('vm:prefer-inline') + $VeilidStateAttachmentCopyWith<$Res> get attachment { + return $VeilidStateAttachmentCopyWith<$Res>(_value.attachment, (value) { + return _then(_value.copyWith(attachment: value) as $Val); + }); + } + + @override + @pragma('vm:prefer-inline') + $VeilidStateNetworkCopyWith<$Res> get network { + return $VeilidStateNetworkCopyWith<$Res>(_value.network, (value) { + return _then(_value.copyWith(network: value) as $Val); + }); + } +} + +/// @nodoc +abstract class _$$ProcessorConnectionStateImplCopyWith<$Res> + implements $ProcessorConnectionStateCopyWith<$Res> { + factory _$$ProcessorConnectionStateImplCopyWith( + _$ProcessorConnectionStateImpl value, + $Res Function(_$ProcessorConnectionStateImpl) then) = + __$$ProcessorConnectionStateImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({VeilidStateAttachment attachment, VeilidStateNetwork network}); + + @override + $VeilidStateAttachmentCopyWith<$Res> get attachment; + @override + $VeilidStateNetworkCopyWith<$Res> get network; +} + +/// @nodoc +class __$$ProcessorConnectionStateImplCopyWithImpl<$Res> + extends _$ProcessorConnectionStateCopyWithImpl<$Res, + _$ProcessorConnectionStateImpl> + implements _$$ProcessorConnectionStateImplCopyWith<$Res> { + __$$ProcessorConnectionStateImplCopyWithImpl( + _$ProcessorConnectionStateImpl _value, + $Res Function(_$ProcessorConnectionStateImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? attachment = null, + Object? network = null, + }) { + return _then(_$ProcessorConnectionStateImpl( + attachment: null == attachment + ? _value.attachment + : attachment // ignore: cast_nullable_to_non_nullable + as VeilidStateAttachment, + network: null == network + ? _value.network + : network // ignore: cast_nullable_to_non_nullable + as VeilidStateNetwork, + )); + } +} + +/// @nodoc + +class _$ProcessorConnectionStateImpl extends _ProcessorConnectionState { + const _$ProcessorConnectionStateImpl( + {required this.attachment, required this.network}) + : super._(); + + @override + final VeilidStateAttachment attachment; + @override + final VeilidStateNetwork network; + + @override + String toString() { + return 'ProcessorConnectionState(attachment: $attachment, network: $network)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$ProcessorConnectionStateImpl && + (identical(other.attachment, attachment) || + other.attachment == attachment) && + (identical(other.network, network) || other.network == network)); + } + + @override + int get hashCode => Object.hash(runtimeType, attachment, network); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$ProcessorConnectionStateImplCopyWith<_$ProcessorConnectionStateImpl> + get copyWith => __$$ProcessorConnectionStateImplCopyWithImpl< + _$ProcessorConnectionStateImpl>(this, _$identity); +} + +abstract class _ProcessorConnectionState extends ProcessorConnectionState { + const factory _ProcessorConnectionState( + {required final VeilidStateAttachment attachment, + required final VeilidStateNetwork network}) = + _$ProcessorConnectionStateImpl; + const _ProcessorConnectionState._() : super._(); + + @override + VeilidStateAttachment get attachment; + @override + VeilidStateNetwork get network; + @override + @JsonKey(ignore: true) + _$$ProcessorConnectionStateImplCopyWith<_$ProcessorConnectionStateImpl> + get copyWith => throw _privateConstructorUsedError; +} diff --git a/lib/veilid_processor/repository/processor_repository.dart b/lib/veilid_processor/repository/processor_repository.dart new file mode 100644 index 0000000..e021648 --- /dev/null +++ b/lib/veilid_processor/repository/processor_repository.dart @@ -0,0 +1,134 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../app.dart'; +import '../../tools/tools.dart'; +import '../models/models.dart'; + +class ProcessorRepository { + ProcessorRepository._() + : startedUp = false, + _controllerConnectionState = StreamController.broadcast(sync: true), + processorConnectionState = ProcessorConnectionState( + attachment: const VeilidStateAttachment( + state: AttachmentState.detached, + publicInternetReady: false, + localNetworkReady: false), + network: VeilidStateNetwork( + started: false, + bpsDown: BigInt.zero, + bpsUp: BigInt.zero, + peers: [])); + + ////////////////////////////////////////////////////////////// + /// Singleton initialization + + static ProcessorRepository instance = ProcessorRepository._(); + + Future startup() async { + if (startedUp) { + return; + } + + var veilidVersion = ''; + + try { + veilidVersion = Veilid.instance.veilidVersionString(); + } on Exception { + veilidVersion = 'Failed to get veilid version.'; + } + + log.info('Veilid version: $veilidVersion'); + + // HACK: In case of hot restart shut down first + try { + await Veilid.instance.shutdownVeilidCore(); + } on Exception { + // Do nothing on failure here + } + + final updateStream = await Veilid.instance + .startupVeilidCore(await getVeilidConfig(kIsWeb, VeilidChatApp.name)); + _updateSubscription = updateStream.listen((update) { + if (update is VeilidLog) { + processLog(update); + } else if (update is VeilidUpdateAttachment) { + processUpdateAttachment(update); + } else if (update is VeilidUpdateConfig) { + processUpdateConfig(update); + } else if (update is VeilidUpdateNetwork) { + processUpdateNetwork(update); + } else if (update is VeilidAppMessage) { + processAppMessage(update); + } else if (update is VeilidAppCall) { + log.info('AppCall: ${update.toJson()}'); + } else if (update is VeilidUpdateValueChange) { + processUpdateValueChange(update); + } else { + log.trace('Update: ${update.toJson()}'); + } + }); + + startedUp = true; + + await Veilid.instance.attach(); + } + + Future shutdown() async { + if (!startedUp) { + return; + } + await Veilid.instance.shutdownVeilidCore(); + await _updateSubscription?.cancel(); + _updateSubscription = null; + + startedUp = false; + } + + Stream streamProcessorConnectionState() => + _controllerConnectionState.stream; + + void processUpdateAttachment(VeilidUpdateAttachment updateAttachment) { + // Set connection meter and ui state for connection state + processorConnectionState = processorConnectionState.copyWith( + attachment: VeilidStateAttachment( + state: updateAttachment.state, + publicInternetReady: updateAttachment.publicInternetReady, + localNetworkReady: updateAttachment.localNetworkReady)); + } + + void processUpdateConfig(VeilidUpdateConfig updateConfig) { + log.debug('VeilidUpdateConfig: ${updateConfig.toJson()}'); + } + + void processUpdateNetwork(VeilidUpdateNetwork updateNetwork) { + // Set connection meter and ui state for connection state + processorConnectionState = processorConnectionState.copyWith( + network: VeilidStateNetwork( + started: updateNetwork.started, + bpsDown: updateNetwork.bpsDown, + bpsUp: updateNetwork.bpsUp, + peers: updateNetwork.peers)); + _controllerConnectionState.add(processorConnectionState); + } + + void processAppMessage(VeilidAppMessage appMessage) { + log.debug('VeilidAppMessage: ${appMessage.toJson()}'); + } + + void processUpdateValueChange(VeilidUpdateValueChange updateValueChange) { + log.debug('UpdateValueChange: ${updateValueChange.toJson()}'); + + // Send value updates to DHTRecordPool + DHTRecordPool.instance.processRemoteValueChange(updateValueChange); + } + + //////////////////////////////////////////// + + StreamSubscription? _updateSubscription; + final StreamController _controllerConnectionState; + bool startedUp; + ProcessorConnectionState processorConnectionState; +} diff --git a/lib/veilid_processor/veilid_processor.dart b/lib/veilid_processor/veilid_processor.dart new file mode 100644 index 0000000..12d36bd --- /dev/null +++ b/lib/veilid_processor/veilid_processor.dart @@ -0,0 +1,4 @@ +export 'cubit/connection_state_cubit.dart'; +export 'models/models.dart'; +export 'repository/processor_repository.dart'; +export 'views/views.dart'; diff --git a/lib/pages/developer.dart b/lib/veilid_processor/views/developer.dart similarity index 95% rename from lib/pages/developer.dart rename to lib/veilid_processor/views/developer.dart index da78c9c..8154da5 100644 --- a/lib/pages/developer.dart +++ b/lib/veilid_processor/views/developer.dart @@ -6,15 +6,15 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_animate/flutter_animate.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_translate/flutter_translate.dart'; import 'package:go_router/go_router.dart'; import 'package:loggy/loggy.dart'; import 'package:quickalert/quickalert.dart'; +import 'package:veilid_support/veilid_support.dart'; import 'package:xterm/xterm.dart'; -import '../tools/tools.dart'; -import '../veilid_support/veilid_support.dart'; +import '../../theme/theme.dart'; +import '../../tools/tools.dart'; final globalDebugTerminal = Terminal( maxLines: 50000, @@ -25,14 +25,14 @@ const kDefaultTerminalStyle = TerminalStyle( // height: 1.2, fontFamily: 'Source Code Pro'); -class DeveloperPage extends ConsumerStatefulWidget { +class DeveloperPage extends StatefulWidget { const DeveloperPage({super.key}); @override - DeveloperPageState createState() => DeveloperPageState(); + State createState() => _DeveloperPageState(); } -class DeveloperPageState extends ConsumerState { +class _DeveloperPageState extends State { final _terminalController = TerminalController(); final _debugCommandController = TextEditingController(); final _logLevelController = DropdownController(duration: 250.ms); @@ -43,6 +43,12 @@ class DeveloperPageState extends ConsumerState { @override void initState() { super.initState(); + + WidgetsBinding.instance.addPostFrameCallback((_) async { + await changeWindowSetup( + TitleBarStyle.normal, OrientationCapability.normal); + }); + _terminalController.addListener(() { setState(() {}); }); diff --git a/lib/veilid_processor/views/signal_strength_meter.dart b/lib/veilid_processor/views/signal_strength_meter.dart new file mode 100644 index 0000000..1b94f78 --- /dev/null +++ b/lib/veilid_processor/views/signal_strength_meter.dart @@ -0,0 +1,89 @@ +import 'package:async_tools/async_tools.dart'; +import 'package:awesome_extensions/awesome_extensions.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:quickalert/quickalert.dart'; +import 'package:signal_strength_indicator/signal_strength_indicator.dart'; +import 'package:veilid_support/veilid_support.dart'; + +import '../../theme/theme.dart'; +import '../cubit/connection_state_cubit.dart'; + +class SignalStrengthMeterWidget extends StatelessWidget { + const SignalStrengthMeterWidget({super.key}); + + @override + // ignore: prefer_expression_function_bodies + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scale = theme.extension()!; + + const iconSize = 16.0; + + return BlocBuilder>(builder: (context, state) { + late final Widget iconWidget; + state.when( + data: (connectionState) { + late final double value; + late final Color color; + late final Color inactiveColor; + + switch (connectionState.attachment.state) { + case AttachmentState.detached: + iconWidget = Icon(Icons.signal_cellular_nodata, + size: iconSize, color: scale.grayScale.text); + return; + case AttachmentState.detaching: + iconWidget = Icon(Icons.signal_cellular_off, + size: iconSize, color: scale.grayScale.text); + return; + case AttachmentState.attaching: + value = 0; + color = scale.primaryScale.text; + case AttachmentState.attachedWeak: + value = 1; + color = scale.primaryScale.text; + case AttachmentState.attachedStrong: + value = 2; + color = scale.primaryScale.text; + case AttachmentState.attachedGood: + value = 3; + color = scale.primaryScale.text; + case AttachmentState.fullyAttached: + value = 4; + color = scale.primaryScale.text; + case AttachmentState.overAttached: + value = 4; + color = scale.secondaryScale.subtleText; + } + inactiveColor = scale.grayScale.subtleText; + + iconWidget = SignalStrengthIndicator.bars( + value: value, + activeColor: color, + inactiveColor: inactiveColor, + size: iconSize, + barCount: 4, + spacing: 1); + }, + loading: () => {iconWidget = const Icon(Icons.warning)}, + error: (e, st) => { + iconWidget = const Icon(Icons.error).onTap( + () async => QuickAlert.show( + type: QuickAlertType.error, + context: context, + title: 'Error', + text: 'Error: {e}\n StackTrace: {st}'), + ) + }); + + return GestureDetector( + onLongPress: () async { + await GoRouterHelper(context).push('/developer'); + }, + child: iconWidget); + }); + } +} diff --git a/lib/veilid_processor/views/views.dart b/lib/veilid_processor/views/views.dart new file mode 100644 index 0000000..3d70862 --- /dev/null +++ b/lib/veilid_processor/views/views.dart @@ -0,0 +1,2 @@ +export 'developer.dart'; +export 'signal_strength_meter.dart'; diff --git a/lib/veilid_support/dht_support/dht_support.dart b/lib/veilid_support/dht_support/dht_support.dart deleted file mode 100644 index d4f0b09..0000000 --- a/lib/veilid_support/dht_support/dht_support.dart +++ /dev/null @@ -1,8 +0,0 @@ -/// Support functions for Veilid DHT data structures - -library dht_support; - -export 'src/dht_record.dart'; -export 'src/dht_record_crypto.dart'; -export 'src/dht_record_pool.dart'; -export 'src/dht_short_array.dart'; diff --git a/lib/veilid_support/dht_support/proto/proto.dart b/lib/veilid_support/dht_support/proto/proto.dart deleted file mode 100644 index f4244c7..0000000 --- a/lib/veilid_support/dht_support/proto/proto.dart +++ /dev/null @@ -1,25 +0,0 @@ -import '../../../proto/dht.pb.dart' as dhtproto; -import '../../proto/proto.dart' as veilidproto; -import '../dht_support.dart'; - -export '../../../proto/dht.pb.dart'; -export '../../../proto/dht.pbenum.dart'; -export '../../../proto/dht.pbjson.dart'; -export '../../../proto/dht.pbserver.dart'; -export '../../proto/proto.dart'; - -/// OwnedDHTRecordPointer protobuf marshaling -/// -extension OwnedDHTRecordPointerProto on OwnedDHTRecordPointer { - dhtproto.OwnedDHTRecordPointer toProto() { - final out = dhtproto.OwnedDHTRecordPointer() - ..recordKey = recordKey.toProto() - ..owner = owner.toProto(); - return out; - } - - static OwnedDHTRecordPointer fromProto(dhtproto.OwnedDHTRecordPointer p) => - OwnedDHTRecordPointer( - recordKey: veilidproto.TypedKeyProto.fromProto(p.recordKey), - owner: veilidproto.KeyPairProto.fromProto(p.owner)); -} diff --git a/lib/veilid_support/dht_support/src/dht_record.dart b/lib/veilid_support/dht_support/src/dht_record.dart deleted file mode 100644 index 3722027..0000000 --- a/lib/veilid_support/dht_support/src/dht_record.dart +++ /dev/null @@ -1,256 +0,0 @@ -import 'dart:async'; -import 'dart:typed_data'; - -import 'package:protobuf/protobuf.dart'; - -import '../../veilid_support.dart'; - -class DHTRecord { - DHTRecord( - {required VeilidRoutingContext routingContext, - required DHTRecordDescriptor recordDescriptor, - int defaultSubkey = 0, - KeyPair? writer, - DHTRecordCrypto crypto = const DHTRecordCryptoPublic()}) - : _crypto = crypto, - _routingContext = routingContext, - _recordDescriptor = recordDescriptor, - _defaultSubkey = defaultSubkey, - _writer = writer, - _open = true, - _valid = true, - _subkeySeqCache = {}; - final VeilidRoutingContext _routingContext; - final DHTRecordDescriptor _recordDescriptor; - final int _defaultSubkey; - final KeyPair? _writer; - final Map _subkeySeqCache; - final DHTRecordCrypto _crypto; - bool _open; - bool _valid; - - int subkeyOrDefault(int subkey) => (subkey == -1) ? _defaultSubkey : subkey; - - VeilidRoutingContext get routingContext => _routingContext; - TypedKey get key => _recordDescriptor.key; - PublicKey get owner => _recordDescriptor.owner; - KeyPair? get ownerKeyPair => _recordDescriptor.ownerKeyPair(); - DHTSchema get schema => _recordDescriptor.schema; - KeyPair? get writer => _writer; - OwnedDHTRecordPointer get ownedDHTRecordPointer => - OwnedDHTRecordPointer(recordKey: key, owner: ownerKeyPair!); - - Future close() async { - if (!_valid) { - throw StateError('already deleted'); - } - if (!_open) { - return; - } - final pool = await DHTRecordPool.instance(); - await _routingContext.closeDHTRecord(_recordDescriptor.key); - pool.recordClosed(_recordDescriptor.key); - _open = false; - } - - Future delete() async { - if (!_valid) { - throw StateError('already deleted'); - } - if (_open) { - await close(); - } - final pool = await DHTRecordPool.instance(); - await pool.deleteDeep(key); - _valid = false; - } - - Future scope(Future Function(DHTRecord) scopeFunction) async { - try { - return await scopeFunction(this); - } finally { - if (_valid) { - await close(); - } - } - } - - Future deleteScope(Future Function(DHTRecord) scopeFunction) async { - try { - final out = await scopeFunction(this); - if (_valid && _open) { - await close(); - } - return out; - } on Exception catch (_) { - if (_valid) { - await delete(); - } - rethrow; - } - } - - Future maybeDeleteScope( - bool delete, Future Function(DHTRecord) scopeFunction) async { - if (delete) { - return deleteScope(scopeFunction); - } else { - return scope(scopeFunction); - } - } - - Future get( - {int subkey = -1, - bool forceRefresh = false, - bool onlyUpdates = false}) async { - subkey = subkeyOrDefault(subkey); - final valueData = await _routingContext.getDHTValue( - _recordDescriptor.key, subkey, forceRefresh); - if (valueData == null) { - return null; - } - final lastSeq = _subkeySeqCache[subkey]; - if (onlyUpdates && lastSeq != null && valueData.seq <= lastSeq) { - return null; - } - final out = _crypto.decrypt(valueData.data, subkey); - _subkeySeqCache[subkey] = valueData.seq; - return out; - } - - Future getJson(T Function(dynamic) fromJson, - {int subkey = -1, - bool forceRefresh = false, - bool onlyUpdates = false}) async { - final data = await get( - subkey: subkey, forceRefresh: forceRefresh, onlyUpdates: onlyUpdates); - if (data == null) { - return null; - } - return jsonDecodeBytes(fromJson, data); - } - - Future getProtobuf( - T Function(List i) fromBuffer, - {int subkey = -1, - bool forceRefresh = false, - bool onlyUpdates = false}) async { - final data = await get( - subkey: subkey, forceRefresh: forceRefresh, onlyUpdates: onlyUpdates); - if (data == null) { - return null; - } - return fromBuffer(data.toList()); - } - - Future tryWriteBytes(Uint8List newValue, - {int subkey = -1}) async { - subkey = subkeyOrDefault(subkey); - newValue = await _crypto.encrypt(newValue, subkey); - - // Set the new data if possible - var valueData = await _routingContext.setDHTValue( - _recordDescriptor.key, subkey, newValue); - if (valueData == null) { - // Get the data to check its sequence number - valueData = await _routingContext.getDHTValue( - _recordDescriptor.key, subkey, false); - assert(valueData != null, "can't get value that was just set"); - _subkeySeqCache[subkey] = valueData!.seq; - return null; - } - _subkeySeqCache[subkey] = valueData.seq; - return valueData.data; - } - - Future eventualWriteBytes(Uint8List newValue, {int subkey = -1}) async { - subkey = subkeyOrDefault(subkey); - newValue = await _crypto.encrypt(newValue, subkey); - - ValueData? valueData; - do { - // Set the new data - valueData = await _routingContext.setDHTValue( - _recordDescriptor.key, subkey, newValue); - - // Repeat if newer data on the network was found - } while (valueData != null); - - // Get the data to check its sequence number - valueData = - await _routingContext.getDHTValue(_recordDescriptor.key, subkey, false); - assert(valueData != null, "can't get value that was just set"); - _subkeySeqCache[subkey] = valueData!.seq; - } - - Future eventualUpdateBytes( - Future Function(Uint8List oldValue) update, - {int subkey = -1}) async { - subkey = subkeyOrDefault(subkey); - // Get existing identity key, do not allow force refresh here - // because if we need a refresh the setDHTValue will fail anyway - var valueData = - await _routingContext.getDHTValue(_recordDescriptor.key, subkey, false); - // Ensure it exists already - if (valueData == null) { - throw const FormatException('value does not exist'); - } - do { - // Update cache - _subkeySeqCache[subkey] = valueData!.seq; - - // Update the data - final oldData = await _crypto.decrypt(valueData.data, subkey); - final updatedData = await update(oldData); - final newData = await _crypto.encrypt(updatedData, subkey); - - // Set it back - valueData = await _routingContext.setDHTValue( - _recordDescriptor.key, subkey, newData); - - // Repeat if newer data on the network was found - } while (valueData != null); - - // Get the data to check its sequence number - valueData = - await _routingContext.getDHTValue(_recordDescriptor.key, subkey, false); - assert(valueData != null, "can't get value that was just set"); - _subkeySeqCache[subkey] = valueData!.seq; - } - - Future tryWriteJson(T Function(dynamic) fromJson, T newValue, - {int subkey = -1}) => - tryWriteBytes(jsonEncodeBytes(newValue), subkey: subkey).then((out) { - if (out == null) { - return null; - } - return jsonDecodeBytes(fromJson, out); - }); - - Future tryWriteProtobuf( - T Function(List) fromBuffer, T newValue, - {int subkey = -1}) => - tryWriteBytes(newValue.writeToBuffer(), subkey: subkey).then((out) { - if (out == null) { - return null; - } - return fromBuffer(out); - }); - - Future eventualWriteJson(T newValue, {int subkey = -1}) => - eventualWriteBytes(jsonEncodeBytes(newValue), subkey: subkey); - - Future eventualWriteProtobuf(T newValue, - {int subkey = -1}) => - eventualWriteBytes(newValue.writeToBuffer(), subkey: subkey); - - Future eventualUpdateJson( - T Function(dynamic) fromJson, Future Function(T) update, - {int subkey = -1}) => - eventualUpdateBytes(jsonUpdate(fromJson, update), subkey: subkey); - - Future eventualUpdateProtobuf( - T Function(List) fromBuffer, Future Function(T) update, - {int subkey = -1}) => - eventualUpdateBytes(protobufUpdate(fromBuffer, update), subkey: subkey); -} diff --git a/lib/veilid_support/dht_support/src/dht_record_pool.dart b/lib/veilid_support/dht_support/src/dht_record_pool.dart deleted file mode 100644 index cbd879e..0000000 --- a/lib/veilid_support/dht_support/src/dht_record_pool.dart +++ /dev/null @@ -1,327 +0,0 @@ -import 'package:fast_immutable_collections/fast_immutable_collections.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:mutex/mutex.dart'; - -import '../../veilid_support.dart'; - -part 'dht_record_pool.freezed.dart'; -part 'dht_record_pool.g.dart'; - -/// Record pool that managed DHTRecords and allows for tagged deletion -@freezed -class DHTRecordPoolAllocations with _$DHTRecordPoolAllocations { - const factory DHTRecordPoolAllocations({ - required IMap> - childrenByParent, // String key due to IMap<> json unsupported in key - required IMap - parentByChild, // String key due to IMap<> json unsupported in key - required ISet rootRecords, - }) = _DHTRecordPoolAllocations; - - factory DHTRecordPoolAllocations.fromJson(dynamic json) => - _$DHTRecordPoolAllocationsFromJson(json as Map); -} - -/// Pointer to an owned record, with key, owner key and owner secret -/// Ensure that these are only serialized encrypted -@freezed -class OwnedDHTRecordPointer with _$OwnedDHTRecordPointer { - const factory OwnedDHTRecordPointer({ - required TypedKey recordKey, - required KeyPair owner, - }) = _OwnedDHTRecordPointer; - - factory OwnedDHTRecordPointer.fromJson(dynamic json) => - _$OwnedDHTRecordPointerFromJson(json as Map); -} - -class DHTRecordPool with AsyncTableDBBacked { - DHTRecordPool._(Veilid veilid, VeilidRoutingContext routingContext) - : _state = DHTRecordPoolAllocations( - childrenByParent: IMap(), - parentByChild: IMap(), - rootRecords: ISet()), - _opened = {}, - _routingContext = routingContext, - _veilid = veilid; - - // Persistent DHT record list - DHTRecordPoolAllocations _state; - // Which DHT records are currently open - final Map _opened; - // Default routing context to use for new keys - final VeilidRoutingContext _routingContext; - // Convenience accessor - final Veilid _veilid; - - static DHTRecordPool? _singleton; - - ////////////////////////////////////////////////////////////// - /// AsyncTableDBBacked - @override - String tableName() => 'dht_record_pool'; - @override - String tableKeyName() => 'pool_allocations'; - @override - DHTRecordPoolAllocations valueFromJson(Object? obj) => obj != null - ? DHTRecordPoolAllocations.fromJson(obj) - : DHTRecordPoolAllocations( - childrenByParent: IMap(), parentByChild: IMap(), rootRecords: ISet()); - @override - Object? valueToJson(DHTRecordPoolAllocations val) => val.toJson(); - - ////////////////////////////////////////////////////////////// - static Mutex instanceSetupMutex = Mutex(); - - // ignore: prefer_expression_function_bodies - static Future instance() async { - return instanceSetupMutex.protect(() async { - if (_singleton == null) { - final routingContext = await Veilid.instance.routingContext(); - final globalPool = DHTRecordPool._(Veilid.instance, routingContext); - globalPool._state = await globalPool.load(); - _singleton = globalPool; - } - return _singleton!; - }); - } - - Veilid get veilid => _veilid; - - Future _recordOpened(TypedKey key) async { - // no race because dart is single threaded until async breaks - final m = _opened[key] ?? Mutex(); - _opened[key] = m; - await m.acquire(); - _opened[key] = m; - } - - void recordClosed(TypedKey key) { - final m = _opened.remove(key); - if (m == null) { - throw StateError('record already closed'); - } - m.release(); - } - - Future deleteDeep(TypedKey parent) async { - // Collect all dependencies - final allDeps = []; - final currentDeps = [parent]; - while (currentDeps.isNotEmpty) { - final nextDep = currentDeps.removeLast(); - - // Ensure we get the exclusive lock on this record - await _recordOpened(nextDep); - - // Remove this child from its parent - await _removeDependency(nextDep); - - allDeps.add(nextDep); - final childDeps = - _state.childrenByParent[nextDep.toJson()]?.toList() ?? []; - currentDeps.addAll(childDeps); - } - - // Delete all records - final allFutures = >[]; - for (final dep in allDeps) { - allFutures.add(_routingContext.deleteDHTRecord(dep)); - recordClosed(dep); - } - await Future.wait(allFutures); - } - - void _validateParent(TypedKey? parent, TypedKey child) { - final childJson = child.toJson(); - final existingParent = _state.parentByChild[childJson]; - if (parent == null) { - if (existingParent != null) { - throw StateError('Child is already parented: $child'); - } - } else { - if (_state.rootRecords.contains(child)) { - throw StateError('Child already added as root: $child'); - } - if (existingParent != null && existingParent != parent) { - throw StateError('Child has two parents: $child <- $parent'); - } - } - } - - Future _addDependency(TypedKey? parent, TypedKey child) async { - if (parent == null) { - if (_state.rootRecords.contains(child)) { - // Dependency already added - return; - } - _state = await store( - _state.copyWith(rootRecords: _state.rootRecords.add(child))); - } else { - final childrenOfParent = - _state.childrenByParent[parent.toJson()] ?? ISet(); - if (childrenOfParent.contains(child)) { - // Dependency already added (consecutive opens, etc) - return; - } - _state = await store(_state.copyWith( - childrenByParent: _state.childrenByParent - .add(parent.toJson(), childrenOfParent.add(child)), - parentByChild: _state.parentByChild.add(child.toJson(), parent))); - } - } - - Future _removeDependency(TypedKey child) async { - if (_state.rootRecords.contains(child)) { - _state = await store( - _state.copyWith(rootRecords: _state.rootRecords.remove(child))); - } else { - final parent = _state.parentByChild[child.toJson()]; - if (parent == null) { - return; - } - final children = _state.childrenByParent[parent.toJson()]!.remove(child); - late final DHTRecordPoolAllocations newState; - if (children.isEmpty) { - newState = _state.copyWith( - childrenByParent: _state.childrenByParent.remove(parent.toJson()), - parentByChild: _state.parentByChild.remove(child.toJson())); - } else { - newState = _state.copyWith( - childrenByParent: - _state.childrenByParent.add(parent.toJson(), children), - parentByChild: _state.parentByChild.remove(child.toJson())); - } - _state = await store(newState); - } - } - - /////////////////////////////////////////////////////////////////////// - - /// Create a root DHTRecord that has no dependent records - Future create({ - VeilidRoutingContext? routingContext, - TypedKey? parent, - DHTSchema schema = const DHTSchema.dflt(oCnt: 1), - int defaultSubkey = 0, - DHTRecordCrypto? crypto, - KeyPair? writer, - }) async { - final dhtctx = routingContext ?? _routingContext; - final recordDescriptor = await dhtctx.createDHTRecord(schema); - - final rec = DHTRecord( - routingContext: dhtctx, - recordDescriptor: recordDescriptor, - defaultSubkey: defaultSubkey, - writer: writer ?? recordDescriptor.ownerKeyPair(), - crypto: crypto ?? - await DHTRecordCryptoPrivate.fromTypedKeyPair( - recordDescriptor.ownerTypedKeyPair()!)); - - await _addDependency(parent, rec.key); - await _recordOpened(rec.key); - - return rec; - } - - /// Open a DHTRecord readonly - Future openRead(TypedKey recordKey, - {VeilidRoutingContext? routingContext, - TypedKey? parent, - int defaultSubkey = 0, - DHTRecordCrypto? crypto}) async { - await _recordOpened(recordKey); - - late final DHTRecord rec; - try { - // If we are opening a key that already exists - // make sure we are using the same parent if one was specified - _validateParent(parent, recordKey); - - // Open from the veilid api - final dhtctx = routingContext ?? _routingContext; - final recordDescriptor = await dhtctx.openDHTRecord(recordKey, null); - rec = DHTRecord( - routingContext: dhtctx, - recordDescriptor: recordDescriptor, - defaultSubkey: defaultSubkey, - crypto: crypto ?? const DHTRecordCryptoPublic()); - - // Register the dependency - await _addDependency(parent, rec.key); - } on Exception catch (_) { - recordClosed(recordKey); - rethrow; - } - - return rec; - } - - /// Open a DHTRecord writable - Future openWrite( - TypedKey recordKey, - KeyPair writer, { - VeilidRoutingContext? routingContext, - TypedKey? parent, - int defaultSubkey = 0, - DHTRecordCrypto? crypto, - }) async { - await _recordOpened(recordKey); - - late final DHTRecord rec; - try { - // If we are opening a key that already exists - // make sure we are using the same parent if one was specified - _validateParent(parent, recordKey); - - // Open from the veilid api - final dhtctx = routingContext ?? _routingContext; - final recordDescriptor = await dhtctx.openDHTRecord(recordKey, writer); - rec = DHTRecord( - routingContext: dhtctx, - recordDescriptor: recordDescriptor, - defaultSubkey: defaultSubkey, - writer: writer, - crypto: crypto ?? - await DHTRecordCryptoPrivate.fromTypedKeyPair( - TypedKeyPair.fromKeyPair(recordKey.kind, writer))); - - // Register the dependency if specified - await _addDependency(parent, rec.key); - } on Exception catch (_) { - recordClosed(recordKey); - rethrow; - } - - return rec; - } - - /// Open a DHTRecord owned - /// This is the same as writable but uses an OwnedDHTRecordPointer - /// for convenience and uses symmetric encryption on the key - /// This is primarily used for backing up private content on to the DHT - /// to synchronizing it between devices. Because it is 'owned', the correct - /// parent must be specified. - Future openOwned( - OwnedDHTRecordPointer ownedDHTRecordPointer, { - required TypedKey parent, - VeilidRoutingContext? routingContext, - int defaultSubkey = 0, - DHTRecordCrypto? crypto, - }) => - openWrite( - ownedDHTRecordPointer.recordKey, - ownedDHTRecordPointer.owner, - routingContext: routingContext, - parent: parent, - defaultSubkey: defaultSubkey, - crypto: crypto, - ); - - /// Get the parent of a DHTRecord key if it exists - TypedKey? getParentRecord(TypedKey child) { - final childJson = child.toJson(); - return _state.parentByChild[childJson]; - } -} diff --git a/lib/veilid_support/dht_support/src/dht_short_array.dart b/lib/veilid_support/dht_support/src/dht_short_array.dart deleted file mode 100644 index 82b701f..0000000 --- a/lib/veilid_support/dht_support/src/dht_short_array.dart +++ /dev/null @@ -1,615 +0,0 @@ -import 'dart:async'; -import 'dart:typed_data'; - -import 'package:protobuf/protobuf.dart'; - -import '../../veilid_support.dart'; -import '../proto/proto.dart' as proto; - -class _DHTShortArrayCache { - _DHTShortArrayCache() - : linkedRecords = List.empty(growable: true), - index = List.empty(growable: true), - free = List.empty(growable: true); - _DHTShortArrayCache.from(_DHTShortArrayCache other) - : linkedRecords = List.of(other.linkedRecords), - index = List.of(other.index), - free = List.of(other.free); - - final List linkedRecords; - final List index; - final List free; - - proto.DHTShortArray toProto() { - final head = proto.DHTShortArray(); - head.keys.addAll(linkedRecords.map((lr) => lr.key.toProto())); - head.index = head.index..addAll(index); - // Do not serialize free list, it gets recreated - return head; - } -} - -class DHTShortArray { - DHTShortArray._({required DHTRecord headRecord}) - : _headRecord = headRecord, - _head = _DHTShortArrayCache() { - late final int stride; - switch (headRecord.schema) { - case DHTSchemaDFLT(oCnt: final oCnt): - if (oCnt <= 1) { - throw StateError('Invalid DFLT schema in DHTShortArray'); - } - stride = oCnt - 1; - case DHTSchemaSMPL(oCnt: final oCnt, members: final members): - if (oCnt != 0 || members.length != 1 || members[0].mCnt <= 1) { - throw StateError('Invalid SMPL schema in DHTShortArray'); - } - stride = members[0].mCnt - 1; - } - assert(stride <= maxElements, 'stride too long'); - _stride = stride; - } - - static const maxElements = 256; - - // Head DHT record - final DHTRecord _headRecord; - late final int _stride; - - // Cached representation refreshed from head record - _DHTShortArrayCache _head; - - // Create a DHTShortArray - // if smplWriter is specified, uses a SMPL schema with a single writer - // rather than the key owner - static Future create( - {int stride = maxElements, - VeilidRoutingContext? routingContext, - TypedKey? parent, - DHTRecordCrypto? crypto, - KeyPair? smplWriter}) async { - assert(stride <= maxElements, 'stride too long'); - final pool = await DHTRecordPool.instance(); - - late final DHTRecord dhtRecord; - if (smplWriter != null) { - final schema = DHTSchema.smpl( - oCnt: 0, - members: [DHTSchemaMember(mKey: smplWriter.key, mCnt: stride + 1)]); - final dhtCreateRecord = await pool.create( - parent: parent, - routingContext: routingContext, - schema: schema, - crypto: crypto, - writer: smplWriter); - // Reopen with SMPL writer - await dhtCreateRecord.close(); - dhtRecord = await pool.openWrite(dhtCreateRecord.key, smplWriter, - parent: parent, routingContext: routingContext, crypto: crypto); - } else { - final schema = DHTSchema.dflt(oCnt: stride + 1); - dhtRecord = await pool.create( - parent: parent, - routingContext: routingContext, - schema: schema, - crypto: crypto); - } - - try { - final dhtShortArray = DHTShortArray._(headRecord: dhtRecord); - if (!await dhtShortArray._tryWriteHead()) { - throw StateError('Failed to write head at this time'); - } - return dhtShortArray; - } on Exception catch (_) { - await dhtRecord.delete(); - rethrow; - } - } - - static Future openRead(TypedKey headRecordKey, - {VeilidRoutingContext? routingContext, - TypedKey? parent, - DHTRecordCrypto? crypto}) async { - final pool = await DHTRecordPool.instance(); - - final dhtRecord = await pool.openRead(headRecordKey, - parent: parent, routingContext: routingContext, crypto: crypto); - try { - final dhtShortArray = DHTShortArray._(headRecord: dhtRecord); - await dhtShortArray._refreshHead(); - return dhtShortArray; - } on Exception catch (_) { - await dhtRecord.close(); - rethrow; - } - } - - static Future openWrite( - TypedKey headRecordKey, - KeyPair writer, { - VeilidRoutingContext? routingContext, - TypedKey? parent, - DHTRecordCrypto? crypto, - }) async { - final pool = await DHTRecordPool.instance(); - final dhtRecord = await pool.openWrite(headRecordKey, writer, - parent: parent, routingContext: routingContext, crypto: crypto); - try { - final dhtShortArray = DHTShortArray._(headRecord: dhtRecord); - await dhtShortArray._refreshHead(); - return dhtShortArray; - } on Exception catch (_) { - await dhtRecord.close(); - rethrow; - } - } - - static Future openOwned( - OwnedDHTRecordPointer ownedDHTRecordPointer, { - required TypedKey parent, - VeilidRoutingContext? routingContext, - DHTRecordCrypto? crypto, - }) => - openWrite( - ownedDHTRecordPointer.recordKey, - ownedDHTRecordPointer.owner, - routingContext: routingContext, - parent: parent, - crypto: crypto, - ); - - DHTRecord get record => _headRecord; - - //////////////////////////////////////////////////////////////// - - /// Serialize and write out the current head record, possibly updating it - /// if a newer copy is available online. Returns true if the write was - /// successful - Future _tryWriteHead() async { - final head = _head.toProto(); - final headBuffer = head.writeToBuffer(); - - final existingData = await _headRecord.tryWriteBytes(headBuffer); - if (existingData != null) { - // Head write failed, incorporate update - await _newHead(proto.DHTShortArray.fromBuffer(existingData)); - return false; - } - - return true; - } - - /// Validate the head from the DHT is properly formatted - /// and calculate the free list from it while we're here - List _validateHeadCacheData( - List> linkedKeys, List index) { - // Ensure nothing is duplicated in the linked keys set - final newKeys = linkedKeys.toSet(); - assert(newKeys.length <= (maxElements + (_stride - 1)) ~/ _stride, - 'too many keys'); - assert(newKeys.length == linkedKeys.length, 'duplicated linked keys'); - final newIndex = index.toSet(); - assert(newIndex.length <= maxElements, 'too many indexes'); - assert(newIndex.length == index.length, 'duplicated index locations'); - // Ensure all the index keys fit into the existing records - final indexCapacity = (linkedKeys.length + 1) * _stride; - int? maxIndex; - for (final idx in newIndex) { - assert(idx >= 0 || idx < indexCapacity, 'index out of range'); - if (maxIndex == null || idx > maxIndex) { - maxIndex = idx; - } - } - final free = []; - if (maxIndex != null) { - for (var i = 0; i < maxIndex; i++) { - if (!newIndex.contains(i)) { - free.add(i); - } - } - } - return free; - } - - /// Open a linked record for reading or writing, same as the head record - Future _openLinkedRecord(TypedKey recordKey) async { - final pool = await DHTRecordPool.instance(); - - final writer = _headRecord.writer; - return (writer != null) - ? await pool.openWrite( - recordKey, - writer, - parent: _headRecord.key, - routingContext: _headRecord.routingContext, - ) - : await pool.openRead( - recordKey, - parent: _headRecord.key, - routingContext: _headRecord.routingContext, - ); - } - - /// Validate a new head record - Future _newHead(proto.DHTShortArray head) async { - // Get the set of new linked keys and validate it - final linkedKeys = - head.keys.map(proto.TypedKeyProto.fromProto).toList(); - final index = head.index; - final free = _validateHeadCacheData(linkedKeys, index); - - // See which records are actually new - final oldRecords = Map.fromEntries( - _head.linkedRecords.map((lr) => MapEntry(lr.key, lr))); - final newRecords = {}; - final sameRecords = {}; - try { - for (var n = 0; n < linkedKeys.length; n++) { - final newKey = linkedKeys[n]; - final oldRecord = oldRecords[newKey]; - if (oldRecord == null) { - // Open the new record - final newRecord = await _openLinkedRecord(newKey); - newRecords[newKey] = newRecord; - } else { - sameRecords[newKey] = oldRecord; - } - } - } on Exception catch (_) { - // On any exception close the records we have opened - await Future.wait(newRecords.entries.map((e) => e.value.close())); - rethrow; - } - - // From this point forward we should not throw an exception or everything - // is possibly invalid. Just pass the exception up it happens and the caller - // will have to delete this short array and reopen it if it can - await Future.wait(oldRecords.entries - .where((e) => !sameRecords.containsKey(e.key)) - .map((e) => e.value.close())); - - // Figure out which indices are free - - // Make the new head cache - _head = _DHTShortArrayCache() - ..linkedRecords.addAll( - linkedKeys.map((key) => (sameRecords[key] ?? newRecords[key])!)) - ..index.addAll(index) - ..free.addAll(free); - } - - /// Pull the latest or updated copy of the head record from the network - Future _refreshHead( - {bool forceRefresh = true, bool onlyUpdates = false}) async { - // Get an updated head record copy if one exists - final head = await _headRecord.getProtobuf(proto.DHTShortArray.fromBuffer, - forceRefresh: forceRefresh, onlyUpdates: onlyUpdates); - if (head == null) { - if (onlyUpdates) { - // No update - return false; - } - throw StateError('head missing during refresh'); - } - - await _newHead(head); - - return true; - } - - //////////////////////////////////////////////////////////////// - - Future close() async { - final futures = >[_headRecord.close()]; - for (final lr in _head.linkedRecords) { - futures.add(lr.close()); - } - await Future.wait(futures); - } - - Future delete() async { - final futures = >[_headRecord.close()]; - for (final lr in _head.linkedRecords) { - futures.add(lr.delete()); - } - await Future.wait(futures); - } - - Future scope(Future Function(DHTShortArray) scopeFunction) async { - try { - return await scopeFunction(this); - } finally { - await close(); - } - } - - Future deleteScope( - Future Function(DHTShortArray) scopeFunction) async { - try { - final out = await scopeFunction(this); - await close(); - return out; - } on Exception catch (_) { - await delete(); - rethrow; - } - } - - DHTRecord? _getRecord(int recordNumber) { - if (recordNumber == 0) { - return _headRecord; - } - recordNumber--; - if (recordNumber >= _head.linkedRecords.length) { - return null; - } - return _head.linkedRecords[recordNumber]; - } - - int _emptyIndex() { - if (_head.free.isNotEmpty) { - return _head.free.removeLast(); - } - if (_head.index.length == maxElements) { - throw StateError('too many elements'); - } - return _head.index.length; - } - - void _freeIndex(int idx) { - _head.free.add(idx); - // xxx: free list optimization here? - } - - int get length => _head.index.length; - - Future getItem(int pos, {bool forceRefresh = false}) async { - await _refreshHead(forceRefresh: forceRefresh, onlyUpdates: true); - - if (pos < 0 || pos >= _head.index.length) { - throw IndexError.withLength(pos, _head.index.length); - } - final index = _head.index[pos]; - final recordNumber = index ~/ _stride; - final record = _getRecord(recordNumber); - assert(record != null, 'Record does not exist'); - - final recordSubkey = (index % _stride) + ((recordNumber == 0) ? 1 : 0); - return record!.get(subkey: recordSubkey, forceRefresh: forceRefresh); - } - - Future getItemJson(T Function(dynamic) fromJson, int pos, - {bool forceRefresh = false}) => - getItem(pos, forceRefresh: forceRefresh) - .then((out) => jsonDecodeOptBytes(fromJson, out)); - - Future getItemProtobuf( - T Function(List) fromBuffer, int pos, - {bool forceRefresh = false}) => - getItem(pos, forceRefresh: forceRefresh) - .then((out) => (out == null) ? null : fromBuffer(out)); - - Future tryAddItem(Uint8List value) async { - await _refreshHead(onlyUpdates: true); - - final oldHead = _DHTShortArrayCache.from(_head); - late final int pos; - try { - // Allocate empty index - final idx = _emptyIndex(); - // Add new index - pos = _head.index.length; - _head.index.add(idx); - - // Write new head - if (!await _tryWriteHead()) { - // Failed to write head means head got overwritten - return false; - } - } on Exception catch (_) { - // Exception on write means state needs to be reverted - _head = oldHead; - return false; - } - - // Head write succeeded, now write item - await eventualWriteItem(pos, value); - return true; - } - - Future tryInsertItem(int pos, Uint8List value) async { - await _refreshHead(onlyUpdates: true); - - final oldHead = _DHTShortArrayCache.from(_head); - try { - // Allocate empty index - final idx = _emptyIndex(); - // Add new index - _head.index.insert(pos, idx); - - // Write new head - if (!await _tryWriteHead()) { - // Failed to write head means head got overwritten - return false; - } - } on Exception catch (_) { - // Exception on write means state needs to be reverted - _head = oldHead; - return false; - } - - // Head write succeeded, now write item - await eventualWriteItem(pos, value); - return true; - } - - Future trySwapItem(int aPos, int bPos) async { - await _refreshHead(onlyUpdates: true); - - final oldHead = _DHTShortArrayCache.from(_head); - try { - // Add new index - final aIdx = _head.index[aPos]; - final bIdx = _head.index[bPos]; - _head.index[aPos] = bIdx; - _head.index[bPos] = aIdx; - - // Write new head - if (!await _tryWriteHead()) { - // Failed to write head means head got overwritten - return false; - } - } on Exception catch (_) { - // Exception on write means state needs to be reverted - _head = oldHead; - return false; - } - return true; - } - - Future tryRemoveItem(int pos) async { - await _refreshHead(onlyUpdates: true); - - final oldHead = _DHTShortArrayCache.from(_head); - try { - final removedIdx = _head.index.removeAt(pos); - _freeIndex(removedIdx); - final recordNumber = removedIdx ~/ _stride; - final record = _getRecord(recordNumber); - assert(record != null, 'Record does not exist'); - final recordSubkey = - (removedIdx % _stride) + ((recordNumber == 0) ? 1 : 0); - - // Write new head - if (!await _tryWriteHead()) { - // Failed to write head means head got overwritten - return null; - } - - return record!.get(subkey: recordSubkey); - } on Exception catch (_) { - // Exception on write means state needs to be reverted - _head = oldHead; - return null; - } - } - - Future tryRemoveItemJson( - T Function(dynamic) fromJson, - int pos, - ) => - tryRemoveItem(pos).then((out) => jsonDecodeOptBytes(fromJson, out)); - - Future tryRemoveItemProtobuf( - T Function(List) fromBuffer, int pos) => - getItem(pos).then((out) => (out == null) ? null : fromBuffer(out)); - - Future tryClear() async { - await _refreshHead(onlyUpdates: true); - - final oldHead = _DHTShortArrayCache.from(_head); - try { - _head.index.clear(); - _head.free.clear(); - - // Write new head - if (!await _tryWriteHead()) { - // Failed to write head means head got overwritten - return false; - } - } on Exception catch (_) { - // Exception on write means state needs to be reverted - _head = oldHead; - return false; - } - return true; - } - - Future tryWriteItem(int pos, Uint8List newValue) async { - if (await _refreshHead(onlyUpdates: true)) { - throw StateError('structure changed'); - } - if (pos < 0 || pos >= _head.index.length) { - throw IndexError.withLength(pos, _head.index.length); - } - final index = _head.index[pos]; - - final recordNumber = index ~/ _stride; - final record = _getRecord(recordNumber); - assert(record != null, 'Record does not exist'); - - final recordSubkey = (index % _stride) + ((recordNumber == 0) ? 1 : 0); - return record!.tryWriteBytes(newValue, subkey: recordSubkey); - } - - Future eventualWriteItem(int pos, Uint8List newValue) async { - Uint8List? oldData; - do { - // Set it back - oldData = await tryWriteItem(pos, newValue); - - // Repeat if newer data on the network was found - } while (oldData != null); - } - - Future eventualUpdateItem( - int pos, Future Function(Uint8List oldValue) update) async { - var oldData = await getItem(pos); - // Ensure it exists already - if (oldData == null) { - throw const FormatException('value does not exist'); - } - do { - // Update the data - final updatedData = await update(oldData!); - - // Set it back - oldData = await tryWriteItem(pos, updatedData); - - // Repeat if newer data on the network was found - } while (oldData != null); - } - - Future tryWriteItemJson( - T Function(dynamic) fromJson, - int pos, - T newValue, - ) => - tryWriteItem(pos, jsonEncodeBytes(newValue)) - .then((out) => jsonDecodeOptBytes(fromJson, out)); - - Future tryWriteItemProtobuf( - T Function(List) fromBuffer, - int pos, - T newValue, - ) => - tryWriteItem(pos, newValue.writeToBuffer()).then((out) { - if (out == null) { - return null; - } - return fromBuffer(out); - }); - - Future eventualWriteItemJson(int pos, T newValue) => - eventualWriteItem(pos, jsonEncodeBytes(newValue)); - - Future eventualWriteItemProtobuf( - int pos, T newValue, - {int subkey = -1}) => - eventualWriteItem(pos, newValue.writeToBuffer()); - - Future eventualUpdateItemJson( - T Function(dynamic) fromJson, - int pos, - Future Function(T) update, - ) => - eventualUpdateItem(pos, jsonUpdate(fromJson, update)); - - Future eventualUpdateItemProtobuf( - T Function(List) fromBuffer, - int pos, - Future Function(T) update, - ) => - eventualUpdateItem(pos, protobufUpdate(fromBuffer, update)); -} diff --git a/lib/veilid_support/proto/proto.dart b/lib/veilid_support/proto/proto.dart deleted file mode 100644 index 941c2af..0000000 --- a/lib/veilid_support/proto/proto.dart +++ /dev/null @@ -1,143 +0,0 @@ -import 'dart:typed_data'; - -import '../../proto/veilid.pb.dart' as proto; -import '../veilid_support.dart'; - -export '../../proto/veilid.pb.dart'; -export '../../proto/veilid.pbenum.dart'; -export '../../proto/veilid.pbjson.dart'; -export '../../proto/veilid.pbserver.dart'; - -/// CryptoKey protobuf marshaling -/// -extension CryptoKeyProto on CryptoKey { - proto.CryptoKey toProto() { - final b = decode().buffer.asByteData(); - final out = proto.CryptoKey() - ..u0 = b.getUint32(0 * 4) - ..u1 = b.getUint32(1 * 4) - ..u2 = b.getUint32(2 * 4) - ..u3 = b.getUint32(3 * 4) - ..u4 = b.getUint32(4 * 4) - ..u5 = b.getUint32(5 * 4) - ..u6 = b.getUint32(6 * 4) - ..u7 = b.getUint32(7 * 4); - return out; - } - - static CryptoKey fromProto(proto.CryptoKey p) { - final b = ByteData(32) - ..setUint32(0 * 4, p.u0) - ..setUint32(1 * 4, p.u1) - ..setUint32(2 * 4, p.u2) - ..setUint32(3 * 4, p.u3) - ..setUint32(4 * 4, p.u4) - ..setUint32(5 * 4, p.u5) - ..setUint32(6 * 4, p.u6) - ..setUint32(7 * 4, p.u7); - return CryptoKey.fromBytes(Uint8List.view(b.buffer)); - } -} - -/// Signature protobuf marshaling -/// -extension SignatureProto on Signature { - proto.Signature toProto() { - final b = decode().buffer.asByteData(); - final out = proto.Signature() - ..u0 = b.getUint32(0 * 4) - ..u1 = b.getUint32(1 * 4) - ..u2 = b.getUint32(2 * 4) - ..u3 = b.getUint32(3 * 4) - ..u4 = b.getUint32(4 * 4) - ..u5 = b.getUint32(5 * 4) - ..u6 = b.getUint32(6 * 4) - ..u7 = b.getUint32(7 * 4) - ..u8 = b.getUint32(8 * 4) - ..u9 = b.getUint32(9 * 4) - ..u10 = b.getUint32(10 * 4) - ..u11 = b.getUint32(11 * 4) - ..u12 = b.getUint32(12 * 4) - ..u13 = b.getUint32(13 * 4) - ..u14 = b.getUint32(14 * 4) - ..u15 = b.getUint32(15 * 4); - return out; - } - - static Signature fromProto(proto.Signature p) { - final b = ByteData(64) - ..setUint32(0 * 4, p.u0) - ..setUint32(1 * 4, p.u1) - ..setUint32(2 * 4, p.u2) - ..setUint32(3 * 4, p.u3) - ..setUint32(4 * 4, p.u4) - ..setUint32(5 * 4, p.u5) - ..setUint32(6 * 4, p.u6) - ..setUint32(7 * 4, p.u7) - ..setUint32(8 * 4, p.u8) - ..setUint32(9 * 4, p.u9) - ..setUint32(10 * 4, p.u10) - ..setUint32(11 * 4, p.u11) - ..setUint32(12 * 4, p.u12) - ..setUint32(13 * 4, p.u13) - ..setUint32(14 * 4, p.u14) - ..setUint32(15 * 4, p.u15); - return Signature.fromBytes(Uint8List.view(b.buffer)); - } -} - -/// Nonce protobuf marshaling -/// -extension NonceProto on Nonce { - proto.Nonce toProto() { - final b = decode().buffer.asByteData(); - final out = proto.Nonce() - ..u0 = b.getUint32(0 * 4) - ..u1 = b.getUint32(1 * 4) - ..u2 = b.getUint32(2 * 4) - ..u3 = b.getUint32(3 * 4) - ..u4 = b.getUint32(4 * 4) - ..u5 = b.getUint32(5 * 4); - return out; - } - - static Nonce fromProto(proto.Nonce p) { - final b = ByteData(24) - ..setUint32(0 * 4, p.u0) - ..setUint32(1 * 4, p.u1) - ..setUint32(2 * 4, p.u2) - ..setUint32(3 * 4, p.u3) - ..setUint32(4 * 4, p.u4) - ..setUint32(5 * 4, p.u5); - return Nonce.fromBytes(Uint8List.view(b.buffer)); - } -} - -/// TypedKey protobuf marshaling -/// -extension TypedKeyProto on TypedKey { - proto.TypedKey toProto() { - final out = proto.TypedKey() - ..kind = kind - ..value = value.toProto(); - return out; - } - - static TypedKey fromProto(proto.TypedKey p) => - TypedKey(kind: p.kind, value: CryptoKeyProto.fromProto(p.value)); -} - -/// KeyPair protobuf marshaling -/// -extension KeyPairProto on KeyPair { - proto.KeyPair toProto() { - final out = proto.KeyPair() - ..key = key.toProto() - ..secret = secret.toProto(); - return out; - } - - static KeyPair fromProto(proto.KeyPair p) => KeyPair( - key: CryptoKeyProto.fromProto(p.key), - secret: CryptoKeyProto.fromProto(p.secret)); -} diff --git a/lib/veilid_support/src/config.dart b/lib/veilid_support/src/config.dart deleted file mode 100644 index 3ffa1ca..0000000 --- a/lib/veilid_support/src/config.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'package:veilid/veilid.dart'; - -Future getVeilidChatConfig() async { - var config = await getDefaultVeilidConfig('VeilidChat'); - // ignore: do_not_use_environment - if (const String.fromEnvironment('DELETE_TABLE_STORE') == '1') { - config = - config.copyWith(tableStore: config.tableStore.copyWith(delete: true)); - } - // ignore: do_not_use_environment - if (const String.fromEnvironment('DELETE_PROTECTED_STORE') == '1') { - config = config.copyWith( - protectedStore: config.protectedStore.copyWith(delete: true)); - } - // ignore: do_not_use_environment - if (const String.fromEnvironment('DELETE_BLOCK_STORE') == '1') { - config = - config.copyWith(blockStore: config.blockStore.copyWith(delete: true)); - } - - return config.copyWith( - capabilities: const VeilidConfigCapabilities(disable: ['DHTV', 'TUNL']), - protectedStore: config.protectedStore.copyWith(allowInsecureFallback: true), - // network: config.network.copyWith( - // dht: config.network.dht.copyWith( - // getValueCount: 3, - // getValueFanout: 8, - // getValueTimeoutMs: 5000, - // setValueCount: 4, - // setValueFanout: 10, - // setValueTimeoutMs: 5000)) - ); -} diff --git a/lib/veilid_support/src/table_db.dart b/lib/veilid_support/src/table_db.dart deleted file mode 100644 index a20b4be..0000000 --- a/lib/veilid_support/src/table_db.dart +++ /dev/null @@ -1,54 +0,0 @@ -import 'package:veilid/veilid.dart'; - -Future tableScope( - String name, Future Function(VeilidTableDB tdb) callback, - {int columnCount = 1}) async { - final tableDB = await Veilid.instance.openTableDB(name, columnCount); - try { - return await callback(tableDB); - } finally { - tableDB.close(); - } -} - -Future transactionScope( - VeilidTableDB tdb, - Future Function(VeilidTableDBTransaction tdbt) callback, -) async { - final tdbt = tdb.transact(); - try { - final ret = await callback(tdbt); - if (!tdbt.isDone()) { - await tdbt.commit(); - } - return ret; - } finally { - if (!tdbt.isDone()) { - await tdbt.rollback(); - } - } -} - -abstract mixin class AsyncTableDBBacked { - String tableName(); - String tableKeyName(); - T valueFromJson(Object? obj); - Object? valueToJson(T val); - - /// Load things from storage - Future load() async { - final obj = await tableScope(tableName(), (tdb) async { - final objJson = await tdb.loadStringJson(0, tableKeyName()); - return valueFromJson(objJson); - }); - return obj; - } - - /// Store things to storage - Future store(T obj) async { - await tableScope(tableName(), (tdb) async { - await tdb.storeStringJson(0, tableKeyName(), valueToJson(obj)); - }); - return obj; - } -} diff --git a/macos/Podfile.lock b/macos/Podfile.lock index e321ce3..7ca005d 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -1,9 +1,6 @@ PODS: - FlutterMacOS (1.0.0) - - FMDB (2.7.5): - - FMDB/standard (= 2.7.5) - - FMDB/standard (2.7.5) - - mobile_scanner (3.0.0): + - mobile_scanner (3.5.6): - FlutterMacOS - pasteboard (0.0.1): - FlutterMacOS @@ -19,9 +16,9 @@ PODS: - FlutterMacOS - smart_auth (0.0.1): - FlutterMacOS - - sqflite (0.0.2): + - sqflite (0.0.3): + - Flutter - FlutterMacOS - - FMDB (>= 2.7.5) - url_launcher_macos (0.0.1): - FlutterMacOS - veilid (0.0.1): @@ -38,15 +35,11 @@ DEPENDENCIES: - share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`) - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) - smart_auth (from `Flutter/ephemeral/.symlinks/plugins/smart_auth/macos`) - - sqflite (from `Flutter/ephemeral/.symlinks/plugins/sqflite/macos`) + - sqflite (from `Flutter/ephemeral/.symlinks/plugins/sqflite/darwin`) - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) - veilid (from `Flutter/ephemeral/.symlinks/plugins/veilid/macos`) - window_manager (from `Flutter/ephemeral/.symlinks/plugins/window_manager/macos`) -SPEC REPOS: - trunk: - - FMDB - EXTERNAL SOURCES: FlutterMacOS: :path: Flutter/ephemeral @@ -65,7 +58,7 @@ EXTERNAL SOURCES: smart_auth: :path: Flutter/ephemeral/.symlinks/plugins/smart_auth/macos sqflite: - :path: Flutter/ephemeral/.symlinks/plugins/sqflite/macos + :path: Flutter/ephemeral/.symlinks/plugins/sqflite/darwin url_launcher_macos: :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos veilid: @@ -75,19 +68,18 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 - FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a - mobile_scanner: ed7618fb749adc6574563e053f3b8e5002c13994 + mobile_scanner: 54ceceae0c8da2457e26a362a6be5c61154b1829 pasteboard: 9b69dba6fedbb04866be632205d532fe2f6b1d99 - path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943 + path_provider_foundation: 3784922295ac71e43754bd15e0653ccfd36a147c screen_retriever: 59634572a57080243dd1bf715e55b6c54f241a38 - share_plus: 76dd39142738f7a68dd57b05093b5e8193f220f7 - shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126 + share_plus: 36537c04ce0c3e3f5bd297ce4318b6d5ee5fd6cf + shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695 smart_auth: b38e3ab4bfe089eacb1e233aca1a2340f96c28e9 - sqflite: a5789cceda41d54d23f31d6de539d65bb14100ea + sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec url_launcher_macos: d2691c7dd33ed713bf3544850a623080ec693d95 veilid: a54f57b7bcf0e4e072fe99272d76ca126b2026d0 window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8 PODFILE CHECKSUM: ff0a9a3ce75ee73f200ca7e2f47745698c917ef9 -COCOAPODS: 1.12.1 +COCOAPODS: 1.15.2 diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index ff30884..97ddd73 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -206,7 +206,7 @@ isa = PBXProject; attributes = { LastSwiftUpdateCheck = 0920; - LastUpgradeCheck = 1430; + LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { 33CC10EC2044A3C60003C045 = { diff --git a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 1a4fb81..c94b139 100644 --- a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,6 +1,6 @@ { + AsyncTagLock() + : _tableLock = Mutex(), + _locks = {}; + + Future lockTag(T tag) async { + await _tableLock.protect(() async { + final lockEntry = _locks[tag]; + if (lockEntry != null) { + lockEntry.waitingCount++; + await lockEntry.mutex.acquire(); + lockEntry.waitingCount--; + } else { + _locks[tag] = _AsyncTagLockEntry(); + } + }); + } + + bool isLocked(T tag) => _locks.containsKey(tag); + + bool tryLock(T tag) { + final lockEntry = _locks[tag]; + if (lockEntry != null) { + return false; + } + _locks[tag] = _AsyncTagLockEntry(); + return true; + } + + void unlockTag(T tag) { + final lockEntry = _locks[tag]!; + if (lockEntry.waitingCount == 0) { + // If nobody is waiting for the mutex we can just drop it + _locks.remove(tag); + } else { + // Someone's waiting for the tag lock so release the mutex for it + lockEntry.mutex.release(); + } + } + + Future protect(T tag, {required Future Function() closure}) async { + await lockTag(tag); + try { + return await closure(); + } finally { + unlockTag(tag); + } + } + + // + final Mutex _tableLock; + final Map _locks; +} diff --git a/packages/async_tools/lib/src/async_value.dart b/packages/async_tools/lib/src/async_value.dart new file mode 100644 index 0000000..aee070d --- /dev/null +++ b/packages/async_tools/lib/src/async_value.dart @@ -0,0 +1,189 @@ +// ignore_for_file: avoid_catches_without_on_clauses + +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'async_value.freezed.dart'; + +/// An utility for safely manipulating asynchronous data. +/// +/// By using [AsyncValue], you are guaranteed that you cannot forget to +/// handle the loading/error state of an asynchronous operation. +/// +/// It also expose some utilities to nicely convert an [AsyncValue] to +/// a different object. +/// For example, a Flutter Widget may use [when] to convert an [AsyncValue] +/// into either a progress indicator, an error screen, or to show the data: +/// +/// ```dart +/// /// A provider that asynchronously expose the current user +/// final userProvider = StreamProvider((_) async* { +/// // fetch the user +/// }); +/// +/// class Example extends ConsumerWidget { +/// @override +/// Widget build(BuildContext context, ScopedReader watch) { +/// final AsyncValue user = watch(userProvider); +/// +/// return user.when( +/// loading: () => CircularProgressIndicator(), +/// error: (error, stack) => Text('Oops, something unexpected happened'), +/// data: (value) => Text('Hello ${user.name}'), +/// ); +/// } +/// } +/// ``` +/// +/// If a consumer of an [AsyncValue] does not care about the loading/error +/// state, consider using [data] to read the state: +/// +/// ```dart +/// Widget build(BuildContext context, ScopedReader watch) { +/// // reads the data state directly – will be null during loading/error states +/// final User user = watch(userProvider).data?.value; +/// +/// return Text('Hello ${user?.name}'); +/// } +/// ``` +/// +/// See also: +/// +/// - [AsyncValue.guard], to simplify transforming a [Future] into an +/// [AsyncValue]. +/// - The package Freezed (https://github.com/rrousselgit/freezed), which have +/// generated this [AsyncValue] class and explains how [map]/[when] works. +@freezed +@sealed +abstract class AsyncValue with _$AsyncValue { + const AsyncValue._(); + + /// Creates an [AsyncValue] with a data. + /// + /// The data can be `null`. + const factory AsyncValue.data(T value) = AsyncData; + + /// Creates an [AsyncValue] in loading state. + /// + /// Prefer always using this constructor with the `const` keyword. + const factory AsyncValue.loading() = AsyncLoading; + + /// Creates an [AsyncValue] in error state. + /// + /// The parameter [error] cannot be `null`. + factory AsyncValue.error(Object error, [StackTrace? stackTrace]) = + AsyncError; + + /// Transforms a [Future] that may fail into something that is safe to read. + /// + /// This is useful to avoid having to do a tedious `try/catch`. Instead of: + /// + /// ```dart + /// class MyNotifier extends StateNotifier { + /// MyNotifier(): super(const AsyncValue.loading()) { + /// _fetchData(); + /// } + /// + /// Future _fetchData() async { + /// state = const AsyncValue.loading(); + /// try { + /// final response = await dio.get('my_api/data'); + /// final data = MyData.fromJson(response); + /// state = AsyncValue.data(data); + /// } catch (err, stack) { + /// state = AsyncValue.error(err, stack); + /// } + /// } + /// } + /// ``` + /// + /// which is redundant as the application grows and we need more and more of + /// this pattern – we can use [guard] to simplify it: + /// + /// + /// ```dart + /// class MyNotifier extends StateNotifier> { + /// MyNotifier(): super(const AsyncValue.loading()) { + /// _fetchData(); + /// } + /// + /// Future _fetchData() async { + /// state = const AsyncValue.loading(); + /// // does the try/catch for us like previously + /// state = await AsyncValue.guard(() async { + /// final response = await dio.get('my_api/data'); + /// return Data.fromJson(response); + /// }); + /// } + /// } + /// ``` + static Future> guard(Future Function() future) async { + try { + return AsyncValue.data(await future()); + } catch (err, stack) { + return AsyncValue.error(err, stack); + } + } + + /// The current data, or null if in loading/error. + /// + /// This is safe to use, as Dart (will) have non-nullable types. + /// As such reading [data] still forces to handle the loading/error cases + /// by having to check `data != null`. + /// + /// ## Why does [AsyncValue.data] return [AsyncData] instead of [T]? + /// + /// The motivation behind this decision is to allow differentiating between: + /// + /// - There is a data, and it is `null`. + /// ```dart + /// // There is a data, and it is "null" + /// AsyncValue configs = AsyncValue.data(null); + /// + /// print(configs.data); // AsyncValue(value: null) + /// print(configs.data.value); // null + /// ``` + /// + /// - There is no data. [AsyncValue] is currently in loading/error state. + /// ```dart + /// // No data, currently loading + /// AsyncValue configs = AsyncValue.loading(); + /// + /// print(configs.data); // null, currently loading + /// print(configs.data.value); // throws null exception + /// ``` + AsyncData? get data => map( + data: (data) => data, + loading: (_) => null, + error: (_) => null, + ); + + /// Shorthand for [when] to handle only the `data` case. + AsyncValue whenData(R Function(T value) cb) => when( + data: (value) { + try { + return AsyncValue.data(cb(value)); + } catch (err, stack) { + return AsyncValue.error(err, stack); + } + }, + loading: () => const AsyncValue.loading(), + error: AsyncValue.error, + ); + + /// Check two AsyncData instances for equality + bool equalsData(AsyncValue other, + {required bool Function(T a, T b) equals}) => + other.when( + data: (nd) => when( + data: (d) => equals(d, nd), + loading: () => true, + error: (_e, _st) => true), + loading: () => when( + data: (_) => true, + loading: () => false, + error: (_e, _st) => true), + error: (ne, nst) => when( + data: (_) => true, + loading: () => true, + error: (e, st) => e != ne || st != nst)); +} diff --git a/packages/async_tools/lib/src/async_value.freezed.dart b/packages/async_tools/lib/src/async_value.freezed.dart new file mode 100644 index 0000000..b6911e2 --- /dev/null +++ b/packages/async_tools/lib/src/async_value.freezed.dart @@ -0,0 +1,480 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'async_value.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); + +/// @nodoc +mixin _$AsyncValue { + @optionalTypeArgs + TResult when({ + required TResult Function(T value) data, + required TResult Function() loading, + required TResult Function(Object error, StackTrace? stackTrace) error, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function(T value)? data, + TResult? Function()? loading, + TResult? Function(Object error, StackTrace? stackTrace)? error, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(T value)? data, + TResult Function()? loading, + TResult Function(Object error, StackTrace? stackTrace)? error, + required TResult orElse(), + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult map({ + required TResult Function(AsyncData value) data, + required TResult Function(AsyncLoading value) loading, + required TResult Function(AsyncError value) error, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(AsyncData value)? data, + TResult? Function(AsyncLoading value)? loading, + TResult? Function(AsyncError value)? error, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeMap({ + TResult Function(AsyncData value)? data, + TResult Function(AsyncLoading value)? loading, + TResult Function(AsyncError value)? error, + required TResult orElse(), + }) => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $AsyncValueCopyWith { + factory $AsyncValueCopyWith( + AsyncValue value, $Res Function(AsyncValue) then) = + _$AsyncValueCopyWithImpl>; +} + +/// @nodoc +class _$AsyncValueCopyWithImpl> + implements $AsyncValueCopyWith { + _$AsyncValueCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; +} + +/// @nodoc +abstract class _$$AsyncDataImplCopyWith { + factory _$$AsyncDataImplCopyWith( + _$AsyncDataImpl value, $Res Function(_$AsyncDataImpl) then) = + __$$AsyncDataImplCopyWithImpl; + @useResult + $Res call({T value}); +} + +/// @nodoc +class __$$AsyncDataImplCopyWithImpl + extends _$AsyncValueCopyWithImpl> + implements _$$AsyncDataImplCopyWith { + __$$AsyncDataImplCopyWithImpl( + _$AsyncDataImpl _value, $Res Function(_$AsyncDataImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? value = freezed, + }) { + return _then(_$AsyncDataImpl( + freezed == value + ? _value.value + : value // ignore: cast_nullable_to_non_nullable + as T, + )); + } +} + +/// @nodoc + +class _$AsyncDataImpl extends AsyncData { + const _$AsyncDataImpl(this.value) : super._(); + + @override + final T value; + + @override + String toString() { + return 'AsyncValue<$T>.data(value: $value)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$AsyncDataImpl && + const DeepCollectionEquality().equals(other.value, value)); + } + + @override + int get hashCode => + Object.hash(runtimeType, const DeepCollectionEquality().hash(value)); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$AsyncDataImplCopyWith> get copyWith => + __$$AsyncDataImplCopyWithImpl>(this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function(T value) data, + required TResult Function() loading, + required TResult Function(Object error, StackTrace? stackTrace) error, + }) { + return data(value); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function(T value)? data, + TResult? Function()? loading, + TResult? Function(Object error, StackTrace? stackTrace)? error, + }) { + return data?.call(value); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(T value)? data, + TResult Function()? loading, + TResult Function(Object error, StackTrace? stackTrace)? error, + required TResult orElse(), + }) { + if (data != null) { + return data(value); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(AsyncData value) data, + required TResult Function(AsyncLoading value) loading, + required TResult Function(AsyncError value) error, + }) { + return data(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(AsyncData value)? data, + TResult? Function(AsyncLoading value)? loading, + TResult? Function(AsyncError value)? error, + }) { + return data?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(AsyncData value)? data, + TResult Function(AsyncLoading value)? loading, + TResult Function(AsyncError value)? error, + required TResult orElse(), + }) { + if (data != null) { + return data(this); + } + return orElse(); + } +} + +abstract class AsyncData extends AsyncValue { + const factory AsyncData(final T value) = _$AsyncDataImpl; + const AsyncData._() : super._(); + + T get value; + @JsonKey(ignore: true) + _$$AsyncDataImplCopyWith> get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class _$$AsyncLoadingImplCopyWith { + factory _$$AsyncLoadingImplCopyWith(_$AsyncLoadingImpl value, + $Res Function(_$AsyncLoadingImpl) then) = + __$$AsyncLoadingImplCopyWithImpl; +} + +/// @nodoc +class __$$AsyncLoadingImplCopyWithImpl + extends _$AsyncValueCopyWithImpl> + implements _$$AsyncLoadingImplCopyWith { + __$$AsyncLoadingImplCopyWithImpl( + _$AsyncLoadingImpl _value, $Res Function(_$AsyncLoadingImpl) _then) + : super(_value, _then); +} + +/// @nodoc + +class _$AsyncLoadingImpl extends AsyncLoading { + const _$AsyncLoadingImpl() : super._(); + + @override + String toString() { + return 'AsyncValue<$T>.loading()'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && other is _$AsyncLoadingImpl); + } + + @override + int get hashCode => runtimeType.hashCode; + + @override + @optionalTypeArgs + TResult when({ + required TResult Function(T value) data, + required TResult Function() loading, + required TResult Function(Object error, StackTrace? stackTrace) error, + }) { + return loading(); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function(T value)? data, + TResult? Function()? loading, + TResult? Function(Object error, StackTrace? stackTrace)? error, + }) { + return loading?.call(); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(T value)? data, + TResult Function()? loading, + TResult Function(Object error, StackTrace? stackTrace)? error, + required TResult orElse(), + }) { + if (loading != null) { + return loading(); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(AsyncData value) data, + required TResult Function(AsyncLoading value) loading, + required TResult Function(AsyncError value) error, + }) { + return loading(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(AsyncData value)? data, + TResult? Function(AsyncLoading value)? loading, + TResult? Function(AsyncError value)? error, + }) { + return loading?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(AsyncData value)? data, + TResult Function(AsyncLoading value)? loading, + TResult Function(AsyncError value)? error, + required TResult orElse(), + }) { + if (loading != null) { + return loading(this); + } + return orElse(); + } +} + +abstract class AsyncLoading extends AsyncValue { + const factory AsyncLoading() = _$AsyncLoadingImpl; + const AsyncLoading._() : super._(); +} + +/// @nodoc +abstract class _$$AsyncErrorImplCopyWith { + factory _$$AsyncErrorImplCopyWith( + _$AsyncErrorImpl value, $Res Function(_$AsyncErrorImpl) then) = + __$$AsyncErrorImplCopyWithImpl; + @useResult + $Res call({Object error, StackTrace? stackTrace}); +} + +/// @nodoc +class __$$AsyncErrorImplCopyWithImpl + extends _$AsyncValueCopyWithImpl> + implements _$$AsyncErrorImplCopyWith { + __$$AsyncErrorImplCopyWithImpl( + _$AsyncErrorImpl _value, $Res Function(_$AsyncErrorImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? error = null, + Object? stackTrace = freezed, + }) { + return _then(_$AsyncErrorImpl( + null == error ? _value.error : error, + freezed == stackTrace + ? _value.stackTrace + : stackTrace // ignore: cast_nullable_to_non_nullable + as StackTrace?, + )); + } +} + +/// @nodoc + +class _$AsyncErrorImpl extends AsyncError { + _$AsyncErrorImpl(this.error, [this.stackTrace]) : super._(); + + @override + final Object error; + @override + final StackTrace? stackTrace; + + @override + String toString() { + return 'AsyncValue<$T>.error(error: $error, stackTrace: $stackTrace)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$AsyncErrorImpl && + const DeepCollectionEquality().equals(other.error, error) && + (identical(other.stackTrace, stackTrace) || + other.stackTrace == stackTrace)); + } + + @override + int get hashCode => Object.hash( + runtimeType, const DeepCollectionEquality().hash(error), stackTrace); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$AsyncErrorImplCopyWith> get copyWith => + __$$AsyncErrorImplCopyWithImpl>(this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function(T value) data, + required TResult Function() loading, + required TResult Function(Object error, StackTrace? stackTrace) error, + }) { + return error(this.error, stackTrace); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function(T value)? data, + TResult? Function()? loading, + TResult? Function(Object error, StackTrace? stackTrace)? error, + }) { + return error?.call(this.error, stackTrace); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(T value)? data, + TResult Function()? loading, + TResult Function(Object error, StackTrace? stackTrace)? error, + required TResult orElse(), + }) { + if (error != null) { + return error(this.error, stackTrace); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(AsyncData value) data, + required TResult Function(AsyncLoading value) loading, + required TResult Function(AsyncError value) error, + }) { + return error(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(AsyncData value)? data, + TResult? Function(AsyncLoading value)? loading, + TResult? Function(AsyncError value)? error, + }) { + return error?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(AsyncData value)? data, + TResult Function(AsyncLoading value)? loading, + TResult Function(AsyncError value)? error, + required TResult orElse(), + }) { + if (error != null) { + return error(this); + } + return orElse(); + } +} + +abstract class AsyncError extends AsyncValue { + factory AsyncError(final Object error, [final StackTrace? stackTrace]) = + _$AsyncErrorImpl; + AsyncError._() : super._(); + + Object get error; + StackTrace? get stackTrace; + @JsonKey(ignore: true) + _$$AsyncErrorImplCopyWith> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/packages/async_tools/lib/src/serial_future.dart b/packages/async_tools/lib/src/serial_future.dart new file mode 100644 index 0000000..17225b7 --- /dev/null +++ b/packages/async_tools/lib/src/serial_future.dart @@ -0,0 +1,57 @@ +// Process a single future at a time per tag queued serially +// +// The closure function is called to produce the future that is to be executed. +// If a future with a particular tag is still executing, it is queued serially +// and executed when the previous tagged future completes. +// When a tagged serialFuture finishes executing, the onDone callback is called. +// If an unhandled exception happens in the closure future, the onError callback +// is called. + +import 'dart:async'; +import 'dart:collection'; + +import 'async_tag_lock.dart'; + +AsyncTagLock _keys = AsyncTagLock(); +typedef SerialFutureQueueItem = Future Function(); +Map> _queues = {}; + +SerialFutureQueueItem _makeSerialFutureQueueItem( + Future Function() closure, + void Function(T)? onDone, + void Function(Object e, StackTrace? st)? onError) => + () async { + try { + final out = await closure(); + if (onDone != null) { + onDone(out); + } + // ignore: avoid_catches_without_on_clauses + } catch (e, sp) { + if (onError != null) { + onError(e, sp); + } else { + rethrow; + } + } + }; + +void serialFuture(Object tag, Future Function() closure, + {void Function(T)? onDone, + void Function(Object e, StackTrace? st)? onError}) { + final queueItem = _makeSerialFutureQueueItem(closure, onDone, onError); + if (!_keys.tryLock(tag)) { + final queue = _queues[tag]; + queue!.add(queueItem); + return; + } + final queue = _queues[tag] = Queue.from([queueItem]); + unawaited(() async { + do { + final queueItem = queue.removeFirst(); + await queueItem(); + } while (queue.isNotEmpty); + _queues.remove(tag); + _keys.unlockTag(tag); + }()); +} diff --git a/packages/async_tools/lib/src/single_future.dart b/packages/async_tools/lib/src/single_future.dart new file mode 100644 index 0000000..7e82e7c --- /dev/null +++ b/packages/async_tools/lib/src/single_future.dart @@ -0,0 +1,42 @@ +import 'dart:async'; + +import 'async_tag_lock.dart'; + +AsyncTagLock _keys = AsyncTagLock(); + +// Process a single future at a time per tag +// +// The closure function is called to produce the future that is to be executed. +// If a future with a particular tag is still executing, the onBusy callback +// is called. +// When a tagged singleFuture finishes executing, the onDone callback is called. +// If an unhandled exception happens in the closure future, the onError callback +// is called. +void singleFuture(Object tag, Future Function() closure, + {void Function()? onBusy, + void Function(T)? onDone, + void Function(Object e, StackTrace? st)? onError}) { + if (!_keys.tryLock(tag)) { + if (onBusy != null) { + onBusy(); + } + return; + } + unawaited(() async { + try { + final out = await closure(); + if (onDone != null) { + onDone(out); + } + // ignore: avoid_catches_without_on_clauses + } catch (e, sp) { + if (onError != null) { + onError(e, sp); + } else { + rethrow; + } + } finally { + _keys.unlockTag(tag); + } + }()); +} diff --git a/packages/async_tools/lib/src/single_state_processor.dart b/packages/async_tools/lib/src/single_state_processor.dart new file mode 100644 index 0000000..18798fa --- /dev/null +++ b/packages/async_tools/lib/src/single_state_processor.dart @@ -0,0 +1,45 @@ +import 'dart:async'; + +import '../async_tools.dart'; + +// Process a single state update at a time ensuring the most +// recent state gets processed asynchronously, possibly skipping +// states that happen while a previous state is still being processed. +// +// Eventually this will always process the most recent state passed to +// updateState. +// +// This is useful for processing state changes asynchronously without waiting +// from a synchronous execution context +class SingleStateProcessor { + SingleStateProcessor(); + + void updateState(State newInputState, Future Function(State) closure) { + // Use a singlefuture here to ensure we get dont lose any updates + // If the input stream gives us an update while we are + // still processing the last update, the most recent input state will + // be saved and processed eventually. + + singleFuture(this, () async { + var newState = newInputState; + var done = false; + while (!done) { + await closure(newState); + + // See if there's another state change to process + final next = _nextState; + _nextState = null; + if (next != null) { + newState = next; + } else { + done = true; + } + } + }, onBusy: () { + // Keep this state until we process again + _nextState = newInputState; + }); + } + + State? _nextState; +} diff --git a/packages/async_tools/lib/src/single_stateless_processor.dart b/packages/async_tools/lib/src/single_stateless_processor.dart new file mode 100644 index 0000000..7cb7ff0 --- /dev/null +++ b/packages/async_tools/lib/src/single_stateless_processor.dart @@ -0,0 +1,48 @@ +import 'dart:async'; + +import '../async_tools.dart'; + +// Process a single stateless update at a time ensuring each request +// gets processed asynchronously, and continuously while update is requested. +// +// This is useful for processing updates asynchronously without waiting +// from a synchronous execution context +class SingleStatelessProcessor { + SingleStatelessProcessor(); + + void update(Future Function() closure) { + singleFuture(this, () async { + do { + _more = false; + await closure(); + + // See if another update was requested + } while (_more); + }, onBusy: () { + // Keep this state until we process again + _more = true; + }); + } + + // Like update, but with a busy wrapper that + // clears once the updating is finished + void busyUpdate( + Future Function(Future Function(void Function(S))) busy, + Future Function(void Function(S)) closure) { + singleFuture( + this, + () async => busy((emit) async { + do { + _more = false; + await closure(emit); + + // See if another update was requested + } while (_more); + }), onBusy: () { + // Keep this state until we process again + _more = true; + }); + } + + bool _more = false; +} diff --git a/packages/async_tools/pubspec.yaml b/packages/async_tools/pubspec.yaml new file mode 100644 index 0000000..42d7a71 --- /dev/null +++ b/packages/async_tools/pubspec.yaml @@ -0,0 +1,19 @@ +name: async_tools +description: Useful data structures and tools for async/Future code +version: 1.0.0 +publish_to: none + +environment: + sdk: '>=3.2.0 <4.0.0' + +# Add regular dependencies here. +dependencies: + freezed_annotation: ^2.4.1 + mutex: + path: ../mutex + +dev_dependencies: + build_runner: ^2.4.8 + freezed: ^2.4.7 + lint_hard: ^4.0.0 + test: ^1.25.2 diff --git a/packages/async_tools/test/async_tools_test.dart b/packages/async_tools/test/async_tools_test.dart new file mode 100644 index 0000000..0d54797 --- /dev/null +++ b/packages/async_tools/test/async_tools_test.dart @@ -0,0 +1,16 @@ +// import 'package:async_tools/async_tools.dart'; +// import 'package:test/test.dart'; + +// void main() { +// group('A group of tests', () { +// final awesome = Awesome(); + +// setUp(() { +// // Additional setup goes here. +// }); + +// test('First Test', () { +// expect(awesome.isAwesome, isTrue); +// }); +// }); +// } diff --git a/packages/bloc_tools/.gitignore b/packages/bloc_tools/.gitignore new file mode 100644 index 0000000..3cceda5 --- /dev/null +++ b/packages/bloc_tools/.gitignore @@ -0,0 +1,7 @@ +# https://dart.dev/guides/libraries/private-files +# Created by `dart pub` +.dart_tool/ + +# Avoid committing pubspec.lock for library packages; see +# https://dart.dev/guides/libraries/private-files#pubspeclock. +pubspec.lock diff --git a/packages/bloc_tools/analysis_options.yaml b/packages/bloc_tools/analysis_options.yaml new file mode 100644 index 0000000..e1620f7 --- /dev/null +++ b/packages/bloc_tools/analysis_options.yaml @@ -0,0 +1,15 @@ +include: package:lint_hard/all.yaml +analyzer: + errors: + invalid_annotation_target: ignore + exclude: + - '**/*.g.dart' + - '**/*.freezed.dart' + - '**/*.pb.dart' + - '**/*.pbenum.dart' + - '**/*.pbjson.dart' + - '**/*.pbserver.dart' +linter: + rules: + unawaited_futures: true + avoid_positional_boolean_parameters: false \ No newline at end of file diff --git a/packages/bloc_tools/example/bloc_tools_example.dart b/packages/bloc_tools/example/bloc_tools_example.dart new file mode 100644 index 0000000..25e6326 --- /dev/null +++ b/packages/bloc_tools/example/bloc_tools_example.dart @@ -0,0 +1,6 @@ +// import 'package:bloc_tools/bloc_tools.dart'; + +// void main() { +// var awesome = Awesome(); +// print('awesome: ${awesome.isAwesome}'); +// } diff --git a/packages/bloc_tools/lib/bloc_tools.dart b/packages/bloc_tools/lib/bloc_tools.dart new file mode 100644 index 0000000..4cc7304 --- /dev/null +++ b/packages/bloc_tools/lib/bloc_tools.dart @@ -0,0 +1,11 @@ +/// BLoC Tools +library; + +export 'src/async_transformer_cubit.dart'; +export 'src/bloc_busy_wrapper.dart'; +export 'src/bloc_map_cubit.dart'; +export 'src/bloc_tools_extension.dart'; +export 'src/future_cubit.dart'; +export 'src/state_follower.dart'; +export 'src/stream_wrapper_cubit.dart'; +export 'src/transformer_cubit.dart'; diff --git a/packages/bloc_tools/lib/src/async_transformer_cubit.dart b/packages/bloc_tools/lib/src/async_transformer_cubit.dart new file mode 100644 index 0000000..fa6eacb --- /dev/null +++ b/packages/bloc_tools/lib/src/async_transformer_cubit.dart @@ -0,0 +1,47 @@ +import 'dart:async'; + +import 'package:async_tools/async_tools.dart'; +import 'package:bloc/bloc.dart'; + +// A cubit with state T that wraps another input cubit of state S and +// produces T fro S via an asynchronous transform closure +// The input cubit becomes 'owned' by the AsyncTransformerCubit and will +// be closed when the AsyncTransformerCubit closes. + +class AsyncTransformerCubit extends Cubit> { + AsyncTransformerCubit(this.input, {required this.transform}) + : super(const AsyncValue.loading()) { + _asyncTransform(input.state); + _subscription = input.stream.listen(_asyncTransform); + } + void _asyncTransform(AsyncValue newInputState) { + _singleStateProcessor.updateState(newInputState, (newState) async { + // Emit the transformed state + try { + if (newState is AsyncLoading) { + emit(const AsyncValue.loading()); + } else if (newState is AsyncError) { + emit(AsyncValue.error(newState.error, newState.stackTrace)); + } else { + final transformedState = await transform(newState.data!.value); + emit(transformedState); + } + } on Exception catch (e, st) { + emit(AsyncValue.error(e, st)); + } + }); + } + + @override + Future close() async { + await _subscription.cancel(); + await input.close(); + await super.close(); + } + + Cubit> input; + final SingleStateProcessor> _singleStateProcessor = + SingleStateProcessor(); + Future> Function(S) transform; + late final StreamSubscription> _subscription; +} diff --git a/packages/bloc_tools/lib/src/bloc_busy_wrapper.dart b/packages/bloc_tools/lib/src/bloc_busy_wrapper.dart new file mode 100644 index 0000000..b6811f2 --- /dev/null +++ b/packages/bloc_tools/lib/src/bloc_busy_wrapper.dart @@ -0,0 +1,78 @@ +import 'dart:async'; + +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:meta/meta.dart'; +import 'package:mutex/mutex.dart'; + +@immutable +class BlocBusyState extends Equatable { + const BlocBusyState(this.state) : busy = false; + const BlocBusyState._busy(this.state) : busy = true; + final bool busy; + final S state; + + @override + List get props => [busy, state]; +} + +mixin BlocBusyWrapper on BlocBase> { + Future busyValue(Future Function(void Function(S) emit) closure) => + _mutex.protect(() async { + void busyemit(S state) { + changedState = state; + } + + // Turn on busy state + emit(BlocBusyState._busy(state.state)); + + // Run the closure + final out = await closure(busyemit); + + // If the closure did one or more 'busy emits' then + // take the most recent one and emit it for real + final finalState = changedState; + if (finalState != null && finalState != state.state) { + emit(BlocBusyState._busy(finalState)); + } else { + emit(BlocBusyState._busy(state.state)); + } + + return out; + }); + + Future busy(Future Function(void Function(S) emit) closure) => + _mutex.protect(() async { + void busyemit(S state) { + changedState = state; + } + + // Turn on busy state + emit(BlocBusyState._busy(state.state)); + + // Run the closure + await closure(busyemit); + + // If the closure did one or more 'busy emits' then + // take the most recent one and emit it for real and + // turn off the busy state + final finalState = changedState; + if (finalState != null && finalState != state.state) { + emit(BlocBusyState(finalState)); + } else { + emit(BlocBusyState(state.state)); + } + }); + void changeState(S state) { + if (_mutex.isLocked) { + changedState = state; + } else { + emit(BlocBusyState(state)); + } + } + + bool get isBusy => _mutex.isLocked; + + final Mutex _mutex = Mutex(); + S? changedState; +} diff --git a/packages/bloc_tools/lib/src/bloc_map_cubit.dart b/packages/bloc_tools/lib/src/bloc_map_cubit.dart new file mode 100644 index 0000000..2553c66 --- /dev/null +++ b/packages/bloc_tools/lib/src/bloc_map_cubit.dart @@ -0,0 +1,112 @@ +import 'dart:async'; + +import 'package:async_tools/async_tools.dart'; +import 'package:bloc/bloc.dart'; +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; + +typedef BlocMapState = IMap; + +class _ItemEntry { + _ItemEntry({required this.bloc, required this.subscription}); + final B bloc; + final StreamSubscription subscription; +} + +// Streaming container cubit that is a map from some immutable key +// to a some other cubit's output state. Output state for this container +// cubit is an immutable map of the key to the output state of the contained +// cubits. +// +// K = Key type for the bloc map, used to look up some mapped cubit +// S = State type for the value, keys will look up values of this type +// B = Bloc/cubit type for the value, output states of type S +abstract class BlocMapCubit> + extends Cubit> { + BlocMapCubit() + : _entries = {}, + _tagLock = AsyncTagLock(), + super(IMap()); + + @override + Future close() async { + await _entries.values.map((e) => e.subscription.cancel()).wait; + await _entries.values.map((e) => e.bloc.close()).wait; + await super.close(); + } + + Future add(MapEntry Function() create) { + // Create new element + final newElement = create(); + final key = newElement.key; + final bloc = newElement.value; + + return _tagLock.protect(key, closure: () async { + // Remove entry with the same key if it exists + await _internalRemove(key); + + // Add entry with this key + _entries[key] = _ItemEntry( + bloc: bloc, + subscription: bloc.stream.listen((data) { + // Add sub-cubit's state to the map state + emit(state.add(key, data)); + })); + + emit(state.add(key, bloc.state)); + }); + } + + Future addState(K key, S value) => + _tagLock.protect(key, closure: () async { + // Remove entry with the same key if it exists + await _internalRemove(key); + + emit(state.add(key, value)); + }); + + Future _internalRemove(K key) async { + final sub = _entries.remove(key); + if (sub != null) { + await sub.subscription.cancel(); + await sub.bloc.close(); + } + } + + Future remove(K key) => _tagLock.protect(key, closure: () async { + await _internalRemove(key); + emit(state.remove(key)); + }); + + R operate(K key, {required R Function(B bloc) closure}) { + final bloc = _entries[key]!.bloc; + return closure(bloc); + } + + R? tryOperate(K key, {required R Function(B bloc) closure}) { + final entry = _entries[key]; + if (entry == null) { + return null; + } + return closure(entry.bloc); + } + + Future operateAsync(K key, + {required Future Function(B bloc) closure}) => + _tagLock.protect(key, closure: () async { + final bloc = _entries[key]!.bloc; + return closure(bloc); + }); + + Future tryOperateAsync(K key, + {required Future Function(B bloc) closure}) => + _tagLock.protect(key, closure: () async { + final entry = _entries[key]; + if (entry == null) { + return null; + } + return closure(entry.bloc); + }); + + final Map> _entries; + final AsyncTagLock _tagLock; +} diff --git a/packages/bloc_tools/lib/src/bloc_tools_extension.dart b/packages/bloc_tools/lib/src/bloc_tools_extension.dart new file mode 100644 index 0000000..44940da --- /dev/null +++ b/packages/bloc_tools/lib/src/bloc_tools_extension.dart @@ -0,0 +1,12 @@ +import 'package:bloc/bloc.dart'; + +mixin BlocTools on BlocBase { + void withStateListen(void Function(State event)? onData, + {Function? onError, void Function()? onDone, bool? cancelOnError}) { + if (onData != null) { + onData(state); + } + stream.listen(onData, + onError: onError, onDone: onDone, cancelOnError: cancelOnError); + } +} diff --git a/packages/bloc_tools/lib/src/future_cubit.dart b/packages/bloc_tools/lib/src/future_cubit.dart new file mode 100644 index 0000000..b14ac72 --- /dev/null +++ b/packages/bloc_tools/lib/src/future_cubit.dart @@ -0,0 +1,16 @@ +import 'dart:async'; + +import 'package:async_tools/async_tools.dart'; +import 'package:bloc/bloc.dart'; + +abstract class FutureCubit extends Cubit> { + FutureCubit(Future fut) : super(const AsyncValue.loading()) { + unawaited(fut.then((value) { + emit(AsyncValue.data(value)); + // ignore: avoid_types_on_closure_parameters + }, onError: (Object e, StackTrace stackTrace) { + emit(AsyncValue.error(e, stackTrace)); + })); + } + FutureCubit.value(State state) : super(AsyncValue.data(state)); +} diff --git a/packages/bloc_tools/lib/src/state_follower.dart b/packages/bloc_tools/lib/src/state_follower.dart new file mode 100644 index 0000000..eebe27b --- /dev/null +++ b/packages/bloc_tools/lib/src/state_follower.dart @@ -0,0 +1,63 @@ +import 'dart:async'; + +import 'package:async_tools/async_tools.dart'; +import 'package:bloc/bloc.dart'; +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; + +// Mixin that automatically keeps two blocs/cubits in sync with each other +// Useful for having a BlocMapCubit 'follow' the state of another input cubit. +// As the state of the input cubit changes, the BlocMapCubit can add/remove +// mapped Cubits that automatically process the input state reactively. +// +// S = Input state type +// K = Key derived from elements of input state +// V = Value derived from elements of input state +abstract mixin class StateFollower { + void follow({ + required S initialInputState, + required Stream stream, + }) { + // + _lastInputStateMap = IMap(); + _updateFollow(initialInputState); + _subscription = stream.listen(_updateFollow); + } + + void followBloc>(B bloc) => + follow(initialInputState: bloc.state, stream: bloc.stream); + + Future close() async { + await _subscription.cancel(); + } + + IMap getStateMap(S state); + Future removeFromState(K key); + Future updateState(K key, V value); + + void _updateFollow(S newInputState) { + _singleStateProcessor.updateState(getStateMap(newInputState), + (newStateMap) async { + for (final k in _lastInputStateMap.keys) { + if (!newStateMap.containsKey(k)) { + // deleted + await removeFromState(k); + } + } + for (final newEntry in newStateMap.entries) { + final v = _lastInputStateMap.get(newEntry.key); + if (v == null || v != newEntry.value) { + // added or changed + await updateState(newEntry.key, newEntry.value); + } + } + + // Keep this state map for the next time + _lastInputStateMap = newStateMap; + }); + } + + late IMap _lastInputStateMap; + late final StreamSubscription _subscription; + final SingleStateProcessor> _singleStateProcessor = + SingleStateProcessor(); +} diff --git a/packages/bloc_tools/lib/src/stream_wrapper_cubit.dart b/packages/bloc_tools/lib/src/stream_wrapper_cubit.dart new file mode 100644 index 0000000..732695b --- /dev/null +++ b/packages/bloc_tools/lib/src/stream_wrapper_cubit.dart @@ -0,0 +1,25 @@ +import 'dart:async'; + +import 'package:async_tools/async_tools.dart'; +import 'package:bloc/bloc.dart'; + +abstract class StreamWrapperCubit extends Cubit> { + StreamWrapperCubit(Stream stream, {State? defaultState}) + : super(defaultState != null + ? AsyncValue.data(defaultState) + : const AsyncValue.loading()) { + _subscription = stream.listen((event) => emit(AsyncValue.data(event)), + // ignore: avoid_types_on_closure_parameters + onError: (Object error, StackTrace stackTrace) { + emit(AsyncValue.error(error, stackTrace)); + }); + } + + @override + Future close() async { + await _subscription.cancel(); + await super.close(); + } + + late final StreamSubscription _subscription; +} diff --git a/packages/bloc_tools/lib/src/transformer_cubit.dart b/packages/bloc_tools/lib/src/transformer_cubit.dart new file mode 100644 index 0000000..e9aa9b6 --- /dev/null +++ b/packages/bloc_tools/lib/src/transformer_cubit.dart @@ -0,0 +1,21 @@ +import 'dart:async'; + +import 'package:bloc/bloc.dart'; + +class TransformerCubit extends Cubit { + TransformerCubit(this.input, {required this.transform}) + : super(transform(input.state)) { + _subscription = input.stream.listen((event) => emit(transform(event))); + } + + @override + Future close() async { + await _subscription.cancel(); + await input.close(); + await super.close(); + } + + Cubit input; + T Function(S) transform; + late final StreamSubscription _subscription; +} diff --git a/packages/bloc_tools/pubspec.yaml b/packages/bloc_tools/pubspec.yaml new file mode 100644 index 0000000..a51fa35 --- /dev/null +++ b/packages/bloc_tools/pubspec.yaml @@ -0,0 +1,24 @@ +name: bloc_tools +description: A starting point for Dart libraries or applications. +version: 1.0.0 +publish_to: none + +environment: + sdk: '>=3.2.0 <4.0.0' + +dependencies: + async_tools: + path: ../async_tools + bloc: ^8.1.3 + equatable: ^2.0.5 + fast_immutable_collections: ^10.1.1 + freezed_annotation: ^2.4.1 + meta: ^1.11.0 + mutex: + path: ../mutex + +dev_dependencies: + build_runner: ^2.4.8 + freezed: ^2.4.7 + lint_hard: ^4.0.0 + test: ^1.25.2 \ No newline at end of file diff --git a/packages/bloc_tools/test/bloc_tools_test.dart b/packages/bloc_tools/test/bloc_tools_test.dart new file mode 100644 index 0000000..5a81be4 --- /dev/null +++ b/packages/bloc_tools/test/bloc_tools_test.dart @@ -0,0 +1,16 @@ +// import 'package:bloc_tools/bloc_tools.dart'; +// import 'package:test/test.dart'; + +// void main() { +// group('A group of tests', () { +// final awesome = Awesome(); + +// setUp(() { +// // Additional setup goes here. +// }); + +// test('First Test', () { +// expect(awesome.isAwesome, isTrue); +// }); +// }); +// } diff --git a/packages/mutex/.gitignore b/packages/mutex/.gitignore new file mode 100644 index 0000000..2ca4cae --- /dev/null +++ b/packages/mutex/.gitignore @@ -0,0 +1,16 @@ +# Files and directories created by pub +.packages +.pub/ +.dart_tool/ +build/ +packages +pubspec.lock + +# Directory created by dartdoc +doc/api/ + +# JetBrains IDEs +.idea/ +*.iml +*.ipr +*.iws diff --git a/packages/mutex/CHANGELOG.md b/packages/mutex/CHANGELOG.md new file mode 100644 index 0000000..9115310 --- /dev/null +++ b/packages/mutex/CHANGELOG.md @@ -0,0 +1,50 @@ +## 3.1.0 + +- Increased minimum Dart SDK to 2.15.0 for `unawaited` function. +- Added development dependencies lints ^2.1.1 and pana: ^0.21.37. +- Fixed code to remove lint warnings. + +## 3.0.1 + +- Fixed bug with new read mutexes preventing a write mutex from being acquired. + +## 3.0.0 + +- BREAKING CHANGE: critical section functions must return a Future. + - This is unlikely to affect real-world code, since only functions + containing asynchronous code would be critical. +- Protect method returns Future to the value from the critical section. + +## 2.0.0 + +- Null safety release. + +## 2.0.0-nullsafety.0 + +- Pre-release version: updated library to null safety (Non-nullable by default). +- Removed support for Dart 1.x. + +## 1.1.0 + +- Added protect, protectRead and protectWrite convenience methods. +- Improved tests to not depend on timing. + +## 1.0.3 + +- Added an example. + +## 1.0.2 + +- Code clean up to satisfy pana 0.13.2 health checks. + +## 1.0.1 + +- Fixed dartanalyzer warnings. + +## 1.0.0 + +- Updated the upper bound of the SDK constraint to <3.0.0. + +## 0.0.1 + +- Initial version diff --git a/packages/mutex/LICENSE b/packages/mutex/LICENSE new file mode 100644 index 0000000..eb40cc8 --- /dev/null +++ b/packages/mutex/LICENSE @@ -0,0 +1,24 @@ +Copyright (c) 2016, Hoylen Sue. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of the nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/mutex/README.md b/packages/mutex/README.md new file mode 100644 index 0000000..1df6b41 --- /dev/null +++ b/packages/mutex/README.md @@ -0,0 +1,191 @@ +# mutex + +A library for creating locks to ensure mutual exclusion when +running critical sections of code. + +## Purpose + +Mutexes can be used to protect critical sections of code to prevent +race conditions. + +Although Dart uses a single thread of execution, race conditions +can still occur when asynchronous operations are used inside +critical sections. For example, + + x = 42; + synchronousOperations(); // this does not modify x + assert(x == 42); // x will NOT have changed + + y = 42; // a variable that other asynchronous code can modify + await asynchronousOperations(); // this does NOT modify y, but... + // There is NO GUARANTEE other async code didn't run and change it! + assert(y == 42 || y != 42); // WARNING: y might have changed + +An example is when Dart is used to implement a server-side Web server +that updates a database (assuming database transactions are not being +used). The update involves querying the database, performing +calculations on those retrieved values, and then updating the database +with the result. You don't want the database to be changed by +"something else" while performing the calculations, since the results +you would write will not incorporate those other changes. That +"something else" could be the same Web server handling another request +in parallel. + +This package provides a normal mutex and a read-write mutex. + +## Mutex + +A mutex guarantees at most only one lock can exist at any one time. + +If the lock has already been acquired, attempts to acquire another +lock will be blocked until the lock has been released. + +```dart +import 'package:mutex/mutex.dart'; + +... + +final m = Mutex(); +``` + +Acquiring the lock before running the critical section of code, +and then releasing the lock. + +```dart +await m.acquire(); +// No other lock can be acquired until the lock is released + +try { + // critical section with asynchronous code + await ... +} finally { + m.release(); +} +``` + +### protect + +The following code uses the _protect_ convenience method to do the +same thing as the above code. Use the convenence method whenever +possible, since it ensures the lock will always be released. + +```dart +await m.protect(() async { + // critical section +}); +``` + +If the critial section returns a Future to a value, the _protect_ +convenience method will return a Future to that value. + +```dart +final result = await m.protect(() async { + // critical section + return valueFromCriticalSection; +}); +// result contains the valueFromCriticalSection +``` + + +## Read-write mutex + +A read-write mutex allows multiple _reads locks_ to be exist +simultaneously, but at most only one _write lock_ can exist at any one +time. A _write lock_ and any _read locks_ cannot both exist together +at the same time. + +If there is one or more _read locks_, attempts to acquire a _write +lock_ will be blocked until all the _read locks_ have been +released. But attempts to acquire more _read locks_ will not be +blocked. If there is a _write lock_, attempts to acquire any lock +(read or write) will be blocked until that _write lock_ is released. + +A read-write mutex can also be described as a single-writer mutex, +multiple-reader mutex, or a reentrant lock. + +```dart +import 'package:mutex/mutex.dart'; + +... + +final m = ReadWriteMutex(); +``` + +Acquiring a write lock: + + await m.acquireWrite(); + // No other locks (read or write) can be acquired until released + + try { + // critical write section with asynchronous code + await ... + } finally { + m.release(); + } + +Acquiring a read lock: + + await m.acquireRead(); + // No write lock can be acquired until all read locks are released, + // but additional read locks can be acquired. + + try { + // critical read section with asynchronous code + await ... + } finally { + m.release(); + } + +### protectWrite and protectRead + +The following code uses the _protectWrite_ and _protectRead_ +convenience methods to do the same thing as the above code. Use the +convenence method whenever possible, since it ensures the lock will +always be released. + +```dart +await m.protectWrite(() async { + // critical write section +}); + +await m.protectRead(() async { + // critical read section +}); +``` + +If the critial section returns a Future to a value, these convenience +methods will return a Future to that value. + +```dart +final result1 await m.protectWrite(() async { + // critical write section + return valueFromCritialSection1; +}); +// result1 contains the valueFromCriticalSection1 + +final result2 = await m.protectRead(() async { + // critical read section + return valueFromCritialSection2; +}); +// result2 contains the valueFromCriticalSection2 +``` + +## When mutual exclusion is not needed + +The critical section should always contain some asynchronous code. If +the critical section only contains synchronous code, there is no need +to put it in a critical section. In Dart, synchronous code cannot be +interrupted, so there is no need to protect it using mutual exclusion. + +Also, if the critical section does not involve data or shared +resources that can be accessed by other asynchronous code, it also +does not need to be protected. For example, if it only uses local +variables that other asynchronous code won't have access to: while the +other asynchronous code could run, it won't be able to make unexpected +changes to the local variables it can't access. + +## Features and bugs + +Please file feature requests and bugs at the [issue tracker][tracker]. + +[tracker]: https://github.com/hoylen/dart-mutex/issues diff --git a/packages/mutex/analysis_options.yaml b/packages/mutex/analysis_options.yaml new file mode 100644 index 0000000..e1620f7 --- /dev/null +++ b/packages/mutex/analysis_options.yaml @@ -0,0 +1,15 @@ +include: package:lint_hard/all.yaml +analyzer: + errors: + invalid_annotation_target: ignore + exclude: + - '**/*.g.dart' + - '**/*.freezed.dart' + - '**/*.pb.dart' + - '**/*.pbenum.dart' + - '**/*.pbjson.dart' + - '**/*.pbserver.dart' +linter: + rules: + unawaited_futures: true + avoid_positional_boolean_parameters: false \ No newline at end of file diff --git a/packages/mutex/example/example.dart b/packages/mutex/example/example.dart new file mode 100644 index 0000000..c13b007 --- /dev/null +++ b/packages/mutex/example/example.dart @@ -0,0 +1,114 @@ +// Mutex example. +// +// This example demonstrates why a mutex is needed. + +import 'dart:async'; +import 'dart:math'; +import 'package:mutex/mutex.dart'; + +//---------------------------------------------------------------- +// Random asynchronous delays to try and simulate race conditions. + +const _maxDelay = 500; // milliseconds + +final _random = Random(); + +Future randomDelay() async { + await Future.delayed( + Duration(milliseconds: _random.nextInt(_maxDelay))); +} + +//---------------------------------------------------------------- +/// Account balance. +/// +/// The classical example of a race condition is when a bank account is updated +/// by different simultaneous operations. + +int balance = 0; + +//---------------------------------------------------------------- +/// Deposit without using mutex. + +Future unsafeUpdate(int id, int depositAmount) async { + // Random delay before updating starts + await randomDelay(); + + // Add the deposit to the balance. But this operation is not atomic if + // there are asynchronous operations in it (as simulated by the randomDelay). + + final oldBalance = balance; + await randomDelay(); + balance = oldBalance + depositAmount; + + print(' [$id] added $depositAmount to $oldBalance -> $balance'); +} + +//---------------------------------------------------------------- +/// Deposit using mutex. + +Mutex m = Mutex(); + +Future safeUpdate(int id, int depositAmount) async { + // Random delay before updating starts + await randomDelay(); + + // Acquire the mutex before running the critical section of code + + await m.protect(() async { + // critical section + + // This is the same as the unsafe update. But since it is performed only + // when the mutex is acquired, it is safe: no other safe update can happen + // until this mutex is released. + + final oldBalance = balance; + await randomDelay(); + balance = oldBalance + depositAmount; + + // end of critical section + + print(' [$id] added $depositAmount to $oldBalance -> $balance'); + }); +} + +//---------------------------------------------------------------- +/// Make a series of deposits and see if the final balance is correct. + +Future makeDeposits({bool safe = true}) async { + print(safe ? 'Using mutex:' : 'Not using mutex:'); + + const numberDeposits = 10; + const amount = 10; + + balance = 0; + + // Create a set of operations, each attempting to deposit the same amount + // into the account. + + final operations = >[]; + for (var x = 0; x < numberDeposits; x++) { + final f = (safe) ? safeUpdate(x, amount) : unsafeUpdate(x, amount); + operations.add(f); + } + + // Wait for all the deposit operations to finish + + await Future.wait(operations); + + // Check if all of the operations succeeded + + final expected = numberDeposits * amount; + if (balance != expected) { + print('Error: deposits were lost (final balance $balance != $expected)'); + } else { + print('Success: no deposits were lost'); + } +} + +//---------------------------------------------------------------- + +void main() async { + await makeDeposits(safe: false); + print(''); + await makeDeposits(safe: true); +} diff --git a/packages/mutex/lib/mutex.dart b/packages/mutex/lib/mutex.dart new file mode 100644 index 0000000..ba224d1 --- /dev/null +++ b/packages/mutex/lib/mutex.dart @@ -0,0 +1,11 @@ +// Copyright (c) 2016, Hoylen Sue. All rights reserved. Use of this source code +// is governed by a BSD-style license that can be found in the LICENSE file. + +/// Mutual exclusion. +/// +library mutex; + +import 'dart:async'; + +part 'src/mutex.dart'; +part 'src/read_write_mutex.dart'; diff --git a/packages/mutex/lib/src/mutex.dart b/packages/mutex/lib/src/mutex.dart new file mode 100644 index 0000000..1c9e9ec --- /dev/null +++ b/packages/mutex/lib/src/mutex.dart @@ -0,0 +1,89 @@ +part of mutex; + +/// Mutual exclusion. +/// +/// The [protect] method is a convenience method for acquiring a lock before +/// running critical code, and then releasing the lock afterwards. Using this +/// convenience method will ensure the lock is always released after use. +/// +/// Usage: +/// +/// m = Mutex(); +/// +/// await m.protect(() async { +/// // critical section +/// }); +/// +/// Alternatively, a lock can be explicitly acquired and managed. In this +/// situation, the program is responsible for releasing the lock after it +/// have been used. Failure to release the lock will prevent other code for +/// ever acquiring the lock. +/// +/// m = Mutex(); +/// +/// await m.acquire(); +/// try { +/// // critical section +/// } +/// finally { +/// m.release(); +/// } + +class Mutex { + //================================================================ + // Constructors + Mutex() : _rwMutex = ReadWriteMutex(); + Mutex.locked() : _rwMutex = ReadWriteMutex.writeLocked(); + + // Implemented as a ReadWriteMutex that is used only with write locks. + final ReadWriteMutex _rwMutex; + + /// Indicates if a lock has been acquired and not released. + bool get isLocked => _rwMutex.isLocked; + + /// Acquire a lock + /// + /// Returns a future that will be completed when the lock has been acquired. + /// + /// Consider using the convenience method [protect], otherwise the caller + /// is responsible for making sure the lock is released after it is no longer + /// needed. Failure to release the lock means no other code can acquire the + /// lock. + + Future acquire() => _rwMutex.acquireWrite(); + + /// Release a lock. + /// + /// Release a lock that has been acquired. + + void release() => _rwMutex.release(); + + /// Convenience method for protecting a function with a lock. + /// + /// This method guarantees a lock is always acquired before invoking the + /// [criticalSection] function. It also guarantees the lock is always + /// released. + /// + /// A critical section should always contain asynchronous code, since purely + /// synchronous code does not need to be protected inside a critical section. + /// Therefore, the critical section is a function that returns a _Future_. + /// If the critical section does not need to return a value, it should be + /// defined as returning `Future`. + /// + /// Returns a _Future_ whose value is the value of the _Future_ returned by + /// the critical section. + /// + /// An exception is thrown if the critical section throws an exception, + /// or an exception is thrown while waiting for the _Future_ returned by + /// the critical section to complete. The lock is released, when those + /// exceptions occur. + + Future protect(Future Function() criticalSection) async { + await acquire(); + try { + return await criticalSection(); + } finally { + release(); + } + } +} diff --git a/packages/mutex/lib/src/read_write_mutex.dart b/packages/mutex/lib/src/read_write_mutex.dart new file mode 100644 index 0000000..8a5c12e --- /dev/null +++ b/packages/mutex/lib/src/read_write_mutex.dart @@ -0,0 +1,304 @@ +part of mutex; + +//################################################################ +/// Internal representation of a request for a lock. +/// +/// This is instantiated for each acquire and, if necessary, it is added +/// to the waiting queue. + +class _ReadWriteMutexRequest { + /// Internal constructor. + /// + /// The [isRead] indicates if this is a request for a read lock (true) or a + /// request for a write lock (false). + + _ReadWriteMutexRequest({required this.isRead}); + + /// Indicates if this is a read or write lock. + + final bool isRead; // true = read lock requested; false = write lock requested + + /// The job's completer. + /// + /// This [Completer] will complete when the job has acquired the lock. + + final Completer completer = Completer(); +} + +//################################################################ +/// Mutual exclusion that supports read and write locks. +/// +/// Multiple read locks can be simultaneously acquired, but at most only +/// one write lock can be acquired at any one time. +/// +/// **Protecting critical code** +/// +/// The [protectWrite] and [protectRead] are convenience methods for acquiring +/// locks and releasing them. Using them will ensure the locks are always +/// released after use. +/// +/// Create the mutex: +/// +/// m = ReadWriteMutex(); +/// +/// Code protected by a write lock: +/// +/// await m.protectWrite(() { +/// // critical write section +/// }); +/// +/// Other code can be protected by a read lock: +/// +/// await m.protectRead(() { +/// // critical read section +/// }); +/// +/// +/// **Explicitly managing locks** +/// +/// Alternatively, the locks can be explicitly acquired and managed. In this +/// situation, the program is responsible for releasing the locks after they +/// have been used. Failure to release the lock will prevent other code for +/// ever acquiring a lock. +/// +/// Create the mutex: +/// +/// m = ReadWriteMutex(); +/// +/// Some code can acquire a write lock: +/// +/// await m.acquireWrite(); +/// try { +/// // critical write section +/// assert(m.isWriteLocked); +/// } finally { +/// m.release(); +/// } +/// +/// Other code can acquire a read lock. +/// +/// await m.acquireRead(); +/// try { +/// // critical read section +/// assert(m.isReadLocked); +/// } finally { +/// m.release(); +/// } +/// +/// The current implementation lets locks be acquired in first-in-first-out +/// order. This ensures there will not be any lock starvation, which can +/// happen if some locks are prioritised over others. Submit a feature +/// request issue, if there is a need for another scheduling algorithm. + +class ReadWriteMutex { + //================================================================ + // Constructors + ReadWriteMutex(); + ReadWriteMutex.writeLocked() : _state = -1; + ReadWriteMutex.readLocked(int? count) : _state = count ?? 1 { + assert(_state > 0, "can't have a negative read lock count"); + } + + //================================================================ + // Members + + /// List of requests waiting for a lock on this mutex. + + final _waiting = <_ReadWriteMutexRequest>[]; + + /// State of the mutex + + int _state = 0; // -1 = write lock, +ve = number of read locks; 0 = no lock + + //================================================================ + // Methods + + /// Indicates if a lock (read or write) has been acquired and not released. + bool get isLocked => _state != 0; + + /// Indicates if a write lock has been acquired and not released. + bool get isWriteLocked => _state == -1; + + /// Indicates if one or more read locks has been acquired and not released. + bool get isReadLocked => 0 < _state; + + /// Indicates the number of waiters on this mutex + int get waiters => _waiting.length; + + /// Acquire a read lock + /// + /// Returns a future that will be completed when the lock has been acquired. + /// + /// A read lock can not be acquired when there is a write lock on the mutex. + /// But it can be acquired if there are other read locks. + /// + /// Consider using the convenience method [protectRead], otherwise the caller + /// is responsible for making sure the lock is released after it is no longer + /// needed. Failure to release the lock means no other code can acquire a + /// write lock. + + Future acquireRead() => _acquire(isRead: true); + + /// Acquire a write lock + /// + /// Returns a future that will be completed when the lock has been acquired. + /// + /// A write lock can only be acquired when there are no other locks (neither + /// read locks nor write locks) on the mutex. + /// + /// Consider using the convenience method [protectWrite], otherwise the caller + /// is responsible for making sure the lock is released after it is no longer + /// needed. Failure to release the lock means no other code can acquire the + /// lock (neither a read lock or a write lock). + + Future acquireWrite() => _acquire(isRead: false); + + /// Release a lock. + /// + /// Release the lock that was previously acquired. + /// + /// When the lock is released, locks waiting to be acquired can be acquired + /// depending on the type of lock waiting and if other locks have been + /// acquired. + /// + /// A [StateError] is thrown if the mutex does not currently have a lock on + /// it. + + void release() { + if (_state == -1) { + // Write lock released + _state = 0; + } else if (0 < _state) { + // Read lock released + _state--; + } else if (_state == 0) { + throw StateError('no lock to release'); + } else { + assert(false, 'invalid state'); + } + + // If there are jobs waiting and the next job can acquire the mutex, + // let it acquire it and remove it from the queue. + // + // This is a while loop, because there could be multiple jobs on the + // queue waiting for a read-only mutex. So they can all be allowed to run. + + while (_waiting.isNotEmpty) { + final nextJob = _waiting.first; + if (_jobAcquired(nextJob)) { + _waiting.removeAt(0); + } else { + // The next job cannot acquire the mutex. This only occurs when: the + // the currently running job has a write mutex (_state == -1); or the + // next job wants write mutex and there is a job currently running + // (regardless of what type of mutex it has acquired). + assert(_state < 0 || !nextJob.isRead, + 'unexpected: next job cannot be acquired'); + break; // no more can be removed from the queue + } + } + } + + /// Convenience method for protecting a function with a read lock. + /// + /// This method guarantees a read lock is always acquired before invoking the + /// [criticalSection] function. It also guarantees the lock is always + /// released. + /// + /// A critical section should always contain asynchronous code, since purely + /// synchronous code does not need to be protected inside a critical section. + /// Therefore, the critical section is a function that returns a _Future_. + /// If the critical section does not need to return a value, it should be + /// defined as returning `Future`. + /// + /// Returns a _Future_ whose value is the value of the _Future_ returned by + /// the critical section. + /// + /// An exception is thrown if the critical section throws an exception, + /// or an exception is thrown while waiting for the _Future_ returned by + /// the critical section to complete. The lock is released, when those + /// exceptions occur. + + Future protectRead(Future Function() criticalSection) async { + await acquireRead(); + try { + return await criticalSection(); + } finally { + release(); + } + } + + /// Convenience method for protecting a function with a write lock. + /// + /// This method guarantees a write lock is always acquired before invoking the + /// [criticalSection] function. It also guarantees the lock is always + /// released. + /// + /// A critical section should always contain asynchronous code, since purely + /// synchronous code does not need to be protected inside a critical section. + /// Therefore, the critical section is a function that returns a _Future_. + /// If the critical section does not need to return a value, it should be + /// defined as returning `Future`. + /// + /// Returns a _Future_ whose value is the value of the _Future_ returned by + /// the critical section. + /// + /// An exception is thrown if the critical section throws an exception, + /// or an exception is thrown while waiting for the _Future_ returned by + /// the critical section to complete. The lock is released, when those + /// exceptions occur. + + Future protectWrite(Future Function() criticalSection) async { + await acquireWrite(); + try { + return await criticalSection(); + } finally { + release(); + } + } + + /// Internal acquire method. + /// + /// Used to acquire a read lock (when [isRead] is true) or a write lock + /// (when [isRead] is false). + /// + /// Returns a Future that completes when the lock has been acquired. + + Future _acquire({required bool isRead}) { + final newJob = _ReadWriteMutexRequest(isRead: isRead); + + if (_waiting.isNotEmpty || !_jobAcquired(newJob)) { + // This new job cannot run yet. There are either other jobs already + // waiting, or there are no waiting jobs but this job cannot start + // because the mutex is currently acquired (namely, either this new job + // or the currently running job is read-write). + // + // Add the new job to the end of the queue. + + _waiting.add(newJob); + } + + return newJob.completer.future; + } + + /// Determine if the [job] can now acquire the lock. + /// + /// If it can acquire the lock, the job's completer is completed, the + /// state updated, and true is returned. If not, false is returned. + /// + /// A job for a read lock can only be acquired if there are no other locks + /// or there are read lock(s). A job for a write lock can only be acquired + /// if there are no other locks. + + bool _jobAcquired(_ReadWriteMutexRequest job) { + assert(-1 <= _state, 'must not be write locked'); + if (_state == 0 || (0 < _state && job.isRead)) { + // Can acquire + _state = (job.isRead) ? (_state + 1) : -1; + job.completer.complete(); + return true; + } else { + return false; + } + } +} diff --git a/packages/mutex/pubspec.yaml b/packages/mutex/pubspec.yaml new file mode 100644 index 0000000..753ae25 --- /dev/null +++ b/packages/mutex/pubspec.yaml @@ -0,0 +1,12 @@ +name: mutex +description: Mutual exclusion with implementation of normal and read-write mutex +version: 3.1.0 +publish_to: none + +environment: + sdk: '>=3.2.0 <4.0.0' + +dev_dependencies: + lint_hard: ^4.0.0 + pana: ^0.22.2 + test: ^1.25.2 diff --git a/packages/mutex/test/mutex_multiple_read_test.dart b/packages/mutex/test/mutex_multiple_read_test.dart new file mode 100644 index 0000000..5ed8345 --- /dev/null +++ b/packages/mutex/test/mutex_multiple_read_test.dart @@ -0,0 +1,102 @@ +// Test contributed by "Cat-sushi" +// + +import 'dart:async'; +// import 'dart:io'; + +import 'package:mutex/mutex.dart'; +import 'package:test/test.dart'; + +//================================================================ +// For debug output +// +// Uncomment the "stdout.write" line in the [debugWrite] method to enable +// debug output. + +int numReadAcquired = 0; +int numReadReleased = 0; + +enum State { waitingToAcquire, acquired, released } + +const stateSymbol = { + State.waitingToAcquire: '?', + State.acquired: '+', + State.released: '-' +}; + +var _outputCount = 0; // to manage line breaks + +void debugOutput(String id, State state) { + debugWrite('$id${stateSymbol[state]} '); + + _outputCount++; + if (_outputCount % 10 == 0) { + debugWrite('\n'); + } +} + +void debugWrite(String str) { + // Uncomment to show what is happening + // stdout.write(str); +} + +//================================================================ + +Future mySleep([int ms = 1000]) async { + await Future.delayed(Duration(milliseconds: ms)); +} + +Future sharedLoop1(ReadWriteMutex mutex, String symbol) async { + while (true) { + debugOutput(symbol, State.waitingToAcquire); + + await mutex.protectRead(() async { + numReadAcquired++; + debugOutput(symbol, State.acquired); + + await mySleep(100); + }); + numReadReleased++; + + debugOutput(symbol, State.released); + } +} + +void main() { + group('exclusive lock tests', () { + test('test1', () async { + const numReadLoops = 5; + + final mutex = ReadWriteMutex(); + + assert(numReadLoops < 26, 'too many read loops for lowercase letters'); + debugWrite('Number of read loops: $numReadLoops\n'); + + for (var x = 0; x < numReadLoops; x++) { + final symbol = String.fromCharCode('a'.codeUnitAt(0) + x); + unawaited(sharedLoop1(mutex, symbol)); + await mySleep(10); + } + + await mySleep(); + + debugWrite('\nAbout to acquireWrite' + ' (reads: acquired=$numReadAcquired released=$numReadReleased' + ' outstanding=${numReadAcquired - numReadReleased})\n'); + _outputCount = 0; // reset line break + + const writeSymbol = 'W'; + + debugOutput(writeSymbol, State.waitingToAcquire); + await mutex.acquireWrite(); + debugOutput(writeSymbol, State.acquired); + mutex.release(); + debugOutput(writeSymbol, State.released); + + debugWrite('\nWrite mutex released\n'); + _outputCount = 0; // reset line break + + expect('a', 'a'); + }); + }); +} diff --git a/packages/mutex/test/mutex_readwrite_test.dart b/packages/mutex/test/mutex_readwrite_test.dart new file mode 100644 index 0000000..310caa1 --- /dev/null +++ b/packages/mutex/test/mutex_readwrite_test.dart @@ -0,0 +1,486 @@ +import 'dart:async'; +import 'package:mutex/mutex.dart'; +import 'package:test/test.dart'; + +//################################################################ + +class RWTester { + int _operation = 0; + final _operationSequences = []; + + /// Execution sequence of the operations done. + /// + /// Each element corresponds to the position of the initial execution + /// order of the read/write operation future. + List get operationSequences => _operationSequences; + + ReadWriteMutex mutex = ReadWriteMutex(); + + /// Set to true to print out read/write to the balance during deposits + static const bool debugOutput = false; + + final DateTime _startTime = DateTime.now(); + + void _debugPrint(String message) { + if (debugOutput) { + final t = DateTime.now().difference(_startTime).inMilliseconds; + // ignore: avoid_print + print('$t: $message'); + } + } + + void reset() { + _operationSequences.clear(); + _debugPrint('reset'); + } + + /// Waits [startDelay] and then invokes critical section with mutex. + /// + /// Writes to [_operationSequences]. If the readwrite locks are respected + /// then the final state of the list will be in ascending order. + Future writing(int startDelay, int sequence, int endDelay) async { + await Future.delayed(Duration(milliseconds: startDelay)); + + await mutex.protectWrite(() async { + final op = ++_operation; + _debugPrint('[$op] write start: <- $_operationSequences'); + final tmp = _operationSequences; + expect(mutex.isWriteLocked, isTrue); + expect(_operationSequences, orderedEquals(tmp)); + // Add the position of operation to the list of operations. + _operationSequences.add(sequence); // add position to list + expect(mutex.isWriteLocked, isTrue); + await Future.delayed(Duration(milliseconds: endDelay)); + _debugPrint('[$op] write finish: -> $_operationSequences'); + }); + } + + /// Waits [startDelay] and then invokes critical section with mutex. + /// + /// + Future reading(int startDelay, int sequence, int endDelay) async { + await Future.delayed(Duration(milliseconds: startDelay)); + + await mutex.protectRead(() async { + final op = ++_operation; + _debugPrint('[$op] read start: <- $_operationSequences'); + expect(mutex.isReadLocked, isTrue); + _operationSequences.add(sequence); // add position to list + await Future.delayed(Duration(milliseconds: endDelay)); + _debugPrint('[$op] read finish: <- $_operationSequences'); + }); + } +} + +//################################################################ + +//---------------------------------------------------------------- + +void main() { + final account = RWTester(); + + setUp(account.reset); + + test('multiple read locks', () async { + await Future.wait([ + account.reading(0, 1, 1000), + account.reading(0, 2, 900), + account.reading(0, 3, 800), + account.reading(0, 4, 700), + account.reading(0, 5, 600), + account.reading(0, 6, 500), + account.reading(0, 7, 400), + account.reading(0, 8, 300), + account.reading(0, 9, 200), + account.reading(0, 10, 100), + ]); + // The first future acquires the lock first and waits the longest to give it + // up. This should however not block any of the other read operations + // as such the reads should finish in ascending orders. + expect( + account.operationSequences, + orderedEquals([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]), + ); + }); + + test('multiple write locks', () async { + await Future.wait([ + account.writing(0, 1, 100), + account.writing(0, 2, 100), + account.writing(0, 3, 100), + ]); + // The first future writes first and holds the lock until 100 ms + // Even though the second future starts execution, the lock cannot be + // acquired until it is released by the first future. + // Therefore the sequence of operations will be in ascending order + // of the futures. + expect( + account.operationSequences, + orderedEquals([1, 2, 3]), + ); + }); + + test('acquireWrite() before acquireRead()', () async { + const lockTimeout = Duration(milliseconds: 100); + + final mutex = ReadWriteMutex(); + + await mutex.acquireWrite(); + expect(mutex.isReadLocked, equals(false)); + expect(mutex.isWriteLocked, equals(true)); + + // Since there is a write lock existing, a read lock cannot be acquired. + final readLock = mutex.acquireRead().timeout(lockTimeout); + expect( + () async => readLock, + throwsA(isA()), + ); + }); + + test('acquireRead() before acquireWrite()', () async { + const lockTimeout = Duration(milliseconds: 100); + + final mutex = ReadWriteMutex(); + + await mutex.acquireRead(); + expect(mutex.isReadLocked, equals(true)); + expect(mutex.isWriteLocked, equals(false)); + + // Since there is a read lock existing, a write lock cannot be acquired. + final writeLock = mutex.acquireWrite().timeout(lockTimeout); + expect( + () async => writeLock, + throwsA(isA()), + ); + }); + + test('mixture of read write locks execution order', () async { + await Future.wait([ + account.reading(0, 1, 100), + account.reading(10, 2, 100), + account.reading(20, 3, 100), + account.writing(30, 4, 100), + account.writing(40, 5, 100), + account.writing(50, 6, 100), + ]); + + expect( + account.operationSequences, + orderedEquals([1, 2, 3, 4, 5, 6]), + ); + }); + + group('protectRead', () { + test('lock obtained and released on success', () async { + final m = ReadWriteMutex(); + + await m.protectRead(() async { + // critical section + expect(m.isLocked, isTrue); + }); + expect(m.isLocked, isFalse); + }); + + test('value returned from critical section', () async { + // These are the normal scenario of the critical section running + // successfully. It tests different return types from the + // critical section. + + final m = ReadWriteMutex(); + + // returns Future + await m.protectRead(() async {}); + + // returns Future + final number = await m.protectRead(() async => 42); + expect(number, equals(42)); + + // returns Future completes with value + final optionalNumber = await m.protectRead(() async => 1024); + expect(optionalNumber, equals(1024)); + + // returns Future completes with null + final optionalNumberNull = await m.protectRead(() async => null); + expect(optionalNumberNull, isNull); + + // returns Future + final word = await m.protectRead(() async => 'foobar'); + expect(word, equals('foobar')); + + // returns Future completes with value + final optionalWord = await m.protectRead(() async => 'baz'); + expect(optionalWord, equals('baz')); + + // returns Future completes with null + final optionalWordNull = await m.protectRead(() async => null); + expect(optionalWordNull, isNull); + + expect(m.isLocked, isFalse); + }); + + test('exception in synchronous code', () async { + // Tests what happens when an exception is raised in the **synchronous** + // part of the critical section. + // + // Locks are correctly managed: the lock is obtained before executing + // the critical section, and is released when the exception is thrown + // by the _protect_ method. + // + // The exception is raised when waiting for the Future returned by + // _protect_ to complete. Even though the exception is synchronously + // raised by the critical section, it won't be thrown when _protect_ + // is invoked. The _protect_ method always successfully returns a + // _Future_. + + Future criticalSection() { + final c = Completer()..complete(42); + + // synchronous exception + throw const FormatException('synchronous exception'); + // ignore: dead_code + return c.future; + } + + // Check the criticalSection behaves as expected for the test + + try { + // ignore: unused_local_variable + final resultFuture = criticalSection(); + fail('critical section did not throw synchronous exception'); + } on FormatException { + // expected: invoking the criticalSection results in the exception + } + + final m = ReadWriteMutex(); + + try { + // Invoke protect to get the Future (this should succeed) + final resultFuture = m.protectRead(criticalSection); + expect(resultFuture, isA>()); + + // Wait for the Future (this should fail) + final result = await resultFuture; + expect(result, isNotNull); + fail('exception not thrown'); + } on FormatException catch (e) { + expect(m.isLocked, isFalse); + expect(e.message, equals('synchronous exception')); + } + + expect(m.isLocked, isFalse); + }); + + test('exception in asynchronous code', () async { + // Tests what happens when an exception is raised in the **asynchronous** + // part of the critical section. + // + // Locks are correctly managed: the lock is obtained before executing + // the critical section, and is released when the exception is thrown + // by the _protect_ method. + // + // The exception is raised when waiting for the Future returned by + // _protect_ to complete. + + Future criticalSection() async { + final c = Completer()..complete(42); + + await Future.delayed(const Duration(seconds: 1), () {}); + + // asynchronous exception (since it must wait for the above line) + throw const FormatException('asynchronous exception'); + // ignore: dead_code + return c.future; + } + + // Check the criticalSection behaves as expected for the test + + final resultFuture = criticalSection(); + expect(resultFuture, isA>()); + // invoking the criticalSection does not result in the exception + try { + await resultFuture; + fail('critical section did not throw asynchronous exception'); + } on FormatException { + // expected: exception happens on the await + } + + final m = ReadWriteMutex(); + + try { + // Invoke protect to get the Future (this should succeed) + final resultFuture = m.protectRead(criticalSection); + expect(resultFuture, isA>()); + + // Even though the criticalSection throws the exception in synchronous + // code, protect causes it to become an asynchronous exception. + + // Wait for the Future (this should fail) + final result = await resultFuture; + expect(result, isNotNull); + fail('exception not thrown'); + } on FormatException catch (e) { + expect(m.isLocked, isFalse); + expect(e.message, equals('asynchronous exception')); + } + + expect(m.isLocked, isFalse); + }); + }); + + group('protectWrite', () { + test('lock obtained and released on success', () async { + final m = ReadWriteMutex(); + + await m.protectWrite(() async { + // critical section + expect(m.isLocked, isTrue); + }); + expect(m.isLocked, isFalse); + }); + + test('value returned from critical section', () async { + // These are the normal scenario of the critical section running + // successfully. It tests different return types from the + // critical section. + + final m = ReadWriteMutex(); + + // returns Future + await m.protectWrite(() async {}); + + // returns Future + final number = await m.protectWrite(() async => 42); + expect(number, equals(42)); + + // returns Future completes with value + final optionalNumber = await m.protectWrite(() async => 1024); + expect(optionalNumber, equals(1024)); + + // returns Future completes with null + final optionalNumberNull = await m.protectWrite(() async => null); + expect(optionalNumberNull, isNull); + + // returns Future + final word = await m.protectWrite(() async => 'foobar'); + expect(word, equals('foobar')); + + // returns Future completes with value + final optionalWord = await m.protectWrite(() async => 'baz'); + expect(optionalWord, equals('baz')); + + // returns Future completes with null + final optionalWordNull = await m.protectWrite(() async => null); + expect(optionalWordNull, isNull); + + expect(m.isLocked, isFalse); + }); + + test('exception in synchronous code', () async { + // Tests what happens when an exception is raised in the **synchronous** + // part of the critical section. + // + // Locks are correctly managed: the lock is obtained before executing + // the critical section, and is released when the exception is thrown + // by the _protect_ method. + // + // The exception is raised when waiting for the Future returned by + // _protect_ to complete. Even though the exception is synchronously + // raised by the critical section, it won't be thrown when _protect_ + // is invoked. The _protect_ method always successfully returns a + // _Future_. + + Future criticalSection() { + final c = Completer()..complete(42); + + // synchronous exception + throw const FormatException('synchronous exception'); + // ignore: dead_code + return c.future; + } + + // Check the criticalSection behaves as expected for the test + + try { + // ignore: unused_local_variable + final resultFuture = criticalSection(); + fail('critical section did not throw synchronous exception'); + } on FormatException { + // expected: invoking the criticalSection results in the exception + } + + final m = ReadWriteMutex(); + + try { + // Invoke protect to get the Future (this should succeed) + final resultFuture = m.protectWrite(criticalSection); + expect(resultFuture, isA>()); + + // Wait for the Future (this should fail) + final result = await resultFuture; + expect(result, isNotNull); + fail('exception not thrown'); + } on FormatException catch (e) { + expect(m.isLocked, isFalse); + expect(e.message, equals('synchronous exception')); + } + + expect(m.isLocked, isFalse); + }); + + test('exception in asynchronous code', () async { + // Tests what happens when an exception is raised in the **asynchronous** + // part of the critical section. + // + // Locks are correctly managed: the lock is obtained before executing + // the critical section, and is released when the exception is thrown + // by the _protect_ method. + // + // The exception is raised when waiting for the Future returned by + // _protect_ to complete. + + Future criticalSection() async { + final c = Completer()..complete(42); + + await Future.delayed(const Duration(seconds: 1), () {}); + + // asynchronous exception (since it must wait for the above line) + throw const FormatException('asynchronous exception'); + // ignore: dead_code + return c.future; + } + + // Check the criticalSection behaves as expected for the test + + final resultFuture = criticalSection(); + expect(resultFuture, isA>()); + // invoking the criticalSection does not result in the exception + try { + await resultFuture; + fail('critical section did not throw asynchronous exception'); + } on FormatException { + // expected: exception happens on the await + } + + final m = ReadWriteMutex(); + + try { + // Invoke protect to get the Future (this should succeed) + final resultFuture = m.protectWrite(criticalSection); + expect(resultFuture, isA>()); + + // Even though the criticalSection throws the exception in synchronous + // code, protect causes it to become an asynchronous exception. + + // Wait for the Future (this should fail) + final result = await resultFuture; + expect(result, isNotNull); + fail('exception not thrown'); + } on FormatException catch (e) { + expect(m.isLocked, isFalse); + expect(e.message, equals('asynchronous exception')); + } + + expect(m.isLocked, isFalse); + }); + }); +} diff --git a/packages/mutex/test/mutex_test.dart b/packages/mutex/test/mutex_test.dart new file mode 100644 index 0000000..0db5f52 --- /dev/null +++ b/packages/mutex/test/mutex_test.dart @@ -0,0 +1,341 @@ +import 'dart:async'; +import 'package:mutex/mutex.dart'; +import 'package:test/test.dart'; + +//################################################################ +/// Account simulating the classic "simultaneous update" concurrency problem. +/// +/// The deposit operation reads the balance, waits for a short time (where +/// problems can occur if the balance is changed) and then writes out the +/// new balance. +/// +class Account { + int get balance => _balance; + int _balance = 0; + + int _operation = 0; + + Mutex mutex = Mutex(); + + /// Set to true to print out read/write to the balance during deposits + static const bool debugOutput = false; + + /// Time used for calculating time offsets in debug messages. + final DateTime _startTime = DateTime.now(); + + void _debugPrint(String message) { + if (debugOutput) { + final t = DateTime.now().difference(_startTime).inMilliseconds; + // ignore: avoid_print + print('$t: $message'); + } + } + + void reset([int startingBalance = 0]) { + _balance = startingBalance; + _debugPrint('reset: balance = $_balance'); + } + + /// Waits [startDelay] and then invokes critical section without mutex. + /// + Future depositUnsafe( + int amount, int startDelay, int dangerWindow) async { + await Future.delayed(Duration(milliseconds: startDelay)); + + await _depositCriticalSection(amount, dangerWindow); + } + + /// Waits [startDelay] and then invokes critical section with mutex. + /// + Future depositWithMutex( + int amount, int startDelay, int dangerWindow) async { + await Future.delayed(Duration(milliseconds: startDelay)); + + await mutex.acquire(); + try { + expect(mutex.isLocked, isTrue); + await _depositCriticalSection(amount, dangerWindow); + expect(mutex.isLocked, isTrue); + } finally { + mutex.release(); + } + } + + /// Critical section of adding [amount] to the balance. + /// + /// Reads the balance, then sleeps for [dangerWindow] milliseconds, before + /// saving the new balance. If not protected, another invocation of this + /// method while it is sleeping will read the balance before it is updated. + /// The one that saves its balance last will overwrite the earlier saved + /// balances (effectively those other deposits will be lost). + /// + Future _depositCriticalSection(int amount, int dangerWindow) async { + final op = ++_operation; + + _debugPrint('[$op] read balance: $_balance'); + + final tmp = _balance; + + await Future.delayed(Duration(milliseconds: dangerWindow)); + + _balance = tmp + amount; + + _debugPrint('[$op] write balance: $_balance (= $tmp + $amount)'); + } +} + +//################################################################ + +//---------------------------------------------------------------- + +void main() { + const correctBalance = 68; + + final account = Account(); + + test('without mutex', () async { + // First demonstrate that without mutex incorrect results are produced. + + // Without mutex produces incorrect result + // 000. a reads 0 + // 025. b reads 0 + // 050. a writes 42 + // 075. b writes 26 + account.reset(); + await Future.wait([ + account.depositUnsafe(42, 0, 50), + account.depositUnsafe(26, 25, 50) // result overwrites first deposit + ]); + expect(account.balance, equals(26)); // incorrect: first deposit lost + + // Without mutex produces incorrect result + // 000. b reads 0 + // 025. a reads 0 + // 050. b writes 26 + // 075. a writes 42 + account.reset(); + await Future.wait([ + account.depositUnsafe(42, 25, 50), // result overwrites second deposit + account.depositUnsafe(26, 0, 50) + ]); + expect(account.balance, equals(42)); // incorrect: second deposit lost + }); + + test('with mutex', () async { +// Test correct results are produced with mutex + + // With mutex produces correct result + // 000. a acquires lock + // 000. a reads 0 + // 025. b is blocked + // 050. a writes 42 + // 050. a releases lock + // 050. b acquires lock + // 050. b reads 42 + // 100. b writes 68 + account.reset(); + await Future.wait([ + account.depositWithMutex(42, 0, 50), + account.depositWithMutex(26, 25, 50) + ]); + expect(account.balance, equals(correctBalance)); + + // With mutex produces correct result + // 000. b acquires lock + // 000. b reads 0 + // 025. a is blocked + // 050. b writes 26 + // 050. b releases lock + // 050. a acquires lock + // 050. a reads 26 + // 100. a writes 68 + account.reset(); + await Future.wait([ + account.depositWithMutex(42, 25, 50), + account.depositWithMutex(26, 0, 50) + ]); + expect(account.balance, equals(correctBalance)); + }); + + test('multiple acquires are serialized', () async { + // Demonstrate that sections running in a mutex are effectively serialized + const delay = 200; // milliseconds + account.reset(); + await Future.wait([ + account.depositWithMutex(1, 0, delay), + account.depositWithMutex(1, 0, delay), + account.depositWithMutex(1, 0, delay), + account.depositWithMutex(1, 0, delay), + account.depositWithMutex(1, 0, delay), + account.depositWithMutex(1, 0, delay), + account.depositWithMutex(1, 0, delay), + account.depositWithMutex(1, 0, delay), + account.depositWithMutex(1, 0, delay), + account.depositWithMutex(1, 0, delay), + ]); + expect(account.balance, equals(10)); + }); + + group('protect', () { + test('lock obtained and released on success', () async { + // This is the normal scenario of the critical section running + // successfully. The lock is acquired before running the critical + // section, and it is released after it runs (and will remain + // unlocked after the _protect_ method returns). + + final m = Mutex(); + + await m.protect(() async { + // critical section: returns Future + expect(m.isLocked, isTrue); + }); + + expect(m.isLocked, isFalse); + }); + + test('value returned from critical section', () async { + // These are the normal scenario of the critical section running + // successfully. It tests different return types from the + // critical section. + + final m = Mutex(); + + // returns Future + await m.protect(() async {}); + + // returns Future + final number = await m.protect(() async => 42); + expect(number, equals(42)); + + // returns Future completes with value + final optionalNumber = await m.protect(() async => 1024); + expect(optionalNumber, equals(1024)); + + // returns Future completes with null + final optionalNumberNull = await m.protect(() async => null); + expect(optionalNumberNull, isNull); + + // returns Future + final word = await m.protect(() async => 'foobar'); + expect(word, equals('foobar')); + + // returns Future completes with value + final optionalWord = await m.protect(() async => 'baz'); + expect(optionalWord, equals('baz')); + + // returns Future completes with null + final optionalWordNull = await m.protect(() async => null); + expect(optionalWordNull, isNull); + + expect(m.isLocked, isFalse); + }); + + test('exception in synchronous code', () async { + // Tests what happens when an exception is raised in the **synchronous** + // part of the critical section. + // + // Locks are correctly managed: the lock is obtained before executing + // the critical section, and is released when the exception is thrown + // by the _protect_ method. + // + // The exception is raised when waiting for the Future returned by + // _protect_ to complete. Even though the exception is synchronously + // raised by the critical section, it won't be thrown when _protect_ + // is invoked. The _protect_ method always successfully returns a + // _Future_. + + Future criticalSection() { + final c = Completer()..complete(42); + + // synchronous exception + throw const FormatException('synchronous exception'); + // ignore: dead_code + return c.future; + } + + // Check the criticalSection behaves as expected for the test + + try { + // ignore: unused_local_variable + final resultFuture = criticalSection(); + fail('critical section did not throw synchronous exception'); + } on FormatException { + // expected: invoking the criticalSection results in the exception + } + + final m = Mutex(); + + try { + // Invoke protect to get the Future (this should succeed) + final resultFuture = m.protect(criticalSection); + expect(resultFuture, isA>()); + + // Wait for the Future (this should fail) + final result = await resultFuture; + expect(result, isNotNull); + fail('exception not thrown'); + } on FormatException catch (e) { + expect(m.isLocked, isFalse); + expect(e.message, equals('synchronous exception')); + } + + expect(m.isLocked, isFalse); + }); + + test('exception in asynchronous code', () async { + // Tests what happens when an exception is raised in the **asynchronous** + // part of the critical section. + // + // Locks are correctly managed: the lock is obtained before executing + // the critical section, and is released when the exception is thrown + // by the _protect_ method. + // + // The exception is raised when waiting for the Future returned by + // _protect_ to complete. + + Future criticalSection() async { + final c = Completer()..complete(42); + + await Future.delayed(const Duration(seconds: 1), () {}); + + // asynchronous exception (since it must wait for the above line) + throw const FormatException('asynchronous exception'); + // ignore: dead_code + return c.future; + } + + // Check the criticalSection behaves as expected for the test + + final resultFuture = criticalSection(); + expect(resultFuture, isA>()); + // invoking the criticalSection does not result in the exception + try { + await resultFuture; + fail('critical section did not throw asynchronous exception'); + } on FormatException { + // expected: exception happens on the await + } + + final m = Mutex(); + + try { + // Invoke protect to get the Future (this should succeed) + final resultFuture = m.protect(criticalSection); + expect(resultFuture, isA>()); + + // Even though the criticalSection throws the exception in synchronous + // code, protect causes it to become an asynchronous exception. + + // Wait for the Future (this should fail) + final result = await resultFuture; + expect(result, isNotNull); + fail('exception not thrown'); + } on FormatException catch (e) { + expect(m.isLocked, isFalse); + expect(e.message, equals('asynchronous exception')); + } + + expect(m.isLocked, isFalse); + }); + }); +} diff --git a/packages/veilid_support/.gitignore b/packages/veilid_support/.gitignore new file mode 100644 index 0000000..df26946 --- /dev/null +++ b/packages/veilid_support/.gitignore @@ -0,0 +1,56 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ + +# Flutter generated files +# Not doing this at this time: https://stackoverflow.com/questions/56110386/should-i-commit-generated-code-in-flutter-dart-to-vcs +# *.g.dart +# *.freezed.dart +# *.pb.dart +# *.pbenum.dart +# *.pbjson.dart +# *.pbserver.dart + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + +# WASM +/web/wasm/ diff --git a/packages/veilid_support/analysis_options.yaml b/packages/veilid_support/analysis_options.yaml new file mode 100644 index 0000000..e1620f7 --- /dev/null +++ b/packages/veilid_support/analysis_options.yaml @@ -0,0 +1,15 @@ +include: package:lint_hard/all.yaml +analyzer: + errors: + invalid_annotation_target: ignore + exclude: + - '**/*.g.dart' + - '**/*.freezed.dart' + - '**/*.pb.dart' + - '**/*.pbenum.dart' + - '**/*.pbjson.dart' + - '**/*.pbserver.dart' +linter: + rules: + unawaited_futures: true + avoid_positional_boolean_parameters: false \ No newline at end of file diff --git a/packages/veilid_support/build.bat b/packages/veilid_support/build.bat new file mode 100644 index 0000000..88d2bb0 --- /dev/null +++ b/packages/veilid_support/build.bat @@ -0,0 +1,7 @@ +@echo off +dart run build_runner build --delete-conflicting-outputs + +pushd lib +protoc --dart_out=proto -I proto -I dht_support\proto dht.proto +protoc --dart_out=proto -I proto veilid.proto +popd diff --git a/packages/veilid_support/build.sh b/packages/veilid_support/build.sh new file mode 100755 index 0000000..0c43bc6 --- /dev/null +++ b/packages/veilid_support/build.sh @@ -0,0 +1,8 @@ +#!/bin/bash +set -e +dart run build_runner build --delete-conflicting-outputs + +pushd lib > /dev/null +protoc --dart_out=proto -I proto -I dht_support/proto dht.proto +protoc --dart_out=proto -I proto veilid.proto +popd > /dev/null diff --git a/packages/veilid_support/lib/dht_support/dht_support.dart b/packages/veilid_support/lib/dht_support/dht_support.dart new file mode 100644 index 0000000..869a267 --- /dev/null +++ b/packages/veilid_support/lib/dht_support/dht_support.dart @@ -0,0 +1,6 @@ +/// Support functions for Veilid DHT data structures + +library dht_support; + +export 'src/dht_record/barrel.dart'; +export 'src/dht_short_array/barrel.dart'; diff --git a/lib/veilid_support/dht_support/proto/dht.proto b/packages/veilid_support/lib/dht_support/proto/dht.proto similarity index 96% rename from lib/veilid_support/dht_support/proto/dht.proto rename to packages/veilid_support/lib/dht_support/proto/dht.proto index 9ad53b6..087cc9c 100644 --- a/lib/veilid_support/dht_support/proto/dht.proto +++ b/packages/veilid_support/lib/dht_support/proto/dht.proto @@ -42,6 +42,10 @@ message DHTShortArray { // key = idx / stride // subkey = idx % stride bytes index = 2; + + // Most recent sequence numbers for elements + repeated uint32 seqs = 3; + // Free items are not represented in the list but can be // calculated through iteration } diff --git a/packages/veilid_support/lib/dht_support/proto/proto.dart b/packages/veilid_support/lib/dht_support/proto/proto.dart new file mode 100644 index 0000000..6b36970 --- /dev/null +++ b/packages/veilid_support/lib/dht_support/proto/proto.dart @@ -0,0 +1,25 @@ +import '../../proto/dht.pb.dart' as dhtproto; +import '../../proto/proto.dart' as veilidproto; +import '../dht_support.dart'; + +export '../../proto/dht.pb.dart'; +export '../../proto/dht.pbenum.dart'; +export '../../proto/dht.pbjson.dart'; +export '../../proto/dht.pbserver.dart'; +export '../../proto/proto.dart'; + +/// OwnedDHTRecordPointer protobuf marshaling +/// +extension OwnedDHTRecordPointerProto on OwnedDHTRecordPointer { + dhtproto.OwnedDHTRecordPointer toProto() { + final out = dhtproto.OwnedDHTRecordPointer() + ..recordKey = recordKey.toProto() + ..owner = owner.toProto(); + return out; + } +} + +extension ProtoOwnedDHTRecordPointer on dhtproto.OwnedDHTRecordPointer { + OwnedDHTRecordPointer toVeilid() => OwnedDHTRecordPointer( + recordKey: recordKey.toVeilid(), owner: owner.toVeilid()); +} diff --git a/packages/veilid_support/lib/dht_support/src/dht_record/barrel.dart b/packages/veilid_support/lib/dht_support/src/dht_record/barrel.dart new file mode 100644 index 0000000..a1e3099 --- /dev/null +++ b/packages/veilid_support/lib/dht_support/src/dht_record/barrel.dart @@ -0,0 +1,3 @@ +export 'dht_record_crypto.dart'; +export 'dht_record_cubit.dart'; +export 'dht_record_pool.dart'; diff --git a/packages/veilid_support/lib/dht_support/src/dht_record/dht_record.dart b/packages/veilid_support/lib/dht_support/src/dht_record/dht_record.dart new file mode 100644 index 0000000..fa98b9d --- /dev/null +++ b/packages/veilid_support/lib/dht_support/src/dht_record/dht_record.dart @@ -0,0 +1,383 @@ +part of 'dht_record_pool.dart'; + +@immutable +class DHTRecordWatchChange extends Equatable { + const DHTRecordWatchChange( + {required this.local, required this.data, required this.subkeys}); + + final bool local; + final Uint8List? data; + final List subkeys; + + @override + List get props => [local, data, subkeys]; +} + +///////////////////////////////////////////////// + +class DHTRecord { + DHTRecord( + {required VeilidRoutingContext routingContext, + required SharedDHTRecordData sharedDHTRecordData, + required int defaultSubkey, + required KeyPair? writer, + required DHTRecordCrypto crypto}) + : _crypto = crypto, + _routingContext = routingContext, + _defaultSubkey = defaultSubkey, + _writer = writer, + _open = true, + _sharedDHTRecordData = sharedDHTRecordData; + + final SharedDHTRecordData _sharedDHTRecordData; + final VeilidRoutingContext _routingContext; + final int _defaultSubkey; + final KeyPair? _writer; + final DHTRecordCrypto _crypto; + + bool _open; + @internal + StreamController? watchController; + @internal + WatchState? watchState; + + int subkeyOrDefault(int subkey) => (subkey == -1) ? _defaultSubkey : subkey; + + VeilidRoutingContext get routingContext => _routingContext; + TypedKey get key => _sharedDHTRecordData.recordDescriptor.key; + PublicKey get owner => _sharedDHTRecordData.recordDescriptor.owner; + KeyPair? get ownerKeyPair => + _sharedDHTRecordData.recordDescriptor.ownerKeyPair(); + DHTSchema get schema => _sharedDHTRecordData.recordDescriptor.schema; + int get subkeyCount => + _sharedDHTRecordData.recordDescriptor.schema.subkeyCount(); + KeyPair? get writer => _writer; + DHTRecordCrypto get crypto => _crypto; + OwnedDHTRecordPointer get ownedDHTRecordPointer => + OwnedDHTRecordPointer(recordKey: key, owner: ownerKeyPair!); + + Future close() async { + if (!_open) { + return; + } + await watchController?.close(); + await DHTRecordPool.instance._recordClosed(this); + _open = false; + } + + Future scope(Future Function(DHTRecord) scopeFunction) async { + try { + return await scopeFunction(this); + } finally { + await close(); + } + } + + Future deleteScope(Future Function(DHTRecord) scopeFunction) async { + try { + final out = await scopeFunction(this); + if (_open) { + await close(); + } + return out; + } on Exception catch (_) { + if (_open) { + await close(); + } + await DHTRecordPool.instance.delete(key); + rethrow; + } + } + + Future maybeDeleteScope( + bool delete, Future Function(DHTRecord) scopeFunction) async { + if (delete) { + return deleteScope(scopeFunction); + } else { + return scope(scopeFunction); + } + } + + Future get( + {int subkey = -1, + bool forceRefresh = false, + bool onlyUpdates = false}) async { + subkey = subkeyOrDefault(subkey); + final valueData = await _routingContext.getDHTValue(key, subkey, + forceRefresh: forceRefresh); + if (valueData == null) { + return null; + } + final lastSeq = _sharedDHTRecordData.subkeySeqCache[subkey]; + if (onlyUpdates && lastSeq != null && valueData.seq <= lastSeq) { + return null; + } + final out = _crypto.decrypt(valueData.data, subkey); + _sharedDHTRecordData.subkeySeqCache[subkey] = valueData.seq; + return out; + } + + Future getJson(T Function(dynamic) fromJson, + {int subkey = -1, + bool forceRefresh = false, + bool onlyUpdates = false}) async { + final data = await get( + subkey: subkey, forceRefresh: forceRefresh, onlyUpdates: onlyUpdates); + if (data == null) { + return null; + } + return jsonDecodeBytes(fromJson, data); + } + + Future getProtobuf( + T Function(List i) fromBuffer, + {int subkey = -1, + bool forceRefresh = false, + bool onlyUpdates = false}) async { + final data = await get( + subkey: subkey, forceRefresh: forceRefresh, onlyUpdates: onlyUpdates); + if (data == null) { + return null; + } + return fromBuffer(data.toList()); + } + + Future tryWriteBytes(Uint8List newValue, + {int subkey = -1}) async { + subkey = subkeyOrDefault(subkey); + final lastSeq = _sharedDHTRecordData.subkeySeqCache[subkey]; + final encryptedNewValue = await _crypto.encrypt(newValue, subkey); + + // Set the new data if possible + var newValueData = await _routingContext + .setDHTValue(key, subkey, encryptedNewValue, writer: _writer); + if (newValueData == null) { + // A newer value wasn't found on the set, but + // we may get a newer value when getting the value for the sequence number + newValueData = await _routingContext.getDHTValue(key, subkey); + if (newValueData == null) { + assert(newValueData != null, "can't get value that was just set"); + return null; + } + } + + // Record new sequence number + final isUpdated = newValueData.seq != lastSeq; + _sharedDHTRecordData.subkeySeqCache[subkey] = newValueData.seq; + + // See if the encrypted data returned is exactly the same + // if so, shortcut and don't bother decrypting it + if (newValueData.data.equals(encryptedNewValue)) { + if (isUpdated) { + DHTRecordPool.instance.processLocalValueChange(key, newValue, subkey); + } + return null; + } + + // Decrypt value to return it + final decryptedNewValue = await _crypto.decrypt(newValueData.data, subkey); + if (isUpdated) { + DHTRecordPool.instance + .processLocalValueChange(key, decryptedNewValue, subkey); + } + return decryptedNewValue; + } + + Future eventualWriteBytes(Uint8List newValue, {int subkey = -1}) async { + subkey = subkeyOrDefault(subkey); + final lastSeq = _sharedDHTRecordData.subkeySeqCache[subkey]; + final encryptedNewValue = await _crypto.encrypt(newValue, subkey); + + ValueData? newValueData; + do { + do { + // Set the new data + newValueData = await _routingContext + .setDHTValue(key, subkey, encryptedNewValue, writer: _writer); + + // Repeat if newer data on the network was found + } while (newValueData != null); + + // Get the data to check its sequence number + newValueData = await _routingContext.getDHTValue(key, subkey); + if (newValueData == null) { + assert(newValueData != null, "can't get value that was just set"); + return; + } + + // Record new sequence number + _sharedDHTRecordData.subkeySeqCache[subkey] = newValueData.seq; + + // The encrypted data returned should be exactly the same + // as what we are trying to set, + // otherwise we still need to keep trying to set the value + } while (!newValueData.data.equals(encryptedNewValue)); + + final isUpdated = newValueData.seq != lastSeq; + if (isUpdated) { + DHTRecordPool.instance.processLocalValueChange(key, newValue, subkey); + } + } + + Future eventualUpdateBytes( + Future Function(Uint8List? oldValue) update, + {int subkey = -1}) async { + subkey = subkeyOrDefault(subkey); + + // Get the existing data, do not allow force refresh here + // because if we need a refresh the setDHTValue will fail anyway + var oldValue = await get(subkey: subkey); + + do { + // Update the data + final updatedValue = await update(oldValue); + + // Try to write it back to the network + oldValue = await tryWriteBytes(updatedValue, subkey: subkey); + + // Repeat update if newer data on the network was found + } while (oldValue != null); + } + + Future tryWriteJson(T Function(dynamic) fromJson, T newValue, + {int subkey = -1}) => + tryWriteBytes(jsonEncodeBytes(newValue), subkey: subkey).then((out) { + if (out == null) { + return null; + } + return jsonDecodeBytes(fromJson, out); + }); + + Future tryWriteProtobuf( + T Function(List) fromBuffer, T newValue, + {int subkey = -1}) => + tryWriteBytes(newValue.writeToBuffer(), subkey: subkey).then((out) { + if (out == null) { + return null; + } + return fromBuffer(out); + }); + + Future eventualWriteJson(T newValue, {int subkey = -1}) => + eventualWriteBytes(jsonEncodeBytes(newValue), subkey: subkey); + + Future eventualWriteProtobuf(T newValue, + {int subkey = -1}) => + eventualWriteBytes(newValue.writeToBuffer(), subkey: subkey); + + Future eventualUpdateJson( + T Function(dynamic) fromJson, Future Function(T?) update, + {int subkey = -1}) => + eventualUpdateBytes(jsonUpdate(fromJson, update), subkey: subkey); + + Future eventualUpdateProtobuf( + T Function(List) fromBuffer, Future Function(T?) update, + {int subkey = -1}) => + eventualUpdateBytes(protobufUpdate(fromBuffer, update), subkey: subkey); + + Future watch( + {List? subkeys, + Timestamp? expiration, + int? count}) async { + // Set up watch requirements which will get picked up by the next tick + final oldWatchState = watchState; + watchState = + WatchState(subkeys: subkeys, expiration: expiration, count: count); + if (oldWatchState != watchState) { + _sharedDHTRecordData.needsWatchStateUpdate = true; + } + } + + Future> listen( + Future Function( + DHTRecord record, Uint8List? data, List subkeys) + onUpdate, + {bool localChanges = true}) async { + // Set up watch requirements + watchController ??= + StreamController.broadcast(onCancel: () { + // If there are no more listeners then we can get rid of the controller + watchController = null; + }); + + return watchController!.stream.listen( + (change) { + if (change.local && !localChanges) { + return; + } + Future.delayed(Duration.zero, () async { + final Uint8List? data; + if (change.local) { + // local changes are not encrypted + data = change.data; + } else { + // incoming/remote changes are encrypted + final changeData = change.data; + data = changeData == null + ? null + : await _crypto.decrypt(changeData, change.subkeys.first.low); + } + await onUpdate(this, data, change.subkeys); + }); + }, + cancelOnError: true, + onError: (e) async { + await watchController!.close(); + watchController = null; + }); + } + + Future cancelWatch() async { + // Tear down watch requirements + if (watchState != null) { + watchState = null; + _sharedDHTRecordData.needsWatchStateUpdate = true; + } + } + + Future inspect( + {List? subkeys, + DHTReportScope scope = DHTReportScope.local}) => + _routingContext.inspectDHTRecord(key, subkeys: subkeys, scope: scope); + + void _addValueChange( + {required bool local, + required Uint8List? data, + required List subkeys}) { + final ws = watchState; + if (ws != null) { + final watchedSubkeys = ws.subkeys; + if (watchedSubkeys == null) { + // Report all subkeys + watchController?.add( + DHTRecordWatchChange(local: local, data: data, subkeys: subkeys)); + } else { + // Only some subkeys are being watched, see if the reported update + // overlaps the subkeys being watched + final overlappedSubkeys = watchedSubkeys.intersectSubkeys(subkeys); + // If the reported data isn't within the + // range we care about, don't pass it through + final overlappedFirstSubkey = overlappedSubkeys.firstSubkey; + final updateFirstSubkey = subkeys.firstSubkey; + final updatedData = (overlappedFirstSubkey != null && + updateFirstSubkey != null && + overlappedFirstSubkey == updateFirstSubkey) + ? data + : null; + // Report only wathced subkeys + watchController?.add(DHTRecordWatchChange( + local: local, data: updatedData, subkeys: overlappedSubkeys)); + } + } + } + + void _addLocalValueChange(Uint8List data, int subkey) { + _addValueChange( + local: true, data: data, subkeys: [ValueSubkeyRange.single(subkey)]); + } + + void _addRemoteValueChange(VeilidUpdateValueChange update) { + _addValueChange( + local: false, data: update.value?.data, subkeys: update.subkeys); + } +} diff --git a/lib/veilid_support/dht_support/src/dht_record_crypto.dart b/packages/veilid_support/lib/dht_support/src/dht_record/dht_record_crypto.dart similarity index 76% rename from lib/veilid_support/dht_support/src/dht_record_crypto.dart rename to packages/veilid_support/lib/dht_support/src/dht_record/dht_record_crypto.dart index 41a8949..0e69078 100644 --- a/lib/veilid_support/dht_support/src/dht_record_crypto.dart +++ b/packages/veilid_support/lib/dht_support/src/dht_record/dht_record_crypto.dart @@ -1,10 +1,10 @@ import 'dart:async'; import 'dart:typed_data'; -import '../../veilid_support.dart'; +import '../../../../../veilid_support.dart'; abstract class DHTRecordCrypto { - FutureOr encrypt(Uint8List data, int subkey); - FutureOr decrypt(Uint8List data, int subkey); + Future encrypt(Uint8List data, int subkey); + Future decrypt(Uint8List data, int subkey); } //////////////////////////////////// @@ -32,11 +32,11 @@ class DHTRecordCryptoPrivate implements DHTRecordCrypto { } @override - FutureOr encrypt(Uint8List data, int subkey) => + Future encrypt(Uint8List data, int subkey) => _cryptoSystem.encryptNoAuthWithNonce(data, _secretKey); @override - FutureOr decrypt(Uint8List data, int subkey) => + Future decrypt(Uint8List data, int subkey) => _cryptoSystem.decryptNoAuthWithNonce(data, _secretKey); } @@ -46,8 +46,8 @@ class DHTRecordCryptoPublic implements DHTRecordCrypto { const DHTRecordCryptoPublic(); @override - FutureOr encrypt(Uint8List data, int subkey) => data; + Future encrypt(Uint8List data, int subkey) async => data; @override - FutureOr decrypt(Uint8List data, int subkey) => data; + Future decrypt(Uint8List data, int subkey) async => data; } diff --git a/packages/veilid_support/lib/dht_support/src/dht_record/dht_record_cubit.dart b/packages/veilid_support/lib/dht_support/src/dht_record/dht_record_cubit.dart new file mode 100644 index 0000000..647c431 --- /dev/null +++ b/packages/veilid_support/lib/dht_support/src/dht_record/dht_record_cubit.dart @@ -0,0 +1,175 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:async_tools/async_tools.dart'; +import 'package:bloc/bloc.dart'; + +import '../../../veilid_support.dart'; + +typedef InitialStateFunction = Future Function(DHTRecord); +typedef StateFunction = Future Function( + DHTRecord, List, Uint8List?); +typedef WatchFunction = Future Function(DHTRecord); + +class DHTRecordCubit extends Cubit> { + DHTRecordCubit({ + required Future Function() open, + required InitialStateFunction initialStateFunction, + required StateFunction stateFunction, + required WatchFunction watchFunction, + }) : _wantsCloseRecord = false, + _stateFunction = stateFunction, + super(const AsyncValue.loading()) { + Future.delayed(Duration.zero, () async { + // Do record open/create + _record = await open(); + _wantsCloseRecord = true; + await _init(initialStateFunction, stateFunction, watchFunction); + }); + } + + // DHTRecordCubit.value({ + // required DHTRecord record, + // required InitialStateFunction initialStateFunction, + // required StateFunction stateFunction, + // required WatchFunction watchFunction, + // }) : _record = record, + // _stateFunction = stateFunction, + // _wantsCloseRecord = false, + // super(const AsyncValue.loading()) { + // Future.delayed(Duration.zero, () async { + // await _init(initialStateFunction, stateFunction, watchFunction); + // }); + // } + + Future _init( + InitialStateFunction initialStateFunction, + StateFunction stateFunction, + WatchFunction watchFunction, + ) async { + // Make initial state update + try { + final initialState = await initialStateFunction(_record); + if (initialState != null) { + emit(AsyncValue.data(initialState)); + } + } on Exception catch (e) { + emit(AsyncValue.error(e)); + } + + _subscription = await _record.listen((record, data, subkeys) async { + try { + final newState = await stateFunction(record, subkeys, data); + if (newState != null) { + emit(AsyncValue.data(newState)); + } + } on Exception catch (e) { + emit(AsyncValue.error(e)); + } + }); + + await watchFunction(_record); + } + + @override + Future close() async { + await _record.cancelWatch(); + await _subscription?.cancel(); + _subscription = null; + if (_wantsCloseRecord) { + await _record.close(); + _wantsCloseRecord = false; + } + await super.close(); + } + + Future refresh(List subkeys) async { + var updateSubkeys = [...subkeys]; + + for (final skr in subkeys) { + for (var sk = skr.low; sk <= skr.high; sk++) { + final data = await _record.get( + subkey: sk, forceRefresh: true, onlyUpdates: true); + if (data != null) { + final newState = await _stateFunction(_record, updateSubkeys, data); + if (newState != null) { + // Emit the new state + emit(AsyncValue.data(newState)); + } + return; + } + // remove sk from update list + // if we did not get an update for that subkey + updateSubkeys = updateSubkeys.removeSubkey(sk); + } + } + } + + DHTRecord get record => _record; + + StreamSubscription? _subscription; + late DHTRecord _record; + bool _wantsCloseRecord; + final StateFunction _stateFunction; +} + +// Cubit that watches the default subkey value of a dhtrecord +class DefaultDHTRecordCubit extends DHTRecordCubit { + DefaultDHTRecordCubit({ + required super.open, + required T Function(List data) decodeState, + }) : super( + initialStateFunction: _makeInitialStateFunction(decodeState), + stateFunction: _makeStateFunction(decodeState), + watchFunction: _makeWatchFunction()); + + // DefaultDHTRecordCubit.value({ + // required super.record, + // required T Function(List data) decodeState, + // }) : super.value( + // initialStateFunction: _makeInitialStateFunction(decodeState), + // stateFunction: _makeStateFunction(decodeState), + // watchFunction: _makeWatchFunction()); + + static InitialStateFunction _makeInitialStateFunction( + T Function(List data) decodeState) => + (record) async { + final initialData = await record.get(); + if (initialData == null) { + return null; + } + return decodeState(initialData); + }; + + static StateFunction _makeStateFunction( + T Function(List data) decodeState) => + (record, subkeys, updatedata) async { + final defaultSubkey = record.subkeyOrDefault(-1); + if (subkeys.containsSubkey(defaultSubkey)) { + final Uint8List data; + final firstSubkey = subkeys.firstOrNull!.low; + if (firstSubkey != defaultSubkey || updatedata == null) { + final maybeData = await record.get(forceRefresh: true); + if (maybeData == null) { + return null; + } + data = maybeData; + } else { + data = updatedata; + } + final newState = decodeState(data); + return newState; + } + return null; + }; + + static WatchFunction _makeWatchFunction() => (record) async { + final defaultSubkey = record.subkeyOrDefault(-1); + await record.watch(subkeys: [ValueSubkeyRange.single(defaultSubkey)]); + }; + + Future refreshDefault() async { + final defaultSubkey = _record.subkeyOrDefault(-1); + await refresh([ValueSubkeyRange(low: defaultSubkey, high: defaultSubkey)]); + } +} diff --git a/packages/veilid_support/lib/dht_support/src/dht_record/dht_record_pool.dart b/packages/veilid_support/lib/dht_support/src/dht_record/dht_record_pool.dart new file mode 100644 index 0000000..b78109b --- /dev/null +++ b/packages/veilid_support/lib/dht_support/src/dht_record/dht_record_pool.dart @@ -0,0 +1,680 @@ +import 'dart:async'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:equatable/equatable.dart'; +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:mutex/mutex.dart'; +import 'package:protobuf/protobuf.dart'; + +import '../../../../veilid_support.dart'; + +part 'dht_record_pool.freezed.dart'; +part 'dht_record_pool.g.dart'; +part 'dht_record.dart'; + +const int watchBackoffMultiplier = 2; +const int watchBackoffMax = 30; + +/// Record pool that managed DHTRecords and allows for tagged deletion +@freezed +class DHTRecordPoolAllocations with _$DHTRecordPoolAllocations { + const factory DHTRecordPoolAllocations({ + required IMap> + childrenByParent, // String key due to IMap<> json unsupported in key + required IMap + parentByChild, // String key due to IMap<> json unsupported in key + required ISet rootRecords, + }) = _DHTRecordPoolAllocations; + + factory DHTRecordPoolAllocations.fromJson(dynamic json) => + _$DHTRecordPoolAllocationsFromJson(json as Map); +} + +/// Pointer to an owned record, with key, owner key and owner secret +/// Ensure that these are only serialized encrypted +@freezed +class OwnedDHTRecordPointer with _$OwnedDHTRecordPointer { + const factory OwnedDHTRecordPointer({ + required TypedKey recordKey, + required KeyPair owner, + }) = _OwnedDHTRecordPointer; + + factory OwnedDHTRecordPointer.fromJson(dynamic json) => + _$OwnedDHTRecordPointerFromJson(json as Map); +} + +/// Watch state +@immutable +class WatchState extends Equatable { + const WatchState( + {required this.subkeys, + required this.expiration, + required this.count, + this.realExpiration}); + final List? subkeys; + final Timestamp? expiration; + final int? count; + final Timestamp? realExpiration; + + @override + List get props => [subkeys, expiration, count, realExpiration]; +} + +/// Data shared amongst all DHTRecord instances +class SharedDHTRecordData { + SharedDHTRecordData( + {required this.recordDescriptor, + required this.defaultWriter, + required this.defaultRoutingContext}); + DHTRecordDescriptor recordDescriptor; + KeyPair? defaultWriter; + VeilidRoutingContext defaultRoutingContext; + Map subkeySeqCache = {}; + bool needsWatchStateUpdate = false; + bool deleteOnClose = false; +} + +// Per opened record data +class OpenedRecordInfo { + OpenedRecordInfo( + {required DHTRecordDescriptor recordDescriptor, + required KeyPair? defaultWriter, + required VeilidRoutingContext defaultRoutingContext}) + : shared = SharedDHTRecordData( + recordDescriptor: recordDescriptor, + defaultWriter: defaultWriter, + defaultRoutingContext: defaultRoutingContext); + SharedDHTRecordData shared; + Set records = {}; +} + +class DHTRecordPool with TableDBBacked { + DHTRecordPool._(Veilid veilid, VeilidRoutingContext routingContext) + : _state = DHTRecordPoolAllocations( + childrenByParent: IMap(), + parentByChild: IMap(), + rootRecords: ISet()), + _mutex = Mutex(), + _opened = {}, + _routingContext = routingContext, + _veilid = veilid; + + // Persistent DHT record list + DHTRecordPoolAllocations _state; + // Create/open Mutex + final Mutex _mutex; + // Which DHT records are currently open + final Map _opened; + // Default routing context to use for new keys + final VeilidRoutingContext _routingContext; + // Convenience accessor + final Veilid _veilid; + // If tick is already running or not + bool _inTick = false; + // Tick counter for backoff + int _tickCount = 0; + // Backoff timer + int _watchBackoffTimer = 1; + + static DHTRecordPool? _singleton; + + ////////////////////////////////////////////////////////////// + /// AsyncTableDBBacked + @override + String tableName() => 'dht_record_pool'; + @override + String tableKeyName() => 'pool_allocations'; + @override + DHTRecordPoolAllocations valueFromJson(Object? obj) => obj != null + ? DHTRecordPoolAllocations.fromJson(obj) + : DHTRecordPoolAllocations( + childrenByParent: IMap(), parentByChild: IMap(), rootRecords: ISet()); + @override + Object? valueToJson(DHTRecordPoolAllocations val) => val.toJson(); + + ////////////////////////////////////////////////////////////// + + static DHTRecordPool get instance => _singleton!; + + static Future init() async { + final routingContext = await Veilid.instance.routingContext(); + final globalPool = DHTRecordPool._(Veilid.instance, routingContext); + globalPool._state = await globalPool.load(); + _singleton = globalPool; + } + + Veilid get veilid => _veilid; + + Future _recordCreateInner( + {required VeilidRoutingContext dhtctx, + required DHTSchema schema, + KeyPair? writer, + TypedKey? parent}) async { + assert(_mutex.isLocked, 'should be locked here'); + + // Create the record + final recordDescriptor = await dhtctx.createDHTRecord(schema); + + // Reopen if a writer is specified to ensure + // we switch the default writer + if (writer != null) { + await dhtctx.openDHTRecord(recordDescriptor.key, writer: writer); + } + final openedRecordInfo = OpenedRecordInfo( + recordDescriptor: recordDescriptor, + defaultWriter: writer ?? recordDescriptor.ownerKeyPair(), + defaultRoutingContext: dhtctx); + _opened[recordDescriptor.key] = openedRecordInfo; + + // Register the dependency + await _addDependencyInner(parent, recordDescriptor.key); + + return openedRecordInfo; + } + + Future _recordOpenInner( + {required VeilidRoutingContext dhtctx, + required TypedKey recordKey, + KeyPair? writer, + TypedKey? parent}) async { + assert(_mutex.isLocked, 'should be locked here'); + + // If we are opening a key that already exists + // make sure we are using the same parent if one was specified + _validateParentInner(parent, recordKey); + + // See if this has been opened yet + final openedRecordInfo = _opened[recordKey]; + if (openedRecordInfo == null) { + // Fresh open, just open the record + final recordDescriptor = + await dhtctx.openDHTRecord(recordKey, writer: writer); + final newOpenedRecordInfo = OpenedRecordInfo( + recordDescriptor: recordDescriptor, + defaultWriter: writer, + defaultRoutingContext: dhtctx); + _opened[recordDescriptor.key] = newOpenedRecordInfo; + + // Register the dependency + await _addDependencyInner(parent, recordKey); + + return newOpenedRecordInfo; + } + + // Already opened + + // See if we need to reopen the record with a default writer and possibly + // a different routing context + if (writer != null && openedRecordInfo.shared.defaultWriter == null) { + final newRecordDescriptor = + await dhtctx.openDHTRecord(recordKey, writer: writer); + openedRecordInfo.shared.defaultWriter = writer; + openedRecordInfo.shared.defaultRoutingContext = dhtctx; + if (openedRecordInfo.shared.recordDescriptor.ownerSecret == null) { + openedRecordInfo.shared.recordDescriptor = newRecordDescriptor; + } + } + + // Register the dependency + await _addDependencyInner(parent, recordKey); + + return openedRecordInfo; + } + + Future _recordClosed(DHTRecord record) async { + await _mutex.protect(() async { + final key = record.key; + final openedRecordInfo = _opened[key]; + if (openedRecordInfo == null || + !openedRecordInfo.records.remove(record)) { + throw StateError('record already closed'); + } + if (openedRecordInfo.records.isEmpty) { + await _routingContext.closeDHTRecord(key); + if (openedRecordInfo.shared.deleteOnClose) { + await _deleteInner(key); + } + _opened.remove(key); + } + }); + } + + // Collect all dependencies (including the record itself) + // in reverse (bottom-up/delete order) + List _collectChildrenInner(TypedKey recordKey) { + assert(_mutex.isLocked, 'should be locked here'); + + final allDeps = []; + final currentDeps = [recordKey]; + while (currentDeps.isNotEmpty) { + final nextDep = currentDeps.removeLast(); + + allDeps.add(nextDep); + final childDeps = + _state.childrenByParent[nextDep.toJson()]?.toList() ?? []; + currentDeps.addAll(childDeps); + } + return allDeps.reversedView; + } + + Future _deleteInner(TypedKey recordKey) async { + // Remove this child from parents + await _removeDependenciesInner([recordKey]); + await _routingContext.deleteDHTRecord(recordKey); + } + + Future delete(TypedKey recordKey) async { + await _mutex.protect(() async { + final allDeps = _collectChildrenInner(recordKey); + + assert(allDeps.singleOrNull == recordKey, 'must delete children first'); + + final ori = _opened[recordKey]; + if (ori != null) { + // delete after close + ori.shared.deleteOnClose = true; + } else { + // delete now + await _deleteInner(recordKey); + } + }); + } + + void _validateParentInner(TypedKey? parent, TypedKey child) { + assert(_mutex.isLocked, 'should be locked here'); + + final childJson = child.toJson(); + final existingParent = _state.parentByChild[childJson]; + if (parent == null) { + if (existingParent != null) { + throw StateError('Child is already parented: $child'); + } + } else { + if (_state.rootRecords.contains(child)) { + throw StateError('Child already added as root: $child'); + } + if (existingParent != null && existingParent != parent) { + throw StateError('Child has two parents: $child <- $parent'); + } + } + } + + Future _addDependencyInner(TypedKey? parent, TypedKey child) async { + assert(_mutex.isLocked, 'should be locked here'); + if (parent == null) { + if (_state.rootRecords.contains(child)) { + // Dependency already added + return; + } + _state = await store( + _state.copyWith(rootRecords: _state.rootRecords.add(child))); + } else { + final childrenOfParent = + _state.childrenByParent[parent.toJson()] ?? ISet(); + if (childrenOfParent.contains(child)) { + // Dependency already added (consecutive opens, etc) + return; + } + _state = await store(_state.copyWith( + childrenByParent: _state.childrenByParent + .add(parent.toJson(), childrenOfParent.add(child)), + parentByChild: _state.parentByChild.add(child.toJson(), parent))); + } + } + + Future _removeDependenciesInner(List childList) async { + assert(_mutex.isLocked, 'should be locked here'); + + var state = _state; + + for (final child in childList) { + if (_state.rootRecords.contains(child)) { + state = state.copyWith(rootRecords: state.rootRecords.remove(child)); + } else { + final parent = state.parentByChild[child.toJson()]; + if (parent == null) { + continue; + } + final children = state.childrenByParent[parent.toJson()]!.remove(child); + if (children.isEmpty) { + state = state.copyWith( + childrenByParent: state.childrenByParent.remove(parent.toJson()), + parentByChild: state.parentByChild.remove(child.toJson())); + } else { + state = state.copyWith( + childrenByParent: + state.childrenByParent.add(parent.toJson(), children), + parentByChild: state.parentByChild.remove(child.toJson())); + } + } + } + + if (state != _state) { + _state = await store(state); + } + } + + /////////////////////////////////////////////////////////////////////// + + /// Create a root DHTRecord that has no dependent records + Future create({ + VeilidRoutingContext? routingContext, + TypedKey? parent, + DHTSchema schema = const DHTSchema.dflt(oCnt: 1), + int defaultSubkey = 0, + DHTRecordCrypto? crypto, + KeyPair? writer, + }) async => + _mutex.protect(() async { + final dhtctx = routingContext ?? _routingContext; + + final openedRecordInfo = await _recordCreateInner( + dhtctx: dhtctx, schema: schema, writer: writer, parent: parent); + + final rec = DHTRecord( + routingContext: dhtctx, + defaultSubkey: defaultSubkey, + sharedDHTRecordData: openedRecordInfo.shared, + writer: writer ?? + openedRecordInfo.shared.recordDescriptor.ownerKeyPair(), + crypto: crypto ?? + await DHTRecordCryptoPrivate.fromTypedKeyPair(openedRecordInfo + .shared.recordDescriptor + .ownerTypedKeyPair()!)); + + openedRecordInfo.records.add(rec); + + return rec; + }); + + /// Open a DHTRecord readonly + Future openRead(TypedKey recordKey, + {VeilidRoutingContext? routingContext, + TypedKey? parent, + int defaultSubkey = 0, + DHTRecordCrypto? crypto}) async => + _mutex.protect(() async { + final dhtctx = routingContext ?? _routingContext; + + final openedRecordInfo = await _recordOpenInner( + dhtctx: dhtctx, recordKey: recordKey, parent: parent); + + final rec = DHTRecord( + routingContext: dhtctx, + defaultSubkey: defaultSubkey, + sharedDHTRecordData: openedRecordInfo.shared, + writer: null, + crypto: crypto ?? const DHTRecordCryptoPublic()); + + openedRecordInfo.records.add(rec); + + return rec; + }); + + /// Open a DHTRecord writable + Future openWrite( + TypedKey recordKey, + KeyPair writer, { + VeilidRoutingContext? routingContext, + TypedKey? parent, + int defaultSubkey = 0, + DHTRecordCrypto? crypto, + }) async => + _mutex.protect(() async { + final dhtctx = routingContext ?? _routingContext; + + final openedRecordInfo = await _recordOpenInner( + dhtctx: dhtctx, + recordKey: recordKey, + parent: parent, + writer: writer); + + final rec = DHTRecord( + routingContext: dhtctx, + defaultSubkey: defaultSubkey, + writer: writer, + sharedDHTRecordData: openedRecordInfo.shared, + crypto: crypto ?? + await DHTRecordCryptoPrivate.fromTypedKeyPair( + TypedKeyPair.fromKeyPair(recordKey.kind, writer))); + + openedRecordInfo.records.add(rec); + + return rec; + }); + + /// Open a DHTRecord owned + /// This is the same as writable but uses an OwnedDHTRecordPointer + /// for convenience and uses symmetric encryption on the key + /// This is primarily used for backing up private content on to the DHT + /// to synchronizing it between devices. Because it is 'owned', the correct + /// parent must be specified. + Future openOwned( + OwnedDHTRecordPointer ownedDHTRecordPointer, { + required TypedKey parent, + VeilidRoutingContext? routingContext, + int defaultSubkey = 0, + DHTRecordCrypto? crypto, + }) => + openWrite( + ownedDHTRecordPointer.recordKey, + ownedDHTRecordPointer.owner, + routingContext: routingContext, + parent: parent, + defaultSubkey: defaultSubkey, + crypto: crypto, + ); + + /// Get the parent of a DHTRecord key if it exists + TypedKey? getParentRecordKey(TypedKey child) { + final childJson = child.toJson(); + return _state.parentByChild[childJson]; + } + + /// Handle the DHT record updates coming from internal to this app + void processLocalValueChange(TypedKey key, Uint8List data, int subkey) { + // Change + for (final kv in _opened.entries) { + if (kv.key == key) { + for (final rec in kv.value.records) { + rec._addLocalValueChange(data, subkey); + } + break; + } + } + } + + /// Handle the DHT record updates coming from Veilid + void processRemoteValueChange(VeilidUpdateValueChange updateValueChange) { + if (updateValueChange.subkeys.isNotEmpty) { + // Change + for (final kv in _opened.entries) { + if (kv.key == updateValueChange.key) { + for (final rec in kv.value.records) { + rec._addRemoteValueChange(updateValueChange); + } + break; + } + } + } else { + final now = Veilid.instance.now().value; + // Expired, process renewal if desired + for (final entry in _opened.entries) { + final openedKey = entry.key; + final openedRecordInfo = entry.value; + + if (openedKey == updateValueChange.key) { + // Renew watch state for each opened recrod + for (final rec in openedRecordInfo.records) { + // See if the watch had an expiration and if it has expired + // otherwise the renewal will keep the same parameters + final watchState = rec.watchState; + if (watchState != null) { + final exp = watchState.expiration; + if (exp != null && exp.value < now) { + // Has expiration, and it has expired, clear watch state + rec.watchState = null; + } + } + } + openedRecordInfo.shared.needsWatchStateUpdate = true; + break; + } + } + } + } + + WatchState? _collectUnionWatchState(Iterable records) { + // Collect union of opened record watch states + int? totalCount; + Timestamp? maxExpiration; + List? allSubkeys; + + var noExpiration = false; + var everySubkey = false; + var cancelWatch = true; + + for (final rec in records) { + final ws = rec.watchState; + if (ws != null) { + cancelWatch = false; + final wsCount = ws.count; + if (wsCount != null) { + totalCount = totalCount ?? 0 + min(wsCount, 0x7FFFFFFF); + totalCount = min(totalCount, 0x7FFFFFFF); + } + final wsExp = ws.expiration; + if (wsExp != null && !noExpiration) { + maxExpiration = maxExpiration == null + ? wsExp + : wsExp.value > maxExpiration.value + ? wsExp + : maxExpiration; + } else { + noExpiration = true; + } + final wsSubkeys = ws.subkeys; + if (wsSubkeys != null && !everySubkey) { + allSubkeys = allSubkeys == null + ? wsSubkeys + : allSubkeys.unionSubkeys(wsSubkeys); + } else { + everySubkey = true; + } + } + } + if (noExpiration) { + maxExpiration = null; + } + if (everySubkey) { + allSubkeys = null; + } + if (cancelWatch) { + return null; + } + + return WatchState( + subkeys: allSubkeys, expiration: maxExpiration, count: totalCount); + } + + void _updateWatchRealExpirations( + Iterable records, Timestamp realExpiration) { + for (final rec in records) { + final ws = rec.watchState; + if (ws != null) { + rec.watchState = WatchState( + subkeys: ws.subkeys, + expiration: ws.expiration, + count: ws.count, + realExpiration: realExpiration); + } + } + } + + /// Ticker to check watch state change requests + Future tick() async { + if (_tickCount < _watchBackoffTimer) { + _tickCount++; + return; + } + if (_inTick) { + return; + } + _inTick = true; + _tickCount = 0; + + try { + final allSuccess = await _mutex.protect(() async { + // See if any opened records need watch state changes + final unord = Function()>[]; + + for (final kv in _opened.entries) { + final openedRecordKey = kv.key; + final openedRecordInfo = kv.value; + final dhtctx = openedRecordInfo.shared.defaultRoutingContext; + + if (openedRecordInfo.shared.needsWatchStateUpdate) { + final watchState = + _collectUnionWatchState(openedRecordInfo.records); + + // Apply watch changes for record + if (watchState == null) { + unord.add(() async { + // Record needs watch cancel + var success = false; + try { + success = await dhtctx.cancelDHTWatch(openedRecordKey); + openedRecordInfo.shared.needsWatchStateUpdate = false; + } on VeilidAPIException { + // Failed to cancel DHT watch, try again next tick + } + return success; + }); + } else { + unord.add(() async { + // Record needs new watch + var success = false; + try { + final realExpiration = await dhtctx.watchDHTValues( + openedRecordKey, + subkeys: watchState.subkeys?.toList(), + count: watchState.count, + expiration: watchState.expiration); + + // Update watch states with real expiration + if (realExpiration.value != BigInt.zero) { + openedRecordInfo.shared.needsWatchStateUpdate = false; + _updateWatchRealExpirations( + openedRecordInfo.records, realExpiration); + success = true; + } + } on VeilidAPIException { + // Failed to cancel DHT watch, try again next tick + } + return success; + }); + } + } + } + + // Process all watch changes + return unord.isEmpty || + (await unord.map((f) => f()).wait).reduce((a, b) => a && b); + }); + + // If any watched did not success, back off the attempts to + // update the watches for a bit + + if (!allSuccess) { + _watchBackoffTimer *= watchBackoffMultiplier; + _watchBackoffTimer = min(_watchBackoffTimer, watchBackoffMax); + } else { + _watchBackoffTimer = 1; + } + } finally { + _inTick = false; + } + } +} diff --git a/lib/veilid_support/dht_support/src/dht_record_pool.freezed.dart b/packages/veilid_support/lib/dht_support/src/dht_record/dht_record_pool.freezed.dart similarity index 99% rename from lib/veilid_support/dht_support/src/dht_record_pool.freezed.dart rename to packages/veilid_support/lib/dht_support/src/dht_record/dht_record_pool.freezed.dart index a90b480..7419c31 100644 --- a/lib/veilid_support/dht_support/src/dht_record_pool.freezed.dart +++ b/packages/veilid_support/lib/dht_support/src/dht_record/dht_record_pool.freezed.dart @@ -12,7 +12,7 @@ part of 'dht_record_pool.dart'; T _$identity(T value) => value; final _privateConstructorUsedError = UnsupportedError( - 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods'); + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); DHTRecordPoolAllocations _$DHTRecordPoolAllocationsFromJson( Map json) { @@ -156,7 +156,7 @@ class _$DHTRecordPoolAllocationsImpl implements _DHTRecordPoolAllocations { } @override - bool operator ==(dynamic other) { + bool operator ==(Object other) { return identical(this, other) || (other.runtimeType == runtimeType && other is _$DHTRecordPoolAllocationsImpl && @@ -327,7 +327,7 @@ class _$OwnedDHTRecordPointerImpl implements _OwnedDHTRecordPointer { } @override - bool operator ==(dynamic other) { + bool operator ==(Object other) { return identical(this, other) || (other.runtimeType == runtimeType && other is _$OwnedDHTRecordPointerImpl && diff --git a/lib/veilid_support/dht_support/src/dht_record_pool.g.dart b/packages/veilid_support/lib/dht_support/src/dht_record/dht_record_pool.g.dart similarity index 75% rename from lib/veilid_support/dht_support/src/dht_record_pool.g.dart rename to packages/veilid_support/lib/dht_support/src/dht_record/dht_record_pool.g.dart index b7bb9c2..ea2a61b 100644 --- a/lib/veilid_support/dht_support/src/dht_record_pool.g.dart +++ b/packages/veilid_support/lib/dht_support/src/dht_record/dht_record_pool.g.dart @@ -11,47 +11,47 @@ _$DHTRecordPoolAllocationsImpl _$$DHTRecordPoolAllocationsImplFromJson( _$DHTRecordPoolAllocationsImpl( childrenByParent: IMap>>.fromJson( - json['children_by_parent'] as Map, + json['childrenByParent'] as Map, (value) => value as String, (value) => ISet>.fromJson(value, (value) => Typed.fromJson(value))), parentByChild: IMap>.fromJson( - json['parent_by_child'] as Map, + json['parentByChild'] as Map, (value) => value as String, (value) => Typed.fromJson(value)), rootRecords: ISet>.fromJson( - json['root_records'], + json['rootRecords'], (value) => Typed.fromJson(value)), ); Map _$$DHTRecordPoolAllocationsImplToJson( _$DHTRecordPoolAllocationsImpl instance) => { - 'children_by_parent': instance.childrenByParent.toJson( + 'childrenByParent': instance.childrenByParent.toJson( (value) => value, (value) => value.toJson( - (value) => value.toJson(), + (value) => value, ), ), - 'parent_by_child': instance.parentByChild.toJson( + 'parentByChild': instance.parentByChild.toJson( + (value) => value, (value) => value, - (value) => value.toJson(), ), - 'root_records': instance.rootRecords.toJson( - (value) => value.toJson(), + 'rootRecords': instance.rootRecords.toJson( + (value) => value, ), }; _$OwnedDHTRecordPointerImpl _$$OwnedDHTRecordPointerImplFromJson( Map json) => _$OwnedDHTRecordPointerImpl( - recordKey: Typed.fromJson(json['record_key']), + recordKey: Typed.fromJson(json['recordKey']), owner: KeyPair.fromJson(json['owner']), ); Map _$$OwnedDHTRecordPointerImplToJson( _$OwnedDHTRecordPointerImpl instance) => { - 'record_key': instance.recordKey.toJson(), - 'owner': instance.owner.toJson(), + 'recordKey': instance.recordKey, + 'owner': instance.owner, }; diff --git a/packages/veilid_support/lib/dht_support/src/dht_short_array/barrel.dart b/packages/veilid_support/lib/dht_support/src/dht_short_array/barrel.dart new file mode 100644 index 0000000..6dc1c97 --- /dev/null +++ b/packages/veilid_support/lib/dht_support/src/dht_short_array/barrel.dart @@ -0,0 +1,2 @@ +export 'dht_short_array.dart'; +export 'dht_short_array_cubit.dart'; diff --git a/packages/veilid_support/lib/dht_support/src/dht_short_array/dht_short_array.dart b/packages/veilid_support/lib/dht_support/src/dht_short_array/dht_short_array.dart new file mode 100644 index 0000000..5b115ae --- /dev/null +++ b/packages/veilid_support/lib/dht_support/src/dht_short_array/dht_short_array.dart @@ -0,0 +1,238 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:mutex/mutex.dart'; +import 'package:protobuf/protobuf.dart'; + +import '../../../veilid_support.dart'; +import '../../proto/proto.dart' as proto; + +part 'dht_short_array_head.dart'; +part 'dht_short_array_read.dart'; +part 'dht_short_array_write.dart'; + +/////////////////////////////////////////////////////////////////////// + +class DHTShortArray { + //////////////////////////////////////////////////////////////// + // Constructors + + DHTShortArray._({required DHTRecord headRecord}) + : _head = _DHTShortArrayHead(headRecord: headRecord) { + _head.onUpdatedHead = () { + _watchController?.sink.add(null); + }; + } + + // Create a DHTShortArray + // if smplWriter is specified, uses a SMPL schema with a single writer + // rather than the key owner + static Future create( + {int stride = maxElements, + VeilidRoutingContext? routingContext, + TypedKey? parent, + DHTRecordCrypto? crypto, + KeyPair? smplWriter}) async { + assert(stride <= maxElements, 'stride too long'); + final pool = DHTRecordPool.instance; + + late final DHTRecord dhtRecord; + if (smplWriter != null) { + final schema = DHTSchema.smpl( + oCnt: 0, + members: [DHTSchemaMember(mKey: smplWriter.key, mCnt: stride + 1)]); + dhtRecord = await pool.create( + parent: parent, + routingContext: routingContext, + schema: schema, + crypto: crypto, + writer: smplWriter); + } else { + final schema = DHTSchema.dflt(oCnt: stride + 1); + dhtRecord = await pool.create( + parent: parent, + routingContext: routingContext, + schema: schema, + crypto: crypto); + } + + try { + final dhtShortArray = DHTShortArray._(headRecord: dhtRecord); + await dhtShortArray._head.operate((head) async { + if (!await head._writeHead()) { + throw StateError('Failed to write head at this time'); + } + }); + return dhtShortArray; + } on Exception catch (_) { + await dhtRecord.close(); + await pool.delete(dhtRecord.key); + rethrow; + } + } + + static Future openRead(TypedKey headRecordKey, + {VeilidRoutingContext? routingContext, + TypedKey? parent, + DHTRecordCrypto? crypto}) async { + final dhtRecord = await DHTRecordPool.instance.openRead(headRecordKey, + parent: parent, routingContext: routingContext, crypto: crypto); + try { + final dhtShortArray = DHTShortArray._(headRecord: dhtRecord); + await dhtShortArray._head.operate((head) => head._loadHead()); + return dhtShortArray; + } on Exception catch (_) { + await dhtRecord.close(); + rethrow; + } + } + + static Future openWrite( + TypedKey headRecordKey, + KeyPair writer, { + VeilidRoutingContext? routingContext, + TypedKey? parent, + DHTRecordCrypto? crypto, + }) async { + final dhtRecord = await DHTRecordPool.instance.openWrite( + headRecordKey, writer, + parent: parent, routingContext: routingContext, crypto: crypto); + try { + final dhtShortArray = DHTShortArray._(headRecord: dhtRecord); + await dhtShortArray._head.operate((head) => head._loadHead()); + return dhtShortArray; + } on Exception catch (_) { + await dhtRecord.close(); + rethrow; + } + } + + static Future openOwned( + OwnedDHTRecordPointer ownedDHTRecordPointer, { + required TypedKey parent, + VeilidRoutingContext? routingContext, + DHTRecordCrypto? crypto, + }) => + openWrite( + ownedDHTRecordPointer.recordKey, + ownedDHTRecordPointer.owner, + routingContext: routingContext, + parent: parent, + crypto: crypto, + ); + + //////////////////////////////////////////////////////////////////////////// + // Public API + + /// Get the record key for this shortarray + TypedKey get recordKey => _head.recordKey; + + /// Get the record pointer foir this shortarray + OwnedDHTRecordPointer get recordPointer => _head.recordPointer; + + /// Free all resources for the DHTShortArray + Future close() async { + await _watchController?.close(); + await _head.close(); + } + + /// Free all resources for the DHTShortArray and delete it from the DHT + Future delete() async { + await close(); + await DHTRecordPool.instance.delete(recordKey); + } + + /// Runs a closure that guarantees the DHTShortArray + /// will be closed upon exit, even if an uncaught exception is thrown + Future scope(Future Function(DHTShortArray) scopeFunction) async { + try { + return await scopeFunction(this); + } finally { + await close(); + } + } + + /// Runs a closure that guarantees the DHTShortArray + /// will be closed upon exit, and deleted if an an + /// uncaught exception is thrown + Future deleteScope( + Future Function(DHTShortArray) scopeFunction) async { + try { + final out = await scopeFunction(this); + await close(); + return out; + } on Exception catch (_) { + await delete(); + rethrow; + } + } + + /// Runs a closure allowing read-only access to the shortarray + Future operate(Future Function(DHTShortArrayRead) closure) async => + _head.operate((head) async { + final reader = _DHTShortArrayRead._(head); + return closure(reader); + }); + + /// Runs a closure allowing read-write access to the shortarray + /// Makes only one attempt to consistently write the changes to the DHT + /// Returns (result, true) of the closure if the write could be performed + /// Returns (null, false) if the write could not be performed at this time + Future<(T?, bool)> operateWrite( + Future Function(DHTShortArrayWrite) closure) async => + _head.operateWrite((head) async { + final writer = _DHTShortArrayWrite._(head); + return closure(writer); + }); + + /// Runs a closure allowing read-write access to the shortarray + /// Will execute the closure multiple times if a consistent write to the DHT + /// is not achieved. Timeout if specified will be thrown as a + /// TimeoutException. The closure should return true if its changes also + /// succeeded, returning false will trigger another eventual consistency + /// attempt. + Future operateWriteEventual( + Future Function(DHTShortArrayWrite) closure, + {Duration? timeout}) async => + _head.operateWriteEventual((head) async { + final writer = _DHTShortArrayWrite._(head); + return closure(writer); + }, timeout: timeout); + + Future> listen( + void Function() onChanged, + ) => + _listenMutex.protect(() async { + // If don't have a controller yet, set it up + if (_watchController == null) { + // Set up watch requirements + _watchController = StreamController.broadcast(onCancel: () { + // If there are no more listeners then we can get + // rid of the controller and drop our subscriptions + unawaited(_listenMutex.protect(() async { + // Cancel watches of head record + await _head.cancelWatch(); + _watchController = null; + })); + }); + + // Start watching head record + await _head.watch(); + } + // Return subscription + return _watchController!.stream.listen((_) => onChanged()); + }); + + //////////////////////////////////////////////////////////////// + // Fields + + static const maxElements = 256; + + // Internal representation refreshed from head record + final _DHTShortArrayHead _head; + + // Watch mutex to ensure we keep the representation valid + final Mutex _listenMutex = Mutex(); + // Stream of external changes + StreamController? _watchController; +} diff --git a/packages/veilid_support/lib/dht_support/src/dht_short_array/dht_short_array_cubit.dart b/packages/veilid_support/lib/dht_support/src/dht_short_array/dht_short_array_cubit.dart new file mode 100644 index 0000000..c145a67 --- /dev/null +++ b/packages/veilid_support/lib/dht_support/src/dht_short_array/dht_short_array_cubit.dart @@ -0,0 +1,111 @@ +import 'dart:async'; + +import 'package:async_tools/async_tools.dart'; +import 'package:bloc/bloc.dart'; +import 'package:bloc_tools/bloc_tools.dart'; +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; + +import '../../../veilid_support.dart'; + +typedef DHTShortArrayState = AsyncValue>; +typedef DHTShortArrayBusyState = BlocBusyState>; + +class DHTShortArrayCubit extends Cubit> + with BlocBusyWrapper> { + DHTShortArrayCubit({ + required Future Function() open, + required T Function(List data) decodeElement, + }) : _decodeElement = decodeElement, + super(const BlocBusyState(AsyncValue.loading())) { + _initFuture = Future(() async { + // Open DHT record + _shortArray = await open(); + _wantsCloseRecord = true; + + // Make initial state update + unawaited(_refreshNoWait()); + _subscription = await _shortArray.listen(_update); + }); + } + + // DHTShortArrayCubit.value({ + // required DHTShortArray shortArray, + // required T Function(List data) decodeElement, + // }) : _shortArray = shortArray, + // _decodeElement = decodeElement, + // super(const BlocBusyState(AsyncValue.loading())) { + // _initFuture = Future(() async { + // // Make initial state update + // unawaited(_refreshNoWait()); + // _subscription = await shortArray.listen(_update); + // }); + // } + + Future refresh({bool forceRefresh = false}) async { + await _initFuture; + await _refreshNoWait(forceRefresh: forceRefresh); + } + + Future _refreshNoWait({bool forceRefresh = false}) async => + busy((emit) async => _refreshInner(emit, forceRefresh: forceRefresh)); + + Future _refreshInner(void Function(AsyncValue>) emit, + {bool forceRefresh = false}) async { + try { + final newState = (await _shortArray.operate( + (reader) => reader.getAllItems(forceRefresh: forceRefresh))) + ?.map(_decodeElement) + .toIList(); + if (newState != null) { + emit(AsyncValue.data(newState)); + } + } on Exception catch (e) { + emit(AsyncValue.error(e)); + } + } + + void _update() { + // Run at most one background update process + // Because this is async, we could get an update while we're + // still processing the last one. Only called after init future has run + // so we dont have to wait for that here. + _sspUpdate.busyUpdate>>( + busy, (emit) async => _refreshInner(emit)); + } + + @override + Future close() async { + await _initFuture; + await _subscription?.cancel(); + _subscription = null; + if (_wantsCloseRecord) { + await _shortArray.close(); + } + await super.close(); + } + + Future operate(Future Function(DHTShortArrayRead) closure) async { + await _initFuture; + return _shortArray.operate(closure); + } + + Future<(R?, bool)> operateWrite( + Future Function(DHTShortArrayWrite) closure) async { + await _initFuture; + return _shortArray.operateWrite(closure); + } + + Future operateWriteEventual( + Future Function(DHTShortArrayWrite) closure, + {Duration? timeout}) async { + await _initFuture; + return _shortArray.operateWriteEventual(closure, timeout: timeout); + } + + late final Future _initFuture; + late final DHTShortArray _shortArray; + final T Function(List data) _decodeElement; + StreamSubscription? _subscription; + bool _wantsCloseRecord = false; + final _sspUpdate = SingleStatelessProcessor(); +} diff --git a/packages/veilid_support/lib/dht_support/src/dht_short_array/dht_short_array_head.dart b/packages/veilid_support/lib/dht_support/src/dht_short_array/dht_short_array_head.dart new file mode 100644 index 0000000..779cbcb --- /dev/null +++ b/packages/veilid_support/lib/dht_support/src/dht_short_array/dht_short_array_head.dart @@ -0,0 +1,510 @@ +part of 'dht_short_array.dart'; + +class _DHTShortArrayHead { + _DHTShortArrayHead({required DHTRecord headRecord}) + : _headRecord = headRecord, + _linkedRecords = [], + _index = [], + _free = [], + _seqs = [], + _localSeqs = [] { + _calculateStride(); + } + + void _calculateStride() { + switch (_headRecord.schema) { + case DHTSchemaDFLT(oCnt: final oCnt): + if (oCnt <= 1) { + throw StateError('Invalid DFLT schema in DHTShortArray'); + } + _stride = oCnt - 1; + case DHTSchemaSMPL(oCnt: final oCnt, members: final members): + if (oCnt != 0 || members.length != 1 || members[0].mCnt <= 1) { + throw StateError('Invalid SMPL schema in DHTShortArray'); + } + _stride = members[0].mCnt - 1; + } + assert(_stride <= DHTShortArray.maxElements, 'stride too long'); + } + + proto.DHTShortArray _toProto() { + assert(_headMutex.isLocked, 'should be in mutex here'); + + final head = proto.DHTShortArray(); + head.keys.addAll(_linkedRecords.map((lr) => lr.key.toProto())); + head.index = List.of(_index); + head.seqs.addAll(_seqs); + // Do not serialize free list, it gets recreated + // Do not serialize local seqs, they are only locally relevant + return head; + } + + TypedKey get recordKey => _headRecord.key; + OwnedDHTRecordPointer get recordPointer => _headRecord.ownedDHTRecordPointer; + int get length => _index.length; + + Future close() async { + final futures = >[_headRecord.close()]; + for (final lr in _linkedRecords) { + futures.add(lr.close()); + } + await Future.wait(futures); + } + + Future operate(Future Function(_DHTShortArrayHead) closure) async => + // ignore: prefer_expression_function_bodies + _headMutex.protect(() async { + return closure(this); + }); + + Future<(T?, bool)> operateWrite( + Future Function(_DHTShortArrayHead) closure) async => + _headMutex.protect(() async { + final oldLinkedRecords = List.of(_linkedRecords); + final oldIndex = List.of(_index); + final oldFree = List.of(_free); + final oldSeqs = List.of(_seqs); + try { + final out = await closure(this); + // Write head assuming it has been changed + if (!await _writeHead()) { + // Failed to write head means head got overwritten so write should + // be considered failed + return (null, false); + } + + onUpdatedHead?.call(); + return (out, true); + } on Exception { + // Exception means state needs to be reverted + _linkedRecords = oldLinkedRecords; + _index = oldIndex; + _free = oldFree; + _seqs = oldSeqs; + + rethrow; + } + }); + + Future operateWriteEventual( + Future Function(_DHTShortArrayHead) closure, + {Duration? timeout}) async { + final timeoutTs = timeout == null + ? null + : Veilid.instance.now().offset(TimestampDuration.fromDuration(timeout)); + + await _headMutex.protect(() async { + late List oldLinkedRecords; + late List oldIndex; + late List oldFree; + late List oldSeqs; + + try { + // Iterate until we have a successful element and head write + + do { + // Save off old values each pass of tryWriteHead because the head + // will have changed + oldLinkedRecords = List.of(_linkedRecords); + oldIndex = List.of(_index); + oldFree = List.of(_free); + oldSeqs = List.of(_seqs); + + // Try to do the element write + while (true) { + if (timeoutTs != null) { + final now = Veilid.instance.now(); + if (now >= timeoutTs) { + throw TimeoutException('timeout reached'); + } + } + if (await closure(this)) { + break; + } + // Failed to write in closure resets state + _linkedRecords = List.of(oldLinkedRecords); + _index = List.of(oldIndex); + _free = List.of(oldFree); + _seqs = List.of(oldSeqs); + } + + // Try to do the head write + } while (!await _writeHead()); + + onUpdatedHead?.call(); + } on Exception { + // Exception means state needs to be reverted + _linkedRecords = oldLinkedRecords; + _index = oldIndex; + _free = oldFree; + _seqs = oldSeqs; + + rethrow; + } + }); + } + + /// Serialize and write out the current head record, possibly updating it + /// if a newer copy is available online. Returns true if the write was + /// successful + Future _writeHead() async { + assert(_headMutex.isLocked, 'should be in mutex here'); + + final headBuffer = _toProto().writeToBuffer(); + + final existingData = await _headRecord.tryWriteBytes(headBuffer); + if (existingData != null) { + // Head write failed, incorporate update + await _updateHead(proto.DHTShortArray.fromBuffer(existingData)); + return false; + } + + return true; + } + + /// Validate a new head record that has come in from the network + Future _updateHead(proto.DHTShortArray head) async { + assert(_headMutex.isLocked, 'should be in mutex here'); + + // Get the set of new linked keys and validate it + final updatedLinkedKeys = head.keys.map((p) => p.toVeilid()).toList(); + final updatedIndex = List.of(head.index); + final updatedSeqs = List.of(head.seqs); + final updatedFree = _makeFreeList(updatedLinkedKeys, updatedIndex); + + // See which records are actually new + final oldRecords = Map.fromEntries( + _linkedRecords.map((lr) => MapEntry(lr.key, lr))); + final newRecords = {}; + final sameRecords = {}; + final updatedLinkedRecords = []; + try { + for (var n = 0; n < updatedLinkedKeys.length; n++) { + final newKey = updatedLinkedKeys[n]; + final oldRecord = oldRecords[newKey]; + if (oldRecord == null) { + // Open the new record + final newRecord = await _openLinkedRecord(newKey); + newRecords[newKey] = newRecord; + updatedLinkedRecords.add(newRecord); + } else { + sameRecords[newKey] = oldRecord; + updatedLinkedRecords.add(oldRecord); + } + } + } on Exception catch (_) { + // On any exception close the records we have opened + await Future.wait(newRecords.entries.map((e) => e.value.close())); + rethrow; + } + + // From this point forward we should not throw an exception or everything + // is possibly invalid. Just pass the exception up it happens and the caller + // will have to delete this short array and reopen it if it can + await oldRecords.entries + .where((e) => !sameRecords.containsKey(e.key)) + .map((e) => e.value.close()) + .wait; + + // Get the localseqs list from inspect results + final localReports = await [_headRecord, ...updatedLinkedRecords].map((r) { + final start = (r.key == _headRecord.key) ? 1 : 0; + return r.inspect( + subkeys: [ValueSubkeyRange.make(start, start + _stride - 1)]); + }).wait; + final updatedLocalSeqs = + localReports.map((l) => l.localSeqs).expand((e) => e).toList(); + + // Make the new head cache + _linkedRecords = updatedLinkedRecords; + _index = updatedIndex; + _free = updatedFree; + _seqs = updatedSeqs; + _localSeqs = updatedLocalSeqs; + } + + // Pull the latest or updated copy of the head record from the network + Future _loadHead( + {bool forceRefresh = true, bool onlyUpdates = false}) async { + // Get an updated head record copy if one exists + final head = await _headRecord.getProtobuf(proto.DHTShortArray.fromBuffer, + subkey: 0, forceRefresh: forceRefresh, onlyUpdates: onlyUpdates); + if (head == null) { + if (onlyUpdates) { + // No update + return false; + } + throw StateError('head missing during refresh'); + } + + await _updateHead(head); + + return true; + } + + ///////////////////////////////////////////////////////////////////////////// + // Linked record management + + Future _getOrCreateLinkedRecord(int recordNumber) async { + if (recordNumber == 0) { + return _headRecord; + } + final pool = DHTRecordPool.instance; + recordNumber--; + while (recordNumber >= _linkedRecords.length) { + // Linked records must use SMPL schema so writer can be specified + // Use the same writer as the head record + final smplWriter = _headRecord.writer!; + final parent = _headRecord.key; + final routingContext = _headRecord.routingContext; + final crypto = _headRecord.crypto; + + final schema = DHTSchema.smpl( + oCnt: 0, + members: [DHTSchemaMember(mKey: smplWriter.key, mCnt: _stride)]); + final dhtRecord = await pool.create( + parent: parent, + routingContext: routingContext, + schema: schema, + crypto: crypto, + writer: smplWriter); + + // Add to linked records + _linkedRecords.add(dhtRecord); + } + if (!await _writeHead()) { + throw StateError('failed to add linked record'); + } + return _linkedRecords[recordNumber]; + } + + /// Open a linked record for reading or writing, same as the head record + Future _openLinkedRecord(TypedKey recordKey) async { + final writer = _headRecord.writer; + return (writer != null) + ? await DHTRecordPool.instance.openWrite( + recordKey, + writer, + parent: _headRecord.key, + routingContext: _headRecord.routingContext, + ) + : await DHTRecordPool.instance.openRead( + recordKey, + parent: _headRecord.key, + routingContext: _headRecord.routingContext, + ); + } + + Future<(DHTRecord, int)> lookupPosition(int pos) async { + final idx = _index[pos]; + return lookupIndex(idx); + } + + Future<(DHTRecord, int)> lookupIndex(int idx) async { + final recordNumber = idx ~/ _stride; + final record = await _getOrCreateLinkedRecord(recordNumber); + final recordSubkey = (idx % _stride) + ((recordNumber == 0) ? 1 : 0); + return (record, recordSubkey); + } + + ///////////////////////////////////////////////////////////////////////////// + // Index management + + /// Allocate an empty index slot at a specific position + void allocateIndex(int pos) { + // Allocate empty index + final idx = _emptyIndex(); + _index.insert(pos, idx); + } + + int _emptyIndex() { + if (_free.isNotEmpty) { + return _free.removeLast(); + } + if (_index.length == DHTShortArray.maxElements) { + throw StateError('too many elements'); + } + return _index.length; + } + + void swapIndex(int aPos, int bPos) { + if (aPos == bPos) { + return; + } + final aIdx = _index[aPos]; + final bIdx = _index[bPos]; + _index[aPos] = bIdx; + _index[bPos] = aIdx; + } + + void clearIndex() { + _index.clear(); + _free.clear(); + } + + /// Release an index at a particular position + void freeIndex(int pos) { + final idx = _index.removeAt(pos); + _free.add(idx); + // xxx: free list optimization here? + } + + /// Validate the head from the DHT is properly formatted + /// and calculate the free list from it while we're here + List _makeFreeList( + List> linkedKeys, List index) { + // Ensure nothing is duplicated in the linked keys set + final newKeys = linkedKeys.toSet(); + assert( + newKeys.length <= + (DHTShortArray.maxElements + (_stride - 1)) ~/ _stride, + 'too many keys'); + assert(newKeys.length == linkedKeys.length, 'duplicated linked keys'); + final newIndex = index.toSet(); + assert(newIndex.length <= DHTShortArray.maxElements, 'too many indexes'); + assert(newIndex.length == index.length, 'duplicated index locations'); + + // Ensure all the index keys fit into the existing records + final indexCapacity = (linkedKeys.length + 1) * _stride; + int? maxIndex; + for (final idx in newIndex) { + assert(idx >= 0 || idx < indexCapacity, 'index out of range'); + if (maxIndex == null || idx > maxIndex) { + maxIndex = idx; + } + } + + // Figure out which indices are free + final free = []; + if (maxIndex != null) { + for (var i = 0; i < maxIndex; i++) { + if (!newIndex.contains(i)) { + free.add(i); + } + } + } + return free; + } + + /// Check if we know that the network has a copy of an index that is newer + /// than our local copy from looking at the seqs list in the head + bool positionNeedsRefresh(int pos) { + final idx = _index[pos]; + + // If our local sequence number is unknown or hasnt been written yet + // then a normal DHT operation is going to pull from the network anyway + if (_localSeqs.length < idx || _localSeqs[idx] == 0xFFFFFFFF) { + return false; + } + + // If the remote sequence number record is unknown or hasnt been written + // at this index yet, then we also do not refresh at this time as it + // is the first time the index is being written to + if (_seqs.length < idx || _seqs[idx] == 0xFFFFFFFF) { + return false; + } + + return _localSeqs[idx] < _seqs[idx]; + } + + /// Update the sequence number for a particular index in + /// our local sequence number list. + /// If a write is happening, update the network copy as well. + Future updatePositionSeq(int pos, bool write) async { + final idx = _index[pos]; + final (record, recordSubkey) = await lookupIndex(idx); + final report = + await record.inspect(subkeys: [ValueSubkeyRange.single(recordSubkey)]); + + while (_localSeqs.length <= idx) { + _localSeqs.add(0xFFFFFFFF); + } + _localSeqs[idx] = report.localSeqs[0]; + if (write) { + while (_seqs.length <= idx) { + _seqs.add(0xFFFFFFFF); + } + _seqs[idx] = report.localSeqs[0]; + } + } + + ///////////////////////////////////////////////////////////////////////////// + // Watch For Updates + + // Watch head for changes + Future watch() async { + // This will update any existing watches if necessary + try { + await _headRecord.watch(subkeys: [ValueSubkeyRange.single(0)]); + + // Update changes to the head record + // Don't watch for local changes because this class already handles + // notifying listeners and knows when it makes local changes + _subscription ??= + await _headRecord.listen(localChanges: false, _onHeadValueChanged); + } on Exception { + // If anything fails, try to cancel the watches + await cancelWatch(); + rethrow; + } + } + + // Stop watching for changes to head and linked records + Future cancelWatch() async { + await _headRecord.cancelWatch(); + await _subscription?.cancel(); + _subscription = null; + } + + Future _onHeadValueChanged( + DHTRecord record, Uint8List? data, List subkeys) async { + // If head record subkey zero changes, then the layout + // of the dhtshortarray has changed + if (data == null) { + throw StateError('head value changed without data'); + } + if (record.key != _headRecord.key || + subkeys.length != 1 || + subkeys[0] != ValueSubkeyRange.single(0)) { + throw StateError('watch returning wrong subkey range'); + } + + // Decode updated head + final headData = proto.DHTShortArray.fromBuffer(data); + + // Then update the head record + await _headMutex.protect(() async { + await _updateHead(headData); + onUpdatedHead?.call(); + }); + } + + //////////////////////////////////////////////////////////////////////////// + + // Head/element mutex to ensure we keep the representation valid + final Mutex _headMutex = Mutex(); + // Subscription to head record internal changes + StreamSubscription? _subscription; + // Notify closure for external head changes + void Function()? onUpdatedHead; + + // Head DHT record + final DHTRecord _headRecord; + // How many elements per linked record + late final int _stride; + + // List of additional records after the head record used for element data + List _linkedRecords; + // Ordering of the subkey indices. + // Elements are subkey numbers. Represents the element order. + List _index; + // List of free subkeys for elements that have been removed. + // Used to optimize allocations. + List _free; + // The sequence numbers of each subkey. + // Index is by subkey number not by element index. + // (n-1 for head record and then the next n for linked records) + List _seqs; + // The local sequence numbers for each subkey. + List _localSeqs; +} diff --git a/packages/veilid_support/lib/dht_support/src/dht_short_array/dht_short_array_read.dart b/packages/veilid_support/lib/dht_support/src/dht_short_array/dht_short_array_read.dart new file mode 100644 index 0000000..fccdf20 --- /dev/null +++ b/packages/veilid_support/lib/dht_support/src/dht_short_array/dht_short_array_read.dart @@ -0,0 +1,101 @@ +part of 'dht_short_array.dart'; + +//////////////////////////////////////////////////////////////////////////// +// Reader interface +abstract class DHTShortArrayRead { + /// Returns the number of elements in the DHTShortArray + int get length; + + /// Return the item at position 'pos' in the DHTShortArray. If 'forceRefresh' + /// is specified, the network will always be checked for newer values + /// rather than returning the existing locally stored copy of the elements. + Future getItem(int pos, {bool forceRefresh = false}); + + /// Return a list of all of the items in the DHTShortArray. If 'forceRefresh' + /// is specified, the network will always be checked for newer values + /// rather than returning the existing locally stored copy of the elements. + Future?> getAllItems({bool forceRefresh = false}); +} + +extension DHTShortArrayReadExt on DHTShortArrayRead { + /// Convenience function: + /// Like getItem but also parses the returned element as JSON + Future getItemJson(T Function(dynamic) fromJson, int pos, + {bool forceRefresh = false}) => + getItem(pos, forceRefresh: forceRefresh) + .then((out) => jsonDecodeOptBytes(fromJson, out)); + + /// Convenience function: + /// Like getAllItems but also parses the returned elements as JSON + Future?> getAllItemsJson(T Function(dynamic) fromJson, + {bool forceRefresh = false}) => + getAllItems(forceRefresh: forceRefresh) + .then((out) => out?.map(fromJson).toList()); + + /// Convenience function: + /// Like getItem but also parses the returned element as a protobuf object + Future getItemProtobuf( + T Function(List) fromBuffer, int pos, + {bool forceRefresh = false}) => + getItem(pos, forceRefresh: forceRefresh) + .then((out) => (out == null) ? null : fromBuffer(out)); + + /// Convenience function: + /// Like getAllItems but also parses the returned elements as protobuf objects + Future?> getAllItemsProtobuf( + T Function(List) fromBuffer, + {bool forceRefresh = false}) => + getAllItems(forceRefresh: forceRefresh) + .then((out) => out?.map(fromBuffer).toList()); +} + +//////////////////////////////////////////////////////////////////////////// +// Reader-only implementation + +class _DHTShortArrayRead implements DHTShortArrayRead { + _DHTShortArrayRead._(_DHTShortArrayHead head) : _head = head; + + /// Returns the number of elements in the DHTShortArray + @override + int get length => _head.length; + + /// Return the item at position 'pos' in the DHTShortArray. If 'forceRefresh' + /// is specified, the network will always be checked for newer values + /// rather than returning the existing locally stored copy of the elements. + @override + Future getItem(int pos, {bool forceRefresh = false}) async { + if (pos < 0 || pos >= length) { + throw IndexError.withLength(pos, length); + } + + final (record, recordSubkey) = await _head.lookupPosition(pos); + + final refresh = forceRefresh || _head.positionNeedsRefresh(pos); + final out = record.get(subkey: recordSubkey, forceRefresh: refresh); + await _head.updatePositionSeq(pos, false); + + return out; + } + + /// Return a list of all of the items in the DHTShortArray. If 'forceRefresh' + /// is specified, the network will always be checked for newer values + /// rather than returning the existing locally stored copy of the elements. + @override + Future?> getAllItems({bool forceRefresh = false}) async { + final out = []; + + for (var pos = 0; pos < _head.length; pos++) { + final elem = await getItem(pos, forceRefresh: forceRefresh); + if (elem == null) { + return null; + } + out.add(elem); + } + + return out; + } + + //////////////////////////////////////////////////////////////////////////// + // Fields + final _DHTShortArrayHead _head; +} diff --git a/packages/veilid_support/lib/dht_support/src/dht_short_array/dht_short_array_write.dart b/packages/veilid_support/lib/dht_support/src/dht_short_array/dht_short_array_write.dart new file mode 100644 index 0000000..76e9ce2 --- /dev/null +++ b/packages/veilid_support/lib/dht_support/src/dht_short_array/dht_short_array_write.dart @@ -0,0 +1,195 @@ +part of 'dht_short_array.dart'; + +//////////////////////////////////////////////////////////////////////////// +// Writer interface +abstract class DHTShortArrayWrite implements DHTShortArrayRead { + /// Try to add an item to the end of the DHTShortArray. Return true if the + /// element was successfully added, and false if the state changed before + /// the element could be added or a newer value was found on the network. + /// This may throw an exception if the number elements added exceeds the + /// built-in limit of 'maxElements = 256' entries. + Future tryAddItem(Uint8List value); + + /// Try to insert an item as position 'pos' of the DHTShortArray. + /// Return true if the element was successfully inserted, and false if the + /// state changed before the element could be inserted or a newer value was + /// found on the network. + /// This may throw an exception if the number elements added exceeds the + /// built-in limit of 'maxElements = 256' entries. + Future tryInsertItem(int pos, Uint8List value); + + /// Try to swap items at position 'aPos' and 'bPos' in the DHTShortArray. + /// Return true if the elements were successfully swapped, and false if the + /// state changed before the elements could be swapped or newer values were + /// found on the network. + /// This may throw an exception if either of the positions swapped exceed + /// the length of the list + Future trySwapItem(int aPos, int bPos); + + /// Try to remove an item at position 'pos' in the DHTShortArray. + /// Return the element if it was successfully removed, and null if the + /// state changed before the elements could be removed or newer values were + /// found on the network. + /// This may throw an exception if the position removed exceeeds the length of + /// the list. + Future tryRemoveItem(int pos); + + /// Try to remove all items in the DHTShortArray. + /// Return true if it was successfully cleared, and false if the + /// state changed before the elements could be cleared or newer values were + /// found on the network. + Future tryClear(); + + /// Try to set an item at position 'pos' of the DHTShortArray. + /// If the set was successful this returns: + /// * The prior contents of the element, or null if there was no value yet + /// * A boolean true + /// If the set was found a newer value on the network: + /// * The newer value of the element, or null if the head record + /// changed. + /// * A boolean false + /// This may throw an exception if the position exceeds the built-in limit of + /// 'maxElements = 256' entries. + Future<(Uint8List?, bool)> tryWriteItem(int pos, Uint8List newValue); +} + +extension DHTShortArrayWriteExt on DHTShortArrayWrite { + /// Convenience function: + /// Like removeItem but also parses the returned element as JSON + Future tryRemoveItemJson( + T Function(dynamic) fromJson, + int pos, + ) => + tryRemoveItem(pos).then((out) => jsonDecodeOptBytes(fromJson, out)); + + /// Convenience function: + /// Like removeItem but also parses the returned element as JSON + Future tryRemoveItemProtobuf( + T Function(List) fromBuffer, int pos) => + getItem(pos).then((out) => (out == null) ? null : fromBuffer(out)); + + /// Convenience function: + /// Like tryWriteItem but also encodes the input value as JSON and parses the + /// returned element as JSON + Future<(T?, bool)> tryWriteItemJson( + T Function(dynamic) fromJson, + int pos, + T newValue, + ) => + tryWriteItem(pos, jsonEncodeBytes(newValue)) + .then((out) => (jsonDecodeOptBytes(fromJson, out.$1), out.$2)); + + /// Convenience function: + /// Like tryWriteItem but also encodes the input value as a protobuf object + /// and parses the returned element as a protobuf object + Future<(T?, bool)> tryWriteItemProtobuf( + T Function(List) fromBuffer, + int pos, + T newValue, + ) => + tryWriteItem(pos, newValue.writeToBuffer()).then( + (out) => ((out.$1 == null ? null : fromBuffer(out.$1!)), out.$2)); +} + +//////////////////////////////////////////////////////////////////////////// +// Writer-only implementation + +class _DHTShortArrayWrite implements DHTShortArrayWrite { + _DHTShortArrayWrite._(_DHTShortArrayHead head) + : _head = head, + _reader = _DHTShortArrayRead._(head); + + @override + Future tryAddItem(Uint8List value) async { + // Allocate empty index at the end of the list + final pos = _head.length; + _head.allocateIndex(pos); + + // Write item + final (_, wasSet) = await tryWriteItem(pos, value); + if (!wasSet) { + return false; + } + + // Get sequence number written + await _head.updatePositionSeq(pos, true); + + return true; + } + + @override + Future tryInsertItem(int pos, Uint8List value) async { + // Allocate empty index at position + _head.allocateIndex(pos); + + // Write item + final (_, wasSet) = await tryWriteItem(pos, value); + if (!wasSet) { + return false; + } + + // Get sequence number written + await _head.updatePositionSeq(pos, true); + + return true; + } + + @override + Future trySwapItem(int aPos, int bPos) async { + // Swap indices + _head.swapIndex(aPos, bPos); + + return true; + } + + @override + Future tryRemoveItem(int pos) async { + final (record, recordSubkey) = await _head.lookupPosition(pos); + final result = await record.get(subkey: recordSubkey); + if (result == null) { + throw StateError('Element does not exist'); + } + _head.freeIndex(pos); + return result; + } + + @override + Future tryClear() async { + _head.clearIndex(); + return true; + } + + @override + Future<(Uint8List?, bool)> tryWriteItem(int pos, Uint8List newValue) async { + if (pos < 0 || pos >= _head.length) { + throw IndexError.withLength(pos, _head.length); + } + final (record, recordSubkey) = await _head.lookupPosition(pos); + final oldValue = await record.get(subkey: recordSubkey); + final result = await record.tryWriteBytes(newValue, subkey: recordSubkey); + if (result != null) { + // A result coming back means the element was overwritten already + return (result, false); + } + return (oldValue, true); + } + + //////////////////////////////////////////////////////////////////////////// + // Reader passthrough + + @override + int get length => _reader.length; + + @override + Future getItem(int pos, {bool forceRefresh = false}) => + _reader.getItem(pos, forceRefresh: forceRefresh); + + @override + Future?> getAllItems({bool forceRefresh = false}) => + _reader.getAllItems(forceRefresh: forceRefresh); + + //////////////////////////////////////////////////////////////////////////// + // Fields + final _DHTShortArrayHead _head; + final _DHTShortArrayRead _reader; +} diff --git a/lib/proto/dht.pb.dart b/packages/veilid_support/lib/proto/dht.pb.dart similarity index 98% rename from lib/proto/dht.pb.dart rename to packages/veilid_support/lib/proto/dht.pb.dart index 94d516b..7c96a7f 100644 --- a/lib/proto/dht.pb.dart +++ b/packages/veilid_support/lib/proto/dht.pb.dart @@ -92,6 +92,7 @@ class DHTShortArray extends $pb.GeneratedMessage { static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'DHTShortArray', package: const $pb.PackageName(_omitMessageNames ? '' : 'dht'), createEmptyInstance: create) ..pc<$0.TypedKey>(1, _omitFieldNames ? '' : 'keys', $pb.PbFieldType.PM, subBuilder: $0.TypedKey.create) ..a<$core.List<$core.int>>(2, _omitFieldNames ? '' : 'index', $pb.PbFieldType.OY) + ..p<$core.int>(3, _omitFieldNames ? '' : 'seqs', $pb.PbFieldType.KU3) ..hasRequiredFields = false ; @@ -127,6 +128,9 @@ class DHTShortArray extends $pb.GeneratedMessage { $core.bool hasIndex() => $_has(1); @$pb.TagNumber(2) void clearIndex() => clearField(2); + + @$pb.TagNumber(3) + $core.List<$core.int> get seqs => $_getList(2); } class DHTLog extends $pb.GeneratedMessage { diff --git a/lib/proto/dht.pbenum.dart b/packages/veilid_support/lib/proto/dht.pbenum.dart similarity index 100% rename from lib/proto/dht.pbenum.dart rename to packages/veilid_support/lib/proto/dht.pbenum.dart diff --git a/lib/proto/dht.pbjson.dart b/packages/veilid_support/lib/proto/dht.pbjson.dart similarity index 96% rename from lib/proto/dht.pbjson.dart rename to packages/veilid_support/lib/proto/dht.pbjson.dart index 939cf65..bf31c30 100644 --- a/lib/proto/dht.pbjson.dart +++ b/packages/veilid_support/lib/proto/dht.pbjson.dart @@ -36,13 +36,14 @@ const DHTShortArray$json = { '2': [ {'1': 'keys', '3': 1, '4': 3, '5': 11, '6': '.veilid.TypedKey', '10': 'keys'}, {'1': 'index', '3': 2, '4': 1, '5': 12, '10': 'index'}, + {'1': 'seqs', '3': 3, '4': 3, '5': 13, '10': 'seqs'}, ], }; /// Descriptor for `DHTShortArray`. Decode as a `google.protobuf.DescriptorProto`. final $typed_data.Uint8List dHTShortArrayDescriptor = $convert.base64Decode( 'Cg1ESFRTaG9ydEFycmF5EiQKBGtleXMYASADKAsyEC52ZWlsaWQuVHlwZWRLZXlSBGtleXMSFA' - 'oFaW5kZXgYAiABKAxSBWluZGV4'); + 'oFaW5kZXgYAiABKAxSBWluZGV4EhIKBHNlcXMYAyADKA1SBHNlcXM='); @$core.Deprecated('Use dHTLogDescriptor instead') const DHTLog$json = { diff --git a/lib/proto/dht.pbserver.dart b/packages/veilid_support/lib/proto/dht.pbserver.dart similarity index 100% rename from lib/proto/dht.pbserver.dart rename to packages/veilid_support/lib/proto/dht.pbserver.dart diff --git a/packages/veilid_support/lib/proto/proto.dart b/packages/veilid_support/lib/proto/proto.dart new file mode 100644 index 0000000..a7a70bb --- /dev/null +++ b/packages/veilid_support/lib/proto/proto.dart @@ -0,0 +1,152 @@ +import 'dart:typed_data'; + +import '../veilid_support.dart' as veilid; +import 'veilid.pb.dart' as proto; + +export 'veilid.pb.dart'; +export 'veilid.pbenum.dart'; +export 'veilid.pbjson.dart'; +export 'veilid.pbserver.dart'; + +/// CryptoKey protobuf marshaling +/// +extension CryptoKeyProto on veilid.CryptoKey { + proto.CryptoKey toProto() { + final b = decode().buffer.asByteData(); + final out = proto.CryptoKey() + ..u0 = b.getUint32(0 * 4) + ..u1 = b.getUint32(1 * 4) + ..u2 = b.getUint32(2 * 4) + ..u3 = b.getUint32(3 * 4) + ..u4 = b.getUint32(4 * 4) + ..u5 = b.getUint32(5 * 4) + ..u6 = b.getUint32(6 * 4) + ..u7 = b.getUint32(7 * 4); + return out; + } +} + +extension ProtoCryptoKey on proto.CryptoKey { + veilid.CryptoKey toVeilid() { + final b = ByteData(32) + ..setUint32(0 * 4, u0) + ..setUint32(1 * 4, u1) + ..setUint32(2 * 4, u2) + ..setUint32(3 * 4, u3) + ..setUint32(4 * 4, u4) + ..setUint32(5 * 4, u5) + ..setUint32(6 * 4, u6) + ..setUint32(7 * 4, u7); + return veilid.CryptoKey.fromBytes(Uint8List.view(b.buffer)); + } +} + +/// Signature protobuf marshaling +/// +extension SignatureProto on veilid.Signature { + proto.Signature toProto() { + final b = decode().buffer.asByteData(); + final out = proto.Signature() + ..u0 = b.getUint32(0 * 4) + ..u1 = b.getUint32(1 * 4) + ..u2 = b.getUint32(2 * 4) + ..u3 = b.getUint32(3 * 4) + ..u4 = b.getUint32(4 * 4) + ..u5 = b.getUint32(5 * 4) + ..u6 = b.getUint32(6 * 4) + ..u7 = b.getUint32(7 * 4) + ..u8 = b.getUint32(8 * 4) + ..u9 = b.getUint32(9 * 4) + ..u10 = b.getUint32(10 * 4) + ..u11 = b.getUint32(11 * 4) + ..u12 = b.getUint32(12 * 4) + ..u13 = b.getUint32(13 * 4) + ..u14 = b.getUint32(14 * 4) + ..u15 = b.getUint32(15 * 4); + return out; + } +} + +extension ProtoSignature on proto.Signature { + veilid.Signature toVeilid() { + final b = ByteData(64) + ..setUint32(0 * 4, u0) + ..setUint32(1 * 4, u1) + ..setUint32(2 * 4, u2) + ..setUint32(3 * 4, u3) + ..setUint32(4 * 4, u4) + ..setUint32(5 * 4, u5) + ..setUint32(6 * 4, u6) + ..setUint32(7 * 4, u7) + ..setUint32(8 * 4, u8) + ..setUint32(9 * 4, u9) + ..setUint32(10 * 4, u10) + ..setUint32(11 * 4, u11) + ..setUint32(12 * 4, u12) + ..setUint32(13 * 4, u13) + ..setUint32(14 * 4, u14) + ..setUint32(15 * 4, u15); + return veilid.Signature.fromBytes(Uint8List.view(b.buffer)); + } +} + +/// Nonce protobuf marshaling +/// +extension NonceProto on veilid.Nonce { + proto.Nonce toProto() { + final b = decode().buffer.asByteData(); + final out = proto.Nonce() + ..u0 = b.getUint32(0 * 4) + ..u1 = b.getUint32(1 * 4) + ..u2 = b.getUint32(2 * 4) + ..u3 = b.getUint32(3 * 4) + ..u4 = b.getUint32(4 * 4) + ..u5 = b.getUint32(5 * 4); + return out; + } +} + +extension ProtoNonce on proto.Nonce { + veilid.Nonce toVeilid() { + final b = ByteData(24) + ..setUint32(0 * 4, u0) + ..setUint32(1 * 4, u1) + ..setUint32(2 * 4, u2) + ..setUint32(3 * 4, u3) + ..setUint32(4 * 4, u4) + ..setUint32(5 * 4, u5); + return veilid.Nonce.fromBytes(Uint8List.view(b.buffer)); + } +} + +/// TypedKey protobuf marshaling +/// +extension TypedKeyProto on veilid.TypedKey { + proto.TypedKey toProto() { + final out = proto.TypedKey() + ..kind = kind + ..value = value.toProto(); + return out; + } +} + +extension ProtoTypedKey on proto.TypedKey { + veilid.TypedKey toVeilid() => + veilid.TypedKey(kind: kind, value: value.toVeilid()); +} + +/// KeyPair protobuf marshaling +/// +extension KeyPairProto on veilid.KeyPair { + proto.KeyPair toProto() { + final out = proto.KeyPair() + ..key = key.toProto() + ..secret = secret.toProto(); + return out; + } +} + +extension ProtoKeyPair on proto.KeyPair { + veilid.KeyPair toVeilid() => + veilid.KeyPair(key: key.toVeilid(), secret: secret.toVeilid()); +} diff --git a/lib/proto/veilid.pb.dart b/packages/veilid_support/lib/proto/veilid.pb.dart similarity index 100% rename from lib/proto/veilid.pb.dart rename to packages/veilid_support/lib/proto/veilid.pb.dart diff --git a/lib/proto/veilid.pbenum.dart b/packages/veilid_support/lib/proto/veilid.pbenum.dart similarity index 100% rename from lib/proto/veilid.pbenum.dart rename to packages/veilid_support/lib/proto/veilid.pbenum.dart diff --git a/lib/proto/veilid.pbjson.dart b/packages/veilid_support/lib/proto/veilid.pbjson.dart similarity index 100% rename from lib/proto/veilid.pbjson.dart rename to packages/veilid_support/lib/proto/veilid.pbjson.dart diff --git a/lib/proto/veilid.pbserver.dart b/packages/veilid_support/lib/proto/veilid.pbserver.dart similarity index 100% rename from lib/proto/veilid.pbserver.dart rename to packages/veilid_support/lib/proto/veilid.pbserver.dart diff --git a/lib/veilid_support/proto/veilid.proto b/packages/veilid_support/lib/proto/veilid.proto similarity index 100% rename from lib/veilid_support/proto/veilid.proto rename to packages/veilid_support/lib/proto/veilid.proto diff --git a/packages/veilid_support/lib/src/async_table_db_backed_cubit.dart b/packages/veilid_support/lib/src/async_table_db_backed_cubit.dart new file mode 100644 index 0000000..a50d893 --- /dev/null +++ b/packages/veilid_support/lib/src/async_table_db_backed_cubit.dart @@ -0,0 +1,49 @@ +import 'dart:async'; + +import 'package:async_tools/async_tools.dart'; +import 'package:bloc/bloc.dart'; + +import 'table_db.dart'; + +abstract class AsyncTableDBBackedCubit extends Cubit> + with TableDBBacked { + AsyncTableDBBackedCubit() : super(const AsyncValue.loading()) { + unawaited(Future.delayed(Duration.zero, _build)); + } + + Future _build() async { + try { + emit(AsyncValue.data(await load())); + } on Exception catch (e, stackTrace) { + emit(AsyncValue.error(e, stackTrace)); + } + } + + Future readyData() async { + final stateStream = stream.distinct(); + await for (final AsyncValue av in stateStream) { + final d = av.when( + data: (value) => value, loading: () => null, error: (e, s) => null); + if (d != null) { + return d; + } + final ef = av.when( + data: (value) => null, + loading: () => null, + error: Future.error); + if (ef != null) { + return ef; + } + } + return Future.error( + StateError("data never became ready in cubit '$runtimeType'")); + } + + Future setState(State newState) async { + try { + emit(AsyncValue.data(await store(newState))); + } on Exception catch (e, stackTrace) { + emit(AsyncValue.error(e, stackTrace)); + } + } +} diff --git a/packages/veilid_support/lib/src/config.dart b/packages/veilid_support/lib/src/config.dart new file mode 100644 index 0000000..c13a8b8 --- /dev/null +++ b/packages/veilid_support/lib/src/config.dart @@ -0,0 +1,95 @@ +import 'package:veilid/veilid.dart'; +import 'dart:io' show Platform; + +Map getDefaultVeilidPlatformConfig( + bool isWeb, String appName) { + final ignoreLogTargetsStr = + // ignore: do_not_use_environment + const String.fromEnvironment('IGNORE_LOG_TARGETS').trim(); + final ignoreLogTargets = ignoreLogTargetsStr.isEmpty + ? [] + : ignoreLogTargetsStr.split(',').map((e) => e.trim()).toList(); + + if (isWeb) { + return VeilidWASMConfig( + logging: VeilidWASMConfigLogging( + performance: VeilidWASMConfigLoggingPerformance( + enabled: true, + level: VeilidConfigLogLevel.debug, + logsInTimings: true, + logsInConsole: false, + ignoreLogTargets: ignoreLogTargets), + api: VeilidWASMConfigLoggingApi( + enabled: true, + level: VeilidConfigLogLevel.info, + ignoreLogTargets: ignoreLogTargets))) + .toJson(); + } + return VeilidFFIConfig( + logging: VeilidFFIConfigLogging( + terminal: VeilidFFIConfigLoggingTerminal( + enabled: false, + level: VeilidConfigLogLevel.debug, + ignoreLogTargets: ignoreLogTargets), + otlp: VeilidFFIConfigLoggingOtlp( + enabled: false, + level: VeilidConfigLogLevel.trace, + grpcEndpoint: '127.0.0.1:4317', + serviceName: appName, + ignoreLogTargets: ignoreLogTargets), + api: VeilidFFIConfigLoggingApi( + enabled: true, + level: VeilidConfigLogLevel.info, + ignoreLogTargets: ignoreLogTargets))) + .toJson(); +} + +Future getVeilidConfig(bool isWeb, String programName) async { + var config = await getDefaultVeilidConfig( + isWeb: isWeb, + programName: programName, + // ignore: avoid_redundant_argument_values, do_not_use_environment + namespace: const String.fromEnvironment('NAMESPACE'), + // ignore: avoid_redundant_argument_values, do_not_use_environment + bootstrap: const String.fromEnvironment('BOOTSTRAP'), + // ignore: avoid_redundant_argument_values, do_not_use_environment + networkKeyPassword: const String.fromEnvironment('NETWORK_KEY'), + ); + + // ignore: do_not_use_environment + if (const String.fromEnvironment('DELETE_TABLE_STORE') == '1') { + config = + config.copyWith(tableStore: config.tableStore.copyWith(delete: true)); + } + // ignore: do_not_use_environment + if (const String.fromEnvironment('DELETE_PROTECTED_STORE') == '1') { + config = config.copyWith( + protectedStore: config.protectedStore.copyWith(delete: true)); + } + // ignore: do_not_use_environment + if (const String.fromEnvironment('DELETE_BLOCK_STORE') == '1') { + config = + config.copyWith(blockStore: config.blockStore.copyWith(delete: true)); + } + + // ignore: do_not_use_environment + const envNetwork = String.fromEnvironment('NETWORK'); + if (envNetwork.isNotEmpty) { + final bootstrap = isWeb + ? ['ws://bootstrap.$envNetwork.veilid.net:5150/ws'] + : ['bootstrap.$envNetwork.veilid.net']; + config = config.copyWith( + network: config.network.copyWith( + routingTable: + config.network.routingTable.copyWith(bootstrap: bootstrap))); + } + + return config.copyWith( + capabilities: + // XXX: Remove DHTV and DHTW when we get background sync implemented + const VeilidConfigCapabilities(disable: ['DHTV', 'DHTW', 'TUNL']), + protectedStore: + // XXX: Linux often does not have a secret storage mechanism installed + config.protectedStore.copyWith(allowInsecureFallback: Platform.isLinux), + ); +} diff --git a/lib/veilid_support/src/identity.dart b/packages/veilid_support/lib/src/identity.dart similarity index 57% rename from lib/veilid_support/src/identity.dart rename to packages/veilid_support/lib/src/identity.dart index 0baf34b..e9ad6b6 100644 --- a/lib/veilid_support/src/identity.dart +++ b/packages/veilid_support/lib/src/identity.dart @@ -5,10 +5,25 @@ import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:protobuf/protobuf.dart'; import '../veilid_support.dart'; +import 'veilid_log.dart'; part 'identity.freezed.dart'; part 'identity.g.dart'; +// Identity errors +enum IdentityException implements Exception { + readError('identity could not be read'), + noAccount('no account record info'), + limitExceeded('too many items for the limit'), + invalid('identity is corrupted or secret is invalid'); + + const IdentityException(this.message); + final String message; + + @override + String toString() => 'IdentityException($name): $message'; +} + // AccountOwnerInfo is the key and owner info for the account dht key that is // stored in the identity key @freezed @@ -77,10 +92,16 @@ class IdentityMaster with _$IdentityMaster { extension IdentityMasterExtension on IdentityMaster { /// Deletes a master identity and the identity record under it Future delete() async { - final pool = await DHTRecordPool.instance(); - await (await pool.openRead(masterRecordKey)).delete(); + final pool = DHTRecordPool.instance; + await pool.delete(masterRecordKey); } + Future get identityCrypto => + Veilid.instance.getCryptoSystem(identityRecordKey.kind); + + Future get masterCrypto => + Veilid.instance.getCryptoSystem(masterRecordKey.kind); + KeyPair identityWriter(SecretKey secret) => KeyPair(key: identityPublicKey, secret: secret); @@ -90,32 +111,38 @@ extension IdentityMasterExtension on IdentityMaster { TypedKey identityPublicTypedKey() => TypedKey(kind: identityRecordKey.kind, value: identityPublicKey); - Future readAccountFromIdentity( + Future validateIdentitySecret( + SecretKey identitySecret) async { + final cs = await identityCrypto; + final keyOk = await cs.validateKeyPair(identityPublicKey, identitySecret); + if (!keyOk) { + throw IdentityException.invalid; + } + return cs; + } + + Future> readAccountsFromIdentity( {required SharedSecret identitySecret, required String accountKey}) async { // Read the identity key to get the account keys - final pool = await DHTRecordPool.instance(); + final pool = DHTRecordPool.instance; final identityRecordCrypto = await DHTRecordCryptoPrivate.fromSecret( identityRecordKey.kind, identitySecret); - late final AccountRecordInfo accountRecordInfo; + late final List accountRecordInfo; await (await pool.openRead(identityRecordKey, parent: masterRecordKey, crypto: identityRecordCrypto)) .scope((identityRec) async { final identity = await identityRec.getJson(Identity.fromJson); if (identity == null) { // Identity could not be read or decrypted from DHT - throw StateError('identity could not be read'); + throw IdentityException.readError; } final accountRecords = IMapOfSets.from(identity.accountRecords); final vcAccounts = accountRecords.get(accountKey); - if (vcAccounts.length != 1) { - // No account, or multiple accounts somehow associated with identity - throw StateError('no single account record info'); - } - accountRecordInfo = vcAccounts.first; + accountRecordInfo = vcAccounts.toList(); }); return accountRecordInfo; @@ -127,45 +154,51 @@ extension IdentityMasterExtension on IdentityMaster { required SharedSecret identitySecret, required String accountKey, required Future Function(TypedKey parent) createAccountCallback, + int maxAccounts = 1, }) async { - final pool = await DHTRecordPool.instance(); + final pool = DHTRecordPool.instance; /////// Add account with profile to DHT // Open identity key for writing + veilidLoggy.debug('Opening master identity'); return (await pool.openWrite( identityRecordKey, identityWriter(identitySecret), parent: masterRecordKey)) - .scope((identityRec) async => - // Create new account to insert into identity - (await pool.create(parent: identityRec.key)) - .deleteScope((accountRec) async { - final account = await createAccountCallback(accountRec.key); - // Write account key - await accountRec.eventualWriteProtobuf(account); + .scope((identityRec) async { + // Create new account to insert into identity + veilidLoggy.debug('Creating new account'); + return (await pool.create(parent: identityRec.key)) + .deleteScope((accountRec) async { + final account = await createAccountCallback(accountRec.key); + // Write account key + veilidLoggy.debug('Writing account record'); + await accountRec.eventualWriteProtobuf(account); - // Update identity key to include account - final newAccountRecordInfo = AccountRecordInfo( - accountRecord: OwnedDHTRecordPointer( - recordKey: accountRec.key, - owner: accountRec.ownerKeyPair!)); + // Update identity key to include account + final newAccountRecordInfo = AccountRecordInfo( + accountRecord: OwnedDHTRecordPointer( + recordKey: accountRec.key, owner: accountRec.ownerKeyPair!)); - await identityRec.eventualUpdateJson(Identity.fromJson, - (oldIdentity) async { - final oldAccountRecords = - IMapOfSets.from(oldIdentity.accountRecords); - // Only allow one account per identity for veilidchat - if (oldAccountRecords.get(accountKey).isNotEmpty) { - throw StateError('Only one account per key in identity'); - } - final accountRecords = oldAccountRecords - .add(accountKey, newAccountRecordInfo) - .asIMap(); - return oldIdentity.copyWith(accountRecords: accountRecords); - }); + veilidLoggy.debug('Updating identity with new account'); + await identityRec.eventualUpdateJson(Identity.fromJson, + (oldIdentity) async { + if (oldIdentity == null) { + throw IdentityException.readError; + } + final oldAccountRecords = IMapOfSets.from(oldIdentity.accountRecords); - return newAccountRecordInfo; - })); + if (oldAccountRecords.get(accountKey).length >= maxAccounts) { + throw IdentityException.limitExceeded; + } + final accountRecords = + oldAccountRecords.add(accountKey, newAccountRecordInfo).asIMap(); + return oldIdentity.copyWith(accountRecords: accountRecords); + }); + + return newAccountRecordInfo; + }); + }); } } @@ -185,66 +218,68 @@ class IdentityMasterWithSecrets { /// Creates a new master identity and returns it with its secrets static Future create() async { - final pool = await DHTRecordPool.instance(); + final pool = DHTRecordPool.instance; // IdentityMaster DHT record is public/unencrypted + veilidLoggy.debug('Creating master identity record'); return (await pool.create(crypto: const DHTRecordCryptoPublic())) - .deleteScope((masterRec) async => - // Identity record is private - (await pool.create(parent: masterRec.key)) - .scope((identityRec) async { - // Make IdentityMaster - final masterRecordKey = masterRec.key; - final masterOwner = masterRec.ownerKeyPair!; - final masterSigBuf = BytesBuilder() - ..add(masterRecordKey.decode()) - ..add(masterOwner.key.decode()); + .deleteScope((masterRec) async { + veilidLoggy.debug('Creating identity record'); + // Identity record is private + return (await pool.create(parent: masterRec.key)) + .scope((identityRec) async { + // Make IdentityMaster + final masterRecordKey = masterRec.key; + final masterOwner = masterRec.ownerKeyPair!; + final masterSigBuf = BytesBuilder() + ..add(masterRecordKey.decode()) + ..add(masterOwner.key.decode()); - final identityRecordKey = identityRec.key; - final identityOwner = identityRec.ownerKeyPair!; - final identitySigBuf = BytesBuilder() - ..add(identityRecordKey.decode()) - ..add(identityOwner.key.decode()); + final identityRecordKey = identityRec.key; + final identityOwner = identityRec.ownerKeyPair!; + final identitySigBuf = BytesBuilder() + ..add(identityRecordKey.decode()) + ..add(identityOwner.key.decode()); - assert(masterRecordKey.kind == identityRecordKey.kind, - 'new master and identity should have same cryptosystem'); - final crypto = - await pool.veilid.getCryptoSystem(masterRecordKey.kind); + assert(masterRecordKey.kind == identityRecordKey.kind, + 'new master and identity should have same cryptosystem'); + final crypto = await pool.veilid.getCryptoSystem(masterRecordKey.kind); - final identitySignature = await crypto.signWithKeyPair( - masterOwner, identitySigBuf.toBytes()); - final masterSignature = await crypto.signWithKeyPair( - identityOwner, masterSigBuf.toBytes()); + final identitySignature = + await crypto.signWithKeyPair(masterOwner, identitySigBuf.toBytes()); + final masterSignature = + await crypto.signWithKeyPair(identityOwner, masterSigBuf.toBytes()); - final identityMaster = IdentityMaster( - identityRecordKey: identityRecordKey, - identityPublicKey: identityOwner.key, - masterRecordKey: masterRecordKey, - masterPublicKey: masterOwner.key, - identitySignature: identitySignature, - masterSignature: masterSignature); + final identityMaster = IdentityMaster( + identityRecordKey: identityRecordKey, + identityPublicKey: identityOwner.key, + masterRecordKey: masterRecordKey, + masterPublicKey: masterOwner.key, + identitySignature: identitySignature, + masterSignature: masterSignature); - // Write identity master to master dht key - await masterRec.eventualWriteJson(identityMaster); + // Write identity master to master dht key + await masterRec.eventualWriteJson(identityMaster); - // Make empty identity - const identity = Identity(accountRecords: IMapConst({})); + // Make empty identity + const identity = Identity(accountRecords: IMapConst({})); - // Write empty identity to identity dht key - await identityRec.eventualWriteJson(identity); + // Write empty identity to identity dht key + await identityRec.eventualWriteJson(identity); - return IdentityMasterWithSecrets._( - identityMaster: identityMaster, - masterSecret: masterOwner.secret, - identitySecret: identityOwner.secret); - })); + return IdentityMasterWithSecrets._( + identityMaster: identityMaster, + masterSecret: masterOwner.secret, + identitySecret: identityOwner.secret); + }); + }); } } /// Opens an existing master identity and validates it Future openIdentityMaster( {required TypedKey identityMasterRecordKey}) async { - final pool = await DHTRecordPool.instance(); + final pool = DHTRecordPool.instance; // IdentityMaster DHT record is public/unencrypted return (await pool.openRead(identityMasterRecordKey)) diff --git a/lib/veilid_support/src/identity.freezed.dart b/packages/veilid_support/lib/src/identity.freezed.dart similarity index 99% rename from lib/veilid_support/src/identity.freezed.dart rename to packages/veilid_support/lib/src/identity.freezed.dart index d8626df..27f34ea 100644 --- a/lib/veilid_support/src/identity.freezed.dart +++ b/packages/veilid_support/lib/src/identity.freezed.dart @@ -12,7 +12,7 @@ part of 'identity.dart'; T _$identity(T value) => value; final _privateConstructorUsedError = UnsupportedError( - 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods'); + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); AccountRecordInfo _$AccountRecordInfoFromJson(Map json) { return _AccountRecordInfo.fromJson(json); @@ -126,7 +126,7 @@ class _$AccountRecordInfoImpl implements _AccountRecordInfo { } @override - bool operator ==(dynamic other) { + bool operator ==(Object other) { return identical(this, other) || (other.runtimeType == runtimeType && other is _$AccountRecordInfoImpl && @@ -268,7 +268,7 @@ class _$IdentityImpl implements _Identity { } @override - bool operator ==(dynamic other) { + bool operator ==(Object other) { return identical(this, other) || (other.runtimeType == runtimeType && other is _$IdentityImpl && @@ -503,7 +503,7 @@ class _$IdentityMasterImpl implements _IdentityMaster { } @override - bool operator ==(dynamic other) { + bool operator ==(Object other) { return identical(this, other) || (other.runtimeType == runtimeType && other is _$IdentityMasterImpl && diff --git a/lib/veilid_support/src/identity.g.dart b/packages/veilid_support/lib/src/identity.g.dart similarity index 62% rename from lib/veilid_support/src/identity.g.dart rename to packages/veilid_support/lib/src/identity.g.dart index 616477a..7d3687e 100644 --- a/lib/veilid_support/src/identity.g.dart +++ b/packages/veilid_support/lib/src/identity.g.dart @@ -9,19 +9,19 @@ part of 'identity.dart'; _$AccountRecordInfoImpl _$$AccountRecordInfoImplFromJson( Map json) => _$AccountRecordInfoImpl( - accountRecord: OwnedDHTRecordPointer.fromJson(json['account_record']), + accountRecord: OwnedDHTRecordPointer.fromJson(json['accountRecord']), ); Map _$$AccountRecordInfoImplToJson( _$AccountRecordInfoImpl instance) => { - 'account_record': instance.accountRecord.toJson(), + 'accountRecord': instance.accountRecord, }; _$IdentityImpl _$$IdentityImplFromJson(Map json) => _$IdentityImpl( accountRecords: IMap>.fromJson( - json['account_records'] as Map, + json['accountRecords'] as Map, (value) => value as String, (value) => ISet.fromJson( value, (value) => AccountRecordInfo.fromJson(value))), @@ -29,10 +29,10 @@ _$IdentityImpl _$$IdentityImplFromJson(Map json) => Map _$$IdentityImplToJson(_$IdentityImpl instance) => { - 'account_records': instance.accountRecords.toJson( + 'accountRecords': instance.accountRecords.toJson( (value) => value, (value) => value.toJson( - (value) => value.toJson(), + (value) => value, ), ), }; @@ -40,24 +40,24 @@ Map _$$IdentityImplToJson(_$IdentityImpl instance) => _$IdentityMasterImpl _$$IdentityMasterImplFromJson(Map json) => _$IdentityMasterImpl( identityRecordKey: - Typed.fromJson(json['identity_record_key']), + Typed.fromJson(json['identityRecordKey']), identityPublicKey: - FixedEncodedString43.fromJson(json['identity_public_key']), + FixedEncodedString43.fromJson(json['identityPublicKey']), masterRecordKey: - Typed.fromJson(json['master_record_key']), - masterPublicKey: FixedEncodedString43.fromJson(json['master_public_key']), + Typed.fromJson(json['masterRecordKey']), + masterPublicKey: FixedEncodedString43.fromJson(json['masterPublicKey']), identitySignature: - FixedEncodedString86.fromJson(json['identity_signature']), - masterSignature: FixedEncodedString86.fromJson(json['master_signature']), + FixedEncodedString86.fromJson(json['identitySignature']), + masterSignature: FixedEncodedString86.fromJson(json['masterSignature']), ); Map _$$IdentityMasterImplToJson( _$IdentityMasterImpl instance) => { - 'identity_record_key': instance.identityRecordKey.toJson(), - 'identity_public_key': instance.identityPublicKey.toJson(), - 'master_record_key': instance.masterRecordKey.toJson(), - 'master_public_key': instance.masterPublicKey.toJson(), - 'identity_signature': instance.identitySignature.toJson(), - 'master_signature': instance.masterSignature.toJson(), + 'identityRecordKey': instance.identityRecordKey, + 'identityPublicKey': instance.identityPublicKey, + 'masterRecordKey': instance.masterRecordKey, + 'masterPublicKey': instance.masterPublicKey, + 'identitySignature': instance.identitySignature, + 'masterSignature': instance.masterSignature, }; diff --git a/lib/veilid_support/src/json_tools.dart b/packages/veilid_support/lib/src/json_tools.dart similarity index 75% rename from lib/veilid_support/src/json_tools.dart rename to packages/veilid_support/lib/src/json_tools.dart index e7bfd09..c5895d0 100644 --- a/lib/veilid_support/src/json_tools.dart +++ b/packages/veilid_support/lib/src/json_tools.dart @@ -13,14 +13,15 @@ Uint8List jsonEncodeBytes(Object? object, utf8.encode(jsonEncode(object, toEncodable: toEncodable))); Future jsonUpdateBytes(T Function(dynamic) fromJson, - Uint8List oldBytes, Future Function(T) update) async { - final oldObj = fromJson(jsonDecode(utf8.decode(oldBytes))); + Uint8List? oldBytes, Future Function(T?) update) async { + final oldObj = + oldBytes == null ? null : fromJson(jsonDecode(utf8.decode(oldBytes))); final newObj = await update(oldObj); return jsonEncodeBytes(newObj); } -Future Function(Uint8List) jsonUpdate( - T Function(dynamic) fromJson, Future Function(T) update) => +Future Function(Uint8List?) jsonUpdate( + T Function(dynamic) fromJson, Future Function(T?) update) => (oldBytes) => jsonUpdateBytes(fromJson, oldBytes, update); T Function(Object?) genericFromJson( diff --git a/packages/veilid_support/lib/src/memory_tools.dart b/packages/veilid_support/lib/src/memory_tools.dart new file mode 100644 index 0000000..08aa8dc --- /dev/null +++ b/packages/veilid_support/lib/src/memory_tools.dart @@ -0,0 +1,72 @@ +import 'dart:math'; +import 'dart:typed_data'; + +/// Compares two [Uint8List] contents for equality by comparing words at a time. +/// Returns true if this == other +extension Uint8ListCompare on Uint8List { + bool equals(Uint8List other) { + if (identical(this, other)) { + return true; + } + if (length != other.length) { + return false; + } + + final words = buffer.asUint32List(); + final otherwords = other.buffer.asUint32List(); + final wordLen = words.length; + + var i = 0; + for (; i < wordLen; i++) { + if (words[i] != otherwords[i]) { + break; + } + } + i <<= 2; + for (; i < length; i++) { + if (this[i] != other[i]) { + return false; + } + } + return true; + } + + /// Compares two [Uint8List] contents for + /// numeric ordering by comparing words at a time. + /// Returns -1 for this < other, 1 for this > other, and 0 for this == other. + int compare(Uint8List other) { + if (identical(this, other)) { + return 0; + } + + final words = buffer.asUint32List(); + final otherwords = other.buffer.asUint32List(); + final minWordLen = min(words.length, otherwords.length); + + var i = 0; + for (; i < minWordLen; i++) { + if (words[i] != otherwords[i]) { + break; + } + } + i <<= 2; + final minLen = min(length, other.length); + for (; i < minLen; i++) { + final a = this[i]; + final b = other[i]; + if (a < b) { + return -1; + } + if (a > b) { + return 1; + } + } + if (length < other.length) { + return -1; + } + if (length > other.length) { + return 1; + } + return 0; + } +} diff --git a/lib/veilid_support/src/protobuf_tools.dart b/packages/veilid_support/lib/src/protobuf_tools.dart similarity index 70% rename from lib/veilid_support/src/protobuf_tools.dart rename to packages/veilid_support/lib/src/protobuf_tools.dart index c24302c..94dc6d1 100644 --- a/lib/veilid_support/src/protobuf_tools.dart +++ b/packages/veilid_support/lib/src/protobuf_tools.dart @@ -4,14 +4,14 @@ import 'package:protobuf/protobuf.dart'; Future protobufUpdateBytes( T Function(List) fromBuffer, - Uint8List oldBytes, - Future Function(T) update) async { - final oldObj = fromBuffer(oldBytes); + Uint8List? oldBytes, + Future Function(T?) update) async { + final oldObj = oldBytes == null ? null : fromBuffer(oldBytes); final newObj = await update(oldObj); return Uint8List.fromList(newObj.writeToBuffer()); } -Future Function(Uint8List) +Future Function(Uint8List?) protobufUpdate( - T Function(List) fromBuffer, Future Function(T) update) => + T Function(List) fromBuffer, Future Function(T?) update) => (oldBytes) => protobufUpdateBytes(fromBuffer, oldBytes, update); diff --git a/packages/veilid_support/lib/src/table_db.dart b/packages/veilid_support/lib/src/table_db.dart new file mode 100644 index 0000000..1e09fc4 --- /dev/null +++ b/packages/veilid_support/lib/src/table_db.dart @@ -0,0 +1,107 @@ +import 'dart:async'; + +import 'package:async_tools/async_tools.dart'; +import 'package:veilid/veilid.dart'; + +Future tableScope( + String name, Future Function(VeilidTableDB tdb) callback, + {int columnCount = 1}) async { + final tableDB = await Veilid.instance.openTableDB(name, columnCount); + try { + return await callback(tableDB); + } finally { + tableDB.close(); + } +} + +Future transactionScope( + VeilidTableDB tdb, + Future Function(VeilidTableDBTransaction tdbt) callback, +) async { + final tdbt = tdb.transact(); + try { + final ret = await callback(tdbt); + if (!tdbt.isDone()) { + await tdbt.commit(); + } + return ret; + } finally { + if (!tdbt.isDone()) { + await tdbt.rollback(); + } + } +} + +abstract mixin class TableDBBacked { + String tableName(); + String tableKeyName(); + T valueFromJson(Object? obj); + Object? valueToJson(T val); + + /// Load things from storage + Future load() async { + final obj = await tableScope(tableName(), (tdb) async { + final objJson = await tdb.loadStringJson(0, tableKeyName()); + return valueFromJson(objJson); + }); + return obj; + } + + /// Store things to storage + Future store(T obj) async { + await tableScope(tableName(), (tdb) async { + await tdb.storeStringJson(0, tableKeyName(), valueToJson(obj)); + }); + return obj; + } +} + +class TableDBValue extends TableDBBacked { + TableDBValue({ + required String tableName, + required String tableKeyName, + required T Function(Object? obj) valueFromJson, + required Object? Function(T obj) valueToJson, + }) : _tableName = tableName, + _valueFromJson = valueFromJson, + _valueToJson = valueToJson, + _tableKeyName = tableKeyName, + _streamController = StreamController.broadcast(); + + AsyncData? get value => _value; + T get requireValue => _value!.value; + Stream get stream => _streamController.stream; + + Future get() async { + final val = _value; + if (val != null) { + return val.value; + } + final loadedValue = await load(); + _value = AsyncData(loadedValue); + return loadedValue; + } + + Future set(T newVal) async { + _value = AsyncData(await store(newVal)); + _streamController.add(newVal); + } + + AsyncData? _value; + final String _tableName; + final String _tableKeyName; + final T Function(Object? obj) _valueFromJson; + final Object? Function(T obj) _valueToJson; + final StreamController _streamController; + + ////////////////////////////////////////////////////////////// + /// AsyncTableDBBacked + @override + String tableName() => _tableName; + @override + String tableKeyName() => _tableKeyName; + @override + T valueFromJson(Object? obj) => _valueFromJson(obj); + @override + Object? valueToJson(T val) => _valueToJson(val); +} diff --git a/lib/veilid_support/src/veilid_log.dart b/packages/veilid_support/lib/src/veilid_log.dart similarity index 74% rename from lib/veilid_support/src/veilid_log.dart rename to packages/veilid_support/lib/src/veilid_log.dart index 8a343eb..0007754 100644 --- a/lib/veilid_support/src/veilid_log.dart +++ b/packages/veilid_support/lib/src/veilid_log.dart @@ -1,5 +1,5 @@ -import 'package:flutter/foundation.dart'; import 'package:loggy/loggy.dart'; +import 'package:meta/meta.dart'; import 'package:veilid/veilid.dart'; // Loggy tools @@ -33,14 +33,19 @@ void setVeilidLogLevel(LogLevel? level) { Veilid.instance.changeLogLevel('all', convertToVeilidConfigLogLevel(level)); } +void changeVeilidLogIgnore(String change) { + Veilid.instance.changeLogIgnore('all', change.split(',')); +} + class VeilidLoggy implements LoggyType { @override Loggy get loggy => Loggy('Veilid'); } -Loggy get _veilidLoggy => Loggy('Veilid'); +@internal +Loggy get veilidLoggy => Loggy('Veilid'); -Future processLog(VeilidLog log) async { +void processLog(VeilidLog log) { StackTrace? stackTrace; Object? error; final backtrace = log.backtrace; @@ -51,31 +56,31 @@ Future processLog(VeilidLog log) async { switch (log.logLevel) { case VeilidLogLevel.error: - _veilidLoggy.error(log.message, error, stackTrace); + veilidLoggy.error(log.message, error, stackTrace); break; case VeilidLogLevel.warn: - _veilidLoggy.warning(log.message, error, stackTrace); + veilidLoggy.warning(log.message, error, stackTrace); break; case VeilidLogLevel.info: - _veilidLoggy.info(log.message, error, stackTrace); + veilidLoggy.info(log.message, error, stackTrace); break; case VeilidLogLevel.debug: - _veilidLoggy.debug(log.message, error, stackTrace); + veilidLoggy.debug(log.message, error, stackTrace); break; case VeilidLogLevel.trace: - _veilidLoggy.trace(log.message, error, stackTrace); + veilidLoggy.trace(log.message, error, stackTrace); break; } } -void initVeilidLog() { +void initVeilidLog(bool debugMode) { // ignore: do_not_use_environment const isTrace = String.fromEnvironment('LOG_TRACE') != ''; LogLevel logLevel; if (isTrace) { logLevel = traceLevel; } else { - logLevel = kDebugMode ? LogLevel.debug : LogLevel.info; + logLevel = debugMode ? LogLevel.debug : LogLevel.info; } setVeilidLogLevel(logLevel); } diff --git a/lib/veilid_support/veilid_support.dart b/packages/veilid_support/lib/veilid_support.dart similarity index 80% rename from lib/veilid_support/veilid_support.dart rename to packages/veilid_support/lib/veilid_support.dart index f873397..56db796 100644 --- a/lib/veilid_support/veilid_support.dart +++ b/packages/veilid_support/lib/veilid_support.dart @@ -9,6 +9,7 @@ export 'dht_support/dht_support.dart'; export 'src/config.dart'; export 'src/identity.dart'; export 'src/json_tools.dart'; +export 'src/memory_tools.dart'; export 'src/protobuf_tools.dart'; export 'src/table_db.dart'; -export 'src/veilid_log.dart'; +export 'src/veilid_log.dart' hide veilidLoggy; diff --git a/packages/veilid_support/pubspec.lock b/packages/veilid_support/pubspec.lock new file mode 100644 index 0000000..f4835ef --- /dev/null +++ b/packages/veilid_support/pubspec.lock @@ -0,0 +1,793 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: "0b2f2bd91ba804e53a61d757b986f89f1f9eaed5b11e4b2f5a2468d86d6c9fc7" + url: "https://pub.dev" + source: hosted + version: "67.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "37577842a27e4338429a1cbc32679d508836510b056f1eedf0c8d20e39c1383d" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + args: + dependency: transitive + description: + name: args + sha256: eef6c46b622e0494a36c5a12d10d77fb4e855501a91c1b9ef9339326e58f0596 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + async: + dependency: transitive + description: + name: async + sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + url: "https://pub.dev" + source: hosted + version: "2.11.0" + async_tools: + dependency: "direct main" + description: + path: "../async_tools" + relative: true + source: path + version: "1.0.0" + bloc: + dependency: "direct main" + description: + name: bloc + sha256: f53a110e3b48dcd78136c10daa5d51512443cea5e1348c9d80a320095fa2db9e + url: "https://pub.dev" + source: hosted + version: "8.1.3" + bloc_tools: + dependency: "direct main" + description: + path: "../bloc_tools" + relative: true + source: path + version: "1.0.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + build: + dependency: transitive + description: + name: build + sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + build_config: + dependency: transitive + description: + name: build_config + sha256: bf80fcfb46a29945b423bd9aad884590fb1dc69b330a4d4700cac476af1708d1 + url: "https://pub.dev" + source: hosted + version: "1.1.1" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: "0343061a33da9c5810b2d6cee51945127d8f4c060b7fbdd9d54917f0a3feaaa1" + url: "https://pub.dev" + source: hosted + version: "4.0.1" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "581bacf68f89ec8792f5e5a0b2c4decd1c948e97ce659dc783688c8a88fbec21" + url: "https://pub.dev" + source: hosted + version: "2.4.8" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + sha256: "4ae8ffe5ac758da294ecf1802f2aff01558d8b1b00616aa7538ea9a8a5d50799" + url: "https://pub.dev" + source: hosted + version: "7.3.0" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: fedde275e0a6b798c3296963c5cd224e3e1b55d0e478d5b7e65e6b540f363a0e + url: "https://pub.dev" + source: hosted + version: "8.9.1" + change_case: + dependency: transitive + description: + name: change_case + sha256: "47c48c36f95f20c6d0ba03efabceff261d05026cca322cc2c4c01c343371b5bb" + url: "https://pub.dev" + source: hosted + version: "2.0.1" + characters: + dependency: transitive + description: + name: characters + sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + charcode: + dependency: transitive + description: + name: charcode + sha256: fb98c0f6d12c920a02ee2d998da788bca066ca5f148492b7085ee23372b12306 + url: "https://pub.dev" + source: hosted + version: "1.3.1" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff + url: "https://pub.dev" + source: hosted + version: "2.0.3" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: f692079e25e7869c14132d39f223f8eec9830eb76131925143b2129c4bb01b37 + url: "https://pub.dev" + source: hosted + version: "4.10.0" + collection: + dependency: transitive + description: + name: collection + sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + url: "https://pub.dev" + source: hosted + version: "1.18.0" + convert: + dependency: transitive + description: + name: convert + sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + coverage: + dependency: transitive + description: + name: coverage + sha256: "8acabb8306b57a409bf4c83522065672ee13179297a6bb0cb9ead73948df7c76" + url: "https://pub.dev" + source: hosted + version: "1.7.2" + crypto: + dependency: transitive + description: + name: crypto + sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab + url: "https://pub.dev" + source: hosted + version: "3.0.3" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "99e066ce75c89d6b29903d788a7bb9369cf754f7b24bf70bf4b6d6d6b26853b9" + url: "https://pub.dev" + source: hosted + version: "2.3.6" + equatable: + dependency: "direct main" + description: + name: equatable + sha256: c2b87cb7756efdf69892005af546c56c0b5037f54d2a88269b4f347a505e3ca2 + url: "https://pub.dev" + source: hosted + version: "2.0.5" + fast_immutable_collections: + dependency: "direct main" + description: + name: fast_immutable_collections + sha256: "49154d1da38a34519b907b0e94a06705a59b7127728131dc4a54fe62fd95a83e" + url: "https://pub.dev" + source: hosted + version: "10.2.1" + ffi: + dependency: transitive + description: + name: ffi + sha256: "493f37e7df1804778ff3a53bd691d8692ddf69702cf4c1c1096a2e41b4779e21" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + file: + dependency: transitive + description: + name: file + sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + flutter: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + freezed: + dependency: "direct dev" + description: + name: freezed + sha256: "57247f692f35f068cae297549a46a9a097100685c6780fe67177503eea5ed4e5" + url: "https://pub.dev" + source: hosted + version: "2.4.7" + freezed_annotation: + dependency: "direct main" + description: + name: freezed_annotation + sha256: c3fd9336eb55a38cc1bbd79ab17573113a8deccd0ecbbf926cca3c62803b5c2d + url: "https://pub.dev" + source: hosted + version: "2.4.1" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: "408e3ca148b31c20282ad6f37ebfa6f4bdc8fede5b74bc2f08d9d92b55db3612" + url: "https://pub.dev" + source: hosted + version: "3.2.0" + glob: + dependency: transitive + description: + name: glob + sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + globbing: + dependency: transitive + description: + name: globbing + sha256: "4f89cfaf6fa74c9c1740a96259da06bd45411ede56744e28017cc534a12b6e2d" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + graphs: + dependency: transitive + description: + name: graphs + sha256: aedc5a15e78fc65a6e23bcd927f24c64dd995062bcd1ca6eda65a3cff92a4d19 + url: "https://pub.dev" + source: hosted + version: "2.3.1" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: "97486f20f9c2f7be8f514851703d0119c3596d14ea63227af6f7a481ef2b2f8b" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + url: "https://pub.dev" + source: hosted + version: "4.0.2" + io: + dependency: transitive + description: + name: io + sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + js: + dependency: transitive + description: + name: js + sha256: c1b2e9b5ea78c45e1a0788d29606ba27dc5f71f019f32ca5140f61ef071838cf + url: "https://pub.dev" + source: hosted + version: "0.7.1" + json_annotation: + dependency: "direct main" + description: + name: json_annotation + sha256: b10a7b2ff83d83c777edba3c6a0f97045ddadd56c944e1a23a3fdf43a1bf4467 + url: "https://pub.dev" + source: hosted + version: "4.8.1" + json_serializable: + dependency: "direct dev" + description: + name: json_serializable + sha256: aa1f5a8912615733e0fdc7a02af03308933c93235bdc8d50d0b0c8a8ccb0b969 + url: "https://pub.dev" + source: hosted + version: "6.7.1" + lint_hard: + dependency: "direct dev" + description: + name: lint_hard + sha256: "44d15ec309b1a8e1aff99069df9dcb1597f49d5f588f32811ca28fb7b38c32fe" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + logging: + dependency: transitive + description: + name: logging + sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + loggy: + dependency: "direct main" + description: + name: loggy + sha256: "981e03162bbd3a5a843026f75f73d26e4a0d8aa035ae060456ca7b30dfd1e339" + url: "https://pub.dev" + source: hosted + version: "2.0.3" + matcher: + dependency: transitive + description: + name: matcher + sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + url: "https://pub.dev" + source: hosted + version: "0.12.16+1" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" + url: "https://pub.dev" + source: hosted + version: "0.8.0" + meta: + dependency: "direct main" + description: + name: meta + sha256: d584fa6707a52763a52446f02cc621b077888fb63b93bbcb1143a7be5a0c0c04 + url: "https://pub.dev" + source: hosted + version: "1.11.0" + mime: + dependency: transitive + description: + name: mime + sha256: "2e123074287cc9fd6c09de8336dae606d1ddb88d9ac47358826db698c176a1f2" + url: "https://pub.dev" + source: hosted + version: "1.0.5" + mutex: + dependency: "direct main" + description: + path: "../mutex" + relative: true + source: path + version: "3.1.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + path: + dependency: transitive + description: + name: path + sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + url: "https://pub.dev" + source: hosted + version: "1.9.0" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: b27217933eeeba8ff24845c34003b003b2b22151de3c908d0e679e8fe1aa078b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "477184d672607c0a3bf68fbbf601805f92ef79c82b64b4d6eb318cbca4c48668" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "5a7999be66e000916500be4f15a3633ebceb8302719b47b9cc49ce924125350f" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: "8bc9f22eee8690981c22aa7fc602f5c85b497a6fb2ceb35ee5a5e5ed85ad8170" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + platform: + dependency: transitive + description: + name: platform + sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec" + url: "https://pub.dev" + source: hosted + version: "3.1.4" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" + url: "https://pub.dev" + source: hosted + version: "1.5.1" + protobuf: + dependency: "direct main" + description: + name: protobuf + sha256: "68645b24e0716782e58948f8467fd42a880f255096a821f9e7d0ec625b00c84d" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: c63b2876e58e194e4b0828fcb080ad0e06d051cb607a6be51a9e084f47cb9367 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + shelf: + dependency: transitive + description: + name: shelf + sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 + url: "https://pub.dev" + source: hosted + version: "1.4.1" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: a41d3f53c4adf0f57480578c1d61d90342cd617de7fc8077b1304643c2d85c1e + url: "https://pub.dev" + source: hosted + version: "1.1.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "9ca081be41c60190ebcb4766b2486a7d50261db7bd0f5d9615f2d653637a84c1" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.99" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + source_helper: + dependency: transitive + description: + name: source_helper + sha256: "6adebc0006c37dd63fe05bca0a929b99f06402fc95aa35bf36d67f5c06de01fd" + url: "https://pub.dev" + source: hosted + version: "1.3.4" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: "84cf769ad83aa6bb61e0aa5a18e53aea683395f196a6f39c4c881fb90ed4f7ae" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "708b3f6b97248e5781f493b765c3337db11c5d2c81c3094f10904bfa8004c703" + url: "https://pub.dev" + source: hosted + version: "0.10.12" + source_span: + dependency: transitive + description: + name: source_span + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + url: "https://pub.dev" + source: hosted + version: "1.10.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + url: "https://pub.dev" + source: hosted + version: "1.11.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + url: "https://pub.dev" + source: hosted + version: "2.1.2" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: "14a00e794c7c11aa145a170587321aedce29769c08d7f58b1d141da75e3b1c6f" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + system_info2: + dependency: transitive + description: + name: system_info2 + sha256: "65206bbef475217008b5827374767550a5420ce70a04d2d7e94d1d2253f3efc9" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + system_info_plus: + dependency: transitive + description: + name: system_info_plus + sha256: b915c811c6605b802f3988859bc2bb79c95f735762a75b5451741f7a2b949d1b + url: "https://pub.dev" + source: hosted + version: "0.0.5" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + test: + dependency: "direct dev" + description: + name: test + sha256: "7ee446762c2c50b3bd4ea96fe13ffac69919352bd3b4b17bac3f3465edc58073" + url: "https://pub.dev" + source: hosted + version: "1.25.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" + url: "https://pub.dev" + source: hosted + version: "0.7.0" + test_core: + dependency: transitive + description: + name: test_core + sha256: "2bc4b4ecddd75309300d8096f781c0e3280ca1ef85beda558d33fcbedc2eead4" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + timing: + dependency: transitive + description: + name: timing + sha256: "70a3b636575d4163c477e6de42f247a23b315ae20e86442bebe32d3cabf61c32" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c + url: "https://pub.dev" + source: hosted + version: "1.3.2" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + veilid: + dependency: "direct main" + description: + path: "../../../veilid/veilid-flutter" + relative: true + source: path + version: "0.3.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: a75f83f14ad81d5fe4b3319710b90dec37da0e22612326b696c9e1b8f34bbf48 + url: "https://pub.dev" + source: hosted + version: "14.2.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + web: + dependency: transitive + description: + name: web + sha256: "97da13628db363c635202ad97068d47c5b8aa555808e7a9411963c533b449b27" + url: "https://pub.dev" + source: hosted + version: "0.5.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: "1d8e795e2a8b3730c41b8a98a2dff2e0fb57ae6f0764a1c46ec5915387d257b2" + url: "https://pub.dev" + source: hosted + version: "2.4.4" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + win32: + dependency: transitive + description: + name: win32 + sha256: "8cb58b45c47dcb42ab3651533626161d6b67a2921917d8d429791f76972b3480" + url: "https://pub.dev" + source: hosted + version: "5.3.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: faea9dee56b520b55a566385b84f2e8de55e7496104adada9962e0bd11bcff1d + url: "https://pub.dev" + source: hosted + version: "1.0.4" + yaml: + dependency: transitive + description: + name: yaml + sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" + url: "https://pub.dev" + source: hosted + version: "3.1.2" +sdks: + dart: ">=3.3.0 <4.0.0" + flutter: ">=3.19.1" diff --git a/packages/veilid_support/pubspec.yaml b/packages/veilid_support/pubspec.yaml new file mode 100644 index 0000000..d7344ef --- /dev/null +++ b/packages/veilid_support/pubspec.yaml @@ -0,0 +1,34 @@ +name: veilid_support +description: Veilid Support Library +publish_to: 'none' +version: 1.0.2+0 + +environment: + sdk: '>=3.2.0 <4.0.0' + +dependencies: + async_tools: + path: ../async_tools + bloc: ^8.1.3 + bloc_tools: + path: ../bloc_tools + equatable: ^2.0.5 + fast_immutable_collections: ^10.1.1 + freezed_annotation: ^2.4.1 + json_annotation: ^4.8.1 + loggy: ^2.0.3 + meta: ^1.11.0 + mutex: + path: ../mutex + + protobuf: ^3.1.0 + veilid: + # veilid: ^0.0.1 + path: ../../../veilid/veilid-flutter + +dev_dependencies: + build_runner: ^2.4.8 + freezed: ^2.4.7 + json_serializable: ^6.7.1 + lint_hard: ^4.0.0 + test: ^1.25.2 diff --git a/pubspec.lock b/pubspec.lock index 04e023f..751e310 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,50 +5,42 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: eb376e9acf6938204f90eb3b1f00b578640d3188b4c8a8ec054f9f479af8d051 + sha256: "0b2f2bd91ba804e53a61d757b986f89f1f9eaed5b11e4b2f5a2468d86d6c9fc7" url: "https://pub.dev" source: hosted - version: "64.0.0" + version: "67.0.0" analyzer: dependency: transitive description: name: analyzer - sha256: "69f54f967773f6c26c7dcb13e93d7ccee8b17a641689da39e878d5cf13b06893" + sha256: "37577842a27e4338429a1cbc32679d508836510b056f1eedf0c8d20e39c1383d" url: "https://pub.dev" source: hosted - version: "6.2.0" - analyzer_plugin: - dependency: transitive - description: - name: analyzer_plugin - sha256: "9661b30b13a685efaee9f02e5d01ed9f2b423bd889d28a304d02d704aee69161" - url: "https://pub.dev" - source: hosted - version: "0.11.3" + version: "6.4.1" animated_theme_switcher: dependency: "direct main" description: name: animated_theme_switcher - sha256: a131266f7021a8a663da4c4848c53c62178949a7517c2af00b22e4c614352302 + sha256: "24ccd74437b8db78f6d1ec701804702817bced5f925b1b3419c7a93071e3d3e9" url: "https://pub.dev" source: hosted - version: "2.0.8" + version: "2.0.10" ansicolor: dependency: "direct main" description: name: ansicolor - sha256: "607f8fa9786f392043f169898923e6c59b4518242b68b8862eb8a8b7d9c30b4a" + sha256: "8bf17a8ff6ea17499e40a2d2542c2f481cd7615760c6d34065cb22bfd22e6880" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "2.0.2" archive: dependency: "direct main" description: name: archive - sha256: "20071638cbe4e5964a427cfa0e86dce55d060bc7d82d56f3554095d7239a8765" + sha256: "22600aa1e926be775fa5fe7e6894e7fb3df9efda8891c73f70fb3262399a432d" url: "https://pub.dev" source: hosted - version: "3.4.2" + version: "3.4.10" args: dependency: transitive description: @@ -65,14 +57,21 @@ packages: url: "https://pub.dev" source: hosted version: "2.11.0" + async_tools: + dependency: "direct main" + description: + path: "packages/async_tools" + relative: true + source: path + version: "1.0.0" awesome_extensions: dependency: "direct main" description: name: awesome_extensions - sha256: "6b9c6a5f70d17959ace71d649d3b816b13b73267196035d431ff17e65a228608" + sha256: "7d235d64a81543a7e200a91b1149bef7d32241290fa483bae25b31be41449a7c" url: "https://pub.dev" source: hosted - version: "2.0.9" + version: "2.0.13" badges: dependency: "direct main" description: @@ -85,10 +84,25 @@ packages: dependency: "direct main" description: name: basic_utils - sha256: "1fb8c5493fc1b9500512b2e153c0b9bcc9e281621cde7f810420f4761be9e38d" + sha256: "2064b21d3c41ed7654bc82cc476fd65542e04d60059b74d5eed490a4da08fc6c" url: "https://pub.dev" source: hosted - version: "5.6.1" + version: "5.7.0" + bloc: + dependency: "direct main" + description: + name: bloc + sha256: "106842ad6569f0b60297619e9e0b1885c2fb9bf84812935490e6c5275777804e" + url: "https://pub.dev" + source: hosted + version: "8.1.4" + bloc_tools: + dependency: "direct main" + description: + path: "packages/bloc_tools" + relative: true + source: path + version: "1.0.0" blurry_modal_progress_hud: dependency: "direct main" description: @@ -125,34 +139,34 @@ packages: dependency: transitive description: name: build_daemon - sha256: "5f02d73eb2ba16483e693f80bee4f088563a820e47d1027d4cdfe62b5bb43e65" + sha256: "0343061a33da9c5810b2d6cee51945127d8f4c060b7fbdd9d54917f0a3feaaa1" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "4.0.1" build_resolvers: dependency: transitive description: name: build_resolvers - sha256: "0713a05b0386bd97f9e63e78108805a4feca5898a4b821d6610857f10c91e975" + sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a" url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.4.2" build_runner: dependency: "direct dev" description: name: build_runner - sha256: "10c6bcdbf9d049a0b666702cf1cee4ddfdc38f02a19d35ae392863b47519848b" + sha256: "3ac61a79bfb6f6cc11f693591063a7f19a7af628dc52f141743edac5c16e8c22" url: "https://pub.dev" source: hosted - version: "2.4.6" + version: "2.4.9" build_runner_core: dependency: transitive description: name: build_runner_core - sha256: c9e32d21dd6626b5c163d48b037ce906bbe428bc23ab77bcd77bb21e593b6185 + sha256: "4ae8ffe5ac758da294ecf1802f2aff01558d8b1b00616aa7538ea9a8a5d50799" url: "https://pub.dev" source: hosted - version: "7.2.11" + version: "7.3.0" built_collection: dependency: transitive description: @@ -165,82 +179,82 @@ packages: dependency: transitive description: name: built_value - sha256: a8de5955205b4d1dbbbc267daddf2178bd737e4bab8987c04a500478c9651e74 + sha256: fedde275e0a6b798c3296963c5cd224e3e1b55d0e478d5b7e65e6b540f363a0e url: "https://pub.dev" source: hosted - version: "8.6.3" + version: "8.9.1" cached_network_image: dependency: transitive description: name: cached_network_image - sha256: f98972704692ba679db144261172a8e20feb145636c617af0eb4022132a6797f + sha256: "28ea9690a8207179c319965c13cd8df184d5ee721ae2ce60f398ced1219cea1f" url: "https://pub.dev" source: hosted - version: "3.3.0" + version: "3.3.1" cached_network_image_platform_interface: dependency: transitive description: name: cached_network_image_platform_interface - sha256: "56aa42a7a01e3c9db8456d9f3f999931f1e05535b5a424271e9a38cabf066613" + sha256: "9e90e78ae72caa874a323d78fa6301b3fb8fa7ea76a8f96dc5b5bf79f283bf2f" url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "4.0.0" cached_network_image_web: dependency: transitive description: name: cached_network_image_web - sha256: "759b9a9f8f6ccbb66c185df805fac107f05730b1dab9c64626d1008cca532257" + sha256: "42a835caa27c220d1294311ac409a43361088625a4f23c820b006dd9bffb3316" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.1" camera: dependency: transitive description: name: camera - sha256: f63f2687fb1795c36f7c57b18a03071880eabb0fd8b5291b0fcd3fb979cb0fb1 + sha256: "9499cbc2e51d8eb0beadc158b288380037618ce4e30c9acbc4fae1ac3ecb5797" url: "https://pub.dev" source: hosted - version: "0.10.5+4" + version: "0.10.5+9" camera_android: dependency: transitive description: name: camera_android - sha256: c978373b41a463c9edda3fea0a06966299f55db63232cd0f0d4efc21a59a0006 + sha256: "1100e527b44a96906987a91ef78c8dacb539e34612a8058de89023380acf67f1" url: "https://pub.dev" source: hosted - version: "0.10.8+12" + version: "0.10.8+18" camera_avfoundation: dependency: transitive description: name: camera_avfoundation - sha256: dde42d19ad4cdf79287f9e410599db72beaec7e505787dc6abfd0ce5b526e9c0 + sha256: "8b113e43ee4434c9244c03c905432a0d5956cedaded3cd7381abaab89ce50297" url: "https://pub.dev" source: hosted - version: "0.9.13+5" + version: "0.9.14+1" camera_platform_interface: dependency: transitive description: name: camera_platform_interface - sha256: "8734d1c682f034bdb12d0d6ff379b0535a9b8e44266b530025bf8266d6a62f28" + sha256: a250314a48ea337b35909a4c9d5416a208d736dcb01d0b02c6af122be66660b0 url: "https://pub.dev" source: hosted - version: "2.5.2" + version: "2.7.4" camera_web: dependency: transitive description: name: camera_web - sha256: d4c2c571c7af04f8b10702ca16bb9ed2a26e64534171e8f75c9349b2c004d8f1 + sha256: f18ccfb33b2a7c49a52ad5aa3f07330b7422faaecbdfd9b9fe8e51182f6ad67d url: "https://pub.dev" source: hosted - version: "0.3.2+3" + version: "0.3.2+4" change_case: dependency: "direct main" description: name: change_case - sha256: f4e08feaa845e75e4f5ad2b0e15f24813d7ea6c27e7b78252f0c17f752cf1157 + sha256: "47c48c36f95f20c6d0ba03efabceff261d05026cca322cc2c4c01c343371b5bb" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "2.0.1" characters: dependency: transitive description: @@ -261,10 +275,10 @@ packages: dependency: transitive description: name: charset - sha256: e8346cf597b6cea278d2d3a29b2d01ed8fb325aad718e70f22b0cb653cb31700 + sha256: "27802032a581e01ac565904ece8c8962564b1070690794f0072f6865958ce8b9" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "2.0.1" checked_yaml: dependency: transitive description: @@ -273,14 +287,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.3" - ci: - dependency: transitive - description: - name: ci - sha256: "145d095ce05cddac4d797a158bc4cf3b6016d1fe63d8c3d2fbd7212590adca13" - url: "https://pub.dev" - source: hosted - version: "0.1.0" circular_profile_avatar: dependency: "direct main" description: @@ -297,14 +303,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.1" - cli_util: - dependency: transitive - description: - name: cli_util - sha256: b8db3080e59b2503ca9e7922c3df2072cf13992354d5e944074ffa836fba43b7 - url: "https://pub.dev" - source: hosted - version: "0.4.0" clock: dependency: transitive description: @@ -317,18 +315,18 @@ packages: dependency: transitive description: name: code_builder - sha256: "1be9be30396d7e4c0db42c35ea6ccd7cc6a1e19916b5dc64d6ac216b5544d677" + sha256: f692079e25e7869c14132d39f223f8eec9830eb76131925143b2129c4bb01b37 url: "https://pub.dev" source: hosted - version: "4.7.0" + version: "4.10.0" collection: dependency: transitive description: name: collection - sha256: f092b211a4319e98e5ff58223576de6c2803db36221657b46c82574721240687 + sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a url: "https://pub.dev" source: hosted - version: "1.17.2" + version: "1.18.0" convert: dependency: transitive description: @@ -349,10 +347,10 @@ packages: dependency: transitive description: name: cross_file - sha256: fd832b5384d0d6da4f6df60b854d33accaaeb63aa9e10e736a87381f08dee2cb + sha256: "55d7b444feb71301ef6b8838dbc1ae02e63dd48c8773f3810ff53bb1e2945b32" url: "https://pub.dev" source: hosted - version: "0.3.3+5" + version: "0.3.4+1" crypto: dependency: transitive description: @@ -377,30 +375,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.6" - custom_lint: - dependency: transitive - description: - name: custom_lint - sha256: "837821e4619c167fd5a547b03bb2fc6be7e65b800ec75528848429705c31ceba" - url: "https://pub.dev" - source: hosted - version: "0.5.3" - custom_lint_core: - dependency: transitive - description: - name: custom_lint_core - sha256: "3bdebdd52a42b4d6e5be9cd833ad1ecfbbc23e1020ca537060e54085497aea9c" - url: "https://pub.dev" - source: hosted - version: "0.5.3" dart_style: dependency: transitive description: name: dart_style - sha256: abd7625e16f51f554ea244d090292945ec4d4be7bfbaf2ec8cccea568919d334 + sha256: "99e066ce75c89d6b29903d788a7bb9369cf754f7b24bf70bf4b6d6d6b26853b9" url: "https://pub.dev" source: hosted - version: "2.3.3" + version: "2.3.6" diffutil_dart: dependency: transitive description: @@ -417,46 +399,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.5" - fake_async: - dependency: transitive - description: - name: fake_async - sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" - url: "https://pub.dev" - source: hosted - version: "1.3.1" fast_immutable_collections: dependency: "direct main" description: name: fast_immutable_collections - sha256: b4f7d3af6e90a80cf7a3dddd0de3b4a46acb446320795b77b034535c4d267fbe + sha256: "38fbc50df5b219dcfb83ebbc3275ec09872530ca1153858fc56fceadb310d037" url: "https://pub.dev" source: hosted - version: "9.1.5" + version: "10.2.2" ffi: dependency: transitive description: name: ffi - sha256: "7bf0adc28a23d395f19f3f1eb21dd7cfd1dd9f8e1c50051c069122e6853bc878" + sha256: "493f37e7df1804778ff3a53bd691d8692ddf69702cf4c1c1096a2e41b4779e21" url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.2" file: dependency: transitive description: name: file - sha256: "1b92bec4fc2a72f59a8e15af5f52cd441e4a7860b49499d69dfa817af20e925d" + sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" url: "https://pub.dev" source: hosted - version: "6.1.4" - file_utils: - dependency: transitive - description: - name: file_utils - sha256: d1e64389a22649095c8405c9e177272caf05139255931c9ff30d53b5c9bcaa34 - url: "https://pub.dev" - source: hosted - version: "1.0.1" + version: "7.0.0" fixnum: dependency: "direct main" description: @@ -474,10 +440,18 @@ packages: dependency: "direct main" description: name: flutter_animate - sha256: "62f346340a96192070e31e3f2a1bd30a28530f1fe8be978821e06cd56b74d6d2" + sha256: "7c8a6594a9252dad30cc2ef16e33270b6248c4dedc3b3d06c86c4f3f4dc05ae5" url: "https://pub.dev" source: hosted - version: "4.2.0+1" + version: "4.5.0" + flutter_bloc: + dependency: "direct main" + description: + name: flutter_bloc + sha256: f0ecf6e6eb955193ca60af2d5ca39565a86b8a142452c5b24d96fb477428f4d2 + url: "https://pub.dev" + source: hosted + version: "8.1.5" flutter_cache_manager: dependency: transitive description: @@ -498,26 +472,26 @@ packages: dependency: "direct main" description: name: flutter_chat_ui - sha256: d2b7d99fae88d17fdab13f4be3e6ae15c4ceaa5d3e199b61c254a67222d42611 + sha256: c8580c85e2d29359ffc84147e643d08d883eb6e757208652377f0105ef58807f url: "https://pub.dev" source: hosted - version: "1.6.9" + version: "1.6.12" flutter_form_builder: dependency: "direct main" description: name: flutter_form_builder - sha256: "8973beed34b6d951d36bf688b52e9e3040b47b763c35c320bd6f4c2f6b13f3a2" + sha256: "560eb5e367d81170c6ade1e7ae63ecc5167936ae2cdfaae8a345e91bce19d2f2" url: "https://pub.dev" source: hosted - version: "9.1.1" + version: "9.2.1" flutter_hooks: dependency: "direct main" description: name: flutter_hooks - sha256: "6ae13b1145c589112cbd5c4fda6c65908993a9cb18d4f82042e9c28dd9fbf611" + sha256: cde36b12f7188c85286fba9b38cc5a902e7279f36dd676967106c041dc9dde70 url: "https://pub.dev" source: hosted - version: "0.20.1" + version: "0.20.5" flutter_link_previewer: dependency: transitive description: @@ -543,10 +517,10 @@ packages: dependency: "direct main" description: name: flutter_native_splash - sha256: ecff62b3b893f2f665de7e4ad3de89f738941fcfcaaba8ee601e749efafa4698 + sha256: edf39bcf4d74aca1eb2c1e43c3e445fd9f494013df7f0da752fefe72020eedc0 url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "2.4.0" flutter_parsed_text: dependency: transitive description: @@ -559,47 +533,42 @@ packages: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: f185ac890306b5779ecbd611f52502d8d4d63d27703ef73161ca0407e815f02c + sha256: b068ffc46f82a55844acfa4fdbb61fad72fa2aef0905548419d97f0f95c456da url: "https://pub.dev" source: hosted - version: "2.0.16" - flutter_riverpod: - dependency: "direct main" + version: "2.0.17" + flutter_shaders: + dependency: transitive description: - name: flutter_riverpod - sha256: fcea39b84b666649280f6f678bc0bb479253bf865abc0387a8b11dac6477bf92 + name: flutter_shaders + sha256: "02750b545c01ff4d8e9bbe8f27a7731aa3778402506c67daa1de7f5fc3f4befe" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "0.1.2" flutter_slidable: dependency: "direct main" description: name: flutter_slidable - sha256: cc4231579e3eae41ae166660df717f4bad1359c87f4a4322ad8ba1befeb3d2be + sha256: "673403d2eeef1f9e8483bd6d8d92aae73b1d8bd71f382bc3930f699c731bc27c" url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "3.1.0" flutter_spinkit: dependency: "direct main" description: name: flutter_spinkit - sha256: b39c753e909d4796906c5696a14daf33639a76e017136c8d82bf3e620ce5bb8e + sha256: d2696eed13732831414595b98863260e33e8882fc069ee80ec35d4ac9ddb0472 url: "https://pub.dev" source: hosted - version: "5.2.0" + version: "5.2.1" flutter_svg: dependency: "direct main" description: name: flutter_svg - sha256: "8c5d68a82add3ca76d792f058b186a0599414f279f00ece4830b9b231b570338" + sha256: "7b4ca6cf3304575fe9c8ec64813c8d02ee41d2afe60bcfe0678bcb5375d596a2" url: "https://pub.dev" source: hosted - version: "2.0.7" - flutter_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" + version: "2.0.10+1" flutter_translate: dependency: "direct main" description: @@ -625,10 +594,10 @@ packages: dependency: "direct dev" description: name: freezed - sha256: be7826ed5d87e98c924a839542674fc14edbcb3e4fc0adbc058d680f2b241837 + sha256: "57247f692f35f068cae297549a46a9a097100685c6780fe67177503eea5ed4e5" url: "https://pub.dev" source: hosted - version: "2.4.3" + version: "2.4.7" freezed_annotation: dependency: "direct main" description: @@ -641,10 +610,10 @@ packages: dependency: transitive description: name: frontend_server_client - sha256: "408e3ca148b31c20282ad6f37ebfa6f4bdc8fede5b74bc2f08d9d92b55db3612" + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 url: "https://pub.dev" source: hosted - version: "3.2.0" + version: "4.0.0" glob: dependency: transitive description: @@ -665,10 +634,10 @@ packages: dependency: "direct main" description: name: go_router - sha256: a07c781bf55bf11ae85133338e4850f0b4e33e261c44a66c750fc707d65d8393 + sha256: "5ed2687bc961f33a752017ccaa7edead3e5601b28b6376a5901bf24728556b85" url: "https://pub.dev" source: hosted - version: "11.1.2" + version: "13.2.2" graphs: dependency: transitive description: @@ -677,14 +646,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.1" - hooks_riverpod: - dependency: "direct main" + hive: + dependency: transitive description: - name: hooks_riverpod - sha256: a5242fee89736eaf7e5565c271e2d87b0aeb9953ee936de819339366aebc6882 + name: hive + sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.2.3" html: dependency: transitive description: @@ -697,10 +666,10 @@ packages: dependency: transitive description: name: http - sha256: "759d1a329847dd0f39226c688d3e06a6b8679668e350e2891a6474f8b4bb8525" + sha256: "761a297c042deedc1ffbb156d6e2af13886bb305c2a343a4d972504cd67dd938" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.2.1" http_multi_server: dependency: transitive description: @@ -717,22 +686,30 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.2" + hydrated_bloc: + dependency: "direct main" + description: + name: hydrated_bloc + sha256: af35b357739fe41728df10bec03aad422cdc725a1e702e03af9d2a41ea05160c + url: "https://pub.dev" + source: hosted + version: "9.1.5" icons_launcher: dependency: "direct dev" description: name: icons_launcher - sha256: "69de6373013966ea033f4cefbbbae258ccbfe790a6cfc69796cb33fda996298a" + sha256: "9b514ffed6ed69b232fd2bf34c44878c8526be71fc74129a658f35c04c9d4a9d" url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.1.7" image: dependency: "direct main" description: name: image - sha256: "028f61960d56f26414eb616b48b04eb37d700cbe477b7fb09bf1d7ce57fd9271" + sha256: "4c68bfd5ae83e700b5204c1e74451e7bf3cf750e6843c6e158289cf56bda018e" url: "https://pub.dev" source: hosted - version: "4.1.3" + version: "4.1.7" intl: dependency: "direct main" description: @@ -809,58 +786,65 @@ packages: dependency: transitive description: name: matcher - sha256: "1803e76e6653768d64ed8ff2e1e67bea3ad4b923eb5c56a295c3e634bad5960e" + sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb url: "https://pub.dev" source: hosted - version: "0.12.16" + version: "0.12.16+1" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9528f2f296073ff54cb9fee677df673ace1218163c3bc7628093e7eed5203d41" + sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" url: "https://pub.dev" source: hosted - version: "0.5.0" + version: "0.8.0" meta: - dependency: transitive + dependency: "direct main" description: name: meta - sha256: "3c74dbf8763d36539f114c799d8a2d87343b5067e9d796ca22b5eb8437090ee3" + sha256: d584fa6707a52763a52446f02cc621b077888fb63b93bbcb1143a7be5a0c0c04 url: "https://pub.dev" source: hosted - version: "1.9.1" + version: "1.11.0" mime: dependency: transitive description: name: mime - sha256: e4ff8e8564c03f255408decd16e7899da1733852a9110a58fe6d1b817684a63e + sha256: "2e123074287cc9fd6c09de8336dae606d1ddb88d9ac47358826db698c176a1f2" url: "https://pub.dev" source: hosted - version: "1.0.4" + version: "1.0.5" mobile_scanner: dependency: "direct main" description: name: mobile_scanner - sha256: "2fbc3914fe625e196c64ea8ffc4084cd36781d2be276d4d5923b11af3b5d44ff" + sha256: "827765afbd4792ff3fd105ad593821ac0f6d8a7d352689013b07ee85be336312" url: "https://pub.dev" source: hosted - version: "3.4.1" + version: "4.0.1" motion_toast: dependency: "direct main" description: name: motion_toast - sha256: "5742e33ec2f11210f5269294304fb9bd0f30eace78ad23925eb9306dce7763c9" + sha256: f3fe9f92d9956814a1aa040c22c8a6c432cfb0c9f783163d9ec64915838e4837 url: "https://pub.dev" source: hosted - version: "2.7.9" + version: "2.9.0" mutex: dependency: "direct main" description: - name: mutex - sha256: "03116a4e46282a671b46c12de649d72c0ed18188ffe12a8d0fc63e83f4ad88f4" + path: "packages/mutex" + relative: true + source: path + version: "3.1.0" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "1.0.0" octo_image: dependency: transitive description: @@ -889,10 +873,10 @@ packages: dependency: "direct main" description: name: path - sha256: "8829d8a55c13fc0e37127c29fedf290c102f4e40ae94ada574091fe0ff96c917" + sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" url: "https://pub.dev" source: hosted - version: "1.8.3" + version: "1.9.0" path_parsing: dependency: transitive description: @@ -905,26 +889,26 @@ packages: dependency: "direct main" description: name: path_provider - sha256: a1aa8aaa2542a6bc57e381f132af822420216c80d4781f7aa085ca3229208aaa + sha256: b27217933eeeba8ff24845c34003b003b2b22151de3c908d0e679e8fe1aa078b url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" path_provider_android: dependency: transitive description: name: path_provider_android - sha256: "6b8b19bd80da4f11ce91b2d1fb931f3006911477cec227cce23d3253d80df3f1" + sha256: "477184d672607c0a3bf68fbbf601805f92ef79c82b64b4d6eb318cbca4c48668" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.2.2" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: "19314d595120f82aca0ba62787d58dde2cc6b5df7d2f0daf72489e38d1b57f2d" + sha256: "5a7999be66e000916500be4f15a3633ebceb8302719b47b9cc49ce924125350f" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.3.2" path_provider_linux: dependency: transitive description: @@ -937,10 +921,10 @@ packages: dependency: transitive description: name: path_provider_platform_interface - sha256: "94b1e0dd80970c1ce43d5d4e050a9918fce4f4a775e6142424c30a29a363265c" + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" path_provider_windows: dependency: transitive description: @@ -953,10 +937,10 @@ packages: dependency: transitive description: name: petitparser - sha256: cb3798bef7fc021ac45b308f4b51208a152792445cce0448c9a4ba5879dd8750 + sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 url: "https://pub.dev" source: hosted - version: "5.4.0" + version: "6.0.2" photo_view: dependency: transitive description: @@ -969,42 +953,34 @@ packages: dependency: "direct main" description: name: pinput - sha256: a92b55ecf9c25d1b9e100af45905385d5bc34fc9b6b04177a9e82cb88fe4d805 + sha256: "6d571e38a484f7515a52e89024ef416f11fa6171ac6f32303701374ab9890efa" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "4.0.0" platform: dependency: transitive description: name: platform - sha256: ae68c7bfcd7383af3629daafb32fb4e8681c7154428da4febcff06200585f102 + sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec" url: "https://pub.dev" source: hosted - version: "3.1.2" - platform_info: - dependency: transitive - description: - name: platform_info - sha256: "012e73712166cf0b56d3eb95c0d33491f56b428c169eca385f036448474147e4" - url: "https://pub.dev" - source: hosted - version: "3.2.0" + version: "3.1.4" plugin_platform_interface: dependency: transitive description: name: plugin_platform_interface - sha256: da3fdfeccc4d4ff2da8f8c556704c08f912542c5fb3cf2233ed75372384a034d + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" url: "https://pub.dev" source: hosted - version: "2.1.6" + version: "2.1.8" pointycastle: dependency: transitive description: name: pointycastle - sha256: "7c1e5f0d23c9016c5bbd8b1473d0d3fb3fc851b876046039509e18e0c7485f2c" + sha256: "43ac87de6e10afabc85c445745a7b799e04de84cebaa4fd7bf55a5e1e9604d29" url: "https://pub.dev" source: hosted - version: "3.7.3" + version: "3.7.4" pool: dependency: transitive description: @@ -1029,6 +1005,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.0" + provider: + dependency: "direct main" + description: + name: provider + sha256: c8a055ee5ce3fd98d6fc872478b03823ffdb448699c6ebdbbc71d59b596fd48c + url: "https://pub.dev" + source: hosted + version: "6.1.2" pub_semver: dependency: transitive description: @@ -1057,10 +1041,10 @@ packages: dependency: "direct main" description: name: qr_code_dart_scan - sha256: "4b5222c044700f9ecb3d1c39ca9c5cf433b508f81a0649b768628d3b5ee2ffc4" + sha256: "8e9732d5b6e4e28d50647dc6d7713bf421148cadf28c768a10e9810bf6f3d87a" url: "https://pub.dev" source: hosted - version: "0.7.2" + version: "0.7.6" qr_flutter: dependency: "direct main" description: @@ -1073,10 +1057,10 @@ packages: dependency: "direct main" description: name: quickalert - sha256: "0c21c9be68b9ae76082e1ad56db9f51202a38e617e08376f05375238277cfb5a" + sha256: b5d62b1e20b08cc0ff5f40b6da519bdc7a5de6082f13d90572cf4e72eea56c5e url: "https://pub.dev" source: hosted - version: "1.0.2" + version: "1.1.0" quiver: dependency: transitive description: @@ -1097,42 +1081,10 @@ packages: dependency: "direct main" description: name: reorderable_grid - sha256: a1322139ec59134e2180acb1b84fe436ea927ce2712ae01da511614131a07d85 + sha256: "0b9cd95ef0f070ef99f92affe9cf85a4aa127099cd1334e5940950ce58cd981d" url: "https://pub.dev" source: hosted - version: "1.0.8" - riverpod: - dependency: transitive - description: - name: riverpod - sha256: ff676bd8a715c7085692fe4919564f78fb90d33b10a1c5c14e740581857cc914 - url: "https://pub.dev" - source: hosted - version: "2.4.1" - riverpod_analyzer_utils: - dependency: transitive - description: - name: riverpod_analyzer_utils - sha256: d72d7096964baf288b55619fe48100001fc4564ab7923ed0a7f5c7650e03c0d6 - url: "https://pub.dev" - source: hosted - version: "0.3.4" - riverpod_annotation: - dependency: "direct main" - description: - name: riverpod_annotation - sha256: aeeb1eb6ccf2d779f2ef730e6d96d560316b677662222316779a8cf0a94ee317 - url: "https://pub.dev" - source: hosted - version: "2.1.6" - riverpod_generator: - dependency: "direct dev" - description: - name: riverpod_generator - sha256: "5b36ad2f2b562cffb37212e8d59390b25499bf045b732276e30a207b16a25f61" - url: "https://pub.dev" - source: hosted - version: "2.3.3" + version: "1.0.10" rxdart: dependency: transitive description: @@ -1161,34 +1113,34 @@ packages: dependency: "direct main" description: name: searchable_listview - sha256: e1ba75eda1460c24648e54c543843a7142811ea4966c2106e0cc6792128b7127 + sha256: "5535ea3efa4599cf23ce52870a9580b52ece5d691aa90655ebec76d5081c9592" url: "https://pub.dev" source: hosted - version: "2.7.1" + version: "2.11.1" share_plus: dependency: "direct main" description: name: share_plus - sha256: "6cec740fa0943a826951223e76218df002804adb588235a8910dc3d6b0654e11" + sha256: "05ec043470319bfbabe0adbc90d3a84cbff0426b9d9f3a6e2ad3e131fa5fa629" url: "https://pub.dev" source: hosted - version: "7.1.0" + version: "8.0.2" share_plus_platform_interface: dependency: transitive description: name: share_plus_platform_interface - sha256: "357412af4178d8e11d14f41723f80f12caea54cf0d5cd29af9dcdab85d58aea7" + sha256: "251eb156a8b5fa9ce033747d73535bf53911071f8d3b6f4f0b578505ce0d4496" url: "https://pub.dev" source: hosted - version: "3.3.0" + version: "3.4.0" shared_preferences: dependency: "direct main" description: name: shared_preferences - sha256: b7f41bad7e521d205998772545de63ff4e6c97714775902c199353f8bf1511ac + sha256: "81429e4481e1ccfb51ede496e916348668fd0921627779233bd24cc3ff6abd02" url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.2.2" shared_preferences_android: dependency: transitive description: @@ -1201,42 +1153,42 @@ packages: dependency: transitive description: name: shared_preferences_foundation - sha256: "7bf53a9f2d007329ee6f3df7268fd498f8373602f943c975598bbb34649b62a7" + sha256: "7708d83064f38060c7b39db12aefe449cb8cdc031d6062280087bc4cdb988f5c" url: "https://pub.dev" source: hosted - version: "2.3.4" + version: "2.3.5" shared_preferences_linux: dependency: transitive description: name: shared_preferences_linux - sha256: c2eb5bf57a2fe9ad6988121609e47d3e07bb3bdca5b6f8444e4cf302428a128a + sha256: "9f2cbcf46d4270ea8be39fa156d86379077c8a5228d9dfdb1164ae0bb93f1faa" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.3.2" shared_preferences_platform_interface: dependency: transitive description: name: shared_preferences_platform_interface - sha256: d4ec5fc9ebb2f2e056c617112aa75dcf92fc2e4faaf2ae999caa297473f75d8a + sha256: "22e2ecac9419b4246d7c22bfbbda589e3acf5c0351137d87dd2939d984d37c3b" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.3.2" shared_preferences_web: dependency: transitive description: name: shared_preferences_web - sha256: d762709c2bbe80626ecc819143013cc820fa49ca5e363620ee20a8b15a3e3daf + sha256: "9aee1089b36bd2aafe06582b7d7817fd317ef05fc30e6ba14bff247d0933042a" url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.3.0" shared_preferences_windows: dependency: transitive description: name: shared_preferences_windows - sha256: f763a101313bd3be87edffe0560037500967de9c394a714cd598d945517f694f + sha256: "841ad54f3c8381c480d0c9b508b89a34036f512482c407e6df7a9c4aa2ef8f59" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.3.2" shelf: dependency: transitive description: @@ -1270,18 +1222,18 @@ packages: dependency: transitive description: name: smart_auth - sha256: a25229b38c02f733d0a4e98d941b42bed91a976cb589e934895e60ccfa674cf6 + sha256: "88aa8fe66e951c78a307f26d1c29672dce2e9eb3da2e12e853864d0e615a73ad" url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "2.0.0" source_gen: dependency: transitive description: name: source_gen - sha256: fc0da689e5302edb6177fdd964efcb7f58912f43c28c2047a808f5bfff643d16 + sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832" url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.5.0" source_helper: dependency: transitive description: @@ -1306,48 +1258,48 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.1" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" sqflite: dependency: transitive description: name: sqflite - sha256: "591f1602816e9c31377d5f008c2d9ef7b8aca8941c3f89cc5fd9d84da0c38a9a" + sha256: a9016f495c927cb90557c909ff26a6d92d9bd54fc42ba92e19d4e79d61e798c6 url: "https://pub.dev" source: hosted - version: "2.3.0" + version: "2.3.2" sqflite_common: dependency: transitive description: name: sqflite_common - sha256: "1b92f368f44b0dee2425bb861cfa17b6f6cf3961f762ff6f941d20b33355660a" + sha256: "3da423ce7baf868be70e2c0976c28a1bb2f73644268b7ffa7d2e08eab71f16a4" url: "https://pub.dev" source: hosted - version: "2.5.0" + version: "2.5.4" stack_trace: - dependency: transitive + dependency: "direct main" description: name: stack_trace - sha256: c3c7d8edb15bee7f0f74debd4b9c5f3c2ea86766fe4178eb2a18eb30a0bdaed5 + sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" url: "https://pub.dev" source: hosted - version: "1.11.0" - state_notifier: - dependency: transitive - description: - name: state_notifier - sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb - url: "https://pub.dev" - source: hosted - version: "1.0.0" + version: "1.11.1" stream_channel: dependency: transitive description: name: stream_channel - sha256: "83615bee9045c1d322bbbd1ba209b7a749c2cbcdcb3fdd1df8eb488b3279c1c8" + sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" stream_transform: - dependency: transitive + dependency: "direct main" description: name: stream_transform sha256: "14a00e794c7c11aa145a170587321aedce29769c08d7f58b1d141da75e3b1c6f" @@ -1374,18 +1326,18 @@ packages: dependency: transitive description: name: synchronized - sha256: "5fcbd27688af6082f5abd611af56ee575342c30e87541d0245f7ff99faa02c60" + sha256: "539ef412b170d65ecdafd780f924e5be3f60032a1128df156adad6c5b373d558" url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "3.1.0+1" system_info2: dependency: transitive description: name: system_info2 - sha256: af2f948e3f31a3367a049932a8ad59faf0063ecf836a020d975b9f41566d8bc9 + sha256: "65206bbef475217008b5827374767550a5420ce70a04d2d7e94d1d2253f3efc9" url: "https://pub.dev" source: hosted - version: "3.0.2" + version: "4.0.0" system_info_plus: dependency: transitive description: @@ -1406,10 +1358,10 @@ packages: dependency: transitive description: name: test_api - sha256: "75760ffd7786fffdfb9597c35c5b27eaeec82be8edfb6d71d32651128ed7aab8" + sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" url: "https://pub.dev" source: hosted - version: "0.6.0" + version: "0.7.0" timing: dependency: transitive description: @@ -1446,98 +1398,98 @@ packages: dependency: transitive description: name: url_launcher - sha256: "47e208a6711459d813ba18af120d9663c20bdf6985d6ad39fe165d2538378d27" + sha256: "0ecc004c62fd3ed36a2ffcbe0dd9700aee63bd7532d0b642a488b1ec310f492e" url: "https://pub.dev" source: hosted - version: "6.1.14" + version: "6.2.5" url_launcher_android: dependency: transitive description: name: url_launcher_android - sha256: b04af59516ab45762b2ca6da40fa830d72d0f6045cd97744450b73493fa76330 + sha256: d4ed0711849dd8e33eb2dd69c25db0d0d3fdc37e0a62e629fe32f57a22db2745 url: "https://pub.dev" source: hosted - version: "6.1.0" + version: "6.3.0" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: "7c65021d5dee51813d652357bc65b8dd4a6177082a9966bc8ba6ee477baa795f" + sha256: "9149d493b075ed740901f3ee844a38a00b33116c7c5c10d7fb27df8987fb51d5" url: "https://pub.dev" source: hosted - version: "6.1.5" + version: "6.2.5" url_launcher_linux: dependency: transitive description: name: url_launcher_linux - sha256: b651aad005e0cb06a01dbd84b428a301916dc75f0e7ea6165f80057fee2d8e8e + sha256: ab360eb661f8879369acac07b6bb3ff09d9471155357da8443fd5d3cf7363811 url: "https://pub.dev" source: hosted - version: "3.0.6" + version: "3.1.1" url_launcher_macos: dependency: transitive description: name: url_launcher_macos - sha256: b55486791f666e62e0e8ff825e58a023fd6b1f71c49926483f1128d3bbd8fe88 + sha256: b7244901ea3cf489c5335bdacda07264a6e960b1c1b1a9f91e4bc371d9e68234 url: "https://pub.dev" source: hosted - version: "3.0.7" + version: "3.1.0" url_launcher_platform_interface: dependency: transitive description: name: url_launcher_platform_interface - sha256: "95465b39f83bfe95fcb9d174829d6476216f2d548b79c38ab2506e0458787618" + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" url: "https://pub.dev" source: hosted - version: "2.1.5" + version: "2.3.2" url_launcher_web: dependency: transitive description: name: url_launcher_web - sha256: "2942294a500b4fa0b918685aff406773ba0a4cd34b7f42198742a94083020ce5" + sha256: "3692a459204a33e04bc94f5fb91158faf4f2c8903281ddd82915adecdb1a901d" url: "https://pub.dev" source: hosted - version: "2.0.20" + version: "2.3.0" url_launcher_windows: dependency: transitive description: name: url_launcher_windows - sha256: "95fef3129dc7cfaba2bc3d5ba2e16063bb561fc6d78e63eee16162bc70029069" + sha256: ecf9725510600aa2bb6d7ddabe16357691b6d2805f66216a97d1b881e21beff7 url: "https://pub.dev" source: hosted - version: "3.0.8" + version: "3.1.1" uuid: dependency: "direct main" description: name: uuid - sha256: "648e103079f7c64a36dc7d39369cabb358d377078a051d6ae2ad3aa539519313" + sha256: cd210a09f7c18cbe5a02511718e0334de6559871052c90a90c0cca46a4aa81c8 url: "https://pub.dev" source: hosted - version: "3.0.7" + version: "4.3.3" vector_graphics: dependency: transitive description: name: vector_graphics - sha256: "670f6e07aca990b4a2bcdc08a784193c4ccdd1932620244c3a86bb72a0eac67f" + sha256: "32c3c684e02f9bc0afb0ae0aa653337a2fe022e8ab064bcd7ffda27a74e288e3" url: "https://pub.dev" source: hosted - version: "1.1.7" + version: "1.1.11+1" vector_graphics_codec: dependency: transitive description: name: vector_graphics_codec - sha256: "7451721781d967db9933b63f5733b1c4533022c0ba373a01bdd79d1a5457f69f" + sha256: c86987475f162fadff579e7320c7ddda04cd2fdeffbe1129227a85d9ac9e03da url: "https://pub.dev" source: hosted - version: "1.1.7" + version: "1.1.11+1" vector_graphics_compiler: dependency: transitive description: name: vector_graphics_compiler - sha256: "80a13c613c8bde758b1464a1755a7b3a8f2b6cec61fbf0f5a53c94c30f03ba2e" + sha256: "12faff3f73b1741a36ca7e31b292ddeb629af819ca9efe9953b70bd63fc8cd81" url: "https://pub.dev" source: hosted - version: "1.1.7" + version: "1.1.11+1" vector_math: dependency: transitive description: @@ -1552,7 +1504,14 @@ packages: path: "../veilid/veilid-flutter" relative: true source: path - version: "0.2.5" + version: "0.3.0" + veilid_support: + dependency: "direct main" + description: + path: "packages/veilid_support" + relative: true + source: path + version: "1.0.2+0" visibility_detector: dependency: transitive description: @@ -1573,58 +1532,58 @@ packages: dependency: transitive description: name: web - sha256: dc8ccd225a2005c1be616fe02951e2e342092edf968cf0844220383757ef8f10 + sha256: "97da13628db363c635202ad97068d47c5b8aa555808e7a9411963c533b449b27" url: "https://pub.dev" source: hosted - version: "0.1.4-beta" + version: "0.5.1" web_socket_channel: dependency: transitive description: name: web_socket_channel - sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b + sha256: "1d8e795e2a8b3730c41b8a98a2dff2e0fb57ae6f0764a1c46ec5915387d257b2" url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.4.4" win32: dependency: transitive description: name: win32 - sha256: "350a11abd2d1d97e0cc7a28a81b781c08002aa2864d9e3f192ca0ffa18b06ed3" + sha256: "0a989dc7ca2bb51eac91e8fd00851297cfffd641aa7538b165c62637ca0eaa4a" url: "https://pub.dev" source: hosted - version: "5.0.9" + version: "5.4.0" window_manager: dependency: "direct main" description: name: window_manager - sha256: "6ee795be9124f90660ea9d05e581a466de19e1c89ee74fc4bf528f60c8600edd" + sha256: b3c895bdf936c77b83c5254bec2e6b3f066710c1f89c38b20b8acc382b525494 url: "https://pub.dev" source: hosted - version: "0.3.6" + version: "0.3.8" xdg_directories: dependency: transitive description: name: xdg_directories - sha256: "589ada45ba9e39405c198fe34eb0f607cddb2108527e658136120892beac46d2" + sha256: faea9dee56b520b55a566385b84f2e8de55e7496104adada9962e0bd11bcff1d url: "https://pub.dev" source: hosted - version: "1.0.3" + version: "1.0.4" xml: dependency: transitive description: name: xml - sha256: "5bc72e1e45e941d825fd7468b9b4cc3b9327942649aeb6fc5cdbf135f0a86e84" + sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 url: "https://pub.dev" source: hosted - version: "6.3.0" + version: "6.5.0" xterm: dependency: "direct main" description: name: xterm - sha256: "6a02b15d03152b8186e12790902ff28c8a932fc441e89fa7255a7491661a8e69" + sha256: "168dfedca77cba33fdb6f52e2cd001e9fde216e398e89335c19b524bb22da3a2" url: "https://pub.dev" source: hosted - version: "3.5.0" + version: "4.0.0" yaml: dependency: transitive description: @@ -1633,22 +1592,30 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + zmodem: + dependency: transitive + description: + name: zmodem + sha256: "3b7e5b29f3a7d8aee472029b05165a68438eff2f3f7766edf13daba1e297adbf" + url: "https://pub.dev" + source: hosted + version: "0.0.6" zxing2: dependency: "direct main" description: name: zxing2 - sha256: "1e141568c9646bc262fa75aacf739bc151ef6ad0226997c0016cc3da358a1bbc" + sha256: "6cf995abd3c86f01ba882968dedffa7bc130185e382f2300239d2e857fc7912c" url: "https://pub.dev" source: hosted - version: "0.2.0" + version: "0.2.3" zxing_lib: dependency: transitive description: name: zxing_lib - sha256: "84f6ec19b04dd54bc0b25c539c7c3567a5f9e872e3feb23763df027a1f855c11" + sha256: "870a63610be3f20009ca9201f7ba2d53d7eaefa675c154b3e8c1f6fc55984d04" url: "https://pub.dev" source: hosted - version: "0.9.0" + version: "1.1.2" sdks: - dart: ">=3.1.0 <4.0.0" - flutter: ">=3.13.0" + dart: ">=3.3.0 <4.0.0" + flutter: ">=3.19.1" diff --git a/pubspec.yaml b/pubspec.yaml index faa4993..0be9f5d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,87 +4,95 @@ publish_to: 'none' version: 0.1.2+4 environment: - sdk: '>=3.0.5 <4.0.0' - flutter: ">=3.10.0" + sdk: '>=3.2.0 <4.0.0' + flutter: '>=3.19.1' dependencies: - animated_theme_switcher: ^2.0.7 - ansicolor: ^2.0.1 - archive: ^3.3.7 - awesome_extensions: ^2.0.9 - badges: ^3.1.1 - basic_utils: ^5.6.1 - blurry_modal_progress_hud: ^1.1.0 - change_case: ^1.1.0 + animated_theme_switcher: ^2.0.10 + ansicolor: ^2.0.2 + archive: ^3.4.10 + async_tools: + path: packages/async_tools + awesome_extensions: ^2.0.13 + badges: ^3.1.2 + basic_utils: ^5.7.0 + bloc: ^8.1.4 + bloc_tools: + path: packages/bloc_tools + blurry_modal_progress_hud: ^1.1.1 + change_case: ^2.0.1 charcode: ^1.3.1 circular_profile_avatar: ^2.0.5 circular_reveal_animation: ^2.0.1 cool_dropdown: ^2.1.0 - cupertino_icons: ^1.0.2 + cupertino_icons: ^1.0.6 equatable: ^2.0.5 - fast_immutable_collections: ^9.1.5 + fast_immutable_collections: ^10.2.2 fixnum: ^1.1.0 flutter: sdk: flutter - flutter_animate: ^4.2.0+1 + flutter_animate: ^4.5.0 + flutter_bloc: ^8.1.5 flutter_chat_types: ^3.6.2 - flutter_chat_ui: ^1.6.9 - flutter_form_builder: ^9.1.0 - flutter_hooks: ^0.20.1 + flutter_chat_ui: ^1.6.12 + flutter_form_builder: ^9.2.1 + flutter_hooks: ^0.20.5 flutter_localizations: sdk: flutter - flutter_native_splash: ^2.3.2 - flutter_riverpod: ^2.1.3 - flutter_slidable: ^3.0.0 - flutter_spinkit: ^5.2.0 - flutter_svg: ^2.0.7 + flutter_native_splash: ^2.4.0 + flutter_slidable: ^3.1.0 + flutter_spinkit: ^5.2.1 + flutter_svg: ^2.0.10+1 flutter_translate: ^4.0.4 - form_builder_validators: ^9.0.0 - freezed_annotation: ^2.2.0 - go_router: ^11.0.0 - hooks_riverpod: ^2.1.3 - image: ^4.1.3 - intl: ^0.18.0 + form_builder_validators: ^9.1.0 + freezed_annotation: ^2.4.1 + go_router: ^13.2.2 + hydrated_bloc: ^9.1.5 + image: ^4.1.7 + intl: ^0.18.1 json_annotation: ^4.8.1 loggy: ^2.0.3 - mobile_scanner: ^3.4.1 - motion_toast: ^2.7.8 - mutex: ^3.0.1 + meta: ^1.11.0 + mobile_scanner: ^4.0.1 + motion_toast: ^2.9.0 + mutex: + path: packages/mutex pasteboard: ^0.2.0 - path: ^1.8.2 - path_provider: ^2.0.11 - pinput: ^3.0.1 + path: ^1.9.0 + path_provider: ^2.1.2 + pinput: ^4.0.0 preload_page_view: ^0.2.0 - protobuf: ^3.0.0 - qr_code_dart_scan: ^0.7.2 + protobuf: ^3.1.0 + provider: ^6.1.2 + qr_code_dart_scan: ^0.7.6 qr_flutter: ^4.1.0 - quickalert: ^1.0.1 + quickalert: ^1.1.0 radix_colors: ^1.0.4 - reorderable_grid: ^1.0.7 - riverpod_annotation: ^2.1.1 - searchable_listview: ^2.7.0 - share_plus: ^7.0.2 - shared_preferences: ^2.0.15 + reorderable_grid: ^1.0.10 + searchable_listview: ^2.11.1 + share_plus: ^8.0.2 + shared_preferences: ^2.2.2 signal_strength_indicator: ^0.4.1 split_view: ^3.2.1 + stack_trace: ^1.11.1 + stream_transform: ^2.1.0 stylish_bottom_bar: ^1.0.3 - uuid: ^3.0.7 + uuid: ^4.3.3 veilid: # veilid: ^0.0.1 path: ../veilid/veilid-flutter - window_manager: ^0.3.5 - xterm: ^3.5.0 - zxing2: ^0.2.0 + veilid_support: + path: packages/veilid_support + window_manager: ^0.3.8 + xterm: ^4.0.0 + zxing2: ^0.2.3 dev_dependencies: - build_runner: ^2.4.6 - flutter_test: - sdk: flutter - freezed: ^2.3.5 - icons_launcher: ^2.1.3 + build_runner: ^2.4.9 + freezed: ^2.4.7 + icons_launcher: ^2.1.7 json_serializable: ^6.7.1 lint_hard: ^4.0.0 - riverpod_generator: ^2.2.3 flutter_native_splash: color: "#8588D0"