mirror of
https://gitlab.com/veilid/veilidchat.git
synced 2025-05-02 14:26:12 -04:00
break everything
This commit is contained in:
parent
e898074387
commit
29210c89d2
121 changed files with 2892 additions and 2608 deletions
145
lib/old_to_refactor/providers/account.dart
Normal file
145
lib/old_to_refactor/providers/account.dart
Normal file
|
@ -0,0 +1,145 @@
|
|||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../../entities/local_account.dart';
|
||||
import '../../entities/user_login.dart';
|
||||
import '../../proto/proto.dart' as proto;
|
||||
import '../../veilid_support/veilid_support.dart';
|
||||
import '../../local_accounts/local_accounts.dart';
|
||||
import 'logins.dart';
|
||||
|
||||
part 'account.g.dart';
|
||||
|
||||
enum AccountInfoStatus {
|
||||
noAccount,
|
||||
accountInvalid,
|
||||
accountLocked,
|
||||
accountReady,
|
||||
}
|
||||
|
||||
@immutable
|
||||
class AccountInfo {
|
||||
const AccountInfo({
|
||||
required this.status,
|
||||
required this.active,
|
||||
this.account,
|
||||
});
|
||||
|
||||
final AccountInfoStatus status;
|
||||
final bool active;
|
||||
final 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<AccountInfo> fetchAccountInfo(FetchAccountInfoRef 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 const 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);
|
||||
}
|
||||
|
||||
@immutable
|
||||
class ActiveAccountInfo {
|
||||
const ActiveAccountInfo({
|
||||
required this.localAccount,
|
||||
required this.userLogin,
|
||||
required this.account,
|
||||
});
|
||||
//
|
||||
|
||||
KeyPair getConversationWriter() {
|
||||
final identityKey = localAccount.identityMaster.identityPublicKey;
|
||||
final identitySecret = userLogin.identitySecret;
|
||||
return KeyPair(key: identityKey, secret: identitySecret.value);
|
||||
}
|
||||
|
||||
//
|
||||
final LocalAccount localAccount;
|
||||
final UserLogin userLogin;
|
||||
final proto.Account account;
|
||||
}
|
||||
|
||||
/// Get the active account info
|
||||
@riverpod
|
||||
Future<ActiveAccountInfo?> fetchActiveAccountInfo(
|
||||
FetchActiveAccountInfoRef 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,
|
||||
);
|
||||
}
|
118
lib/old_to_refactor/providers/chat.dart
Normal file
118
lib/old_to_refactor/providers/chat.dart
Normal file
|
@ -0,0 +1,118 @@
|
|||
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 '../../veilid_support/veilid_support.dart';
|
||||
import 'account.dart';
|
||||
|
||||
part 'chat.g.dart';
|
||||
|
||||
/// Create a new chat (singleton for single contact chats)
|
||||
Future<void> getOrCreateChatSingleContact({
|
||||
required ActiveAccountInfo activeAccountInfo,
|
||||
required TypedKey remoteConversationRecordKey,
|
||||
}) async {
|
||||
final accountRecordKey =
|
||||
activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey;
|
||||
|
||||
// Create conversation type Chat
|
||||
final chat = proto.Chat()
|
||||
..type = proto.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 = proto.Chat.fromBuffer(cbuf);
|
||||
if (c == chat) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (await chatList.tryAddItem(chat.writeToBuffer()) == false) {
|
||||
throw Exception('Failed to add chat');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Delete a chat
|
||||
Future<void> 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 = proto.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<IList<proto.Chat>?> 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<proto.Chat> 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(proto.Chat.fromBuffer(cir));
|
||||
}
|
||||
});
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
// The selected chat
|
||||
final activeChatState = StateController<TypedKey?>(null);
|
||||
final activeChatStateProvider =
|
||||
StateNotifierProvider<StateController<TypedKey?>, TypedKey?>(
|
||||
(ref) => activeChatState);
|
29
lib/old_to_refactor/providers/connection_state.dart
Normal file
29
lib/old_to_refactor/providers/connection_state.dart
Normal file
|
@ -0,0 +1,29 @@
|
|||
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<ConnectionState>(const ConnectionState(
|
||||
attachment: VeilidStateAttachment(
|
||||
state: AttachmentState.detached,
|
||||
publicInternetReady: false,
|
||||
localNetworkReady: false)));
|
||||
final connectionStateProvider =
|
||||
StateNotifierProvider<StateController<ConnectionState>, ConnectionState>(
|
||||
(ref) => connectionState);
|
150
lib/old_to_refactor/providers/connection_state.freezed.dart
Normal file
150
lib/old_to_refactor/providers/connection_state.freezed.dart
Normal file
|
@ -0,0 +1,150 @@
|
|||
// 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>(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<ConnectionState> 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 ==(Object 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;
|
||||
}
|
132
lib/old_to_refactor/providers/contact.dart
Normal file
132
lib/old_to_refactor/providers/contact.dart
Normal file
|
@ -0,0 +1,132 @@
|
|||
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 '../../veilid_support/veilid_support.dart';
|
||||
import '../../tools/tools.dart';
|
||||
import 'account.dart';
|
||||
import 'chat.dart';
|
||||
|
||||
part 'contact.g.dart';
|
||||
|
||||
Future<void> 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 = proto.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<void> deleteContact(
|
||||
{required ActiveAccountInfo activeAccountInfo,
|
||||
required proto.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<IList<proto.Contact>?> 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<proto.Contact> 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(proto.Contact.fromBuffer(cir));
|
||||
}
|
||||
});
|
||||
|
||||
return out;
|
||||
}
|
|
@ -0,0 +1,583 @@
|
|||
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
|
||||
import 'package:fixnum/fixnum.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:mutex/mutex.dart';
|
||||
|
||||
import '../../entities/entities.dart';
|
||||
import '../../proto/proto.dart' as proto;
|
||||
import '../../tools/tools.dart';
|
||||
import '../../veilid_support/veilid_support.dart';
|
||||
import 'account.dart';
|
||||
|
||||
part 'contact_invitation_list_manager.g.dart';
|
||||
|
||||
//////////////////////////////////////////////////
|
||||
|
||||
class ContactInviteInvalidKeyException implements Exception {
|
||||
const ContactInviteInvalidKeyException(this.type) : super();
|
||||
final EncryptionKeyType type;
|
||||
}
|
||||
|
||||
typedef GetEncryptionKeyCallback = Future<SecretKey?> Function(
|
||||
VeilidCryptoSystem cs,
|
||||
EncryptionKeyType encryptionKeyType,
|
||||
Uint8List encryptedSecret);
|
||||
|
||||
//////////////////////////////////////////////////
|
||||
@immutable
|
||||
class AcceptedContact {
|
||||
const AcceptedContact({
|
||||
required this.profile,
|
||||
required this.remoteIdentity,
|
||||
required this.remoteConversationRecordKey,
|
||||
required this.localConversationRecordKey,
|
||||
});
|
||||
|
||||
final proto.Profile profile;
|
||||
final IdentityMaster remoteIdentity;
|
||||
final TypedKey remoteConversationRecordKey;
|
||||
final TypedKey localConversationRecordKey;
|
||||
}
|
||||
|
||||
@immutable
|
||||
class InvitationStatus {
|
||||
const InvitationStatus({required this.acceptedContact});
|
||||
final AcceptedContact? acceptedContact;
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////
|
||||
|
||||
//////////////////////////////////////////////////
|
||||
// Mutable state for per-account contact invitations
|
||||
@riverpod
|
||||
class ContactInvitationListManager extends _$ContactInvitationListManager {
|
||||
ContactInvitationListManager._({
|
||||
required ActiveAccountInfo activeAccountInfo,
|
||||
required DHTShortArray dhtRecord,
|
||||
}) : _activeAccountInfo = activeAccountInfo,
|
||||
_dhtRecord = dhtRecord,
|
||||
_records = IList();
|
||||
|
||||
@override
|
||||
FutureOr<IList<proto.ContactInvitationRecord>> build(
|
||||
ActiveAccountInfo activeAccountInfo) async {
|
||||
// Load initial todo list from the remote repository
|
||||
ref.onDispose xxxx call close and pass dhtrecord through... could use a context object
|
||||
and a DHTValueChangeProvider that we watch in build that updates when dht records change
|
||||
return _open(activeAccountInfo);
|
||||
}
|
||||
|
||||
static Future<ContactInvitationListManager> _open(
|
||||
ActiveAccountInfo activeAccountInfo) async {
|
||||
final accountRecordKey =
|
||||
activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey;
|
||||
|
||||
final dhtRecord = await DHTShortArray.openOwned(
|
||||
proto.OwnedDHTRecordPointerProto.fromProto(
|
||||
activeAccountInfo.account.contactInvitationRecords),
|
||||
parent: accountRecordKey);
|
||||
|
||||
return ContactInvitationListManager._(
|
||||
activeAccountInfo: activeAccountInfo, dhtRecord: dhtRecord);
|
||||
}
|
||||
|
||||
Future<void> close() async {
|
||||
state = "";
|
||||
await _dhtRecord.close();
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
for (var i = 0; i < _dhtRecord.length; i++) {
|
||||
final cir = await _dhtRecord.getItem(i);
|
||||
if (cir == null) {
|
||||
throw Exception('Failed to get contact invitation record');
|
||||
}
|
||||
_records = _records.add(proto.ContactInvitationRecord.fromBuffer(cir));
|
||||
}
|
||||
}
|
||||
|
||||
Future<Uint8List> createInvitation(
|
||||
{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 = _activeAccountInfo.getConversationWriter();
|
||||
|
||||
// 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 = proto.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 = proto.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 = proto.ContactInvitation()
|
||||
..contactRequestInboxKey = contactRequestInbox.key.toProto()
|
||||
..writerSecret = encryptedSecret;
|
||||
final cinvbytes = cinv.writeToBuffer();
|
||||
final scinv = proto.SignedContactInvitation()
|
||||
..contactInvitation = cinvbytes
|
||||
..identitySignature =
|
||||
(await cs.sign(identityKey, identitySecret, 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 (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;
|
||||
}
|
||||
|
||||
Future<void> deleteInvitation(
|
||||
{required bool accepted,
|
||||
required proto.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<ValidContactInvitation?> validateInvitation(
|
||||
{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 = _records.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._(
|
||||
contactInvitationManager: this,
|
||||
signedContactInvitation: signedContactInvitation,
|
||||
contactInvitation: contactInvitation,
|
||||
contactRequestInboxKey: contactRequestInboxKey,
|
||||
contactRequest: contactRequest,
|
||||
contactRequestPrivate: contactRequestPrivate,
|
||||
contactIdentityMaster: contactIdentityMaster,
|
||||
writer: writer);
|
||||
});
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
Future<InvitationStatus?> checkInvitationStatus(
|
||||
{required ActiveAccountInfo activeAccountInfo,
|
||||
required proto.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(
|
||||
proto.SignedContactResponse.fromBuffer,
|
||||
forceRefresh: true);
|
||||
if (signedContactResponse == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final contactResponseBytes =
|
||||
Uint8List.fromList(signedContactResponse.contactResponse);
|
||||
final contactResponse =
|
||||
proto.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 const InvitationStatus(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 InvitationStatus(
|
||||
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 deleteInvitation(
|
||||
accepted: acceptReject.acceptedContact != null,
|
||||
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 deleteInvitation(
|
||||
accepted: false, contactInvitationRecord: contactInvitationRecord);
|
||||
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
final ActiveAccountInfo _activeAccountInfo;
|
||||
final DHTShortArray _dhtRecord;
|
||||
IList<proto.ContactInvitationRecord> _records;
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////
|
||||
///
|
||||
|
||||
class ValidContactInvitation {
|
||||
ValidContactInvitation._(
|
||||
{required ContactInvitationListManager contactInvitationManager,
|
||||
required proto.SignedContactInvitation signedContactInvitation,
|
||||
required proto.ContactInvitation contactInvitation,
|
||||
required TypedKey contactRequestInboxKey,
|
||||
required proto.ContactRequest contactRequest,
|
||||
required proto.ContactRequestPrivate contactRequestPrivate,
|
||||
required IdentityMaster contactIdentityMaster,
|
||||
required KeyPair writer})
|
||||
: _contactInvitationManager = contactInvitationManager,
|
||||
_signedContactInvitation = signedContactInvitation,
|
||||
_contactInvitation = contactInvitation,
|
||||
_contactRequestInboxKey = contactRequestInboxKey,
|
||||
_contactRequest = contactRequest,
|
||||
_contactRequestPrivate = contactRequestPrivate,
|
||||
_contactIdentityMaster = contactIdentityMaster,
|
||||
_writer = writer;
|
||||
|
||||
Future<AcceptedContact?> accept() async {
|
||||
final pool = await DHTRecordPool.instance();
|
||||
final activeAccountInfo = _contactInvitationManager._activeAccountInfo;
|
||||
try {
|
||||
// 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))
|
||||
// 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:
|
||||
_contactIdentityMaster.identityPublicTypedKey(),
|
||||
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
|
||||
.localAccount.identityMaster.identityPublicKey,
|
||||
activeAccountInfo.userLogin.identitySecret.value,
|
||||
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(
|
||||
profile: _contactRequestPrivate.profile,
|
||||
remoteIdentity: _contactIdentityMaster,
|
||||
remoteConversationRecordKey: proto.TypedKeyProto.fromProto(
|
||||
_contactRequestPrivate.chatRecordKey),
|
||||
localConversationRecordKey: localConversation.key,
|
||||
);
|
||||
});
|
||||
});
|
||||
} on Exception catch (e) {
|
||||
log.debug('exception: $e', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> reject() async {
|
||||
final pool = await DHTRecordPool.instance();
|
||||
final activeAccountInfo = _contactInvitationManager._activeAccountInfo;
|
||||
|
||||
// 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.localAccount.identityMaster.identityPublicKey,
|
||||
activeAccountInfo.userLogin.identitySecret.value,
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
//
|
||||
ContactInvitationListManager _contactInvitationManager;
|
||||
proto.SignedContactInvitation _signedContactInvitation;
|
||||
proto.ContactInvitation _contactInvitation;
|
||||
TypedKey _contactRequestInboxKey;
|
||||
proto.ContactRequest _contactRequest;
|
||||
proto.ContactRequestPrivate _contactRequestPrivate;
|
||||
IdentityMaster _contactIdentityMaster;
|
||||
KeyPair _writer;
|
||||
}
|
56
lib/old_to_refactor/providers/contact_invite.dart
Normal file
56
lib/old_to_refactor/providers/contact_invite.dart
Normal file
|
@ -0,0 +1,56 @@
|
|||
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 '../../tools/tools.dart';
|
||||
import '../../veilid_support/veilid_support.dart';
|
||||
import 'account.dart';
|
||||
import 'conversation.dart';
|
||||
|
||||
part 'contact_invite.g.dart';
|
||||
|
||||
/// Get the active account contact invitation list
|
||||
@riverpod
|
||||
Future<IList<proto.ContactInvitationRecord>?> 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<proto.ContactInvitationRecord> 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(proto.ContactInvitationRecord.fromBuffer(cir));
|
||||
}
|
||||
});
|
||||
} on VeilidAPIExceptionTryAgain catch (_) {
|
||||
// Try again later
|
||||
ref.invalidateSelf();
|
||||
return null;
|
||||
} on Exception catch (_) {
|
||||
// Try again later
|
||||
ref.invalidateSelf();
|
||||
rethrow;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
355
lib/old_to_refactor/providers/conversation.dart
Normal file
355
lib/old_to_refactor/providers/conversation.dart
Normal file
|
@ -0,0 +1,355 @@
|
|||
// 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:convert';
|
||||
|
||||
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../../proto/proto.dart' as proto;
|
||||
|
||||
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';
|
||||
|
||||
class Conversation {
|
||||
Conversation._(
|
||||
{required ActiveAccountInfo activeAccountInfo,
|
||||
required TypedKey localConversationRecordKey,
|
||||
required TypedKey remoteIdentityPublicKey,
|
||||
required TypedKey remoteConversationRecordKey})
|
||||
: _activeAccountInfo = activeAccountInfo,
|
||||
_localConversationRecordKey = localConversationRecordKey,
|
||||
_remoteIdentityPublicKey = remoteIdentityPublicKey,
|
||||
_remoteConversationRecordKey = remoteConversationRecordKey;
|
||||
|
||||
Future<Conversation> open() async {}
|
||||
|
||||
Future<void> close() async {
|
||||
//
|
||||
}
|
||||
|
||||
Future<proto.Conversation?> readRemoteConversation() async {
|
||||
final accountRecordKey =
|
||||
_activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey;
|
||||
final pool = await DHTRecordPool.instance();
|
||||
|
||||
final crypto = await getConversationCrypto();
|
||||
return (await pool.openRead(_remoteConversationRecordKey,
|
||||
parent: accountRecordKey, crypto: crypto))
|
||||
.scope((remoteConversation) async {
|
||||
//
|
||||
final conversation =
|
||||
await remoteConversation.getProtobuf(proto.Conversation.fromBuffer);
|
||||
return conversation;
|
||||
});
|
||||
}
|
||||
|
||||
Future<proto.Conversation?> readLocalConversation() async {
|
||||
final accountRecordKey =
|
||||
_activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey;
|
||||
final pool = await DHTRecordPool.instance();
|
||||
|
||||
final crypto = await getConversationCrypto();
|
||||
return (await pool.openRead(_localConversationRecordKey,
|
||||
parent: accountRecordKey, crypto: crypto))
|
||||
.scope((localConversation) async {
|
||||
//
|
||||
final update =
|
||||
await localConversation.getProtobuf(proto.Conversation.fromBuffer);
|
||||
if (update != null) {
|
||||
return update;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<proto.Conversation?> writeLocalConversation({
|
||||
required proto.Conversation conversation,
|
||||
}) async {
|
||||
final accountRecordKey =
|
||||
_activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey;
|
||||
final pool = await DHTRecordPool.instance();
|
||||
|
||||
final crypto = await getConversationCrypto();
|
||||
final writer = _activeAccountInfo.getConversationWriter();
|
||||
|
||||
return (await pool.openWrite(_localConversationRecordKey, writer,
|
||||
parent: accountRecordKey, crypto: crypto))
|
||||
.scope((localConversation) async {
|
||||
//
|
||||
final update = await localConversation.tryWriteProtobuf(
|
||||
proto.Conversation.fromBuffer, conversation);
|
||||
if (update != null) {
|
||||
return update;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> addLocalConversationMessage(
|
||||
{required proto.Message message}) async {
|
||||
final conversation = await readLocalConversation();
|
||||
if (conversation == null) {
|
||||
return;
|
||||
}
|
||||
final messagesRecordKey =
|
||||
proto.TypedKeyProto.fromProto(conversation.messages);
|
||||
final crypto = await getConversationCrypto();
|
||||
final writer = _activeAccountInfo.getConversationWriter();
|
||||
|
||||
await (await DHTShortArray.openWrite(messagesRecordKey, writer,
|
||||
parent: _localConversationRecordKey, crypto: crypto))
|
||||
.scope((messages) async {
|
||||
await messages.tryAddItem(message.writeToBuffer());
|
||||
});
|
||||
}
|
||||
|
||||
Future<bool> mergeLocalConversationMessages(
|
||||
{required IList<proto.Message> newMessages}) async {
|
||||
final conversation = await readLocalConversation();
|
||||
if (conversation == null) {
|
||||
return false;
|
||||
}
|
||||
var changed = false;
|
||||
final messagesRecordKey =
|
||||
proto.TypedKeyProto.fromProto(conversation.messages);
|
||||
final crypto = await getConversationCrypto();
|
||||
final writer = _activeAccountInfo.getConversationWriter();
|
||||
|
||||
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<IList<proto.Message>?> getRemoteConversationMessages() async {
|
||||
final conversation = await readRemoteConversation();
|
||||
if (conversation == null) {
|
||||
return null;
|
||||
}
|
||||
final messagesRecordKey =
|
||||
proto.TypedKeyProto.fromProto(conversation.messages);
|
||||
final crypto = await getConversationCrypto();
|
||||
|
||||
return (await DHTShortArray.openRead(messagesRecordKey,
|
||||
parent: _remoteConversationRecordKey, crypto: crypto))
|
||||
.scope((messages) async {
|
||||
var out = IList<proto.Message>();
|
||||
for (var i = 0; i < messages.length; i++) {
|
||||
final msg = await messages.getItemProtobuf(proto.Message.fromBuffer, i);
|
||||
if (msg == null) {
|
||||
throw Exception('Failed to get message');
|
||||
}
|
||||
out = out.add(msg);
|
||||
}
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
Future<DHTRecordCrypto> getConversationCrypto() async {
|
||||
var conversationCrypto = _conversationCrypto;
|
||||
if (conversationCrypto != null) {
|
||||
return conversationCrypto;
|
||||
}
|
||||
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);
|
||||
|
||||
conversationCrypto = await DHTRecordCryptoPrivate.fromSecret(
|
||||
identitySecret.kind, sharedSecret);
|
||||
_conversationCrypto = conversationCrypto;
|
||||
return conversationCrypto;
|
||||
}
|
||||
|
||||
Future<IList<proto.Message>?> getLocalConversationMessages() async {
|
||||
final conversation = await readLocalConversation();
|
||||
if (conversation == null) {
|
||||
return null;
|
||||
}
|
||||
final messagesRecordKey =
|
||||
proto.TypedKeyProto.fromProto(conversation.messages);
|
||||
final crypto = await getConversationCrypto();
|
||||
|
||||
return (await DHTShortArray.openRead(messagesRecordKey,
|
||||
parent: _localConversationRecordKey, crypto: crypto))
|
||||
.scope((messages) async {
|
||||
var out = IList<proto.Message>();
|
||||
for (var i = 0; i < messages.length; i++) {
|
||||
final msg = await messages.getItemProtobuf(proto.Message.fromBuffer, i);
|
||||
if (msg == null) {
|
||||
throw Exception('Failed to get message');
|
||||
}
|
||||
out = out.add(msg);
|
||||
}
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
final ActiveAccountInfo _activeAccountInfo;
|
||||
final TypedKey _localConversationRecordKey;
|
||||
final TypedKey _remoteIdentityPublicKey;
|
||||
final TypedKey _remoteConversationRecordKey;
|
||||
//
|
||||
DHTRecordCrypto? _conversationCrypto;
|
||||
}
|
||||
|
||||
// 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<T> createConversation<T>(
|
||||
{required ActiveAccountInfo activeAccountInfo,
|
||||
required TypedKey remoteIdentityPublicKey,
|
||||
required FutureOr<T> 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 = activeAccountInfo.getConversationWriter();
|
||||
|
||||
// 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 = proto.Conversation()
|
||||
..profile = activeAccountInfo.account.profile
|
||||
..identityMasterJson =
|
||||
jsonEncode(activeAccountInfo.localAccount.identityMaster.toJson())
|
||||
..messages = messages.record.key.toProto();
|
||||
|
||||
//
|
||||
final update = await localConversation.tryWriteProtobuf(
|
||||
proto.Conversation.fromBuffer, conversation);
|
||||
if (update != null) {
|
||||
throw Exception('Failed to write local conversation');
|
||||
}
|
||||
return await callback(localConversation);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
//
|
||||
//
|
||||
//
|
||||
//
|
||||
|
||||
@riverpod
|
||||
class ActiveConversationMessages extends _$ActiveConversationMessages {
|
||||
/// Get message for active conversation
|
||||
@override
|
||||
FutureOr<IList<proto.Message>?> 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,
|
||||
);
|
||||
}
|
||||
}
|
83
lib/old_to_refactor/providers/window_control.dart
Normal file
83
lib/old_to_refactor/providers/window_control.dart
Normal file
|
@ -0,0 +1,83 @@
|
|||
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<bool> build() async {
|
||||
await _doWindowSetup(TitleBarStyle.hidden, OrientationCapability.normal);
|
||||
return true;
|
||||
}
|
||||
|
||||
static Future<void> 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<void> _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<void> changeWindowSetup(TitleBarStyle titleBarStyle,
|
||||
OrientationCapability orientationCapability) async {
|
||||
state = const AsyncValue.loading();
|
||||
await _doWindowSetup(titleBarStyle, orientationCapability);
|
||||
state = const AsyncValue.data(true);
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue