Merge pull request #420 from twonlyapp/dev

Dev
This commit is contained in:
Tobi 2026-06-19 00:51:16 +02:00 committed by GitHub
commit c7db5b7bdc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
110 changed files with 49115 additions and 1158 deletions

View file

@ -1,5 +1,17 @@
# Changelog # Changelog
## 0.3.1
- New: Promotion of sharing contacts when contact is new to twonly
- Improve: Onboarding of new users to the verification badges
- Improve: Better feedback when a QR code is scanned
- Fix: Shared contacts now correctly show the blue verification badge
- Fix: Suppressed link previews for scanned QR codes
- Fix: Black screen on iOS when a link is clicked
- Fix: Fixed size of the typing indicator to prevent the chat from glitching
- Fix: Push notifications are not shown for chats that are already open
- Fix: Multiple smaller performance and UI issues
## 0.3.0 ## 0.3.0
- Improved: Design of some UI components - Improved: Design of some UI components

View file

@ -19,6 +19,7 @@ analyzer:
- "lib/core/**" - "lib/core/**"
- "lib/src/localization/**" - "lib/src/localization/**"
- "rust_builder/" - "rust_builder/"
- "scripts/"
- "build/" - "build/"
- "dependencies/**" - "dependencies/**"
- "pubspec.yaml" - "pubspec.yaml"

Binary file not shown.

Binary file not shown.

View file

@ -18,33 +18,6 @@
"version" : "11.2.0" "version" : "11.2.0"
} }
}, },
{
"identity" : "dkcamera",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKCamera",
"state" : {
"branch" : "master",
"revision" : "5c691d11014b910aff69f960475d70e65d9dcc96"
}
},
{
"identity" : "dkimagepickercontroller",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKImagePickerController",
"state" : {
"branch" : "4.3.9",
"revision" : "0bdfeacefa308545adde07bef86e349186335915"
}
},
{
"identity" : "dkphotogallery",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKPhotoGallery",
"state" : {
"branch" : "master",
"revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d"
}
},
{ {
"identity" : "firebase-ios-sdk", "identity" : "firebase-ios-sdk",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
@ -153,15 +126,6 @@
"version" : "2.4.0" "version" : "2.4.0"
} }
}, },
{
"identity" : "sdwebimage",
"kind" : "remoteSourceControl",
"location" : "https://github.com/SDWebImage/SDWebImage",
"state" : {
"revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0",
"version" : "5.21.7"
}
},
{ {
"identity" : "sentry-cocoa", "identity" : "sentry-cocoa",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
@ -170,24 +134,6 @@
"revision" : "16cd512711375fa73f25ae5e373f596bdf4251ae", "revision" : "16cd512711375fa73f25ae5e373f596bdf4251ae",
"version" : "8.58.0" "version" : "8.58.0"
} }
},
{
"identity" : "swiftygif",
"kind" : "remoteSourceControl",
"location" : "https://github.com/kirualex/SwiftyGif.git",
"state" : {
"revision" : "4430cbc148baa3907651d40562d96325426f409a",
"version" : "5.4.5"
}
},
{
"identity" : "tocropviewcontroller",
"kind" : "remoteSourceControl",
"location" : "https://github.com/TimOliver/TOCropViewController",
"state" : {
"revision" : "d4a6d8100f4b886fdbc8ae399bf144ff3e9afb7e",
"version" : "2.8.0"
}
} }
], ],
"version" : 2 "version" : 2

View file

@ -18,33 +18,6 @@
"version" : "11.2.0" "version" : "11.2.0"
} }
}, },
{
"identity" : "dkcamera",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKCamera",
"state" : {
"branch" : "master",
"revision" : "5c691d11014b910aff69f960475d70e65d9dcc96"
}
},
{
"identity" : "dkimagepickercontroller",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKImagePickerController",
"state" : {
"branch" : "4.3.9",
"revision" : "0bdfeacefa308545adde07bef86e349186335915"
}
},
{
"identity" : "dkphotogallery",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKPhotoGallery",
"state" : {
"branch" : "master",
"revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d"
}
},
{ {
"identity" : "firebase-ios-sdk", "identity" : "firebase-ios-sdk",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
@ -153,15 +126,6 @@
"version" : "2.4.0" "version" : "2.4.0"
} }
}, },
{
"identity" : "sdwebimage",
"kind" : "remoteSourceControl",
"location" : "https://github.com/SDWebImage/SDWebImage",
"state" : {
"revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0",
"version" : "5.21.7"
}
},
{ {
"identity" : "sentry-cocoa", "identity" : "sentry-cocoa",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
@ -170,24 +134,6 @@
"revision" : "16cd512711375fa73f25ae5e373f596bdf4251ae", "revision" : "16cd512711375fa73f25ae5e373f596bdf4251ae",
"version" : "8.58.0" "version" : "8.58.0"
} }
},
{
"identity" : "swiftygif",
"kind" : "remoteSourceControl",
"location" : "https://github.com/kirualex/SwiftyGif.git",
"state" : {
"revision" : "4430cbc148baa3907651d40562d96325426f409a",
"version" : "5.4.5"
}
},
{
"identity" : "tocropviewcontroller",
"kind" : "remoteSourceControl",
"location" : "https://github.com/TimOliver/TOCropViewController",
"state" : {
"revision" : "d4a6d8100f4b886fdbc8ae399bf144ff3e9afb7e",
"version" : "2.8.0"
}
} }
], ],
"version" : 2 "version" : 2

View file

@ -35,6 +35,6 @@ class AppState {
static bool isInBackgroundTask = false; static bool isInBackgroundTask = false;
static bool allowErrorTrackingViaSentry = false; static bool allowErrorTrackingViaSentry = false;
static bool gotMessageFromServer = false; static bool gotMessageFromServer = false;
static int latestAppVersionId = 116; static int latestAppVersionId = 117;
static bool hasCameraPermissions = false; static bool hasCameraPermissions = false;
} }

View file

@ -40,16 +40,13 @@ Future<bool> twonlyMinimumInitialization() async {
lockName: 'init', lockName: 'init',
mutex: _initMutex, mutex: _initMutex,
action: () async { action: () async {
Log.info('twonlyMinimumInitialization: started'); Log.info('twonlyMinimumInitialization started');
setupLocator(); setupLocator();
Log.info('twonlyMinimumInitialization: RustLib.init()');
await RustLib.init(); await RustLib.init();
Log.info('twonlyMinimumInitialization: initFlutterCallbacksForRust()');
await initFlutterCallbacksForRust(); await initFlutterCallbacksForRust();
Log.info('twonlyMinimumInitialization: bridge.initializeTwonlyFlutter()');
try { try {
await bridge.initializeTwonlyFlutter( await bridge.initializeTwonlyFlutter(
config: bridge.InitConfig( config: bridge.InitConfig(

View file

@ -56,6 +56,18 @@ class ContactsDao extends DatabaseAccessor<TwonlyDB> with _$ContactsDaoMixin {
return (delete(contacts)..where((t) => t.userId.equals(userId))).go(); return (delete(contacts)..where((t) => t.userId.equals(userId))).go();
} }
Future<void> resetRecoveryDataForAllContacts() async {
await update(
contacts,
).write(
const ContactsCompanion(
recoveryIsTrustedFriend: Value(false),
recoveryLastHeartbeat: Value(null),
recoverySecretShare: Value(null),
),
);
}
Future<void> updateContact( Future<void> updateContact(
int userId, int userId,
ContactsCompanion updatedValues, ContactsCompanion updatedValues,

View file

@ -57,18 +57,70 @@ class KeyVerificationDao extends DatabaseAccessor<TwonlyDB>
} }
Future<bool> isContactVerified(int contactId) async { Future<bool> isContactVerified(int contactId) async {
final row = final verifierKv = alias(keyVerifications, 'verifierKv');
await (select(keyVerifications) final query = select(keyVerifications).join([
..where((kv) => kv.contactId.equals(contactId)) leftOuterJoin(
..limit(1)) verifierKv,
.getSingleOrNull(); verifierKv.contactId.equalsExp(keyVerifications.verifiedBy),
return row != null; ),
])..where(keyVerifications.contactId.equals(contactId));
final rows = await query.get();
for (final row in rows) {
final kv = row.readTable(keyVerifications);
final hasVerifierKv = row.readTableOrNull(verifierKv) != null;
if (kv.type == VerificationType.contactSharedByVerified) {
if (hasVerifierKv) return true;
} else {
return true;
}
}
return false;
} }
Stream<List<KeyVerification>> watchContactVerification(int contactId) { Stream<List<(KeyVerification, Contact?)>> watchContactVerification(
return (select( int contactId,
keyVerifications, ) {
)..where((kv) => kv.contactId.equals(contactId))).watch(); final verifier = alias(contacts, 'verifier');
final verifierKv = alias(keyVerifications, 'verifierKv');
final query = select(keyVerifications).join([
leftOuterJoin(
verifier,
verifier.userId.equalsExp(keyVerifications.verifiedBy),
),
leftOuterJoin(
verifierKv,
verifierKv.contactId.equalsExp(keyVerifications.verifiedBy),
),
])..where(keyVerifications.contactId.equals(contactId));
return query.watch().map((rows) {
final uniqueKvs =
<int, (KeyVerification, Contact?, bool isVerifierVerified)>{};
for (final row in rows) {
final kv = row.readTable(keyVerifications);
final contact = row.readTableOrNull(verifier);
final hasVerifierKv = row.readTableOrNull(verifierKv) != null;
final existing = uniqueKvs[kv.verificationId];
if (existing == null || hasVerifierKv) {
uniqueKvs[kv.verificationId] = (kv, contact, hasVerifierKv);
}
}
return uniqueKvs.values
.where((item) {
final kv = item.$1;
final isVerifierVerified = item.$3;
if (kv.type == VerificationType.contactSharedByVerified) {
return isVerifierVerified;
}
return true;
})
.map((item) => (item.$1, item.$2))
.toList();
});
} }
Future<List<KeyVerification>> getContactVerification(int contactId) async { Future<List<KeyVerification>> getContactVerification(int contactId) async {
@ -207,12 +259,17 @@ class KeyVerificationDao extends DatabaseAccessor<TwonlyDB>
}); });
} }
Future<void> addKeyVerification(int contactId, VerificationType type) async { Future<void> addKeyVerification(
int contactId,
VerificationType type, {
int? verifiedBy,
}) async {
try { try {
await into(keyVerifications).insertOnConflictUpdate( await into(keyVerifications).insertOnConflictUpdate(
KeyVerificationsCompanion( KeyVerificationsCompanion(
contactId: Value(contactId), contactId: Value(contactId),
type: Value(type), type: Value(type),
verifiedBy: Value(verifiedBy),
), ),
); );
if (userService.currentUser.isUserDiscoveryEnabled) { if (userService.currentUser.isUserDiscoveryEnabled) {

View file

@ -66,6 +66,25 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
return query.map((row) => row.readTable(messages)).watch(); return query.map((row) => row.readTable(messages)).watch();
} }
Stream<List<MediaFile>> watchUnopenedMediaFiles() {
final query =
select(messages).join([
leftOuterJoin(
mediaFiles,
mediaFiles.mediaId.equalsExp(messages.mediaId),
),
])
..where(
messages.openedAt.isNull() &
messages.mediaId.isNotNull() &
messages.type.equals(MessageType.media.name) &
mediaFiles.downloadState.equals(DownloadState.ready.name) &
(mediaFiles.type.equals(MediaType.image.name) |
mediaFiles.type.equals(MediaType.gif.name)),
);
return query.map((row) => row.readTable(mediaFiles)).watch();
}
Future<Stream<Message?>> watchLastMessage(String groupId) async { Future<Stream<Message?>> watchLastMessage(String groupId) async {
final group = await twonlyDB.groupsDao.getGroup(groupId); final group = await twonlyDB.groupsDao.getGroup(groupId);
final deletionTime = clock.now().subtract( final deletionTime = clock.now().subtract(

View file

@ -234,6 +234,12 @@ class UserDiscoveryDao extends DatabaseAccessor<TwonlyDB>
)..where((tbl) => tbl.announcedUserId.equals(id))).getSingleOrNull(); )..where((tbl) => tbl.announcedUserId.equals(id))).getSingleOrNull();
} }
Stream<UserDiscoveryAnnouncedUser?> watchAnnouncedUser(int id) {
return (select(userDiscoveryAnnouncedUsers)
..where((tbl) => tbl.announcedUserId.equals(id)))
.watchSingleOrNull();
}
Stream<List<UserDiscoveryAnnouncedUser>> watchAllAnnouncedUsers() => Stream<List<UserDiscoveryAnnouncedUser>> watchAllAnnouncedUsers() =>
select(userDiscoveryAnnouncedUsers).watch(); select(userDiscoveryAnnouncedUsers).watch();

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -23,15 +23,21 @@ class Contacts extends Table {
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
// contact_versions: HashMap<UserID, Vec<u8>>, // User Discovery
BlobColumn get userDiscoveryVersion => blob().nullable()(); BlobColumn get userDiscoveryVersion => blob().nullable()();
BoolColumn get userDiscoveryExcluded => BoolColumn get userDiscoveryExcluded =>
boolean().withDefault(const Constant(false))(); boolean().withDefault(const Constant(false))();
BoolColumn get userDiscoveryManualApproved => BoolColumn get userDiscoveryManualApproved =>
boolean().nullable().withDefault(const Constant(false))(); boolean().nullable().withDefault(const Constant(false))();
// Passwordless-Recovery
BoolColumn get recoveryIsTrustedFriend =>
boolean().withDefault(const Constant(false))();
DateTimeColumn get recoveryLastHeartbeat => dateTime().nullable()();
BlobColumn get recoverySecretShare => blob().nullable()();
BoolColumn get askForFriendPromotions => boolean().nullable()();
IntColumn get mediaSendCounter => integer().withDefault(const Constant(0))(); IntColumn get mediaSendCounter => integer().withDefault(const Constant(0))();
IntColumn get mediaReceivedCounter => IntColumn get mediaReceivedCounter =>
integer().withDefault(const Constant(0))(); integer().withDefault(const Constant(0))();
@ -57,6 +63,11 @@ class KeyVerifications extends Table {
onDelete: KeyAction.cascade, onDelete: KeyAction.cascade,
)(); )();
TextColumn get type => textEnum<VerificationType>()(); TextColumn get type => textEnum<VerificationType>()();
IntColumn get verifiedBy => integer().nullable().references(
Contacts,
#userId,
onDelete: KeyAction.cascade,
)();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
} }

View file

@ -82,7 +82,7 @@ class TwonlyDB extends _$TwonlyDB {
TwonlyDB.forTesting(DatabaseConnection super.connection); TwonlyDB.forTesting(DatabaseConnection super.connection);
@override @override
int get schemaVersion => 17; int get schemaVersion => 20;
static QueryExecutor _openConnection() { static QueryExecutor _openConnection() {
final connection = driftDatabase( final connection = driftDatabase(
@ -233,6 +233,32 @@ class TwonlyDB extends _$TwonlyDB {
schema.userDiscoveryAnnouncedUsers.wasAskedFriends, schema.userDiscoveryAnnouncedUsers.wasAskedFriends,
); );
}, },
from17To18: (m, schema) async {
await m.addColumn(
schema.contacts,
schema.contacts.askForFriendPromotions,
);
},
from18To19: (m, schema) async {
await m.addColumn(
schema.keyVerifications,
schema.keyVerifications.verifiedBy,
);
},
from19To20: (m, schema) async {
await m.addColumn(
schema.contacts,
schema.contacts.recoveryIsTrustedFriend,
);
await m.addColumn(
schema.contacts,
schema.contacts.recoveryLastHeartbeat,
);
await m.addColumn(
schema.contacts,
schema.contacts.recoverySecretShare,
);
},
)(m, from, to); )(m, from, to);
}, },
); );

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -914,6 +914,12 @@ abstract class AppLocalizations {
/// **'Verified by {username}'** /// **'Verified by {username}'**
String contactVerifiedBy(Object username); String contactVerifiedBy(Object username);
/// No description provided for @contactSharedByUnknown.
///
/// In en, this message translates to:
/// **'Shared by a verified contact (username not available)'**
String get contactSharedByUnknown;
/// No description provided for @verificationTypeQrScanned. /// No description provided for @verificationTypeQrScanned.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@ -1922,6 +1928,12 @@ abstract class AppLocalizations {
/// **'Already in Group'** /// **'Already in Group'**
String get alreadyInGroup; String get alreadyInGroup;
/// No description provided for @contactNotVerified.
///
/// In en, this message translates to:
/// **'Not verified'**
String get contactNotVerified;
/// No description provided for @removeContactFromGroupTitle. /// No description provided for @removeContactFromGroupTitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@ -2567,7 +2579,7 @@ abstract class AppLocalizations {
/// No description provided for @verifiedPublicKey. /// No description provided for @verifiedPublicKey.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'The public key of {username} has been verified and is valid.'** /// **'The identity of {username} has been successfully verified.'**
String verifiedPublicKey(Object username); String verifiedPublicKey(Object username);
/// No description provided for @memoriesAYearAgo. /// No description provided for @memoriesAYearAgo.
@ -2717,7 +2729,7 @@ abstract class AppLocalizations {
/// No description provided for @verificationBadgeGeneralDesc. /// No description provided for @verificationBadgeGeneralDesc.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'The checkmark gives you the certainty that you are messaging the right person. You can verify contacts at any time by scanning their QR code.'** /// **'The badge *protects you from scammers and attackers*. It will be displayed next to a contact that has been *manually verified* by you or a friend.'**
String get verificationBadgeGeneralDesc; String get verificationBadgeGeneralDesc;
/// No description provided for @verificationBadgeGreenDesc. /// No description provided for @verificationBadgeGreenDesc.
@ -2768,6 +2780,12 @@ abstract class AppLocalizations {
/// **'{username} has scanned your QR code and is now verified.'** /// **'{username} has scanned your QR code and is now verified.'**
String secretQrTokenVerifiedSnackbar(Object username); String secretQrTokenVerifiedSnackbar(Object username);
/// No description provided for @askForFriendPromotionsPrompt.
///
/// In en, this message translates to:
/// **'Help {username} find familiar faces on twonly by sharing mutual friends.'**
String askForFriendPromotionsPrompt(Object username);
/// No description provided for @mutualGroupsTitle. /// No description provided for @mutualGroupsTitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -447,6 +447,10 @@ class AppLocalizationsDe extends AppLocalizations {
return 'Verifiziert von $username'; return 'Verifiziert von $username';
} }
@override
String get contactSharedByUnknown =>
'Geteilt von einem verifizierten Kontakt (Benutzername nicht verfügbar)';
@override @override
String get verificationTypeQrScanned => 'Du hast den QR-Code gescannt.'; String get verificationTypeQrScanned => 'Du hast den QR-Code gescannt.';
@ -1022,6 +1026,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get alreadyInGroup => 'Bereits Mitglied'; String get alreadyInGroup => 'Bereits Mitglied';
@override
String get contactNotVerified => 'Nicht verifiziert';
@override @override
String removeContactFromGroupTitle(Object username) { String removeContactFromGroupTitle(Object username) {
return '$username aus dieser Gruppe entfernen?'; return '$username aus dieser Gruppe entfernen?';
@ -1429,7 +1436,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String verifiedPublicKey(Object username) { String verifiedPublicKey(Object username) {
return 'Der öffentliche Schlüssel von $username wurde überprüft und ist gültig.'; return 'Die Identität von $username wurde erfolgreich überprüft.';
} }
@override @override
@ -1528,7 +1535,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get verificationBadgeGeneralDesc => String get verificationBadgeGeneralDesc =>
'Der Haken gibt dir die Sicherheit, dass du mit der richtigen Person schreibst. Du kannst Kontakte jederzeit verifizieren, indem du deren QR-Code scannst.'; 'Der Haken *schützt dich vor Betrügern und Angreifern*. Es wird neben einem Kontakt angezeigt, der von dir oder einem Freund *manuell überprüft* wurde.';
@override @override
String get verificationBadgeGreenDesc => String get verificationBadgeGreenDesc =>
@ -1560,6 +1567,11 @@ class AppLocalizationsDe extends AppLocalizations {
return '$username hat deinen QR-Code gescannt und ist nun verifiziert.'; return '$username hat deinen QR-Code gescannt und ist nun verifiziert.';
} }
@override
String askForFriendPromotionsPrompt(Object username) {
return 'Hilf $username, bekannte Gesichter auf twonly zu finden, indem du gemeinsame Freunde teilst.';
}
@override @override
String mutualGroupsTitle(num count) { String mutualGroupsTitle(num count) {
String _temp0 = intl.Intl.pluralLogic( String _temp0 = intl.Intl.pluralLogic(

View file

@ -443,6 +443,10 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Verified by $username'; return 'Verified by $username';
} }
@override
String get contactSharedByUnknown =>
'Shared by a verified contact (username not available)';
@override @override
String get verificationTypeQrScanned => 'You scanned their QR code.'; String get verificationTypeQrScanned => 'You scanned their QR code.';
@ -1016,6 +1020,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get alreadyInGroup => 'Already in Group'; String get alreadyInGroup => 'Already in Group';
@override
String get contactNotVerified => 'Not verified';
@override @override
String removeContactFromGroupTitle(Object username) { String removeContactFromGroupTitle(Object username) {
return 'Remove $username from this group?'; return 'Remove $username from this group?';
@ -1418,7 +1425,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String verifiedPublicKey(Object username) { String verifiedPublicKey(Object username) {
return 'The public key of $username has been verified and is valid.'; return 'The identity of $username has been successfully verified.';
} }
@override @override
@ -1515,7 +1522,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get verificationBadgeGeneralDesc => String get verificationBadgeGeneralDesc =>
'The checkmark gives you the certainty that you are messaging the right person. You can verify contacts at any time by scanning their QR code.'; 'The badge *protects you from scammers and attackers*. It will be displayed next to a contact that has been *manually verified* by you or a friend.';
@override @override
String get verificationBadgeGreenDesc => String get verificationBadgeGreenDesc =>
@ -1547,6 +1554,11 @@ class AppLocalizationsEn extends AppLocalizations {
return '$username has scanned your QR code and is now verified.'; return '$username has scanned your QR code and is now verified.';
} }
@override
String askForFriendPromotionsPrompt(Object username) {
return 'Help $username find familiar faces on twonly by sharing mutual friends.';
}
@override @override
String mutualGroupsTitle(num count) { String mutualGroupsTitle(num count) {
String _temp0 = intl.Intl.pluralLogic( String _temp0 = intl.Intl.pluralLogic(

@ -1 +1 @@
Subproject commit 673f6d8c3036d64060b1114912bd5bf5515d5420 Subproject commit 80b21d7e566a12d105f573cb7224b6cdfe37048b

View file

@ -128,6 +128,9 @@ class UserData {
// -- Custom DATA -- // -- Custom DATA --
@JsonKey(defaultValue: true)
bool askForFriendPromotions = true;
@JsonKey(defaultValue: 100_000) @JsonKey(defaultValue: 100_000)
int currentPreKeyIndexStart = 100_000; int currentPreKeyIndexStart = 100_000;
@ -153,6 +156,8 @@ class UserData {
@JsonKey(defaultValue: false) @JsonKey(defaultValue: false)
bool isBackupEnabled = false; bool isBackupEnabled = false;
PasswordLessRecovery? passwordLessRecovery;
// Used for push notifcation via FCM. // Used for push notifcation via FCM.
String? fcmToken; String? fcmToken;
@ -200,3 +205,25 @@ class TwonlySafeBackup {
List<int> encryptionKey; List<int> encryptionKey;
Map<String, dynamic> toJson() => _$TwonlySafeBackupToJson(this); Map<String, dynamic> toJson() => _$TwonlySafeBackupToJson(this);
} }
@JsonSerializable()
class PasswordLessRecovery {
PasswordLessRecovery({
this.email,
this.pinSeed,
this.pinUnlockToken,
this.threshold,
this.lastHeartbeat,
});
factory PasswordLessRecovery.fromJson(Map<String, dynamic> json) =>
_$PasswordLessRecoveryFromJson(json);
String? email;
String? pinSeed;
String? pinUnlockToken;
int? threshold;
DateTime? lastHeartbeat;
Map<String, dynamic> toJson() => _$PasswordLessRecoveryToJson(this);
}

View file

@ -85,6 +85,7 @@ UserData _$UserDataFromJson(Map<String, dynamic> json) =>
json['userDiscoverySharePromotion'] as bool? ?? true json['userDiscoverySharePromotion'] as bool? ?? true
..userDiscoveryInitializationError = ..userDiscoveryInitializationError =
json['userDiscoveryInitializationError'] as bool? ?? false json['userDiscoveryInitializationError'] as bool? ?? false
..askForFriendPromotions = json['askForFriendPromotions'] as bool? ?? true
..currentPreKeyIndexStart = ..currentPreKeyIndexStart =
(json['currentPreKeyIndexStart'] as num?)?.toInt() ?? 100000 (json['currentPreKeyIndexStart'] as num?)?.toInt() ?? 100000
..currentSignedPreKeyIndexStart = ..currentSignedPreKeyIndexStart =
@ -102,6 +103,11 @@ UserData _$UserDataFromJson(Map<String, dynamic> json) =>
json['twonlySafeBackup'] as Map<String, dynamic>, json['twonlySafeBackup'] as Map<String, dynamic>,
) )
..isBackupEnabled = json['isBackupEnabled'] as bool? ?? false ..isBackupEnabled = json['isBackupEnabled'] as bool? ?? false
..passwordLessRecovery = json['passwordLessRecovery'] == null
? null
: PasswordLessRecovery.fromJson(
json['passwordLessRecovery'] as Map<String, dynamic>,
)
..fcmToken = json['fcmToken'] as String? ..fcmToken = json['fcmToken'] as String?
..askedForUserStudyPermission = ..askedForUserStudyPermission =
json['askedForUserStudyPermission'] as bool? ?? false json['askedForUserStudyPermission'] as bool? ?? false
@ -161,6 +167,7 @@ Map<String, dynamic> _$UserDataToJson(UserData instance) => <String, dynamic>{
instance.userDiscoveryRequiresManualApproval, instance.userDiscoveryRequiresManualApproval,
'userDiscoverySharePromotion': instance.userDiscoverySharePromotion, 'userDiscoverySharePromotion': instance.userDiscoverySharePromotion,
'userDiscoveryInitializationError': instance.userDiscoveryInitializationError, 'userDiscoveryInitializationError': instance.userDiscoveryInitializationError,
'askForFriendPromotions': instance.askForFriendPromotions,
'currentPreKeyIndexStart': instance.currentPreKeyIndexStart, 'currentPreKeyIndexStart': instance.currentPreKeyIndexStart,
'currentSignedPreKeyIndexStart': instance.currentSignedPreKeyIndexStart, 'currentSignedPreKeyIndexStart': instance.currentSignedPreKeyIndexStart,
'lastChangeLogHash': instance.lastChangeLogHash, 'lastChangeLogHash': instance.lastChangeLogHash,
@ -169,6 +176,7 @@ Map<String, dynamic> _$UserDataToJson(UserData instance) => <String, dynamic>{
'canUseLoginTokenForAuth': instance.canUseLoginTokenForAuth, 'canUseLoginTokenForAuth': instance.canUseLoginTokenForAuth,
'twonlySafeBackup': instance.twonlySafeBackup, 'twonlySafeBackup': instance.twonlySafeBackup,
'isBackupEnabled': instance.isBackupEnabled, 'isBackupEnabled': instance.isBackupEnabled,
'passwordLessRecovery': instance.passwordLessRecovery,
'fcmToken': instance.fcmToken, 'fcmToken': instance.fcmToken,
'askedForUserStudyPermission': instance.askedForUserStudyPermission, 'askedForUserStudyPermission': instance.askedForUserStudyPermission,
'userStudyParticipantsToken': instance.userStudyParticipantsToken, 'userStudyParticipantsToken': instance.userStudyParticipantsToken,
@ -232,3 +240,25 @@ const _$LastBackupUploadStateEnumMap = {
LastBackupUploadState.failed: 'failed', LastBackupUploadState.failed: 'failed',
LastBackupUploadState.success: 'success', LastBackupUploadState.success: 'success',
}; };
PasswordLessRecovery _$PasswordLessRecoveryFromJson(
Map<String, dynamic> json,
) => PasswordLessRecovery(
email: json['email'] as String?,
pinSeed: json['pinSeed'] as String?,
pinUnlockToken: json['pinUnlockToken'] as String?,
threshold: (json['threshold'] as num?)?.toInt(),
lastHeartbeat: json['lastHeartbeat'] == null
? null
: DateTime.parse(json['lastHeartbeat'] as String),
);
Map<String, dynamic> _$PasswordLessRecoveryToJson(
PasswordLessRecovery instance,
) => <String, dynamic>{
'email': instance.email,
'pinSeed': instance.pinSeed,
'pinUnlockToken': instance.pinUnlockToken,
'threshold': instance.threshold,
'lastHeartbeat': instance.lastHeartbeat?.toIso8601String(),
};

View file

@ -368,10 +368,12 @@ class EncryptedContent_GroupCreate extends $pb.GeneratedMessage {
factory EncryptedContent_GroupCreate({ factory EncryptedContent_GroupCreate({
$core.List<$core.int>? stateKey, $core.List<$core.int>? stateKey,
$core.List<$core.int>? groupPublicKey, $core.List<$core.int>? groupPublicKey,
$core.String? groupName,
}) { }) {
final result = create(); final result = create();
if (stateKey != null) result.stateKey = stateKey; if (stateKey != null) result.stateKey = stateKey;
if (groupPublicKey != null) result.groupPublicKey = groupPublicKey; if (groupPublicKey != null) result.groupPublicKey = groupPublicKey;
if (groupName != null) result.groupName = groupName;
return result; return result;
} }
@ -391,6 +393,7 @@ class EncryptedContent_GroupCreate extends $pb.GeneratedMessage {
3, _omitFieldNames ? '' : 'stateKey', $pb.PbFieldType.OY) 3, _omitFieldNames ? '' : 'stateKey', $pb.PbFieldType.OY)
..a<$core.List<$core.int>>( ..a<$core.List<$core.int>>(
4, _omitFieldNames ? '' : 'groupPublicKey', $pb.PbFieldType.OY) 4, _omitFieldNames ? '' : 'groupPublicKey', $pb.PbFieldType.OY)
..aOS(5, _omitFieldNames ? '' : 'groupName')
..hasRequiredFields = false; ..hasRequiredFields = false;
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
@ -433,6 +436,15 @@ class EncryptedContent_GroupCreate extends $pb.GeneratedMessage {
$core.bool hasGroupPublicKey() => $_has(1); $core.bool hasGroupPublicKey() => $_has(1);
@$pb.TagNumber(4) @$pb.TagNumber(4)
void clearGroupPublicKey() => $_clearField(4); void clearGroupPublicKey() => $_clearField(4);
@$pb.TagNumber(5)
$core.String get groupName => $_getSZ(2);
@$pb.TagNumber(5)
set groupName($core.String value) => $_setString(2, value);
@$pb.TagNumber(5)
$core.bool hasGroupName() => $_has(2);
@$pb.TagNumber(5)
void clearGroupName() => $_clearField(5);
} }
class EncryptedContent_GroupJoin extends $pb.GeneratedMessage { class EncryptedContent_GroupJoin extends $pb.GeneratedMessage {
@ -1852,6 +1864,7 @@ class EncryptedContent extends $pb.GeneratedMessage {
EncryptedContent_UserDiscoveryRequest? userDiscoveryRequest, EncryptedContent_UserDiscoveryRequest? userDiscoveryRequest,
EncryptedContent_UserDiscoveryUpdate? userDiscoveryUpdate, EncryptedContent_UserDiscoveryUpdate? userDiscoveryUpdate,
EncryptedContent_KeyVerificationProof? keyVerificationProof, EncryptedContent_KeyVerificationProof? keyVerificationProof,
$core.bool? askForFriendPromotions,
}) { }) {
final result = create(); final result = create();
if (groupId != null) result.groupId = groupId; if (groupId != null) result.groupId = groupId;
@ -1884,6 +1897,8 @@ class EncryptedContent extends $pb.GeneratedMessage {
result.userDiscoveryUpdate = userDiscoveryUpdate; result.userDiscoveryUpdate = userDiscoveryUpdate;
if (keyVerificationProof != null) if (keyVerificationProof != null)
result.keyVerificationProof = keyVerificationProof; result.keyVerificationProof = keyVerificationProof;
if (askForFriendPromotions != null)
result.askForFriendPromotions = askForFriendPromotions;
return result; return result;
} }
@ -1955,6 +1970,7 @@ class EncryptedContent extends $pb.GeneratedMessage {
..aOM<EncryptedContent_KeyVerificationProof>( ..aOM<EncryptedContent_KeyVerificationProof>(
24, _omitFieldNames ? '' : 'keyVerificationProof', 24, _omitFieldNames ? '' : 'keyVerificationProof',
subBuilder: EncryptedContent_KeyVerificationProof.create) subBuilder: EncryptedContent_KeyVerificationProof.create)
..aOB(25, _omitFieldNames ? '' : 'askForFriendPromotions')
..hasRequiredFields = false; ..hasRequiredFields = false;
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
@ -2238,6 +2254,15 @@ class EncryptedContent extends $pb.GeneratedMessage {
@$pb.TagNumber(24) @$pb.TagNumber(24)
EncryptedContent_KeyVerificationProof ensureKeyVerificationProof() => EncryptedContent_KeyVerificationProof ensureKeyVerificationProof() =>
$_ensure(22); $_ensure(22);
@$pb.TagNumber(25)
$core.bool get askForFriendPromotions => $_getBF(23);
@$pb.TagNumber(25)
set askForFriendPromotions($core.bool value) => $_setBool(23, value);
@$pb.TagNumber(25)
$core.bool hasAskForFriendPromotions() => $_has(23);
@$pb.TagNumber(25)
void clearAskForFriendPromotions() => $_clearField(25);
} }
const $core.bool _omitFieldNames = const $core.bool _omitFieldNames =

View file

@ -79,16 +79,20 @@ class EncryptedContent_ErrorMessages_Type extends $pb.ProtobufEnum {
static const EncryptedContent_ErrorMessages_Type SESSION_OUT_OF_SYNC = static const EncryptedContent_ErrorMessages_Type SESSION_OUT_OF_SYNC =
EncryptedContent_ErrorMessages_Type._( EncryptedContent_ErrorMessages_Type._(
3, _omitEnumNames ? '' : 'SESSION_OUT_OF_SYNC'); 3, _omitEnumNames ? '' : 'SESSION_OUT_OF_SYNC');
static const EncryptedContent_ErrorMessages_Type
GROUP_NOT_FOUND_OR_NOT_A_MEMBER = EncryptedContent_ErrorMessages_Type._(
4, _omitEnumNames ? '' : 'GROUP_NOT_FOUND_OR_NOT_A_MEMBER');
static const $core.List<EncryptedContent_ErrorMessages_Type> values = static const $core.List<EncryptedContent_ErrorMessages_Type> values =
<EncryptedContent_ErrorMessages_Type>[ <EncryptedContent_ErrorMessages_Type>[
ERROR_PROCESSING_MESSAGE_CREATED_ACCOUNT_REQUEST_INSTEAD, ERROR_PROCESSING_MESSAGE_CREATED_ACCOUNT_REQUEST_INSTEAD,
UNKNOWN_MESSAGE_TYPE, UNKNOWN_MESSAGE_TYPE,
SESSION_OUT_OF_SYNC, SESSION_OUT_OF_SYNC,
GROUP_NOT_FOUND_OR_NOT_A_MEMBER,
]; ];
static final $core.List<EncryptedContent_ErrorMessages_Type?> _byValue = static final $core.List<EncryptedContent_ErrorMessages_Type?> _byValue =
$pb.ProtobufEnum.$_initByValueList(values, 3); $pb.ProtobufEnum.$_initByValueList(values, 4);
static EncryptedContent_ErrorMessages_Type? valueOf($core.int value) => static EncryptedContent_ErrorMessages_Type? valueOf($core.int value) =>
value < 0 || value >= _byValue.length ? null : _byValue[value]; value < 0 || value >= _byValue.length ? null : _byValue[value];

View file

@ -186,13 +186,22 @@ const EncryptedContent$json = {
'10': 'senderUserDiscoveryVersion', '10': 'senderUserDiscoveryVersion',
'17': true '17': true
}, },
{
'1': 'ask_for_friend_promotions',
'3': 25,
'4': 1,
'5': 8,
'9': 4,
'10': 'askForFriendPromotions',
'17': true
},
{ {
'1': 'message_update', '1': 'message_update',
'3': 5, '3': 5,
'4': 1, '4': 1,
'5': 11, '5': 11,
'6': '.EncryptedContent.MessageUpdate', '6': '.EncryptedContent.MessageUpdate',
'9': 4, '9': 5,
'10': 'messageUpdate', '10': 'messageUpdate',
'17': true '17': true
}, },
@ -202,7 +211,7 @@ const EncryptedContent$json = {
'4': 1, '4': 1,
'5': 11, '5': 11,
'6': '.EncryptedContent.Media', '6': '.EncryptedContent.Media',
'9': 5, '9': 6,
'10': 'media', '10': 'media',
'17': true '17': true
}, },
@ -212,7 +221,7 @@ const EncryptedContent$json = {
'4': 1, '4': 1,
'5': 11, '5': 11,
'6': '.EncryptedContent.MediaUpdate', '6': '.EncryptedContent.MediaUpdate',
'9': 6, '9': 7,
'10': 'mediaUpdate', '10': 'mediaUpdate',
'17': true '17': true
}, },
@ -222,7 +231,7 @@ const EncryptedContent$json = {
'4': 1, '4': 1,
'5': 11, '5': 11,
'6': '.EncryptedContent.ContactUpdate', '6': '.EncryptedContent.ContactUpdate',
'9': 7, '9': 8,
'10': 'contactUpdate', '10': 'contactUpdate',
'17': true '17': true
}, },
@ -232,7 +241,7 @@ const EncryptedContent$json = {
'4': 1, '4': 1,
'5': 11, '5': 11,
'6': '.EncryptedContent.ContactRequest', '6': '.EncryptedContent.ContactRequest',
'9': 8, '9': 9,
'10': 'contactRequest', '10': 'contactRequest',
'17': true '17': true
}, },
@ -242,7 +251,7 @@ const EncryptedContent$json = {
'4': 1, '4': 1,
'5': 11, '5': 11,
'6': '.EncryptedContent.FlameSync', '6': '.EncryptedContent.FlameSync',
'9': 9, '9': 10,
'10': 'flameSync', '10': 'flameSync',
'17': true '17': true
}, },
@ -252,7 +261,7 @@ const EncryptedContent$json = {
'4': 1, '4': 1,
'5': 11, '5': 11,
'6': '.EncryptedContent.PushKeys', '6': '.EncryptedContent.PushKeys',
'9': 10, '9': 11,
'10': 'pushKeys', '10': 'pushKeys',
'17': true '17': true
}, },
@ -262,7 +271,7 @@ const EncryptedContent$json = {
'4': 1, '4': 1,
'5': 11, '5': 11,
'6': '.EncryptedContent.Reaction', '6': '.EncryptedContent.Reaction',
'9': 11, '9': 12,
'10': 'reaction', '10': 'reaction',
'17': true '17': true
}, },
@ -272,7 +281,7 @@ const EncryptedContent$json = {
'4': 1, '4': 1,
'5': 11, '5': 11,
'6': '.EncryptedContent.TextMessage', '6': '.EncryptedContent.TextMessage',
'9': 12, '9': 13,
'10': 'textMessage', '10': 'textMessage',
'17': true '17': true
}, },
@ -282,7 +291,7 @@ const EncryptedContent$json = {
'4': 1, '4': 1,
'5': 11, '5': 11,
'6': '.EncryptedContent.GroupCreate', '6': '.EncryptedContent.GroupCreate',
'9': 13, '9': 14,
'10': 'groupCreate', '10': 'groupCreate',
'17': true '17': true
}, },
@ -292,7 +301,7 @@ const EncryptedContent$json = {
'4': 1, '4': 1,
'5': 11, '5': 11,
'6': '.EncryptedContent.GroupJoin', '6': '.EncryptedContent.GroupJoin',
'9': 14, '9': 15,
'10': 'groupJoin', '10': 'groupJoin',
'17': true '17': true
}, },
@ -302,7 +311,7 @@ const EncryptedContent$json = {
'4': 1, '4': 1,
'5': 11, '5': 11,
'6': '.EncryptedContent.GroupUpdate', '6': '.EncryptedContent.GroupUpdate',
'9': 15, '9': 16,
'10': 'groupUpdate', '10': 'groupUpdate',
'17': true '17': true
}, },
@ -312,7 +321,7 @@ const EncryptedContent$json = {
'4': 1, '4': 1,
'5': 11, '5': 11,
'6': '.EncryptedContent.ResendGroupPublicKey', '6': '.EncryptedContent.ResendGroupPublicKey',
'9': 16, '9': 17,
'10': 'resendGroupPublicKey', '10': 'resendGroupPublicKey',
'17': true '17': true
}, },
@ -322,7 +331,7 @@ const EncryptedContent$json = {
'4': 1, '4': 1,
'5': 11, '5': 11,
'6': '.EncryptedContent.ErrorMessages', '6': '.EncryptedContent.ErrorMessages',
'9': 17, '9': 18,
'10': 'errorMessages', '10': 'errorMessages',
'17': true '17': true
}, },
@ -332,7 +341,7 @@ const EncryptedContent$json = {
'4': 1, '4': 1,
'5': 11, '5': 11,
'6': '.EncryptedContent.AdditionalDataMessage', '6': '.EncryptedContent.AdditionalDataMessage',
'9': 18, '9': 19,
'10': 'additionalDataMessage', '10': 'additionalDataMessage',
'17': true '17': true
}, },
@ -342,7 +351,7 @@ const EncryptedContent$json = {
'4': 1, '4': 1,
'5': 11, '5': 11,
'6': '.EncryptedContent.TypingIndicator', '6': '.EncryptedContent.TypingIndicator',
'9': 19, '9': 20,
'10': 'typingIndicator', '10': 'typingIndicator',
'17': true '17': true
}, },
@ -352,7 +361,7 @@ const EncryptedContent$json = {
'4': 1, '4': 1,
'5': 11, '5': 11,
'6': '.EncryptedContent.UserDiscoveryRequest', '6': '.EncryptedContent.UserDiscoveryRequest',
'9': 20, '9': 21,
'10': 'userDiscoveryRequest', '10': 'userDiscoveryRequest',
'17': true '17': true
}, },
@ -362,7 +371,7 @@ const EncryptedContent$json = {
'4': 1, '4': 1,
'5': 11, '5': 11,
'6': '.EncryptedContent.UserDiscoveryUpdate', '6': '.EncryptedContent.UserDiscoveryUpdate',
'9': 21, '9': 22,
'10': 'userDiscoveryUpdate', '10': 'userDiscoveryUpdate',
'17': true '17': true
}, },
@ -372,7 +381,7 @@ const EncryptedContent$json = {
'4': 1, '4': 1,
'5': 11, '5': 11,
'6': '.EncryptedContent.KeyVerificationProof', '6': '.EncryptedContent.KeyVerificationProof',
'9': 22, '9': 23,
'10': 'keyVerificationProof', '10': 'keyVerificationProof',
'17': true '17': true
}, },
@ -403,6 +412,7 @@ const EncryptedContent$json = {
{'1': '_is_direct_chat'}, {'1': '_is_direct_chat'},
{'1': '_sender_profile_counter'}, {'1': '_sender_profile_counter'},
{'1': '_sender_user_discovery_version'}, {'1': '_sender_user_discovery_version'},
{'1': '_ask_for_friend_promotions'},
{'1': '_message_update'}, {'1': '_message_update'},
{'1': '_media'}, {'1': '_media'},
{'1': '_media_update'}, {'1': '_media_update'},
@ -455,6 +465,7 @@ const EncryptedContent_ErrorMessages_Type$json = {
{'1': 'ERROR_PROCESSING_MESSAGE_CREATED_ACCOUNT_REQUEST_INSTEAD', '2': 0}, {'1': 'ERROR_PROCESSING_MESSAGE_CREATED_ACCOUNT_REQUEST_INSTEAD', '2': 0},
{'1': 'UNKNOWN_MESSAGE_TYPE', '2': 2}, {'1': 'UNKNOWN_MESSAGE_TYPE', '2': 2},
{'1': 'SESSION_OUT_OF_SYNC', '2': 3}, {'1': 'SESSION_OUT_OF_SYNC', '2': 3},
{'1': 'GROUP_NOT_FOUND_OR_NOT_A_MEMBER', '2': 4},
], ],
}; };
@ -464,6 +475,18 @@ const EncryptedContent_GroupCreate$json = {
'2': [ '2': [
{'1': 'state_key', '3': 3, '4': 1, '5': 12, '10': 'stateKey'}, {'1': 'state_key', '3': 3, '4': 1, '5': 12, '10': 'stateKey'},
{'1': 'group_public_key', '3': 4, '4': 1, '5': 12, '10': 'groupPublicKey'}, {'1': 'group_public_key', '3': 4, '4': 1, '5': 12, '10': 'groupPublicKey'},
{
'1': 'group_name',
'3': 5,
'4': 1,
'5': 9,
'9': 0,
'10': 'groupName',
'17': true
},
],
'8': [
{'1': '_group_name'},
], ],
}; };
@ -938,103 +961,106 @@ final $typed_data.Uint8List encryptedContentDescriptor = $convert.base64Decode(
'NfZGlyZWN0X2NoYXQYAyABKAhIAVIMaXNEaXJlY3RDaGF0iAEBEjkKFnNlbmRlcl9wcm9maWxl' 'NfZGlyZWN0X2NoYXQYAyABKAhIAVIMaXNEaXJlY3RDaGF0iAEBEjkKFnNlbmRlcl9wcm9maWxl'
'X2NvdW50ZXIYBCABKANIAlIUc2VuZGVyUHJvZmlsZUNvdW50ZXKIAQESRgodc2VuZGVyX3VzZX' 'X2NvdW50ZXIYBCABKANIAlIUc2VuZGVyUHJvZmlsZUNvdW50ZXKIAQESRgodc2VuZGVyX3VzZX'
'JfZGlzY292ZXJ5X3ZlcnNpb24YFSABKAxIA1Iac2VuZGVyVXNlckRpc2NvdmVyeVZlcnNpb26I' 'JfZGlzY292ZXJ5X3ZlcnNpb24YFSABKAxIA1Iac2VuZGVyVXNlckRpc2NvdmVyeVZlcnNpb26I'
'AQESSwoObWVzc2FnZV91cGRhdGUYBSABKAsyHy5FbmNyeXB0ZWRDb250ZW50Lk1lc3NhZ2VVcG' 'AQESPgoZYXNrX2Zvcl9mcmllbmRfcHJvbW90aW9ucxgZIAEoCEgEUhZhc2tGb3JGcmllbmRQcm'
'RhdGVIBFINbWVzc2FnZVVwZGF0ZYgBARIyCgVtZWRpYRgGIAEoCzIXLkVuY3J5cHRlZENvbnRl' '9tb3Rpb25ziAEBEksKDm1lc3NhZ2VfdXBkYXRlGAUgASgLMh8uRW5jcnlwdGVkQ29udGVudC5N'
'bnQuTWVkaWFIBVIFbWVkaWGIAQESRQoMbWVkaWFfdXBkYXRlGAcgASgLMh0uRW5jcnlwdGVkQ2' 'ZXNzYWdlVXBkYXRlSAVSDW1lc3NhZ2VVcGRhdGWIAQESMgoFbWVkaWEYBiABKAsyFy5FbmNyeX'
'9udGVudC5NZWRpYVVwZGF0ZUgGUgttZWRpYVVwZGF0ZYgBARJLCg5jb250YWN0X3VwZGF0ZRgI' 'B0ZWRDb250ZW50Lk1lZGlhSAZSBW1lZGlhiAEBEkUKDG1lZGlhX3VwZGF0ZRgHIAEoCzIdLkVu'
'IAEoCzIfLkVuY3J5cHRlZENvbnRlbnQuQ29udGFjdFVwZGF0ZUgHUg1jb250YWN0VXBkYXRliA' 'Y3J5cHRlZENvbnRlbnQuTWVkaWFVcGRhdGVIB1ILbWVkaWFVcGRhdGWIAQESSwoOY29udGFjdF'
'EBEk4KD2NvbnRhY3RfcmVxdWVzdBgJIAEoCzIgLkVuY3J5cHRlZENvbnRlbnQuQ29udGFjdFJl' '91cGRhdGUYCCABKAsyHy5FbmNyeXB0ZWRDb250ZW50LkNvbnRhY3RVcGRhdGVICFINY29udGFj'
'cXVlc3RICFIOY29udGFjdFJlcXVlc3SIAQESPwoKZmxhbWVfc3luYxgKIAEoCzIbLkVuY3J5cH' 'dFVwZGF0ZYgBARJOCg9jb250YWN0X3JlcXVlc3QYCSABKAsyIC5FbmNyeXB0ZWRDb250ZW50Lk'
'RlZENvbnRlbnQuRmxhbWVTeW5jSAlSCWZsYW1lU3luY4gBARI8CglwdXNoX2tleXMYCyABKAsy' 'NvbnRhY3RSZXF1ZXN0SAlSDmNvbnRhY3RSZXF1ZXN0iAEBEj8KCmZsYW1lX3N5bmMYCiABKAsy'
'Gi5FbmNyeXB0ZWRDb250ZW50LlB1c2hLZXlzSApSCHB1c2hLZXlziAEBEjsKCHJlYWN0aW9uGA' 'Gy5FbmNyeXB0ZWRDb250ZW50LkZsYW1lU3luY0gKUglmbGFtZVN5bmOIAQESPAoJcHVzaF9rZX'
'wgASgLMhouRW5jcnlwdGVkQ29udGVudC5SZWFjdGlvbkgLUghyZWFjdGlvbogBARJFCgx0ZXh0' 'lzGAsgASgLMhouRW5jcnlwdGVkQ29udGVudC5QdXNoS2V5c0gLUghwdXNoS2V5c4gBARI7Cghy'
'X21lc3NhZ2UYDSABKAsyHS5FbmNyeXB0ZWRDb250ZW50LlRleHRNZXNzYWdlSAxSC3RleHRNZX' 'ZWFjdGlvbhgMIAEoCzIaLkVuY3J5cHRlZENvbnRlbnQuUmVhY3Rpb25IDFIIcmVhY3Rpb26IAQ'
'NzYWdliAEBEkUKDGdyb3VwX2NyZWF0ZRgOIAEoCzIdLkVuY3J5cHRlZENvbnRlbnQuR3JvdXBD' 'ESRQoMdGV4dF9tZXNzYWdlGA0gASgLMh0uRW5jcnlwdGVkQ29udGVudC5UZXh0TWVzc2FnZUgN'
'cmVhdGVIDVILZ3JvdXBDcmVhdGWIAQESPwoKZ3JvdXBfam9pbhgPIAEoCzIbLkVuY3J5cHRlZE' 'Ugt0ZXh0TWVzc2FnZYgBARJFCgxncm91cF9jcmVhdGUYDiABKAsyHS5FbmNyeXB0ZWRDb250ZW'
'NvbnRlbnQuR3JvdXBKb2luSA5SCWdyb3VwSm9pbogBARJFCgxncm91cF91cGRhdGUYECABKAsy' '50Lkdyb3VwQ3JlYXRlSA5SC2dyb3VwQ3JlYXRliAEBEj8KCmdyb3VwX2pvaW4YDyABKAsyGy5F'
'HS5FbmNyeXB0ZWRDb250ZW50Lkdyb3VwVXBkYXRlSA9SC2dyb3VwVXBkYXRliAEBEmIKF3Jlc2' 'bmNyeXB0ZWRDb250ZW50Lkdyb3VwSm9pbkgPUglncm91cEpvaW6IAQESRQoMZ3JvdXBfdXBkYX'
'VuZF9ncm91cF9wdWJsaWNfa2V5GBEgASgLMiYuRW5jcnlwdGVkQ29udGVudC5SZXNlbmRHcm91' 'RlGBAgASgLMh0uRW5jcnlwdGVkQ29udGVudC5Hcm91cFVwZGF0ZUgQUgtncm91cFVwZGF0ZYgB'
'cFB1YmxpY0tleUgQUhRyZXNlbmRHcm91cFB1YmxpY0tleYgBARJLCg5lcnJvcl9tZXNzYWdlcx' 'ARJiChdyZXNlbmRfZ3JvdXBfcHVibGljX2tleRgRIAEoCzImLkVuY3J5cHRlZENvbnRlbnQuUm'
'gSIAEoCzIfLkVuY3J5cHRlZENvbnRlbnQuRXJyb3JNZXNzYWdlc0gRUg1lcnJvck1lc3NhZ2Vz' 'VzZW5kR3JvdXBQdWJsaWNLZXlIEVIUcmVzZW5kR3JvdXBQdWJsaWNLZXmIAQESSwoOZXJyb3Jf'
'iAEBEmQKF2FkZGl0aW9uYWxfZGF0YV9tZXNzYWdlGBMgASgLMicuRW5jcnlwdGVkQ29udGVudC' 'bWVzc2FnZXMYEiABKAsyHy5FbmNyeXB0ZWRDb250ZW50LkVycm9yTWVzc2FnZXNIElINZXJyb3'
'5BZGRpdGlvbmFsRGF0YU1lc3NhZ2VIElIVYWRkaXRpb25hbERhdGFNZXNzYWdliAEBElEKEHR5' 'JNZXNzYWdlc4gBARJkChdhZGRpdGlvbmFsX2RhdGFfbWVzc2FnZRgTIAEoCzInLkVuY3J5cHRl'
'cGluZ19pbmRpY2F0b3IYFCABKAsyIS5FbmNyeXB0ZWRDb250ZW50LlR5cGluZ0luZGljYXRvck' 'ZENvbnRlbnQuQWRkaXRpb25hbERhdGFNZXNzYWdlSBNSFWFkZGl0aW9uYWxEYXRhTWVzc2FnZY'
'gTUg90eXBpbmdJbmRpY2F0b3KIAQESYQoWdXNlcl9kaXNjb3ZlcnlfcmVxdWVzdBgWIAEoCzIm' 'gBARJRChB0eXBpbmdfaW5kaWNhdG9yGBQgASgLMiEuRW5jcnlwdGVkQ29udGVudC5UeXBpbmdJ'
'LkVuY3J5cHRlZENvbnRlbnQuVXNlckRpc2NvdmVyeVJlcXVlc3RIFFIUdXNlckRpc2NvdmVyeV' 'bmRpY2F0b3JIFFIPdHlwaW5nSW5kaWNhdG9yiAEBEmEKFnVzZXJfZGlzY292ZXJ5X3JlcXVlc3'
'JlcXVlc3SIAQESXgoVdXNlcl9kaXNjb3ZlcnlfdXBkYXRlGBcgASgLMiUuRW5jcnlwdGVkQ29u' 'QYFiABKAsyJi5FbmNyeXB0ZWRDb250ZW50LlVzZXJEaXNjb3ZlcnlSZXF1ZXN0SBVSFHVzZXJE'
'dGVudC5Vc2VyRGlzY292ZXJ5VXBkYXRlSBVSE3VzZXJEaXNjb3ZlcnlVcGRhdGWIAQESYQoWa2' 'aXNjb3ZlcnlSZXF1ZXN0iAEBEl4KFXVzZXJfZGlzY292ZXJ5X3VwZGF0ZRgXIAEoCzIlLkVuY3'
'V5X3ZlcmlmaWNhdGlvbl9wcm9vZhgYIAEoCzImLkVuY3J5cHRlZENvbnRlbnQuS2V5VmVyaWZp' 'J5cHRlZENvbnRlbnQuVXNlckRpc2NvdmVyeVVwZGF0ZUgWUhN1c2VyRGlzY292ZXJ5VXBkYXRl'
'Y2F0aW9uUHJvb2ZIFlIUa2V5VmVyaWZpY2F0aW9uUHJvb2aIAQEa8AEKDUVycm9yTWVzc2FnZX' 'iAEBEmEKFmtleV92ZXJpZmljYXRpb25fcHJvb2YYGCABKAsyJi5FbmNyeXB0ZWRDb250ZW50Lk'
'MSOAoEdHlwZRgBIAEoDjIkLkVuY3J5cHRlZENvbnRlbnQuRXJyb3JNZXNzYWdlcy5UeXBlUgR0' 'tleVZlcmlmaWNhdGlvblByb29mSBdSFGtleVZlcmlmaWNhdGlvblByb29miAEBGpYCCg1FcnJv'
'eXBlEiwKEnJlbGF0ZWRfcmVjZWlwdF9pZBgCIAEoCVIQcmVsYXRlZFJlY2VpcHRJZCJ3CgRUeX' 'ck1lc3NhZ2VzEjgKBHR5cGUYASABKA4yJC5FbmNyeXB0ZWRDb250ZW50LkVycm9yTWVzc2FnZX'
'BlEjwKOEVSUk9SX1BST0NFU1NJTkdfTUVTU0FHRV9DUkVBVEVEX0FDQ09VTlRfUkVRVUVTVF9J' 'MuVHlwZVIEdHlwZRIsChJyZWxhdGVkX3JlY2VpcHRfaWQYAiABKAlSEHJlbGF0ZWRSZWNlaXB0'
'TlNURUFEEAASGAoUVU5LTk9XTl9NRVNTQUdFX1RZUEUQAhIXChNTRVNTSU9OX09VVF9PRl9TWU' 'SWQinAEKBFR5cGUSPAo4RVJST1JfUFJPQ0VTU0lOR19NRVNTQUdFX0NSRUFURURfQUNDT1VOVF'
'5DEAMaVAoLR3JvdXBDcmVhdGUSGwoJc3RhdGVfa2V5GAMgASgMUghzdGF0ZUtleRIoChBncm91' '9SRVFVRVNUX0lOU1RFQUQQABIYChRVTktOT1dOX01FU1NBR0VfVFlQRRACEhcKE1NFU1NJT05f'
'cF9wdWJsaWNfa2V5GAQgASgMUg5ncm91cFB1YmxpY0tleRo1CglHcm91cEpvaW4SKAoQZ3JvdX' 'T1VUX09GX1NZTkMQAxIjCh9HUk9VUF9OT1RfRk9VTkRfT1JfTk9UX0FfTUVNQkVSEAQahwEKC0'
'BfcHVibGljX2tleRgBIAEoDFIOZ3JvdXBQdWJsaWNLZXkaFgoUUmVzZW5kR3JvdXBQdWJsaWNL' 'dyb3VwQ3JlYXRlEhsKCXN0YXRlX2tleRgDIAEoDFIIc3RhdGVLZXkSKAoQZ3JvdXBfcHVibGlj'
'ZXkayAIKC0dyb3VwVXBkYXRlEioKEWdyb3VwX2FjdGlvbl90eXBlGAEgASgJUg9ncm91cEFjdG' 'X2tleRgEIAEoDFIOZ3JvdXBQdWJsaWNLZXkSIgoKZ3JvdXBfbmFtZRgFIAEoCUgAUglncm91cE'
'lvblR5cGUSMwoTYWZmZWN0ZWRfY29udGFjdF9pZBgCIAEoA0gAUhFhZmZlY3RlZENvbnRhY3RJ' '5hbWWIAQFCDQoLX2dyb3VwX25hbWUaNQoJR3JvdXBKb2luEigKEGdyb3VwX3B1YmxpY19rZXkY'
'ZIgBARIpCg5uZXdfZ3JvdXBfbmFtZRgDIAEoCUgBUgxuZXdHcm91cE5hbWWIAQESVwombmV3X2' 'ASABKAxSDmdyb3VwUHVibGljS2V5GhYKFFJlc2VuZEdyb3VwUHVibGljS2V5GsgCCgtHcm91cF'
'RlbGV0ZV9tZXNzYWdlc19hZnRlcl9taWxsaXNlY29uZHMYBCABKANIAlIibmV3RGVsZXRlTWVz' 'VwZGF0ZRIqChFncm91cF9hY3Rpb25fdHlwZRgBIAEoCVIPZ3JvdXBBY3Rpb25UeXBlEjMKE2Fm'
'c2FnZXNBZnRlck1pbGxpc2Vjb25kc4gBAUIWChRfYWZmZWN0ZWRfY29udGFjdF9pZEIRCg9fbm' 'ZmVjdGVkX2NvbnRhY3RfaWQYAiABKANIAFIRYWZmZWN0ZWRDb250YWN0SWSIAQESKQoObmV3X2'
'V3X2dyb3VwX25hbWVCKQonX25ld19kZWxldGVfbWVzc2FnZXNfYWZ0ZXJfbWlsbGlzZWNvbmRz' 'dyb3VwX25hbWUYAyABKAlIAVIMbmV3R3JvdXBOYW1liAEBElcKJm5ld19kZWxldGVfbWVzc2Fn'
'Gq8BCgtUZXh0TWVzc2FnZRIqChFzZW5kZXJfbWVzc2FnZV9pZBgBIAEoCVIPc2VuZGVyTWVzc2' 'ZXNfYWZ0ZXJfbWlsbGlzZWNvbmRzGAQgASgDSAJSIm5ld0RlbGV0ZU1lc3NhZ2VzQWZ0ZXJNaW'
'FnZUlkEhIKBHRleHQYAiABKAlSBHRleHQSHAoJdGltZXN0YW1wGAMgASgDUgl0aW1lc3RhbXAS' 'xsaXNlY29uZHOIAQFCFgoUX2FmZmVjdGVkX2NvbnRhY3RfaWRCEQoPX25ld19ncm91cF9uYW1l'
'LQoQcXVvdGVfbWVzc2FnZV9pZBgEIAEoCUgAUg5xdW90ZU1lc3NhZ2VJZIgBAUITChFfcXVvdG' 'QikKJ19uZXdfZGVsZXRlX21lc3NhZ2VzX2FmdGVyX21pbGxpc2Vjb25kcxqvAQoLVGV4dE1lc3'
'VfbWVzc2FnZV9pZBrOAQoVQWRkaXRpb25hbERhdGFNZXNzYWdlEioKEXNlbmRlcl9tZXNzYWdl' 'NhZ2USKgoRc2VuZGVyX21lc3NhZ2VfaWQYASABKAlSD3NlbmRlck1lc3NhZ2VJZBISCgR0ZXh0'
'X2lkGAEgASgJUg9zZW5kZXJNZXNzYWdlSWQSHAoJdGltZXN0YW1wGAIgASgDUgl0aW1lc3RhbX' 'GAIgASgJUgR0ZXh0EhwKCXRpbWVzdGFtcBgDIAEoA1IJdGltZXN0YW1wEi0KEHF1b3RlX21lc3'
'ASEgoEdHlwZRgDIAEoCVIEdHlwZRI7ChdhZGRpdGlvbmFsX21lc3NhZ2VfZGF0YRgEIAEoDEgA' 'NhZ2VfaWQYBCABKAlIAFIOcXVvdGVNZXNzYWdlSWSIAQFCEwoRX3F1b3RlX21lc3NhZ2VfaWQa'
'UhVhZGRpdGlvbmFsTWVzc2FnZURhdGGIAQFCGgoYX2FkZGl0aW9uYWxfbWVzc2FnZV9kYXRhGm' 'zgEKFUFkZGl0aW9uYWxEYXRhTWVzc2FnZRIqChFzZW5kZXJfbWVzc2FnZV9pZBgBIAEoCVIPc2'
'QKCFJlYWN0aW9uEioKEXRhcmdldF9tZXNzYWdlX2lkGAEgASgJUg90YXJnZXRNZXNzYWdlSWQS' 'VuZGVyTWVzc2FnZUlkEhwKCXRpbWVzdGFtcBgCIAEoA1IJdGltZXN0YW1wEhIKBHR5cGUYAyAB'
'FAoFZW1vamkYAiABKAlSBWVtb2ppEhYKBnJlbW92ZRgDIAEoCFIGcmVtb3ZlGr4CCg1NZXNzYW' 'KAlSBHR5cGUSOwoXYWRkaXRpb25hbF9tZXNzYWdlX2RhdGEYBCABKAxIAFIVYWRkaXRpb25hbE'
'dlVXBkYXRlEjgKBHR5cGUYASABKA4yJC5FbmNyeXB0ZWRDb250ZW50Lk1lc3NhZ2VVcGRhdGUu' '1lc3NhZ2VEYXRhiAEBQhoKGF9hZGRpdGlvbmFsX21lc3NhZ2VfZGF0YRpkCghSZWFjdGlvbhIq'
'VHlwZVIEdHlwZRIvChFzZW5kZXJfbWVzc2FnZV9pZBgCIAEoCUgAUg9zZW5kZXJNZXNzYWdlSW' 'ChF0YXJnZXRfbWVzc2FnZV9pZBgBIAEoCVIPdGFyZ2V0TWVzc2FnZUlkEhQKBWVtb2ppGAIgAS'
'SIAQESPQobbXVsdGlwbGVfdGFyZ2V0X21lc3NhZ2VfaWRzGAMgAygJUhhtdWx0aXBsZVRhcmdl' 'gJUgVlbW9qaRIWCgZyZW1vdmUYAyABKAhSBnJlbW92ZRq+AgoNTWVzc2FnZVVwZGF0ZRI4CgR0'
'dE1lc3NhZ2VJZHMSFwoEdGV4dBgEIAEoCUgBUgR0ZXh0iAEBEhwKCXRpbWVzdGFtcBgFIAEoA1' 'eXBlGAEgASgOMiQuRW5jcnlwdGVkQ29udGVudC5NZXNzYWdlVXBkYXRlLlR5cGVSBHR5cGUSLw'
'IJdGltZXN0YW1wIi0KBFR5cGUSCgoGREVMRVRFEAASDQoJRURJVF9URVhUEAESCgoGT1BFTkVE' 'oRc2VuZGVyX21lc3NhZ2VfaWQYAiABKAlIAFIPc2VuZGVyTWVzc2FnZUlkiAEBEj0KG211bHRp'
'EAJCFAoSX3NlbmRlcl9tZXNzYWdlX2lkQgcKBV90ZXh0GoUGCgVNZWRpYRIqChFzZW5kZXJfbW' 'cGxlX3RhcmdldF9tZXNzYWdlX2lkcxgDIAMoCVIYbXVsdGlwbGVUYXJnZXRNZXNzYWdlSWRzEh'
'Vzc2FnZV9pZBgBIAEoCVIPc2VuZGVyTWVzc2FnZUlkEjAKBHR5cGUYAiABKA4yHC5FbmNyeXB0' 'cKBHRleHQYBCABKAlIAVIEdGV4dIgBARIcCgl0aW1lc3RhbXAYBSABKANSCXRpbWVzdGFtcCIt'
'ZWRDb250ZW50Lk1lZGlhLlR5cGVSBHR5cGUSRgodZGlzcGxheV9saW1pdF9pbl9taWxsaXNlY2' 'CgRUeXBlEgoKBkRFTEVURRAAEg0KCUVESVRfVEVYVBABEgoKBk9QRU5FRBACQhQKEl9zZW5kZX'
'9uZHMYAyABKANIAFIaZGlzcGxheUxpbWl0SW5NaWxsaXNlY29uZHOIAQESNwoXcmVxdWlyZXNf' 'JfbWVzc2FnZV9pZEIHCgVfdGV4dBqFBgoFTWVkaWESKgoRc2VuZGVyX21lc3NhZ2VfaWQYASAB'
'YXV0aGVudGljYXRpb24YBCABKAhSFnJlcXVpcmVzQXV0aGVudGljYXRpb24SHAoJdGltZXN0YW' 'KAlSD3NlbmRlck1lc3NhZ2VJZBIwCgR0eXBlGAIgASgOMhwuRW5jcnlwdGVkQ29udGVudC5NZW'
'1wGAUgASgDUgl0aW1lc3RhbXASLQoQcXVvdGVfbWVzc2FnZV9pZBgGIAEoCUgBUg5xdW90ZU1l' 'RpYS5UeXBlUgR0eXBlEkYKHWRpc3BsYXlfbGltaXRfaW5fbWlsbGlzZWNvbmRzGAMgASgDSABS'
'c3NhZ2VJZIgBARIqCg5kb3dubG9hZF90b2tlbhgHIAEoDEgCUg1kb3dubG9hZFRva2VuiAEBEi' 'GmRpc3BsYXlMaW1pdEluTWlsbGlzZWNvbmRziAEBEjcKF3JlcXVpcmVzX2F1dGhlbnRpY2F0aW'
'oKDmVuY3J5cHRpb25fa2V5GAggASgMSANSDWVuY3J5cHRpb25LZXmIAQESKgoOZW5jcnlwdGlv' '9uGAQgASgIUhZyZXF1aXJlc0F1dGhlbnRpY2F0aW9uEhwKCXRpbWVzdGFtcBgFIAEoA1IJdGlt'
'bl9tYWMYCSABKAxIBFINZW5jcnlwdGlvbk1hY4gBARIuChBlbmNyeXB0aW9uX25vbmNlGAogAS' 'ZXN0YW1wEi0KEHF1b3RlX21lc3NhZ2VfaWQYBiABKAlIAVIOcXVvdGVNZXNzYWdlSWSIAQESKg'
'gMSAVSD2VuY3J5cHRpb25Ob25jZYgBARI7ChdhZGRpdGlvbmFsX21lc3NhZ2VfZGF0YRgLIAEo' 'oOZG93bmxvYWRfdG9rZW4YByABKAxIAlINZG93bmxvYWRUb2tlbogBARIqCg5lbmNyeXB0aW9u'
'DEgGUhVhZGRpdGlvbmFsTWVzc2FnZURhdGGIAQEiPgoEVHlwZRIMCghSRVVQTE9BRBAAEgkKBU' 'X2tleRgIIAEoDEgDUg1lbmNyeXB0aW9uS2V5iAEBEioKDmVuY3J5cHRpb25fbWFjGAkgASgMSA'
'lNQUdFEAESCQoFVklERU8QAhIHCgNHSUYQAxIJCgVBVURJTxAEQiAKHl9kaXNwbGF5X2xpbWl0' 'RSDWVuY3J5cHRpb25NYWOIAQESLgoQZW5jcnlwdGlvbl9ub25jZRgKIAEoDEgFUg9lbmNyeXB0'
'X2luX21pbGxpc2Vjb25kc0ITChFfcXVvdGVfbWVzc2FnZV9pZEIRCg9fZG93bmxvYWRfdG9rZW' 'aW9uTm9uY2WIAQESOwoXYWRkaXRpb25hbF9tZXNzYWdlX2RhdGEYCyABKAxIBlIVYWRkaXRpb2'
'5CEQoPX2VuY3J5cHRpb25fa2V5QhEKD19lbmNyeXB0aW9uX21hY0ITChFfZW5jcnlwdGlvbl9u' '5hbE1lc3NhZ2VEYXRhiAEBIj4KBFR5cGUSDAoIUkVVUExPQUQQABIJCgVJTUFHRRABEgkKBVZJ'
'b25jZUIaChhfYWRkaXRpb25hbF9tZXNzYWdlX2RhdGEaqQEKC01lZGlhVXBkYXRlEjYKBHR5cG' 'REVPEAISBwoDR0lGEAMSCQoFQVVESU8QBEIgCh5fZGlzcGxheV9saW1pdF9pbl9taWxsaXNlY2'
'UYASABKA4yIi5FbmNyeXB0ZWRDb250ZW50Lk1lZGlhVXBkYXRlLlR5cGVSBHR5cGUSKgoRdGFy' '9uZHNCEwoRX3F1b3RlX21lc3NhZ2VfaWRCEQoPX2Rvd25sb2FkX3Rva2VuQhEKD19lbmNyeXB0'
'Z2V0X21lc3NhZ2VfaWQYAiABKAlSD3RhcmdldE1lc3NhZ2VJZCI2CgRUeXBlEgwKCFJFT1BFTk' 'aW9uX2tleUIRCg9fZW5jcnlwdGlvbl9tYWNCEwoRX2VuY3J5cHRpb25fbm9uY2VCGgoYX2FkZG'
'VEEAASCgoGU1RPUkVEEAESFAoQREVDUllQVElPTl9FUlJPUhACGngKDkNvbnRhY3RSZXF1ZXN0' 'l0aW9uYWxfbWVzc2FnZV9kYXRhGqkBCgtNZWRpYVVwZGF0ZRI2CgR0eXBlGAEgASgOMiIuRW5j'
'EjkKBHR5cGUYASABKA4yJS5FbmNyeXB0ZWRDb250ZW50LkNvbnRhY3RSZXF1ZXN0LlR5cGVSBH' 'cnlwdGVkQ29udGVudC5NZWRpYVVwZGF0ZS5UeXBlUgR0eXBlEioKEXRhcmdldF9tZXNzYWdlX2'
'R5cGUiKwoEVHlwZRILCgdSRVFVRVNUEAASCgoGUkVKRUNUEAESCgoGQUNDRVBUEAIapAIKDUNv' 'lkGAIgASgJUg90YXJnZXRNZXNzYWdlSWQiNgoEVHlwZRIMCghSRU9QRU5FRBAAEgoKBlNUT1JF'
'bnRhY3RVcGRhdGUSOAoEdHlwZRgBIAEoDjIkLkVuY3J5cHRlZENvbnRlbnQuQ29udGFjdFVwZG' 'RBABEhQKEERFQ1JZUFRJT05fRVJST1IQAhp4Cg5Db250YWN0UmVxdWVzdBI5CgR0eXBlGAEgAS'
'F0ZS5UeXBlUgR0eXBlEjcKFWF2YXRhcl9zdmdfY29tcHJlc3NlZBgCIAEoDEgAUhNhdmF0YXJT' 'gOMiUuRW5jcnlwdGVkQ29udGVudC5Db250YWN0UmVxdWVzdC5UeXBlUgR0eXBlIisKBFR5cGUS'
'dmdDb21wcmVzc2VkiAEBEh8KCHVzZXJuYW1lGAMgASgJSAFSCHVzZXJuYW1liAEBEiYKDGRpc3' 'CwoHUkVRVUVTVBAAEgoKBlJFSkVDVBABEgoKBkFDQ0VQVBACGqQCCg1Db250YWN0VXBkYXRlEj'
'BsYXlfbmFtZRgEIAEoCUgCUgtkaXNwbGF5TmFtZYgBASIfCgRUeXBlEgsKB1JFUVVFU1QQABIK' 'gKBHR5cGUYASABKA4yJC5FbmNyeXB0ZWRDb250ZW50LkNvbnRhY3RVcGRhdGUuVHlwZVIEdHlw'
'CgZVUERBVEUQAUIYChZfYXZhdGFyX3N2Z19jb21wcmVzc2VkQgsKCV91c2VybmFtZUIPCg1fZG' 'ZRI3ChVhdmF0YXJfc3ZnX2NvbXByZXNzZWQYAiABKAxIAFITYXZhdGFyU3ZnQ29tcHJlc3NlZI'
'lzcGxheV9uYW1lGtkBCghQdXNoS2V5cxIzCgR0eXBlGAEgASgOMh8uRW5jcnlwdGVkQ29udGVu' 'gBARIfCgh1c2VybmFtZRgDIAEoCUgBUgh1c2VybmFtZYgBARImCgxkaXNwbGF5X25hbWUYBCAB'
'dC5QdXNoS2V5cy5UeXBlUgR0eXBlEhoKBmtleV9pZBgCIAEoA0gAUgVrZXlJZIgBARIVCgNrZX' 'KAlIAlILZGlzcGxheU5hbWWIAQEiHwoEVHlwZRILCgdSRVFVRVNUEAASCgoGVVBEQVRFEAFCGA'
'kYAyABKAxIAVIDa2V5iAEBEiIKCmNyZWF0ZWRfYXQYBCABKANIAlIJY3JlYXRlZEF0iAEBIh8K' 'oWX2F2YXRhcl9zdmdfY29tcHJlc3NlZEILCglfdXNlcm5hbWVCDwoNX2Rpc3BsYXlfbmFtZRrZ'
'BFR5cGUSCwoHUkVRVUVTVBAAEgoKBlVQREFURRABQgkKB19rZXlfaWRCBgoEX2tleUINCgtfY3' 'AQoIUHVzaEtleXMSMwoEdHlwZRgBIAEoDjIfLkVuY3J5cHRlZENvbnRlbnQuUHVzaEtleXMuVH'
'JlYXRlZF9hdBqvAQoJRmxhbWVTeW5jEiMKDWZsYW1lX2NvdW50ZXIYASABKANSDGZsYW1lQ291' 'lwZVIEdHlwZRIaCgZrZXlfaWQYAiABKANIAFIFa2V5SWSIAQESFQoDa2V5GAMgASgMSAFSA2tl'
'bnRlchI5ChlsYXN0X2ZsYW1lX2NvdW50ZXJfY2hhbmdlGAIgASgDUhZsYXN0RmxhbWVDb3VudG' 'eYgBARIiCgpjcmVhdGVkX2F0GAQgASgDSAJSCWNyZWF0ZWRBdIgBASIfCgRUeXBlEgsKB1JFUV'
'VyQ2hhbmdlEh8KC2Jlc3RfZnJpZW5kGAMgASgIUgpiZXN0RnJpZW5kEiEKDGZvcmNlX3VwZGF0' 'VFU1QQABIKCgZVUERBVEUQAUIJCgdfa2V5X2lkQgYKBF9rZXlCDQoLX2NyZWF0ZWRfYXQarwEK'
'ZRgEIAEoCFILZm9yY2VVcGRhdGUaTQoPVHlwaW5nSW5kaWNhdG9yEhsKCWlzX3R5cGluZxgBIA' 'CUZsYW1lU3luYxIjCg1mbGFtZV9jb3VudGVyGAEgASgDUgxmbGFtZUNvdW50ZXISOQoZbGFzdF'
'EoCFIIaXNUeXBpbmcSHQoKY3JlYXRlZF9hdBgCIAEoA1IJY3JlYXRlZEF0Gj8KFFVzZXJEaXNj' '9mbGFtZV9jb3VudGVyX2NoYW5nZRgCIAEoA1IWbGFzdEZsYW1lQ291bnRlckNoYW5nZRIfCgti'
'b3ZlcnlSZXF1ZXN0EicKD2N1cnJlbnRfdmVyc2lvbhgBIAEoDFIOY3VycmVudFZlcnNpb24aMQ' 'ZXN0X2ZyaWVuZBgDIAEoCFIKYmVzdEZyaWVuZBIhCgxmb3JjZV91cGRhdGUYBCABKAhSC2Zvcm'
'oTVXNlckRpc2NvdmVyeVVwZGF0ZRIaCghtZXNzYWdlcxgBIAMoDFIIbWVzc2FnZXMaPQoUS2V5' 'NlVXBkYXRlGk0KD1R5cGluZ0luZGljYXRvchIbCglpc190eXBpbmcYASABKAhSCGlzVHlwaW5n'
'VmVyaWZpY2F0aW9uUHJvb2YSJQoOY2FsY3VsYXRlZF9tYWMYASABKAxSDWNhbGN1bGF0ZWRNYW' 'Eh0KCmNyZWF0ZWRfYXQYAiABKANSCWNyZWF0ZWRBdBo/ChRVc2VyRGlzY292ZXJ5UmVxdWVzdB'
'NCCwoJX2dyb3VwX2lkQhEKD19pc19kaXJlY3RfY2hhdEIZChdfc2VuZGVyX3Byb2ZpbGVfY291' 'InCg9jdXJyZW50X3ZlcnNpb24YASABKAxSDmN1cnJlbnRWZXJzaW9uGjEKE1VzZXJEaXNjb3Zl'
'bnRlckIgCh5fc2VuZGVyX3VzZXJfZGlzY292ZXJ5X3ZlcnNpb25CEQoPX21lc3NhZ2VfdXBkYX' 'cnlVcGRhdGUSGgoIbWVzc2FnZXMYASADKAxSCG1lc3NhZ2VzGj0KFEtleVZlcmlmaWNhdGlvbl'
'RlQggKBl9tZWRpYUIPCg1fbWVkaWFfdXBkYXRlQhEKD19jb250YWN0X3VwZGF0ZUISChBfY29u' 'Byb29mEiUKDmNhbGN1bGF0ZWRfbWFjGAEgASgMUg1jYWxjdWxhdGVkTWFjQgsKCV9ncm91cF9p'
'dGFjdF9yZXF1ZXN0Qg0KC19mbGFtZV9zeW5jQgwKCl9wdXNoX2tleXNCCwoJX3JlYWN0aW9uQg' 'ZEIRCg9faXNfZGlyZWN0X2NoYXRCGQoXX3NlbmRlcl9wcm9maWxlX2NvdW50ZXJCIAoeX3Nlbm'
'8KDV90ZXh0X21lc3NhZ2VCDwoNX2dyb3VwX2NyZWF0ZUINCgtfZ3JvdXBfam9pbkIPCg1fZ3Jv' 'Rlcl91c2VyX2Rpc2NvdmVyeV92ZXJzaW9uQhwKGl9hc2tfZm9yX2ZyaWVuZF9wcm9tb3Rpb25z'
'dXBfdXBkYXRlQhoKGF9yZXNlbmRfZ3JvdXBfcHVibGljX2tleUIRCg9fZXJyb3JfbWVzc2FnZX' 'QhEKD19tZXNzYWdlX3VwZGF0ZUIICgZfbWVkaWFCDwoNX21lZGlhX3VwZGF0ZUIRCg9fY29udG'
'NCGgoYX2FkZGl0aW9uYWxfZGF0YV9tZXNzYWdlQhMKEV90eXBpbmdfaW5kaWNhdG9yQhkKF191' 'FjdF91cGRhdGVCEgoQX2NvbnRhY3RfcmVxdWVzdEINCgtfZmxhbWVfc3luY0IMCgpfcHVzaF9r'
'c2VyX2Rpc2NvdmVyeV9yZXF1ZXN0QhgKFl91c2VyX2Rpc2NvdmVyeV91cGRhdGVCGQoXX2tleV' 'ZXlzQgsKCV9yZWFjdGlvbkIPCg1fdGV4dF9tZXNzYWdlQg8KDV9ncm91cF9jcmVhdGVCDQoLX2'
'92ZXJpZmljYXRpb25fcHJvb2Y='); 'dyb3VwX2pvaW5CDwoNX2dyb3VwX3VwZGF0ZUIaChhfcmVzZW5kX2dyb3VwX3B1YmxpY19rZXlC'
'EQoPX2Vycm9yX21lc3NhZ2VzQhoKGF9hZGRpdGlvbmFsX2RhdGFfbWVzc2FnZUITChFfdHlwaW'
'5nX2luZGljYXRvckIZChdfdXNlcl9kaXNjb3ZlcnlfcmVxdWVzdEIYChZfdXNlcl9kaXNjb3Zl'
'cnlfdXBkYXRlQhkKF19rZXlfdmVyaWZpY2F0aW9uX3Byb29m');

View file

@ -0,0 +1,670 @@
// This is a generated file - do not edit.
//
// Generated from types.proto.
// @dart = 3.3
// ignore_for_file: annotate_overrides, camel_case_types, comment_references
// ignore_for_file: constant_identifier_names
// ignore_for_file: curly_braces_in_flow_control_structures
// ignore_for_file: deprecated_member_use_from_same_package, library_prefixes
// ignore_for_file: non_constant_identifier_names, prefer_relative_imports
import 'dart:core' as $core;
import 'package:fixnum/fixnum.dart' as $fixnum;
import 'package:protobuf/protobuf.dart' as $pb;
export 'package:protobuf/protobuf.dart' show GeneratedMessageGenericExtensions;
/// Send from the person who tries to recover their account.
/// This can be done via a link, which will then be opend in the app of the contact.
/// The contact than has to manualy select from which user he got the request.
/// -> Using this phishing is harder, as the user has to manualy select the user to recovery
/// -> The user who wants to recover his account does not need to remember her old username
class RecoveryRequest extends $pb.GeneratedMessage {
factory RecoveryRequest({
$fixnum.Int64? tempId,
$core.List<$core.int>? publicKey,
}) {
final result = create();
if (tempId != null) result.tempId = tempId;
if (publicKey != null) result.publicKey = publicKey;
return result;
}
RecoveryRequest._();
factory RecoveryRequest.fromBuffer($core.List<$core.int> data,
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
create()..mergeFromBuffer(data, registry);
factory RecoveryRequest.fromJson($core.String json,
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
create()..mergeFromJson(json, registry);
static final $pb.BuilderInfo _i = $pb.BuilderInfo(
_omitMessageNames ? '' : 'RecoveryRequest',
package: const $pb.PackageName(
_omitMessageNames ? '' : 'passwordless_recovery'),
createEmptyInstance: create)
..aInt64(1, _omitFieldNames ? '' : 'tempId')
..a<$core.List<$core.int>>(
2, _omitFieldNames ? '' : 'publicKey', $pb.PbFieldType.OY)
..hasRequiredFields = false;
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
RecoveryRequest clone() => deepCopy();
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
RecoveryRequest copyWith(void Function(RecoveryRequest) updates) =>
super.copyWith((message) => updates(message as RecoveryRequest))
as RecoveryRequest;
@$core.override
$pb.BuilderInfo get info_ => _i;
@$core.pragma('dart2js:noInline')
static RecoveryRequest create() => RecoveryRequest._();
@$core.override
RecoveryRequest createEmptyInstance() => create();
@$core.pragma('dart2js:noInline')
static RecoveryRequest getDefault() => _defaultInstance ??=
$pb.GeneratedMessage.$_defaultFor<RecoveryRequest>(create);
static RecoveryRequest? _defaultInstance;
@$pb.TagNumber(1)
$fixnum.Int64 get tempId => $_getI64(0);
@$pb.TagNumber(1)
set tempId($fixnum.Int64 value) => $_setInt64(0, value);
@$pb.TagNumber(1)
$core.bool hasTempId() => $_has(0);
@$pb.TagNumber(1)
void clearTempId() => $_clearField(1);
@$pb.TagNumber(2)
$core.List<$core.int> get publicKey => $_getN(1);
@$pb.TagNumber(2)
set publicKey($core.List<$core.int> value) => $_setBytes(1, value);
@$pb.TagNumber(2)
$core.bool hasPublicKey() => $_has(1);
@$pb.TagNumber(2)
void clearPublicKey() => $_clearField(2);
}
/// Used as envelope for TrustedFriendShare and RecoveryData
class EncryptedEnvelope extends $pb.GeneratedMessage {
factory EncryptedEnvelope({
$core.List<$core.int>? encryptedData,
$core.List<$core.int>? iv,
$core.List<$core.int>? mac,
}) {
final result = create();
if (encryptedData != null) result.encryptedData = encryptedData;
if (iv != null) result.iv = iv;
if (mac != null) result.mac = mac;
return result;
}
EncryptedEnvelope._();
factory EncryptedEnvelope.fromBuffer($core.List<$core.int> data,
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
create()..mergeFromBuffer(data, registry);
factory EncryptedEnvelope.fromJson($core.String json,
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
create()..mergeFromJson(json, registry);
static final $pb.BuilderInfo _i = $pb.BuilderInfo(
_omitMessageNames ? '' : 'EncryptedEnvelope',
package: const $pb.PackageName(
_omitMessageNames ? '' : 'passwordless_recovery'),
createEmptyInstance: create)
..a<$core.List<$core.int>>(
1, _omitFieldNames ? '' : 'encryptedData', $pb.PbFieldType.OY)
..a<$core.List<$core.int>>(
2, _omitFieldNames ? '' : 'iv', $pb.PbFieldType.OY)
..a<$core.List<$core.int>>(
3, _omitFieldNames ? '' : 'mac', $pb.PbFieldType.OY)
..hasRequiredFields = false;
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
EncryptedEnvelope clone() => deepCopy();
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
EncryptedEnvelope copyWith(void Function(EncryptedEnvelope) updates) =>
super.copyWith((message) => updates(message as EncryptedEnvelope))
as EncryptedEnvelope;
@$core.override
$pb.BuilderInfo get info_ => _i;
@$core.pragma('dart2js:noInline')
static EncryptedEnvelope create() => EncryptedEnvelope._();
@$core.override
EncryptedEnvelope createEmptyInstance() => create();
@$core.pragma('dart2js:noInline')
static EncryptedEnvelope getDefault() => _defaultInstance ??=
$pb.GeneratedMessage.$_defaultFor<EncryptedEnvelope>(create);
static EncryptedEnvelope? _defaultInstance;
@$pb.TagNumber(1)
$core.List<$core.int> get encryptedData => $_getN(0);
@$pb.TagNumber(1)
set encryptedData($core.List<$core.int> value) => $_setBytes(0, value);
@$pb.TagNumber(1)
$core.bool hasEncryptedData() => $_has(0);
@$pb.TagNumber(1)
void clearEncryptedData() => $_clearField(1);
@$pb.TagNumber(2)
$core.List<$core.int> get iv => $_getN(1);
@$pb.TagNumber(2)
set iv($core.List<$core.int> value) => $_setBytes(1, value);
@$pb.TagNumber(2)
$core.bool hasIv() => $_has(1);
@$pb.TagNumber(2)
void clearIv() => $_clearField(2);
@$pb.TagNumber(3)
$core.List<$core.int> get mac => $_getN(2);
@$pb.TagNumber(3)
set mac($core.List<$core.int> value) => $_setBytes(2, value);
@$pb.TagNumber(3)
$core.bool hasMac() => $_has(2);
@$pb.TagNumber(3)
void clearMac() => $_clearField(3);
}
class TrustedFriendShare_User extends $pb.GeneratedMessage {
factory TrustedFriendShare_User({
$fixnum.Int64? userId,
$core.String? displayName,
$core.List<$core.int>? avatar,
}) {
final result = create();
if (userId != null) result.userId = userId;
if (displayName != null) result.displayName = displayName;
if (avatar != null) result.avatar = avatar;
return result;
}
TrustedFriendShare_User._();
factory TrustedFriendShare_User.fromBuffer($core.List<$core.int> data,
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
create()..mergeFromBuffer(data, registry);
factory TrustedFriendShare_User.fromJson($core.String json,
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
create()..mergeFromJson(json, registry);
static final $pb.BuilderInfo _i = $pb.BuilderInfo(
_omitMessageNames ? '' : 'TrustedFriendShare.User',
package: const $pb.PackageName(
_omitMessageNames ? '' : 'passwordless_recovery'),
createEmptyInstance: create)
..aInt64(1, _omitFieldNames ? '' : 'userId')
..aOS(2, _omitFieldNames ? '' : 'displayName')
..a<$core.List<$core.int>>(
3, _omitFieldNames ? '' : 'avatar', $pb.PbFieldType.OY)
..hasRequiredFields = false;
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
TrustedFriendShare_User clone() => deepCopy();
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
TrustedFriendShare_User copyWith(
void Function(TrustedFriendShare_User) updates) =>
super.copyWith((message) => updates(message as TrustedFriendShare_User))
as TrustedFriendShare_User;
@$core.override
$pb.BuilderInfo get info_ => _i;
@$core.pragma('dart2js:noInline')
static TrustedFriendShare_User create() => TrustedFriendShare_User._();
@$core.override
TrustedFriendShare_User createEmptyInstance() => create();
@$core.pragma('dart2js:noInline')
static TrustedFriendShare_User getDefault() => _defaultInstance ??=
$pb.GeneratedMessage.$_defaultFor<TrustedFriendShare_User>(create);
static TrustedFriendShare_User? _defaultInstance;
@$pb.TagNumber(1)
$fixnum.Int64 get userId => $_getI64(0);
@$pb.TagNumber(1)
set userId($fixnum.Int64 value) => $_setInt64(0, value);
@$pb.TagNumber(1)
$core.bool hasUserId() => $_has(0);
@$pb.TagNumber(1)
void clearUserId() => $_clearField(1);
@$pb.TagNumber(2)
$core.String get displayName => $_getSZ(1);
@$pb.TagNumber(2)
set displayName($core.String value) => $_setString(1, value);
@$pb.TagNumber(2)
$core.bool hasDisplayName() => $_has(1);
@$pb.TagNumber(2)
void clearDisplayName() => $_clearField(2);
@$pb.TagNumber(3)
$core.List<$core.int> get avatar => $_getN(2);
@$pb.TagNumber(3)
set avatar($core.List<$core.int> value) => $_setBytes(2, value);
@$pb.TagNumber(3)
$core.bool hasAvatar() => $_has(2);
@$pb.TagNumber(3)
void clearAvatar() => $_clearField(3);
}
/// Send from the trusted friend to
/// This is encrypted with the received public key.
class TrustedFriendShare extends $pb.GeneratedMessage {
factory TrustedFriendShare({
TrustedFriendShare_User? trustedFriend,
TrustedFriendShare_User? shareUser,
$core.int? threshold,
$core.List<$core.int>? sharedSecretData,
}) {
final result = create();
if (trustedFriend != null) result.trustedFriend = trustedFriend;
if (shareUser != null) result.shareUser = shareUser;
if (threshold != null) result.threshold = threshold;
if (sharedSecretData != null) result.sharedSecretData = sharedSecretData;
return result;
}
TrustedFriendShare._();
factory TrustedFriendShare.fromBuffer($core.List<$core.int> data,
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
create()..mergeFromBuffer(data, registry);
factory TrustedFriendShare.fromJson($core.String json,
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
create()..mergeFromJson(json, registry);
static final $pb.BuilderInfo _i = $pb.BuilderInfo(
_omitMessageNames ? '' : 'TrustedFriendShare',
package: const $pb.PackageName(
_omitMessageNames ? '' : 'passwordless_recovery'),
createEmptyInstance: create)
..aOM<TrustedFriendShare_User>(1, _omitFieldNames ? '' : 'trustedFriend',
subBuilder: TrustedFriendShare_User.create)
..aOM<TrustedFriendShare_User>(2, _omitFieldNames ? '' : 'shareUser',
subBuilder: TrustedFriendShare_User.create)
..aI(3, _omitFieldNames ? '' : 'threshold')
..a<$core.List<$core.int>>(
4, _omitFieldNames ? '' : 'sharedSecretData', $pb.PbFieldType.OY)
..hasRequiredFields = false;
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
TrustedFriendShare clone() => deepCopy();
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
TrustedFriendShare copyWith(void Function(TrustedFriendShare) updates) =>
super.copyWith((message) => updates(message as TrustedFriendShare))
as TrustedFriendShare;
@$core.override
$pb.BuilderInfo get info_ => _i;
@$core.pragma('dart2js:noInline')
static TrustedFriendShare create() => TrustedFriendShare._();
@$core.override
TrustedFriendShare createEmptyInstance() => create();
@$core.pragma('dart2js:noInline')
static TrustedFriendShare getDefault() => _defaultInstance ??=
$pb.GeneratedMessage.$_defaultFor<TrustedFriendShare>(create);
static TrustedFriendShare? _defaultInstance;
/// This allows to display the user which user has send him his recovery data.
@$pb.TagNumber(1)
TrustedFriendShare_User get trustedFriend => $_getN(0);
@$pb.TagNumber(1)
set trustedFriend(TrustedFriendShare_User value) => $_setField(1, value);
@$pb.TagNumber(1)
$core.bool hasTrustedFriend() => $_has(0);
@$pb.TagNumber(1)
void clearTrustedFriend() => $_clearField(1);
@$pb.TagNumber(1)
TrustedFriendShare_User ensureTrustedFriend() => $_ensure(0);
/// This allows to display the userdata, showing that he is recovering the correct person.
@$pb.TagNumber(2)
TrustedFriendShare_User get shareUser => $_getN(1);
@$pb.TagNumber(2)
set shareUser(TrustedFriendShare_User value) => $_setField(2, value);
@$pb.TagNumber(2)
$core.bool hasShareUser() => $_has(1);
@$pb.TagNumber(2)
void clearShareUser() => $_clearField(2);
@$pb.TagNumber(2)
TrustedFriendShare_User ensureShareUser() => $_ensure(1);
/// The minimum threshold required to decrypte the shares.
@$pb.TagNumber(3)
$core.int get threshold => $_getIZ(2);
@$pb.TagNumber(3)
set threshold($core.int value) => $_setSignedInt32(2, value);
@$pb.TagNumber(3)
$core.bool hasThreshold() => $_has(2);
@$pb.TagNumber(3)
void clearThreshold() => $_clearField(3);
/// The actual share which will become: SharedSecretData
@$pb.TagNumber(4)
$core.List<$core.int> get sharedSecretData => $_getN(3);
@$pb.TagNumber(4)
set sharedSecretData($core.List<$core.int> value) => $_setBytes(3, value);
@$pb.TagNumber(4)
$core.bool hasSharedSecretData() => $_has(3);
@$pb.TagNumber(4)
void clearSharedSecretData() => $_clearField(4);
}
class SharedSecretData_SecondFactorPin extends $pb.GeneratedMessage {
factory SharedSecretData_SecondFactorPin({
$core.List<$core.int>? unlockToken,
$core.List<$core.int>? pinSeed,
}) {
final result = create();
if (unlockToken != null) result.unlockToken = unlockToken;
if (pinSeed != null) result.pinSeed = pinSeed;
return result;
}
SharedSecretData_SecondFactorPin._();
factory SharedSecretData_SecondFactorPin.fromBuffer(
$core.List<$core.int> data,
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
create()..mergeFromBuffer(data, registry);
factory SharedSecretData_SecondFactorPin.fromJson($core.String json,
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
create()..mergeFromJson(json, registry);
static final $pb.BuilderInfo _i = $pb.BuilderInfo(
_omitMessageNames ? '' : 'SharedSecretData.SecondFactorPin',
package: const $pb.PackageName(
_omitMessageNames ? '' : 'passwordless_recovery'),
createEmptyInstance: create)
..a<$core.List<$core.int>>(
1, _omitFieldNames ? '' : 'unlockToken', $pb.PbFieldType.OY)
..a<$core.List<$core.int>>(
2, _omitFieldNames ? '' : 'pinSeed', $pb.PbFieldType.OY)
..hasRequiredFields = false;
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
SharedSecretData_SecondFactorPin clone() => deepCopy();
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
SharedSecretData_SecondFactorPin copyWith(
void Function(SharedSecretData_SecondFactorPin) updates) =>
super.copyWith(
(message) => updates(message as SharedSecretData_SecondFactorPin))
as SharedSecretData_SecondFactorPin;
@$core.override
$pb.BuilderInfo get info_ => _i;
@$core.pragma('dart2js:noInline')
static SharedSecretData_SecondFactorPin create() =>
SharedSecretData_SecondFactorPin._();
@$core.override
SharedSecretData_SecondFactorPin createEmptyInstance() => create();
@$core.pragma('dart2js:noInline')
static SharedSecretData_SecondFactorPin getDefault() => _defaultInstance ??=
$pb.GeneratedMessage.$_defaultFor<SharedSecretData_SecondFactorPin>(
create);
static SharedSecretData_SecondFactorPin? _defaultInstance;
/// Required to try the PIN to get the share from the server.
/// This prevents that someone else can lock the pin, as the server only
/// allows 3 tries then after 1 day again 3 tries until the key is deleted.
@$pb.TagNumber(1)
$core.List<$core.int> get unlockToken => $_getN(0);
@$pb.TagNumber(1)
set unlockToken($core.List<$core.int> value) => $_setBytes(0, value);
@$pb.TagNumber(1)
$core.bool hasUnlockToken() => $_has(0);
@$pb.TagNumber(1)
void clearUnlockToken() => $_clearField(1);
/// This never is send to the server but used to hash the pin before sending it to the server.
/// This prevents that the server every knows the shot 4-diget PIN.
@$pb.TagNumber(2)
$core.List<$core.int> get pinSeed => $_getN(1);
@$pb.TagNumber(2)
set pinSeed($core.List<$core.int> value) => $_setBytes(1, value);
@$pb.TagNumber(2)
$core.bool hasPinSeed() => $_has(1);
@$pb.TagNumber(2)
void clearPinSeed() => $_clearField(2);
}
class SharedSecretData_SecondFactorMail extends $pb.GeneratedMessage {
factory SharedSecretData_SecondFactorMail() => create();
SharedSecretData_SecondFactorMail._();
factory SharedSecretData_SecondFactorMail.fromBuffer(
$core.List<$core.int> data,
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
create()..mergeFromBuffer(data, registry);
factory SharedSecretData_SecondFactorMail.fromJson($core.String json,
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
create()..mergeFromJson(json, registry);
static final $pb.BuilderInfo _i = $pb.BuilderInfo(
_omitMessageNames ? '' : 'SharedSecretData.SecondFactorMail',
package: const $pb.PackageName(
_omitMessageNames ? '' : 'passwordless_recovery'),
createEmptyInstance: create)
..hasRequiredFields = false;
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
SharedSecretData_SecondFactorMail clone() => deepCopy();
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
SharedSecretData_SecondFactorMail copyWith(
void Function(SharedSecretData_SecondFactorMail) updates) =>
super.copyWith((message) =>
updates(message as SharedSecretData_SecondFactorMail))
as SharedSecretData_SecondFactorMail;
@$core.override
$pb.BuilderInfo get info_ => _i;
@$core.pragma('dart2js:noInline')
static SharedSecretData_SecondFactorMail create() =>
SharedSecretData_SecondFactorMail._();
@$core.override
SharedSecretData_SecondFactorMail createEmptyInstance() => create();
@$core.pragma('dart2js:noInline')
static SharedSecretData_SecondFactorMail getDefault() => _defaultInstance ??=
$pb.GeneratedMessage.$_defaultFor<SharedSecretData_SecondFactorMail>(
create);
static SharedSecretData_SecondFactorMail? _defaultInstance;
}
/// After received all shares this is decrypted by the user restoring its own
class SharedSecretData extends $pb.GeneratedMessage {
factory SharedSecretData({
RecoveryData? recoveryData,
SharedSecretData_SecondFactorMail? secondFactorMail,
SharedSecretData_SecondFactorPin? secondFactorPin,
$core.List<$core.int>? recoveryDataEncrypted,
}) {
final result = create();
if (recoveryData != null) result.recoveryData = recoveryData;
if (secondFactorMail != null) result.secondFactorMail = secondFactorMail;
if (secondFactorPin != null) result.secondFactorPin = secondFactorPin;
if (recoveryDataEncrypted != null)
result.recoveryDataEncrypted = recoveryDataEncrypted;
return result;
}
SharedSecretData._();
factory SharedSecretData.fromBuffer($core.List<$core.int> data,
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
create()..mergeFromBuffer(data, registry);
factory SharedSecretData.fromJson($core.String json,
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
create()..mergeFromJson(json, registry);
static final $pb.BuilderInfo _i = $pb.BuilderInfo(
_omitMessageNames ? '' : 'SharedSecretData',
package: const $pb.PackageName(
_omitMessageNames ? '' : 'passwordless_recovery'),
createEmptyInstance: create)
..aOM<RecoveryData>(1, _omitFieldNames ? '' : 'recoveryData',
subBuilder: RecoveryData.create)
..aOM<SharedSecretData_SecondFactorMail>(
2, _omitFieldNames ? '' : 'secondFactorMail',
subBuilder: SharedSecretData_SecondFactorMail.create)
..aOM<SharedSecretData_SecondFactorPin>(
3, _omitFieldNames ? '' : 'secondFactorPin',
subBuilder: SharedSecretData_SecondFactorPin.create)
..a<$core.List<$core.int>>(
4, _omitFieldNames ? '' : 'recoveryDataEncrypted', $pb.PbFieldType.OY)
..hasRequiredFields = false;
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
SharedSecretData clone() => deepCopy();
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
SharedSecretData copyWith(void Function(SharedSecretData) updates) =>
super.copyWith((message) => updates(message as SharedSecretData))
as SharedSecretData;
@$core.override
$pb.BuilderInfo get info_ => _i;
@$core.pragma('dart2js:noInline')
static SharedSecretData create() => SharedSecretData._();
@$core.override
SharedSecretData createEmptyInstance() => create();
@$core.pragma('dart2js:noInline')
static SharedSecretData getDefault() => _defaultInstance ??=
$pb.GeneratedMessage.$_defaultFor<SharedSecretData>(create);
static SharedSecretData? _defaultInstance;
/// No second factor was selected
@$pb.TagNumber(1)
RecoveryData get recoveryData => $_getN(0);
@$pb.TagNumber(1)
set recoveryData(RecoveryData value) => $_setField(1, value);
@$pb.TagNumber(1)
$core.bool hasRecoveryData() => $_has(0);
@$pb.TagNumber(1)
void clearRecoveryData() => $_clearField(1);
@$pb.TagNumber(1)
RecoveryData ensureRecoveryData() => $_ensure(0);
/// Server has
@$pb.TagNumber(2)
SharedSecretData_SecondFactorMail get secondFactorMail => $_getN(1);
@$pb.TagNumber(2)
set secondFactorMail(SharedSecretData_SecondFactorMail value) =>
$_setField(2, value);
@$pb.TagNumber(2)
$core.bool hasSecondFactorMail() => $_has(1);
@$pb.TagNumber(2)
void clearSecondFactorMail() => $_clearField(2);
@$pb.TagNumber(2)
SharedSecretData_SecondFactorMail ensureSecondFactorMail() => $_ensure(1);
@$pb.TagNumber(3)
SharedSecretData_SecondFactorPin get secondFactorPin => $_getN(2);
@$pb.TagNumber(3)
set secondFactorPin(SharedSecretData_SecondFactorPin value) =>
$_setField(3, value);
@$pb.TagNumber(3)
$core.bool hasSecondFactorPin() => $_has(2);
@$pb.TagNumber(3)
void clearSecondFactorPin() => $_clearField(3);
@$pb.TagNumber(3)
SharedSecretData_SecondFactorPin ensureSecondFactorPin() => $_ensure(2);
/// The recovery data in case a second factor was used
/// The decryption key is loaded from the server either using the PIN or the MAIL
@$pb.TagNumber(4)
$core.List<$core.int> get recoveryDataEncrypted => $_getN(3);
@$pb.TagNumber(4)
set recoveryDataEncrypted($core.List<$core.int> value) =>
$_setBytes(3, value);
@$pb.TagNumber(4)
$core.bool hasRecoveryDataEncrypted() => $_has(3);
@$pb.TagNumber(4)
void clearRecoveryDataEncrypted() => $_clearField(4);
}
/// The data which is recovered at the end.
/// The backup_master_key allows to recover the actual backup uploaded in the background to the server.
/// In case the backup is not available any more the user can use its user_id and his private_key to requister as a new user.
class RecoveryData extends $pb.GeneratedMessage {
factory RecoveryData({
$fixnum.Int64? userId,
$core.List<$core.int>? keyManager,
}) {
final result = create();
if (userId != null) result.userId = userId;
if (keyManager != null) result.keyManager = keyManager;
return result;
}
RecoveryData._();
factory RecoveryData.fromBuffer($core.List<$core.int> data,
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
create()..mergeFromBuffer(data, registry);
factory RecoveryData.fromJson($core.String json,
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
create()..mergeFromJson(json, registry);
static final $pb.BuilderInfo _i = $pb.BuilderInfo(
_omitMessageNames ? '' : 'RecoveryData',
package: const $pb.PackageName(
_omitMessageNames ? '' : 'passwordless_recovery'),
createEmptyInstance: create)
..aInt64(1, _omitFieldNames ? '' : 'userId')
..a<$core.List<$core.int>>(
3, _omitFieldNames ? '' : 'keyManager', $pb.PbFieldType.OY)
..hasRequiredFields = false;
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
RecoveryData clone() => deepCopy();
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
RecoveryData copyWith(void Function(RecoveryData) updates) =>
super.copyWith((message) => updates(message as RecoveryData))
as RecoveryData;
@$core.override
$pb.BuilderInfo get info_ => _i;
@$core.pragma('dart2js:noInline')
static RecoveryData create() => RecoveryData._();
@$core.override
RecoveryData createEmptyInstance() => create();
@$core.pragma('dart2js:noInline')
static RecoveryData getDefault() => _defaultInstance ??=
$pb.GeneratedMessage.$_defaultFor<RecoveryData>(create);
static RecoveryData? _defaultInstance;
@$pb.TagNumber(1)
$fixnum.Int64 get userId => $_getI64(0);
@$pb.TagNumber(1)
set userId($fixnum.Int64 value) => $_setInt64(0, value);
@$pb.TagNumber(1)
$core.bool hasUserId() => $_has(0);
@$pb.TagNumber(1)
void clearUserId() => $_clearField(1);
@$pb.TagNumber(3)
$core.List<$core.int> get keyManager => $_getN(1);
@$pb.TagNumber(3)
set keyManager($core.List<$core.int> value) => $_setBytes(1, value);
@$pb.TagNumber(3)
$core.bool hasKeyManager() => $_has(1);
@$pb.TagNumber(3)
void clearKeyManager() => $_clearField(3);
}
const $core.bool _omitFieldNames =
$core.bool.fromEnvironment('protobuf.omit_field_names');
const $core.bool _omitMessageNames =
$core.bool.fromEnvironment('protobuf.omit_message_names');

View file

@ -0,0 +1,11 @@
// This is a generated file - do not edit.
//
// Generated from types.proto.
// @dart = 3.3
// ignore_for_file: annotate_overrides, camel_case_types, comment_references
// ignore_for_file: constant_identifier_names
// ignore_for_file: curly_braces_in_flow_control_structures
// ignore_for_file: deprecated_member_use_from_same_package, library_prefixes
// ignore_for_file: non_constant_identifier_names, prefer_relative_imports

View file

@ -0,0 +1,195 @@
// This is a generated file - do not edit.
//
// Generated from types.proto.
// @dart = 3.3
// ignore_for_file: annotate_overrides, camel_case_types, comment_references
// ignore_for_file: constant_identifier_names
// ignore_for_file: curly_braces_in_flow_control_structures
// ignore_for_file: deprecated_member_use_from_same_package, library_prefixes
// ignore_for_file: non_constant_identifier_names, prefer_relative_imports
// ignore_for_file: unused_import
import 'dart:convert' as $convert;
import 'dart:core' as $core;
import 'dart:typed_data' as $typed_data;
@$core.Deprecated('Use recoveryRequestDescriptor instead')
const RecoveryRequest$json = {
'1': 'RecoveryRequest',
'2': [
{'1': 'temp_id', '3': 1, '4': 1, '5': 3, '10': 'tempId'},
{'1': 'public_key', '3': 2, '4': 1, '5': 12, '10': 'publicKey'},
],
};
/// Descriptor for `RecoveryRequest`. Decode as a `google.protobuf.DescriptorProto`.
final $typed_data.Uint8List recoveryRequestDescriptor = $convert.base64Decode(
'Cg9SZWNvdmVyeVJlcXVlc3QSFwoHdGVtcF9pZBgBIAEoA1IGdGVtcElkEh0KCnB1YmxpY19rZX'
'kYAiABKAxSCXB1YmxpY0tleQ==');
@$core.Deprecated('Use encryptedEnvelopeDescriptor instead')
const EncryptedEnvelope$json = {
'1': 'EncryptedEnvelope',
'2': [
{'1': 'encrypted_data', '3': 1, '4': 1, '5': 12, '10': 'encryptedData'},
{'1': 'iv', '3': 2, '4': 1, '5': 12, '10': 'iv'},
{'1': 'mac', '3': 3, '4': 1, '5': 12, '10': 'mac'},
],
};
/// Descriptor for `EncryptedEnvelope`. Decode as a `google.protobuf.DescriptorProto`.
final $typed_data.Uint8List encryptedEnvelopeDescriptor = $convert.base64Decode(
'ChFFbmNyeXB0ZWRFbnZlbG9wZRIlCg5lbmNyeXB0ZWRfZGF0YRgBIAEoDFINZW5jcnlwdGVkRG'
'F0YRIOCgJpdhgCIAEoDFICaXYSEAoDbWFjGAMgASgMUgNtYWM=');
@$core.Deprecated('Use trustedFriendShareDescriptor instead')
const TrustedFriendShare$json = {
'1': 'TrustedFriendShare',
'2': [
{
'1': 'trusted_friend',
'3': 1,
'4': 1,
'5': 11,
'6': '.passwordless_recovery.TrustedFriendShare.User',
'10': 'trustedFriend'
},
{
'1': 'share_user',
'3': 2,
'4': 1,
'5': 11,
'6': '.passwordless_recovery.TrustedFriendShare.User',
'10': 'shareUser'
},
{'1': 'threshold', '3': 3, '4': 1, '5': 5, '10': 'threshold'},
{
'1': 'shared_secret_data',
'3': 4,
'4': 1,
'5': 12,
'10': 'sharedSecretData'
},
],
'3': [TrustedFriendShare_User$json],
};
@$core.Deprecated('Use trustedFriendShareDescriptor instead')
const TrustedFriendShare_User$json = {
'1': 'User',
'2': [
{'1': 'user_id', '3': 1, '4': 1, '5': 3, '10': 'userId'},
{'1': 'display_name', '3': 2, '4': 1, '5': 9, '10': 'displayName'},
{'1': 'avatar', '3': 3, '4': 1, '5': 12, '10': 'avatar'},
],
};
/// Descriptor for `TrustedFriendShare`. Decode as a `google.protobuf.DescriptorProto`.
final $typed_data.Uint8List trustedFriendShareDescriptor = $convert.base64Decode(
'ChJUcnVzdGVkRnJpZW5kU2hhcmUSVQoOdHJ1c3RlZF9mcmllbmQYASABKAsyLi5wYXNzd29yZG'
'xlc3NfcmVjb3ZlcnkuVHJ1c3RlZEZyaWVuZFNoYXJlLlVzZXJSDXRydXN0ZWRGcmllbmQSTQoK'
'c2hhcmVfdXNlchgCIAEoCzIuLnBhc3N3b3JkbGVzc19yZWNvdmVyeS5UcnVzdGVkRnJpZW5kU2'
'hhcmUuVXNlclIJc2hhcmVVc2VyEhwKCXRocmVzaG9sZBgDIAEoBVIJdGhyZXNob2xkEiwKEnNo'
'YXJlZF9zZWNyZXRfZGF0YRgEIAEoDFIQc2hhcmVkU2VjcmV0RGF0YRpaCgRVc2VyEhcKB3VzZX'
'JfaWQYASABKANSBnVzZXJJZBIhCgxkaXNwbGF5X25hbWUYAiABKAlSC2Rpc3BsYXlOYW1lEhYK'
'BmF2YXRhchgDIAEoDFIGYXZhdGFy');
@$core.Deprecated('Use sharedSecretDataDescriptor instead')
const SharedSecretData$json = {
'1': 'SharedSecretData',
'2': [
{
'1': 'recovery_data',
'3': 1,
'4': 1,
'5': 11,
'6': '.passwordless_recovery.RecoveryData',
'9': 0,
'10': 'recoveryData',
'17': true
},
{
'1': 'second_factor_mail',
'3': 2,
'4': 1,
'5': 11,
'6': '.passwordless_recovery.SharedSecretData.SecondFactorMail',
'9': 1,
'10': 'secondFactorMail',
'17': true
},
{
'1': 'second_factor_pin',
'3': 3,
'4': 1,
'5': 11,
'6': '.passwordless_recovery.SharedSecretData.SecondFactorPin',
'9': 2,
'10': 'secondFactorPin',
'17': true
},
{
'1': 'recovery_data_encrypted',
'3': 4,
'4': 1,
'5': 12,
'9': 3,
'10': 'recoveryDataEncrypted',
'17': true
},
],
'3': [
SharedSecretData_SecondFactorPin$json,
SharedSecretData_SecondFactorMail$json
],
'8': [
{'1': '_recovery_data'},
{'1': '_second_factor_mail'},
{'1': '_second_factor_pin'},
{'1': '_recovery_data_encrypted'},
],
};
@$core.Deprecated('Use sharedSecretDataDescriptor instead')
const SharedSecretData_SecondFactorPin$json = {
'1': 'SecondFactorPin',
'2': [
{'1': 'unlock_token', '3': 1, '4': 1, '5': 12, '10': 'unlockToken'},
{'1': 'pin_seed', '3': 2, '4': 1, '5': 12, '10': 'pinSeed'},
],
};
@$core.Deprecated('Use sharedSecretDataDescriptor instead')
const SharedSecretData_SecondFactorMail$json = {
'1': 'SecondFactorMail',
};
/// Descriptor for `SharedSecretData`. Decode as a `google.protobuf.DescriptorProto`.
final $typed_data.Uint8List sharedSecretDataDescriptor = $convert.base64Decode(
'ChBTaGFyZWRTZWNyZXREYXRhEk0KDXJlY292ZXJ5X2RhdGEYASABKAsyIy5wYXNzd29yZGxlc3'
'NfcmVjb3ZlcnkuUmVjb3ZlcnlEYXRhSABSDHJlY292ZXJ5RGF0YYgBARJrChJzZWNvbmRfZmFj'
'dG9yX21haWwYAiABKAsyOC5wYXNzd29yZGxlc3NfcmVjb3ZlcnkuU2hhcmVkU2VjcmV0RGF0YS'
'5TZWNvbmRGYWN0b3JNYWlsSAFSEHNlY29uZEZhY3Rvck1haWyIAQESaAoRc2Vjb25kX2ZhY3Rv'
'cl9waW4YAyABKAsyNy5wYXNzd29yZGxlc3NfcmVjb3ZlcnkuU2hhcmVkU2VjcmV0RGF0YS5TZW'
'NvbmRGYWN0b3JQaW5IAlIPc2Vjb25kRmFjdG9yUGluiAEBEjsKF3JlY292ZXJ5X2RhdGFfZW5j'
'cnlwdGVkGAQgASgMSANSFXJlY292ZXJ5RGF0YUVuY3J5cHRlZIgBARpPCg9TZWNvbmRGYWN0b3'
'JQaW4SIQoMdW5sb2NrX3Rva2VuGAEgASgMUgt1bmxvY2tUb2tlbhIZCghwaW5fc2VlZBgCIAEo'
'DFIHcGluU2VlZBoSChBTZWNvbmRGYWN0b3JNYWlsQhAKDl9yZWNvdmVyeV9kYXRhQhUKE19zZW'
'NvbmRfZmFjdG9yX21haWxCFAoSX3NlY29uZF9mYWN0b3JfcGluQhoKGF9yZWNvdmVyeV9kYXRh'
'X2VuY3J5cHRlZA==');
@$core.Deprecated('Use recoveryDataDescriptor instead')
const RecoveryData$json = {
'1': 'RecoveryData',
'2': [
{'1': 'user_id', '3': 1, '4': 1, '5': 3, '10': 'userId'},
{'1': 'key_manager', '3': 3, '4': 1, '5': 12, '10': 'keyManager'},
],
};
/// Descriptor for `RecoveryData`. Decode as a `google.protobuf.DescriptorProto`.
final $typed_data.Uint8List recoveryDataDescriptor = $convert.base64Decode(
'CgxSZWNvdmVyeURhdGESFwoHdXNlcl9pZBgBIAEoA1IGdXNlcklkEh8KC2tleV9tYW5hZ2VyGA'
'MgASgMUgprZXlNYW5hZ2Vy');

View file

@ -37,6 +37,7 @@ message EncryptedContent {
/// This can be added, so the receiver can check weather he is up to date with the current profile /// This can be added, so the receiver can check weather he is up to date with the current profile
optional int64 sender_profile_counter = 4; optional int64 sender_profile_counter = 4;
optional bytes sender_user_discovery_version = 21; optional bytes sender_user_discovery_version = 21;
optional bool ask_for_friend_promotions = 25;
optional MessageUpdate message_update = 5; optional MessageUpdate message_update = 5;
optional Media media = 6; optional Media media = 6;
@ -63,6 +64,7 @@ message EncryptedContent {
ERROR_PROCESSING_MESSAGE_CREATED_ACCOUNT_REQUEST_INSTEAD = 0; ERROR_PROCESSING_MESSAGE_CREATED_ACCOUNT_REQUEST_INSTEAD = 0;
UNKNOWN_MESSAGE_TYPE = 2; UNKNOWN_MESSAGE_TYPE = 2;
SESSION_OUT_OF_SYNC = 3; SESSION_OUT_OF_SYNC = 3;
GROUP_NOT_FOUND_OR_NOT_A_MEMBER = 4;
} }
Type type = 1; Type type = 1;
string related_receipt_id = 2; string related_receipt_id = 2;
@ -72,6 +74,7 @@ message EncryptedContent {
// key for the state stored on the server // key for the state stored on the server
bytes state_key = 3; bytes state_key = 3;
bytes group_public_key = 4; bytes group_public_key = 4;
optional string group_name = 5;
} }
message GroupJoin { message GroupJoin {

View file

@ -2,8 +2,11 @@ import 'package:clock/clock.dart' show clock;
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:twonly/locator.dart'; import 'package:twonly/locator.dart';
import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/protobuf/client/generated/data.pb.dart'
as pb_data;
import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart'; import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart';
import 'package:twonly/src/services/api/utils.api.dart'; import 'package:twonly/src/services/api/utils.api.dart';
import 'package:twonly/src/services/key_verification.service.dart';
import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/log.dart';
Future<void> handleAdditionalDataMessage( Future<void> handleAdditionalDataMessage(
@ -28,6 +31,25 @@ Future<void> handleAdditionalDataMessage(
return; return;
} }
try {
final additionalData = pb_data.AdditionalMessageData.fromBuffer(
message.additionalMessageData,
);
if (additionalData.type == pb_data.AdditionalMessageData_Type.CONTACTS) {
for (final sharedContact in additionalData.contacts) {
await KeyVerificationService.verifySharedContact(
contactId: sharedContact.userId.toInt(),
sharedPublicIdentityKey: sharedContact.publicIdentityKey,
senderId: fromUserId,
);
}
}
} catch (e) {
Log.error(
'Failed to parse additional message data or verify shared contacts: $e',
);
}
final msg = await twonlyDB.messagesDao.insertMessage( final msg = await twonlyDB.messagesDao.insertMessage(
MessagesCompanion( MessagesCompanion(
messageId: Value(message.senderMessageId), messageId: Value(message.senderMessageId),
@ -46,6 +68,8 @@ Future<void> handleAdditionalDataMessage(
fromTimestamp(message.timestamp), fromTimestamp(message.timestamp),
); );
if (msg != null) { if (msg != null) {
Log.info('[$receiptId] Inserted a new text message with ID: ${msg.messageId}'); Log.info(
'[$receiptId] Inserted a new text message with ID: ${msg.messageId}',
);
} }
} }

View file

@ -1,15 +1,22 @@
import 'package:clock/clock.dart'; import 'package:clock/clock.dart';
import 'package:drift/drift.dart' show Value; import 'package:drift/drift.dart' show Value;
import 'package:fixnum/fixnum.dart' show Int64;
import 'package:libsignal_protocol_dart/libsignal_protocol_dart.dart'
show IdentityKeyPair;
import 'package:twonly/locator.dart'; import 'package:twonly/locator.dart';
import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/protobuf/client/generated/messages.pbserver.dart'; import 'package:twonly/src/model/protobuf/client/generated/messages.pbserver.dart';
import 'package:twonly/src/services/api/messages.api.dart'
show sendCipherText, tryToSendCompleteMessage;
import 'package:twonly/src/services/group.service.dart' show fetchGroupState;
import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/log.dart';
Future<void> handleErrorMessage( Future<void> handleErrorMessage(
int fromUserId, int fromUserId,
EncryptedContent_ErrorMessages error, EncryptedContent_ErrorMessages error,
String receiptId, String receiptId, {
) async { String? groupId,
}) async {
Log.error('[$receiptId] Got error from $fromUserId: $error'); Log.error('[$receiptId] Got error from $fromUserId: $error');
switch (error.type) { switch (error.type) {
@ -29,6 +36,59 @@ Future<void> handleErrorMessage(
); );
case EncryptedContent_ErrorMessages_Type.SESSION_OUT_OF_SYNC: case EncryptedContent_ErrorMessages_Type.SESSION_OUT_OF_SYNC:
break; // The other user initiated a new signal session, so ignore the error in this case, as the new session works... break; // The other user initiated a new signal session, so ignore the error in this case, as the new session works...
case EncryptedContent_ErrorMessages_Type.GROUP_NOT_FOUND_OR_NOT_A_MEMBER:
if (groupId == null) {
Log.error(
'[$receiptId] GROUP_NOT_FOUND_OR_NOT_A_MEMBER error received, but groupId is null.',
);
return;
}
final group = await twonlyDB.groupsDao.getGroup(groupId);
if (group == null) {
Log.error(
'[$receiptId] GROUP_NOT_FOUND_OR_NOT_A_MEMBER error received, but group $groupId is not found in database.',
);
return;
}
// Update group state from the server to ensure the user is still part of the group...
final updatedState = await fetchGroupState(group);
if (updatedState != null) {
final (_, state) = updatedState;
final isStillMember = state.memberIds.contains(Int64(fromUserId));
if (isStillMember) {
final keyPair = IdentityKeyPair.fromSerialized(
group.myGroupPrivateKey!,
);
await sendCipherText(
fromUserId,
EncryptedContent(
groupId: groupId,
groupCreate: EncryptedContent_GroupCreate(
stateKey: group.stateEncryptionKey,
groupPublicKey: keyPair.getPublicKey().serialize(),
),
),
);
}
final r = await twonlyDB.receiptsDao.getReceiptById(
error.relatedReceiptId,
);
if (r != null) {
await twonlyDB.receiptsDao.updateReceiptWidthUserId(
fromUserId,
error.relatedReceiptId,
ReceiptsCompanion(
markForRetry: Value(clock.now()),
retryCount: Value(r.retryCount + 1),
),
);
}
// then resend: error.relatedReceiptId
await tryToSendCompleteMessage(
receiptId: error.relatedReceiptId,
blocking: false,
);
} else {}
// ignore: no_default_cases // ignore: no_default_cases
default: default:
break; break;

View file

@ -17,7 +17,9 @@ Future<void> handleGroupCreate(
EncryptedContent_GroupCreate newGroup, EncryptedContent_GroupCreate newGroup,
String receiptId, String receiptId,
) async { ) async {
final user = await twonlyDB.contactsDao.getContactByUserId(fromUserId).getSingleOrNull(); final user = await twonlyDB.contactsDao
.getContactByUserId(fromUserId)
.getSingleOrNull();
if (user == null) { if (user == null) {
// Only contacts can invite other contacts, so this can (via the UI) not happen. // Only contacts can invite other contacts, so this can (via the UI) not happen.
Log.error( Log.error(
@ -43,11 +45,26 @@ Future<void> handleGroupCreate(
stateVersionId: const Value(0), stateVersionId: const Value(0),
stateEncryptionKey: Value(Uint8List.fromList(newGroup.stateKey)), stateEncryptionKey: Value(Uint8List.fromList(newGroup.stateKey)),
myGroupPrivateKey: Value(myGroupKey.serialize()), myGroupPrivateKey: Value(myGroupKey.serialize()),
groupName: const Value(''), groupName: Value(newGroup.hasGroupName() ? newGroup.groupName : ''),
joinedGroup: const Value(false), joinedGroup: const Value(false),
), ),
); );
} else { } else {
// In this case make a group state update and check if the fromUserId is still a admin. otherwise return with an log error message
final updatedState = await fetchGroupState(group);
if (updatedState == null) {
Log.error(
'[$receiptId] Received group invite/create for $groupId, but failed to fetch group state from server.',
);
return;
}
final (_, state) = updatedState;
if (!state.adminIds.any((id) => id.toInt() == fromUserId)) {
Log.error(
'[$receiptId] Received group invite/create for $groupId from $fromUserId, but they are not an admin of this group.',
);
return;
}
// User was already in the group, so update leftGroup back to false // User was already in the group, so update leftGroup back to false
await twonlyDB.groupsDao.updateGroup( await twonlyDB.groupsDao.updateGroup(
groupId, groupId,
@ -55,7 +72,6 @@ Future<void> handleGroupCreate(
stateVersionId: const Value(0), stateVersionId: const Value(0),
stateEncryptionKey: Value(Uint8List.fromList(newGroup.stateKey)), stateEncryptionKey: Value(Uint8List.fromList(newGroup.stateKey)),
myGroupPrivateKey: Value(myGroupKey.serialize()), myGroupPrivateKey: Value(myGroupKey.serialize()),
groupName: const Value(''),
joinedGroup: const Value(false), joinedGroup: const Value(false),
leftGroup: const Value(false), leftGroup: const Value(false),
deletedContent: const Value(false), deletedContent: const Value(false),
@ -79,18 +95,8 @@ Future<void> handleGroupCreate(
), ),
); );
await twonlyDB.groupsDao.insertOrUpdateGroupMember( // Load group members from the server as this is the single source of truth.
GroupMembersCompanion( // This can be done in the background, so the WebSocket message can be ACKed.
groupId: Value(groupId),
contactId: Value(fromUserId),
memberState: const Value(
MemberState.admin, // is the group creator, so must be admin...
),
groupPublicKey: Value(Uint8List.fromList(newGroup.groupPublicKey)),
),
);
// can be done in the background -> websocket message can be ACK
unawaited(fetchGroupStatesForUnjoinedGroups()); unawaited(fetchGroupStatesForUnjoinedGroups());
await sendCipherTextToGroup( await sendCipherTextToGroup(
@ -113,7 +119,9 @@ Future<void> handleGroupUpdate(
final actionType = groupActionTypeFromString(update.groupActionType); final actionType = groupActionTypeFromString(update.groupActionType);
if (actionType == null) { if (actionType == null) {
Log.error('[$receiptId] Group action ${update.groupActionType} is unknown ignoring.'); Log.error(
'[$receiptId] Group action ${update.groupActionType} is unknown ignoring.',
);
return; return;
} }

View file

@ -227,9 +227,16 @@ Future<void> finishStartedPreprocessing() async {
if (!service.originalPath.existsSync() && if (!service.originalPath.existsSync() &&
!service.uploadRequestPath.existsSync()) { !service.uploadRequestPath.existsSync()) {
if (service.storedPath.existsSync()) { if (service.storedPath.existsSync()) {
// media files was just stored.. // media file was stored, we can recover tempPath from storedPath and upload it.
try {
if (!service.tempPath.existsSync()) {
service.storedPath.copySync(service.tempPath.path);
}
} catch (e) {
Log.error('Error recovering tempPath from storedPath: $e');
continue; continue;
} }
} else {
if (mediaFile.reuploadRequestedBy != null) { if (mediaFile.reuploadRequestedBy != null) {
Log.warn( Log.warn(
'Reupload requested for ${mediaFile.mediaId} but files are missing. Cancelling reupload but keeping record.', 'Reupload requested for ${mediaFile.mediaId} but files are missing. Cancelling reupload but keeping record.',
@ -268,6 +275,7 @@ Future<void> finishStartedPreprocessing() async {
} }
continue; continue;
} }
}
Log.info( Log.info(
'Finishing started preprocessing of ${mediaFile.mediaId} in state ${mediaFile.uploadState}.', 'Finishing started preprocessing of ${mediaFile.mediaId} in state ${mediaFile.uploadState}.',
); );
@ -434,14 +442,38 @@ Future<void> _startBackgroundMediaUploadInternal(
if (mediaService.mediaFile.uploadState == UploadState.initialized || if (mediaService.mediaFile.uploadState == UploadState.initialized ||
mediaService.mediaFile.uploadState == UploadState.preprocessing) { mediaService.mediaFile.uploadState == UploadState.preprocessing) {
Log.info( Log.info(
'Hanlding media file ${mediaService.mediaFile.mediaId} in ${mediaService.mediaFile.uploadState}', 'Handling media file ${mediaService.mediaFile.mediaId} in ${mediaService.mediaFile.uploadState}',
); );
await mediaService.setUploadState(UploadState.preprocessing); await mediaService.setUploadState(UploadState.preprocessing);
if (!mediaService.tempPath.existsSync()) { if (!mediaService.tempPath.existsSync()) {
if (mediaService.storedPath.existsSync()) {
try {
mediaService.storedPath.copySync(mediaService.tempPath.path);
} catch (e) {
Log.error('Error copying storedPath to tempPath: $e');
}
} else {
await mediaService.compressMedia(); await mediaService.compressMedia();
}
if (!mediaService.tempPath.existsSync()) { if (!mediaService.tempPath.existsSync()) {
final messages = await twonlyDB.messagesDao.getMessagesByMediaId(
mediaService.mediaFile.mediaId,
);
if (messages.isEmpty) {
Log.warn(
'Media files ${mediaService.mediaFile.mediaId} has no original, temp, or stored path. Removing it from DB as files are not existent.',
);
await twonlyDB.mediaFilesDao.deleteMediaFile(
mediaService.mediaFile.mediaId,
);
} else {
Log.warn(
'Media files ${mediaService.mediaFile.mediaId} has no original, temp, or stored path, but messages still reference it. Marking as uploaded to stop retries.',
);
await mediaService.setUploadState(UploadState.uploaded);
}
return; return;
} }
} }

View file

@ -19,6 +19,7 @@ import 'package:twonly/src/model/protobuf/client/generated/push_notification.pb.
import 'package:twonly/src/services/notifications/pushkeys.notifications.dart'; import 'package:twonly/src/services/notifications/pushkeys.notifications.dart';
import 'package:twonly/src/services/signal/encryption.signal.dart'; import 'package:twonly/src/services/signal/encryption.signal.dart';
import 'package:twonly/src/services/signal/session.signal.dart'; import 'package:twonly/src/services/signal/session.signal.dart';
import 'package:twonly/src/services/user.service.dart' show UserService;
import 'package:twonly/src/services/user_discovery.service.dart'; import 'package:twonly/src/services/user_discovery.service.dart';
import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
@ -176,7 +177,17 @@ Future<(Uint8List, Uint8List?)?> _tryToSendCompleteMessageInternal({
Uint8List.fromList(message.encryptedContent), Uint8List.fromList(message.encryptedContent),
); );
if (cipherText == null) { if (cipherText == null) {
Log.error('Could not encrypt the message. Aborting and trying again.'); Log.error(
'[${receipt.receiptId}] Could not encrypt the message for user ${receipt.contactId}. Aborting and trying again.',
);
if (receipt.messageId != null) {
await twonlyDB.messagesDao.handleMessageAckByServer(
receipt.contactId,
receipt.messageId!,
clock.now(),
);
}
await twonlyDB.receiptsDao.deleteReceipt(receipt.receiptId);
return null; return null;
} }
message.encryptedContent = cipherText.serialize(); message.encryptedContent = cipherText.serialize();
@ -434,7 +445,7 @@ Future<(Uint8List, Uint8List?)?> sendCipherText(
final openReceipts = await twonlyDB.receiptsDao.getReceiptCountForContact( final openReceipts = await twonlyDB.receiptsDao.getReceiptCountForContact(
contactId, contactId,
); );
if (openReceipts > 6) { if (openReceipts > 10) {
// this prevents that these types of messages are send in case the receiver is offline // this prevents that these types of messages are send in case the receiver is offline
return null; return null;
} }
@ -443,6 +454,19 @@ Future<(Uint8List, Uint8List?)?> sendCipherText(
userService.currentUser.avatarCounter, userService.currentUser.avatarCounter,
); );
{
if (userService.currentUser.askForFriendPromotions) {
final contacts = await twonlyDB.contactsDao.getAllContacts();
final contactCount = contacts.where((c) => c.accepted).length;
if (contactCount > 5) {
await UserService.update((u) {
u.askForFriendPromotions = false;
});
} else {
encryptedContent.askForFriendPromotions = true;
}
}
if (userService.currentUser.isUserDiscoveryEnabled && messageId != null) { if (userService.currentUser.isUserDiscoveryEnabled && messageId != null) {
final contact = await twonlyDB.contactsDao.getContactById(contactId); final contact = await twonlyDB.contactsDao.getContactById(contactId);
if (UserDiscoveryService.isContactAllowed(contact)) { if (UserDiscoveryService.isContactAllowed(contact)) {
@ -452,6 +476,7 @@ Future<(Uint8List, Uint8List?)?> sendCipherText(
} }
} }
} }
}
final response = pb.Message() final response = pb.Message()
..type = pb.Message_Type.CIPHERTEXT ..type = pb.Message_Type.CIPHERTEXT

View file

@ -211,6 +211,8 @@ Future<void> _handleClient2ClientMessage(
type: Message_Type.CIPHERTEXT, type: Message_Type.CIPHERTEXT,
encryptedContent: encryptedContent.writeToBuffer(), encryptedContent: encryptedContent.writeToBuffer(),
); );
// Use Value.absent() for CIPHERTEXT messages so that insertReceipt generates a new UUID.
// This prevents receipt ID collisions and ensures the recipient's ACK is tracked correctly.
receiptIdDB = const Value.absent(); receiptIdDB = const Value.absent();
} else { } else {
// Message was successful processed // Message was successful processed
@ -219,8 +221,9 @@ Future<void> _handleClient2ClientMessage(
response ??= Message(type: Message_Type.SENDER_DELIVERY_RECEIPT); response ??= Message(type: Message_Type.SENDER_DELIVERY_RECEIPT);
String? targetReceiptId;
try { try {
await twonlyDB.receiptsDao.insertReceipt( final inserted = await twonlyDB.receiptsDao.insertReceipt(
ReceiptsCompanion( ReceiptsCompanion(
receiptId: receiptIdDB ?? Value(receiptId), receiptId: receiptIdDB ?? Value(receiptId),
contactId: Value(fromUserId), contactId: Value(fromUserId),
@ -228,10 +231,18 @@ Future<void> _handleClient2ClientMessage(
contactWillSendsReceipt: const Value(false), contactWillSendsReceipt: const Value(false),
), ),
); );
// Use the inserted receipt's ID because for CIPHERTEXT messages we generate a new UUID
// (receiptIdDB is Value.absent()) to avoid ID collisions and properly track individual ACKs.
targetReceiptId = inserted?.receiptId;
} catch (e) { } catch (e) {
Log.warn('[$receiptId] Error inserting receipt: $e'); Log.warn('[$receiptId] Error inserting receipt: $e');
} }
await tryToSendCompleteMessage(receiptId: receiptId, blocking: false); if (targetReceiptId != null) {
await tryToSendCompleteMessage(
receiptId: targetReceiptId,
blocking: false,
);
}
} }
case Message_Type.TEST_NOTIFICATION: case Message_Type.TEST_NOTIFICATION:
break; break;
@ -320,6 +331,16 @@ Future<(EncryptedContent?, PlaintextContent?)> handleEncryptedMessage(
); );
} }
if (content.hasAskForFriendPromotions() && content.askForFriendPromotions) {
final contact = await twonlyDB.contactsDao.getContactById(fromUserId);
if (contact != null && contact.askForFriendPromotions == null) {
await twonlyDB.contactsDao.updateContact(
fromUserId,
const ContactsCompanion(askForFriendPromotions: Value(true)),
);
}
}
if (content.hasContactRequest()) { if (content.hasContactRequest()) {
if (!await handleContactRequest( if (!await handleContactRequest(
fromUserId, fromUserId,
@ -340,6 +361,7 @@ Future<(EncryptedContent?, PlaintextContent?)> handleEncryptedMessage(
fromUserId, fromUserId,
content.errorMessages, content.errorMessages,
receiptId, receiptId,
groupId: content.hasGroupId() ? content.groupId : null,
); );
return (null, null); return (null, null);
} }
@ -420,6 +442,7 @@ Future<(EncryptedContent?, PlaintextContent?)> handleEncryptedMessage(
/// Verify that the user is (still) in that group... /// Verify that the user is (still) in that group...
if (!await twonlyDB.groupsDao.isContactInGroup(fromUserId, content.groupId)) { if (!await twonlyDB.groupsDao.isContactInGroup(fromUserId, content.groupId)) {
// Check if this is a direct chat...
if (getUUIDforDirectChat(userService.currentUser.userId, fromUserId) == if (getUUIDforDirectChat(userService.currentUser.userId, fromUserId) ==
content.groupId) { content.groupId) {
final contact = await twonlyDB.contactsDao final contact = await twonlyDB.contactsDao
@ -467,9 +490,19 @@ Future<(EncryptedContent?, PlaintextContent?)> handleEncryptedMessage(
} }
Log.error( Log.error(
'[$receiptId] User $fromUserId tried to access group ${content.groupId}.', '[$receiptId] User $fromUserId tried to access group ${content.groupId}. Sending GROUP_NOT_FOUND_OR_NOT_A_MEMBER error.',
);
return (
EncryptedContent(
groupId: content.groupId,
errorMessages: EncryptedContent_ErrorMessages(
type: EncryptedContent_ErrorMessages_Type
.GROUP_NOT_FOUND_OR_NOT_A_MEMBER,
relatedReceiptId: receiptId,
),
),
null,
); );
return (null, null);
} }
} }

View file

@ -139,6 +139,7 @@ Future<bool> createNewGroup(String groupName, List<Contact> members) async {
groupCreate: EncryptedContent_GroupCreate( groupCreate: EncryptedContent_GroupCreate(
stateKey: stateEncryptionKey, stateKey: stateEncryptionKey,
groupPublicKey: myGroupKey.getPublicKey().serialize(), groupPublicKey: myGroupKey.getPublicKey().serialize(),
groupName: group.groupName,
), ),
), ),
); );
@ -770,6 +771,7 @@ Future<bool> addNewGroupMembers(
groupCreate: EncryptedContent_GroupCreate( groupCreate: EncryptedContent_GroupCreate(
stateKey: group.stateEncryptionKey, stateKey: group.stateEncryptionKey,
groupPublicKey: keyPair.getPublicKey().serialize(), groupPublicKey: keyPair.getPublicKey().serialize(),
groupName: group.groupName,
), ),
), ),
); );

View file

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
@ -13,7 +14,7 @@ import 'package:twonly/src/services/signal/identity.signal.dart';
import 'package:twonly/src/services/signal/session.signal.dart'; import 'package:twonly/src/services/signal/session.signal.dart';
import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/snackbar.dart'; import 'package:twonly/src/visual/components/verification_success_dialog.comp.dart';
class KeyVerificationService { class KeyVerificationService {
static Future<List<int>> getNewSecretVerificationToken() async { static Future<List<int>> getNewSecretVerificationToken() async {
@ -77,12 +78,14 @@ class KeyVerificationService {
final contact = await twonlyDB.contactsDao.getContactById(fromUserId); final contact = await twonlyDB.contactsDao.getContactById(fromUserId);
final context = rootNavigatorKey.currentContext; final context = rootNavigatorKey.currentContext;
if (context != null && context.mounted && contact != null) { if (context != null && context.mounted && contact != null) {
showSnackbar( unawaited(
VerificationSuccessDialog.show(
context, context,
context.lang.secretQrTokenVerifiedSnackbar( contact,
message: context.lang.secretQrTokenVerifiedSnackbar(
getContactDisplayName(contact), getContactDisplayName(contact),
), ),
level: SnackbarLevel.success, ),
); );
} }
return; return;
@ -91,6 +94,29 @@ class KeyVerificationService {
Log.error('No valid secret token could be found...'); Log.error('No valid secret token could be found...');
} }
static Future<void> verifySharedContact({
required int contactId,
required List<int> sharedPublicIdentityKey,
required int senderId,
}) async {
final publicIdentityKey = await getPublicKeyFromContact(contactId);
if (publicIdentityKey == null) {
Log.info('No public key stored for contact $contactId');
return;
}
if (publicIdentityKey.equals(sharedPublicIdentityKey)) {
Log.info('Verified a user which was shared by a contact');
await twonlyDB.keyVerificationDao.addKeyVerification(
contactId,
VerificationType.contactSharedByVerified,
verifiedBy: senderId,
);
} else {
Log.error('Public identity keys do not match for contact $contactId');
}
}
} }
Future<List<int>> _createVerificationBytes( Future<List<int>> _createVerificationBytes(

View file

@ -158,9 +158,21 @@ Future<void> runMigrations() async {
} }
await UserService.update((u) => u.appVersion = 116); await UserService.update((u) => u.appVersion = 116);
} }
if (userService.currentUser.appVersion < 117) {
final contacts = await twonlyDB.contactsDao.getAllContacts();
final contactCount = contacts.where((c) => c.accepted).length;
await UserService.update((u) {
u.appVersion = 117;
if (contactCount > 5) {
u.askForFriendPromotions = false;
}
});
}
if (kDebugMode) { if (kDebugMode) {
assert( assert(
AppState.latestAppVersionId == 116, AppState.latestAppVersionId == 117,
'Forgot to update the target version in runMigrations() after incrementing AppState.latestAppVersionId.', 'Forgot to update the target version in runMigrations() after incrementing AppState.latestAppVersionId.',
); );
assert( assert(

View file

@ -1,11 +1,11 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'dart:math'; import 'dart:math';
import 'package:cryptography_flutter_plus/cryptography_flutter_plus.dart'; import 'package:cryptography_flutter_plus/cryptography_flutter_plus.dart';
import 'package:cryptography_plus/cryptography_plus.dart'; import 'package:cryptography_plus/cryptography_plus.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:twonly/globals.dart'; import 'package:twonly/globals.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/constants/routes.keys.dart'; import 'package:twonly/src/constants/routes.keys.dart';
import 'package:twonly/src/constants/secure_storage.keys.dart'; import 'package:twonly/src/constants/secure_storage.keys.dart';
import 'package:twonly/src/localization/generated/app_localizations.dart'; import 'package:twonly/src/localization/generated/app_localizations.dart';
@ -13,6 +13,7 @@ import 'package:twonly/src/localization/generated/app_localizations_de.dart';
import 'package:twonly/src/localization/generated/app_localizations_en.dart'; import 'package:twonly/src/localization/generated/app_localizations_en.dart';
import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart'; import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart';
import 'package:twonly/src/model/protobuf/client/generated/push_notification.pb.dart'; import 'package:twonly/src/model/protobuf/client/generated/push_notification.pb.dart';
import 'package:twonly/src/providers/routing.provider.dart';
import 'package:twonly/src/services/notifications/pushkeys.notifications.dart'; import 'package:twonly/src/services/notifications/pushkeys.notifications.dart';
import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
@ -70,69 +71,6 @@ Future<void> showPushNotificationFromServerMessages(
} }
} }
Future<void> handlePushData(String pushDataB64) async {
try {
final pushData = EncryptedPushNotification.fromBuffer(
base64.decode(pushDataB64),
);
PushNotification? pushNotification;
PushUser? foundPushUser;
if (pushData.keyId == 0) {
final key = 'InsecureOnlyUsedForAddingContact'.codeUnits;
pushNotification = await tryDecryptMessage(key, pushData);
} else {
final pushUsers = await getPushKeys(SecureStorageKeys.receivingPushKeys);
for (final pushUser in pushUsers) {
for (final key in pushUser.pushKeys) {
if (key.id == pushData.keyId) {
pushNotification = await tryDecryptMessage(key.key, pushData);
if (pushNotification != null) {
foundPushUser = pushUser;
break;
}
}
}
// found correct key and user
if (foundPushUser != null) break;
}
}
if (pushNotification != null) {
if (pushNotification.kind == PushKind.TEST_NOTIFICATION) {
await customLocalPushNotification(
'Test notification',
'This is a test notification.',
);
} else if (foundPushUser != null) {
if (pushNotification.hasMessageId()) {
if (isUUIDNewer(
foundPushUser.lastMessageId,
pushNotification.messageId,
)) {
Log.info(
'Got a push notification for a message which was already opened.',
);
return;
}
}
await showLocalPushNotification(foundPushUser, pushNotification);
} else {
await showLocalPushNotificationWithoutUserId(pushNotification);
}
}
} catch (e) {
Log.error(e);
final lang = getLocalizations();
await customLocalPushNotification(
lang.notificationTitleUnknown,
lang.notificationBodyUnknown,
);
}
}
Future<PushNotification?> tryDecryptMessage( Future<PushNotification?> tryDecryptMessage(
List<int> key, List<int> key,
EncryptedPushNotification push, EncryptedPushNotification push,
@ -172,6 +110,32 @@ Future<void> showLocalPushNotification(
return; return;
} }
var targetGroupId = groupId;
if (targetGroupId == null) {
try {
if (userService.isUserCreated) {
targetGroupId = getUUIDforDirectChat(
userService.currentUser.userId,
pushUser.userId.toInt(),
);
}
} catch (_) {}
}
if (targetGroupId != null) {
try {
final currentUri = routerProvider.routerDelegate.currentConfiguration.uri;
if (currentUri.path.contains(targetGroupId)) {
Log.info(
'Suppressing local push notification because chat with group $targetGroupId is currently open.',
);
return;
}
} catch (e) {
Log.error('Error checking current route: $e');
}
}
title = pushUser.displayName; title = pushUser.displayName;
body = getPushNotificationText(pushNotification); body = getPushNotificationText(pushNotification);
if (body == '') { if (body == '') {

View file

@ -0,0 +1,115 @@
import 'dart:async';
import 'dart:convert' show base64Encode, utf8;
import 'dart:typed_data';
import 'package:cryptography_flutter_plus/cryptography_flutter_plus.dart'
show FlutterChacha20;
import 'package:cryptography_plus/cryptography_plus.dart' show SecretKey;
import 'package:hashlib/hashlib.dart' show Scrypt;
import 'package:twonly/locator.dart';
import 'package:twonly/src/model/json/userdata.model.dart'
show PasswordLessRecovery;
import 'package:twonly/src/model/protobuf/client/generated/passwordless_recovery/types.pb.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart';
enum SecondFactorType { none, pin, email }
class PasswordlessRecoveryService {
static Future<bool> enablePasswordlessRecovery({
required List<int> trustedFriendIds,
required SecondFactorType secondFactorType,
required String secondFactorValue,
required int threshold,
}) async {
await twonlyDB.contactsDao.resetRecoveryDataForAllContacts();
// 2. Update the user model configuration
final config = PasswordLessRecovery();
final serverKey = getRandomUint8List(32);
Uint8List? protectedEmailServerKey;
Uint8List? pinUnlockToken;
Uint8List? protectedPin;
switch (secondFactorType) {
case SecondFactorType.email:
final emailSeed = getRandomUint8List(32);
// Store it, so the user can see it again.
config.email = secondFactorValue;
final chacha20 = FlutterChacha20.poly1305Aead();
final scrypt = Scrypt(
cost: 65536,
salt: emailSeed,
);
final protectedEmailKey = scrypt
.convert(utf8.encode(secondFactorValue))
.bytes;
// there must be a emailSeed like for the pin...
// The serverKey is encrypted with the email; this protects the server from seeing the user's email address, while
// also ensuring that the server can only send the real secret to the user's configured email, as a different
// email will result in a different secret key.
final secretBox = await chacha20.encrypt(
serverKey,
secretKey: SecretKey(
Uint8List.fromList(protectedEmailKey),
),
nonce: chacha20.newNonce(),
);
protectedEmailServerKey = EncryptedEnvelope(
encryptedData: secretBox.cipherText,
// iv: secretBox.nonce,
mac: secretBox.mac.bytes,
).writeToBuffer();
case SecondFactorType.pin:
final pinSeed = getRandomUint8List(32);
pinUnlockToken = getRandomUint8List(32);
// The pin seed is to ensure that the server does never learns the real 4-digit pin. The seed is never send to
// the server. As the pin are heavily protected against brute-forcing and will be discared by the server after X
// tries, this prevents that a malicous user trigger this deletion...
config.pinSeed = base64Encode(pinSeed);
config.pinUnlockToken = base64Encode(pinUnlockToken);
final scrypt = Scrypt(
cost: 65536,
salt: pinSeed,
);
final key = scrypt.convert(utf8.encode(secondFactorValue)).bytes;
protectedPin = key.sublist(0, 32);
case SecondFactorType.none:
}
// 3. Use the second factor and generate the shares...
// Generate the shares, and store them in the contacrs.table.
Log.info('Enabling passwordless recovery with:');
Log.info(' - Trusted Friends: $trustedFriendIds');
Log.info(' - Second Factor Type: $secondFactorType');
Log.info(' - Second Factor Value: $secondFactorValue');
Log.info(' - Threshold: $threshold');
// Use the serverKey to protect the recovery_data_encrypted
// 4. Send the data to the server:
// to th server
// protectedPin, pinUnlockToken, protectedEmailServerKey;
// 5. send to the contacts / implement the heartbeat check, and notify the user in case the hearbeath shows that to few users are active...
return true;
}
}

View file

@ -29,7 +29,7 @@ Future<CiphertextMessage?> _signalEncryptMessage(
final session = SessionCipher.fromStore(signalStore, address); final session = SessionCipher.fromStore(signalStore, address);
return await session.encrypt(plaintextContent); return await session.encrypt(plaintextContent);
} catch (e) { } catch (e) {
Log.error(e.toString()); Log.error('Could not encrypt message for target $target: $e');
return null; return null;
} }
} }

View file

@ -7,6 +7,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_svg/svg.dart'; import 'package:flutter_svg/svg.dart';
import 'package:twonly/globals.dart'; import 'package:twonly/globals.dart';
import 'package:twonly/locator.dart'; import 'package:twonly/locator.dart';
import 'package:twonly/src/utils/log.dart';
String getAvatarSvg(Uint8List avatarSvgCompressed) { String getAvatarSvg(Uint8List avatarSvgCompressed) {
return utf8.decode(gzip.decode(avatarSvgCompressed)); return utf8.decode(gzip.decode(avatarSvgCompressed));
@ -16,6 +17,7 @@ Future<void> createPushAvatars({int? forceForUserId}) async {
final contacts = await twonlyDB.contactsDao.getAllContacts(); final contacts = await twonlyDB.contactsDao.getAllContacts();
for (final contact in contacts) { for (final contact in contacts) {
try {
if (contact.avatarSvgCompressed == null) continue; if (contact.avatarSvgCompressed == null) continue;
if (forceForUserId == null) { if (forceForUserId == null) {
@ -29,7 +31,10 @@ Future<void> createPushAvatars({int? forceForUserId}) async {
final avatarSvg = getAvatarSvg(contact.avatarSvgCompressed!); final avatarSvg = getAvatarSvg(contact.avatarSvgCompressed!);
final pictureInfo = await vg.loadPicture(SvgStringLoader(avatarSvg), null); final pictureInfo = await vg.loadPicture(
SvgStringLoader(avatarSvg),
null,
);
final image = await pictureInfo.picture.toImage(270, 300); final image = await pictureInfo.picture.toImage(270, 300);
@ -38,6 +43,9 @@ Future<void> createPushAvatars({int? forceForUserId}) async {
await avatarPNGFile(contact.userId).writeAsBytes(pngBytes); await avatarPNGFile(contact.userId).writeAsBytes(pngBytes);
pictureInfo.picture.dispose(); pictureInfo.picture.dispose();
} catch (e) {
Log.error(e);
}
} }
} }
@ -52,10 +60,26 @@ File avatarPNGFile(int contactId) {
return File('${avatarsDirectory.path}/$contactId.png'); return File('${avatarsDirectory.path}/$contactId.png');
} }
Future<Uint8List> getUserAvatar() async { File currentUserAvatarFile(int avatarCounter) {
final avatarsDirectory = Directory(
'${AppEnvironment.cacheDir}/avatars',
);
if (!avatarsDirectory.existsSync()) {
avatarsDirectory.createSync(recursive: true);
}
return File('${avatarsDirectory.path}/user_$avatarCounter.png');
}
Future<String?> getUserAvatar() async {
if (userService.currentUser.avatarSvg == null) { if (userService.currentUser.avatarSvg == null) {
final data = await rootBundle.load('assets/images/default_avatar.png'); return null;
return data.buffer.asUint8List(); }
final avatarCounter = userService.currentUser.avatarCounter;
final file = currentUserAvatarFile(avatarCounter);
if (file.existsSync()) {
return file.path;
} }
final pictureInfo = await vg.loadPicture( final pictureInfo = await vg.loadPicture(
@ -68,9 +92,8 @@ Future<Uint8List> getUserAvatar() async {
final byteData = await image.toByteData(format: ui.ImageByteFormat.png); final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
final pngBytes = byteData!.buffer.asUint8List(); final pngBytes = byteData!.buffer.asUint8List();
final file = avatarPNGFile(userService.currentUser.userId) await file.writeAsBytes(pngBytes);
..writeAsBytesSync(pngBytes);
pictureInfo.picture.dispose(); pictureInfo.picture.dispose();
return file.readAsBytesSync(); return file.path;
} }

View file

@ -0,0 +1,87 @@
import 'package:flutter/material.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart';
class AddContactDialog extends StatelessWidget {
const AddContactDialog({
required this.username,
super.key,
});
final String username;
static Future<bool?> show(BuildContext context, String username) {
return showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (context) => AddContactDialog(username: username),
);
}
@override
Widget build(BuildContext context) {
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
backgroundColor: Theme.of(context).colorScheme.surface,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 20,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const AvatarIcon(
fontSize: 16,
),
const SizedBox(width: 12),
Flexible(
child: Text(
username,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18,
),
),
),
],
),
const SizedBox(height: 24),
Text(
context.lang.userFoundBody(username),
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 16,
),
),
const SizedBox(height: 28),
Row(
children: [
Expanded(
child: MyButton(
variant: MyButtonVariant.secondary,
onPressed: () => Navigator.pop(context, false),
child: Text(context.lang.cancel),
),
),
const SizedBox(width: 12),
Expanded(
child: MyButton(
onPressed: () => Navigator.pop(context, true),
child: Text(context.lang.friendSuggestionsRequest),
),
),
],
),
],
),
),
);
}
}

View file

@ -244,6 +244,7 @@ class EmojiAnimationComp extends StatelessWidget {
return Lottie.asset( return Lottie.asset(
'assets/animated_icons/${animatedIcons[emoji]}', 'assets/animated_icons/${animatedIcons[emoji]}',
repeat: repeat, repeat: repeat,
renderCache: RenderCache.raster,
); );
} else if (isOneEmoji(emoji)) { } else if (isOneEmoji(emoji)) {
return Text( return Text(

View file

@ -1,10 +1,12 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart'; import 'package:flutter_svg/svg.dart';
import 'package:twonly/locator.dart'; import 'package:twonly/locator.dart';
import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/utils/avatars.dart'; import 'package:twonly/src/utils/avatars.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:vector_graphics/vector_graphics.dart'; import 'package:vector_graphics/vector_graphics.dart';
class AvatarIcon extends StatefulWidget { class AvatarIcon extends StatefulWidget {
@ -28,7 +30,7 @@ class AvatarIcon extends StatefulWidget {
class _AvatarIconState extends State<AvatarIcon> { class _AvatarIconState extends State<AvatarIcon> {
List<Contact> _avatarContacts = []; List<Contact> _avatarContacts = [];
String? _avatarSvg; String? _myAvatarPath;
StreamSubscription<List<Contact>>? groupStream; StreamSubscription<List<Contact>>? groupStream;
StreamSubscription<List<Contact>>? contactsStream; StreamSubscription<List<Contact>>? contactsStream;
@ -64,6 +66,12 @@ class _AvatarIconState extends State<AvatarIcon> {
errorBuilder: errorBuilder, errorBuilder: errorBuilder,
); );
} }
Log.warn(
'PNG avatar file for contact ${contact.userId} does not exist. Generating in background.',
);
unawaited(createPushAvatars(forceForUserId: contact.userId));
if (contact.avatarSvgCompressed != null) { if (contact.avatarSvgCompressed != null) {
return SvgPicture.string( return SvgPicture.string(
getAvatarSvg(contact.avatarSvgCompressed!), getAvatarSvg(contact.avatarSvgCompressed!),
@ -94,19 +102,9 @@ class _AvatarIconState extends State<AvatarIcon> {
}); });
} else if (widget.myAvatar) { } else if (widget.myAvatar) {
_userSub = userService.onUserUpdated.listen((_) { _userSub = userService.onUserUpdated.listen((_) {
if (mounted) { unawaited(_updateMyAvatar());
setState(() {
if (userService.currentUser.avatarSvg != null) {
_avatarSvg = userService.currentUser.avatarSvg;
} else {
_avatarContacts = [];
}
}); });
} unawaited(_updateMyAvatar());
});
if (userService.currentUser.avatarSvg != null) {
_avatarSvg = userService.currentUser.avatarSvg;
}
} else if (widget.contactId != null) { } else if (widget.contactId != null) {
contactStream = twonlyDB.contactsDao contactStream = twonlyDB.contactsDao
.watchContact(widget.contactId!) .watchContact(widget.contactId!)
@ -120,17 +118,44 @@ class _AvatarIconState extends State<AvatarIcon> {
if (mounted) setState(() {}); if (mounted) setState(() {});
} }
Future<void> _updateMyAvatar() async {
final avatarSvg = userService.currentUser.avatarSvg;
if (avatarSvg == null) {
if (mounted) {
setState(() {
_myAvatarPath = null;
_avatarContacts = [];
});
}
return;
}
final path = await getUserAvatar();
if (mounted) {
setState(() {
_myAvatarPath = path;
});
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final proSize = (widget.fontSize == null) ? 40 : (widget.fontSize! * 2); final proSize = (widget.fontSize == null) ? 40 : (widget.fontSize! * 2);
Widget avatars = Container(); Widget avatars = Container();
if (_avatarSvg != null) { if (widget.myAvatar) {
avatars = SvgPicture.string( if (_myAvatarPath != null) {
_avatarSvg!, avatars = Image.file(
File(_myAvatarPath!),
errorBuilder: errorBuilder, errorBuilder: errorBuilder,
); );
} else {
avatars = const SvgPicture(
AssetBytesLoader('assets/images/default_avatar.svg.vec'),
);
}
} else if (_avatarContacts.length == 1) { } else if (_avatarContacts.length == 1) {
avatars = getAvatarForContact(_avatarContacts.first); avatars = getAvatarForContact(_avatarContacts.first);
} else if (_avatarContacts.length >= 2) { } else if (_avatarContacts.length >= 2) {

View file

@ -1,31 +0,0 @@
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:go_router/go_router.dart';
import 'package:twonly/src/constants/routes.keys.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/context_menu/context_menu.helper.dart';
class UserContextMenu extends StatelessWidget {
const UserContextMenu({
required this.contact,
required this.child,
super.key,
});
final Widget child;
final Contact contact;
@override
Widget build(BuildContext context) {
return ContextMenu(
items: [
ContextMenuItem(
title: context.lang.contextMenuUserProfile,
onTap: () => context.push(Routes.profileContact(contact.userId)),
icon: FontAwesomeIcons.user,
),
],
child: child,
);
}
}

View file

@ -1,5 +1,6 @@
import 'dart:typed_data'; import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:qr_flutter/qr_flutter.dart'; import 'package:qr_flutter/qr_flutter.dart';
import 'package:twonly/src/utils/avatars.dart'; import 'package:twonly/src/utils/avatars.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
@ -32,11 +33,20 @@ class _ProfileQrCodeCompState extends State<ProfileQrCodeComp> {
Future<void> _loadData() async { Future<void> _loadData() async {
final qr = await QrCodeUtils.publicProfileLink(); final qr = await QrCodeUtils.publicProfileLink();
final avatar = widget.showAvatar ? await getUserAvatar() : null; Uint8List? avatarBytes;
if (widget.showAvatar) {
final avatarPath = await getUserAvatar();
if (avatarPath != null) {
avatarBytes = await File(avatarPath).readAsBytes();
} else {
final data = await rootBundle.load('assets/images/default_avatar.png');
avatarBytes = data.buffer.asUint8List();
}
}
if (mounted) { if (mounted) {
setState(() { setState(() {
_qrCode = qr; _qrCode = qr;
_userAvatar = avatar; _userAvatar = avatarBytes;
_isLoading = false; _isLoading = false;
}); });
} }
@ -53,7 +63,6 @@ class _ProfileQrCodeCompState extends State<ProfileQrCodeComp> {
child: loaded child: loaded
? Container( ? Container(
key: const ValueKey('qr_code_container'), key: const ValueKey('qr_code_container'),
// padding: const EdgeInsets.all(3),
decoration: BoxDecoration( decoration: BoxDecoration(
color: context.color.primary, color: context.color.primary,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
@ -79,7 +88,9 @@ class _ProfileQrCodeCompState extends State<ProfileQrCodeComp> {
borderRadius: 2, borderRadius: 2,
), ),
gapless: false, gapless: false,
embeddedImage: (widget.showAvatar && _userAvatar != null) ? MemoryImage(_userAvatar!) : null, embeddedImage: (widget.showAvatar && _userAvatar != null)
? MemoryImage(_userAvatar!)
: null,
embeddedImageStyle: QrEmbeddedImageStyle( embeddedImageStyle: QrEmbeddedImageStyle(
size: const Size(60, 66), size: const Size(60, 66),
embeddedImageShape: EmbeddedImageShape.square, embeddedImageShape: EmbeddedImageShape.square,

View file

@ -87,6 +87,15 @@ class _SelectChatDeletionTimeListTitleState
deleteMessagesAfterMilliseconds: Value(selected), deleteMessagesAfterMilliseconds: Value(selected),
), ),
); );
await twonlyDB.groupsDao.insertGroupAction(
GroupHistoriesCompanion(
groupId: Value(group!.groupId),
type: const Value(GroupActionType.changeDisplayMaxTime),
newDeleteMessagesAfterMilliseconds: Value(
selected,
),
),
);
await sendCipherTextToGroup( await sendCipherTextToGroup(
group!.groupId, group!.groupId,
EncryptedContent( EncryptedContent(

View file

@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart';
import 'package:twonly/locator.dart'; import 'package:twonly/locator.dart';
import 'package:twonly/src/constants/routes.keys.dart'; import 'package:twonly/src/constants/routes.keys.dart';
import 'package:twonly/src/database/daos/key_verification.dao.dart'; import 'package:twonly/src/database/daos/key_verification.dao.dart';
import 'package:twonly/src/database/tables/contacts.table.dart';
import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/visual/components/verification_badge_info.comp.dart'; import 'package:twonly/src/visual/components/verification_badge_info.comp.dart';
import 'package:twonly/src/visual/elements/svg_icon.element.dart'; import 'package:twonly/src/visual/elements/svg_icon.element.dart';
@ -33,10 +34,17 @@ class VerificationBadgeComp extends StatefulWidget {
class _VerificationBadgeCompState extends State<VerificationBadgeComp> { class _VerificationBadgeCompState extends State<VerificationBadgeComp> {
bool _isVerified = false; bool _isVerified = false;
bool _isSharedVerified = false;
int _verifiedByTransferredTrustCount = 0; int _verifiedByTransferredTrustCount = 0;
int _sharedByVerifiedCount = 0;
int _transferredTrustBaseCount = 0;
List<(KeyVerification, Contact?)> _keyVerifications = [];
List<(Contact, DateTime)> _transferredTrust = [];
StreamSubscription<VerificationStatus>? _streamAllVerified; StreamSubscription<VerificationStatus>? _streamAllVerified;
StreamSubscription<List<KeyVerification>>? _streamContactVerification; StreamSubscription<List<(KeyVerification, Contact?)>>?
_streamContactVerification;
StreamSubscription<List<(Contact, DateTime)>>? _streamTransferredTrust; StreamSubscription<List<(Contact, DateTime)>>? _streamTransferredTrust;
@override @override
@ -45,6 +53,33 @@ class _VerificationBadgeCompState extends State<VerificationBadgeComp> {
initAsync(); initAsync();
} }
void _updateVerificationCounts() {
setState(() {
final sharedVerifications = _keyVerifications
.where(
(pair) => pair.$1.type == VerificationType.contactSharedByVerified,
)
.toList();
_isVerified = _keyVerifications.any(
(pair) => pair.$1.type != VerificationType.contactSharedByVerified,
);
_isSharedVerified = sharedVerifications.isNotEmpty;
_sharedByVerifiedCount = sharedVerifications.length;
final sharedByVerifierIds = sharedVerifications
.where((pair) => pair.$1.verifiedBy != null)
.map((pair) => pair.$1.verifiedBy!)
.toSet();
_transferredTrustBaseCount = _transferredTrust
.where((tt) => !sharedByVerifierIds.contains(tt.$1.userId))
.length;
_verifiedByTransferredTrustCount =
_sharedByVerifiedCount + _transferredTrustBaseCount;
});
}
Future<void> initAsync() async { Future<void> initAsync() async {
var group = widget.group; var group = widget.group;
var contact = widget.contact; var contact = widget.contact;
@ -64,6 +99,7 @@ class _VerificationBadgeCompState extends State<VerificationBadgeComp> {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_isVerified = false; _isVerified = false;
_isSharedVerified = false;
_verifiedByTransferredTrustCount = 0; _verifiedByTransferredTrustCount = 0;
if (update == VerificationStatus.trusted) { if (update == VerificationStatus.trusted) {
_isVerified = true; _isVerified = true;
@ -78,18 +114,16 @@ class _VerificationBadgeCompState extends State<VerificationBadgeComp> {
.watchContactVerification(contact.userId) .watchContactVerification(contact.userId)
.listen((update) { .listen((update) {
if (!mounted) return; if (!mounted) return;
setState(() { _keyVerifications = update;
_isVerified = update.isNotEmpty; _updateVerificationCounts();
});
}); });
_streamTransferredTrust = twonlyDB.keyVerificationDao _streamTransferredTrust = twonlyDB.keyVerificationDao
.watchTransferredTrustVerifications(contact.userId) .watchTransferredTrustVerifications(contact.userId)
.listen((update) { .listen((update) {
if (!mounted) return; if (!mounted) return;
setState(() { _transferredTrust = update;
_verifiedByTransferredTrustCount = update.length; _updateVerificationCounts();
});
}); });
} else if (widget.isVerifiedByTransferredTrust != null) { } else if (widget.isVerifiedByTransferredTrust != null) {
setState(() { setState(() {
@ -109,6 +143,7 @@ class _VerificationBadgeCompState extends State<VerificationBadgeComp> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (!_isVerified && if (!_isVerified &&
!_isSharedVerified &&
_verifiedByTransferredTrustCount == 0 && _verifiedByTransferredTrustCount == 0 &&
widget.showOnlyIfVerified) { widget.showOnlyIfVerified) {
return Container(); return Container();

View file

@ -23,10 +23,17 @@ class VerificationBadgeInfo extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return Column(
children: [ children: [
Text( RichText(
text: TextSpan(
children: formattedText(
context,
context.lang.verificationBadgeGeneralDesc, context.lang.verificationBadgeGeneralDesc,
),
style: const TextStyle(fontSize: 16),
),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: 30), const SizedBox(height: 30),
_buildItem( _buildItem(
context, context,

View file

@ -0,0 +1,478 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:twonly/src/visual/themes/light.dart';
import 'package:vector_graphics/vector_graphics.dart';
/// Animated chain-link logo for the "verification success" moment.
///
/// Sequence:
/// 1. Two chain links fly in from opposite sides with a bounce.
/// 2. Colour shifts from muted grey to the brand success-green.
/// 3. A scale-pulse + expanding ripple rings signal the connection.
/// 4. Sparkle particles radiate outward.
/// 5. A secure-checkmark badge pops in at the centre.
class VerificationSuccessAnimation extends StatefulWidget {
const VerificationSuccessAnimation({
super.key,
this.size = 200,
this.onComplete,
this.autoStart = true,
});
/// Logical size of the animation widget (width = height).
final double size;
/// Called once the full sequence finishes.
final VoidCallback? onComplete;
/// When `true` the animation begins automatically after the first frame.
final bool autoStart;
@override
State<VerificationSuccessAnimation> createState() =>
VerificationSuccessAnimationState();
}
class VerificationSuccessAnimationState
extends State<VerificationSuccessAnimation>
with TickerProviderStateMixin {
late final AnimationController _ctrl;
late final AnimationController _idleCtrl;
late final Animation<double> _flyIn;
late final Animation<double> _colorFade;
late final Animation<double> _pulse;
late final Animation<double> _glow;
late final Animation<double> _ripple;
late final Animation<double> _sparkle;
late final Animation<double> _badge;
late final Animation<double> _idleBadgeScale;
late final Animation<double> _chainsFade;
bool _hapticTriggered = false;
// Upper-right chain link
static const _path1 =
'M451.5 160C434.9 160 418.8 164.5 404.7 172.7C388.9 156.7 '
'370.5 143.3 350.2 133.2C378.4 109.2 414.3 96 451.5 96C537.9 '
'96 608 166 608 252.5C608 294 591.5 333.8 562.2 363.1L491.1 '
'434.2C461.8 463.5 422 480 380.5 480C294.1 480 224 410 224 '
'323.5C224 322 224 320.5 224.1 319C224.6 301.3 239.3 287.4 257 '
'287.9C274.7 288.4 288.6 303.1 288.1 320.8C288.1 321.7 288.1 '
'322.6 288.1 323.4C288.1 374.5 329.5 415.9 380.6 415.9C405.1 '
'415.9 428.6 406.2 446 388.8L517.1 317.7C534.4 300.4 544.2 '
'276.8 544.2 252.3C544.2 201.2 502.8 159.8 451.7 159.8z';
// Lower-left chain link
static const _path2 =
'M307.2 237.3C305.3 236.5 303.4 235.4 301.7 234.2C289.1 '
'227.7 274.7 224 259.6 224C235.1 224 211.6 233.7 194.2 '
'251.1L123.1 322.2C105.8 339.5 96 363.1 96 387.6C96 438.7 '
'137.4 480.1 188.5 480.1C205 480.1 221.1 475.7 235.2 '
'467.5C251 483.5 269.4 496.9 289.8 507C261.6 530.9 225.8 '
'544.2 188.5 544.2C102.1 544.2 32 474.2 32 387.7C32 346.2 '
'48.5 306.4 77.8 277.1L148.9 206C178.2 176.7 218 160.2 259.5 '
'160.2C346.1 160.2 416 230.8 416 317.1C416 318.4 416 319.7 '
'416 321C415.6 338.7 400.9 352.6 383.2 352.2C365.5 351.8 '
'351.6 337.1 352 319.4C352 318.6 352 317.9 352 317.1C352 '
'283.4 334 253.8 307.2 237.5z';
// Pre-built SVG markup (white fill colour applied via ColorFilter
// so flutter_svg can cache the parsed picture).
static const _svg1 =
'<svg viewBox="0 0 640 640"><path d="$_path1" fill="white"/></svg>';
static const _svg2 =
'<svg viewBox="0 0 640 640"><path d="$_path2" fill="white"/></svg>';
static const _grey = Color(0xFF8E9AAF);
static const Color _green = primaryColor;
@override
void initState() {
super.initState();
_ctrl = AnimationController(
duration: const Duration(milliseconds: 2800),
vsync: this,
);
// Continuous idle pulse for the badge (makes it feel alive)
_idleCtrl = AnimationController(
duration: const Duration(milliseconds: 900),
vsync: this,
)..repeat(reverse: true);
_idleBadgeScale =
Tween<double>(
begin: 0.99,
end: 1.01,
).animate(
CurvedAnimation(parent: _idleCtrl, curve: Curves.easeInOut),
);
// Stage 1 links fly in with a satisfying bounce
_flyIn = CurvedAnimation(
parent: _ctrl,
curve: const Interval(0, 0.38, curve: _SmoothBounceCurve()),
);
// Stage 2 colour shift grey green
_colorFade = CurvedAnimation(
parent: _ctrl,
curve: const Interval(0.15, 0.42, curve: Curves.easeInOut),
);
// Ambient glow ramps up around connection
_glow = CurvedAnimation(
parent: _ctrl,
curve: const Interval(0.30, 0.50, curve: Curves.easeIn),
);
// Scale-pulse on impact
_pulse = CurvedAnimation(
parent: _ctrl,
curve: const Interval(0.34, 0.46, curve: Curves.easeInOut),
);
// Expanding ripple rings
_ripple = CurvedAnimation(
parent: _ctrl,
curve: const Interval(0.36, 0.62, curve: Curves.easeOut),
);
// Sparkle particles
_sparkle = CurvedAnimation(
parent: _ctrl,
curve: const Interval(0.38, 0.68, curve: Curves.easeOut),
);
// Verification badge
_badge = CurvedAnimation(
parent: _ctrl,
curve: const Interval(0.44, 0.74, curve: Curves.elasticOut),
);
// Monotonic fade out for the chains, aligned with badge appearance
_chainsFade = CurvedAnimation(
parent: _ctrl,
curve: const Interval(0.44, 0.56, curve: Curves.easeOut),
);
_ctrl
..addListener(() {
if (_ctrl.value >= 0.44 && !_hapticTriggered) {
_hapticTriggered = true;
HapticFeedback.successNotification();
}
})
..addStatusListener((status) {
if (status == AnimationStatus.completed) {
widget.onComplete?.call();
}
});
if (widget.autoStart) {
WidgetsBinding.instance.addPostFrameCallback((_) => play());
}
}
/// Start (or replay) the full animation from the beginning.
void play() {
_hapticTriggered = false;
_ctrl.forward(from: 0);
}
@override
void dispose() {
_ctrl.dispose();
_idleCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: Listenable.merge([_ctrl, _idleCtrl]),
builder: (context, _) {
final scale = widget.size / 640;
final t = _flyIn.value; // 0 1 (fly-in progress)
final sep = 1.0 - t; // 1 0 (separation)
// Fly-in: upper-right link arrives from upper-right corner,
// lower-left link arrives from lower-left corner.
final dx = sep * 100 * scale;
final dy = sep * -60 * scale;
// Fly-in rotation: each link starts rotated ~50° from its
// interlocked position so they visually thread into each other.
final rotation = sep * 0.7;
// Interpolated colour.
final color = Color.lerp(_grey, _green, _colorFade.value)!;
// Pulse: 1 1.08 1 (smooth sine bump).
final pulseScale = 1.0 + math.sin(_pulse.value * math.pi) * 0.08;
// Chains fade out as badge appears (monotonic to avoid springy pop-in glitches).
final chainsOpacity = 1.0 - _chainsFade.value;
return SizedBox(
width: widget.size,
height: widget.size,
child: Stack(
children: [
// Ambient glow
if (_glow.value > 0)
Center(
child: Container(
width: widget.size * 0.5,
height: widget.size * 0.5,
decoration: BoxDecoration(
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: _green.withValues(alpha: 0.3 * _glow.value),
blurRadius: widget.size * 0.45 * _glow.value,
spreadRadius: widget.size * 0.08 * _glow.value,
),
],
),
),
),
// Ripple rings
if (_ripple.value > 0)
Positioned.fill(
child: CustomPaint(
painter: _RipplePainter(
progress: _ripple.value,
color: _green,
maxRadius: widget.size * 0.45,
),
),
),
// Chain links
if (chainsOpacity > 0)
Center(
child: Opacity(
opacity: chainsOpacity,
child: Transform.scale(
scale: pulseScale,
child: SizedBox(
width: widget.size,
height: widget.size,
child: Stack(
children: [
// Upper-right link pivots around (416, 288) in
// the 640×640 viewport, matching link_logo_animation.
Positioned.fill(
child: Transform(
alignment: const Alignment(
(416 * 2 / 640) - 1, // 0.3
(288 * 2 / 640) - 1, // -0.1
),
transform: Matrix4.identity()
..translateByDouble(dx, dy, 0, 1)
..rotateZ(-rotation),
child: Opacity(
opacity: (0.35 + 0.65 * t).clamp(0.0, 1.0),
child: SvgPicture.string(
_svg1,
colorFilter: ColorFilter.mode(
color,
BlendMode.srcIn,
),
),
),
),
),
// Lower-left link pivots around (224, 352).
Positioned.fill(
child: Transform(
alignment: const Alignment(
(224 * 2 / 640) - 1, // -0.3
(352 * 2 / 640) - 1, // 0.1
),
transform: Matrix4.identity()
..translateByDouble(-dx, -dy, 0, 1)
..rotateZ(-rotation),
child: Opacity(
opacity: (0.35 + 0.65 * t).clamp(0.0, 1.0),
child: SvgPicture.string(
_svg2,
colorFilter: ColorFilter.mode(
color,
BlendMode.srcIn,
),
),
),
),
),
],
),
),
),
),
),
// Sparkle particles
if (_sparkle.value > 0)
Positioned.fill(
child: CustomPaint(
painter: _SparklePainter(
progress: _sparkle.value,
color: _green,
maxRadius: widget.size * 0.4,
),
),
),
// Verification badge
if (_badge.value > 0)
Center(
child: Transform.scale(
scale: _badge.value * _idleBadgeScale.value,
child: Opacity(
opacity: _badge.value.clamp(0.0, 1.0),
child: SizedBox(
width: widget.size * 0.5,
height: widget.size * 0.5,
child: const SvgPicture(
AssetBytesLoader(
'assets/icons/verified_badge_green.svg.vec',
),
),
),
),
),
),
],
),
);
},
);
}
}
/// A custom curve that simulates a smooth, organic spring/bounce overshoot.
/// It overshoots, returns, and settles without any sharp direction changes.
class _SmoothBounceCurve extends Curve {
const _SmoothBounceCurve();
@override
double transformInternal(double t) {
// Damped harmonic oscillator equation: 1 - e^(-6*t) * cos(2.5 * pi * t)
// Starts exactly at 0.0 (t=0) and ends exactly at 1.0 (t=1).
return 1.0 - math.exp(-6.0 * t) * math.cos(2.5 * math.pi * t);
}
}
/// Draws 3 staggered, concentric ring outlines that expand and fade out.
class _RipplePainter extends CustomPainter {
const _RipplePainter({
required this.progress,
required this.color,
required this.maxRadius,
});
final double progress;
final Color color;
final double maxRadius;
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
for (var i = 0; i < 3; i++) {
final delay = i * 0.15;
final p = ((progress - delay) / (1.0 - delay)).clamp(0.0, 1.0);
if (p <= 0) continue;
final radius = p * maxRadius;
final opacity = (1.0 - p) * 0.35;
if (opacity <= 0) continue;
canvas.drawCircle(
center,
radius,
Paint()
..color = color.withValues(alpha: opacity)
..style = PaintingStyle.stroke
..strokeWidth = 2.5 * (1.0 - p) + 0.5,
);
}
}
@override
bool shouldRepaint(_RipplePainter old) => old.progress != progress;
}
/// Draws 12 small dots that radiate outward from the centre and fade away.
class _SparklePainter extends CustomPainter {
const _SparklePainter({
required this.progress,
required this.color,
required this.maxRadius,
});
final double progress;
final Color color;
final double maxRadius;
static const _outerCount = 8;
static const _innerCount = 8;
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
// Outer ring 8 larger dots
for (var i = 0; i < _outerCount; i++) {
_drawDot(
canvas,
center,
angle: (i / _outerCount) * 2 * math.pi + math.pi / 8,
radius: progress * maxRadius,
opacity: (1.0 - progress) * 0.7,
dotSize: (1.0 - progress) * 3.0 + 0.8,
);
}
// Inner ring 8 smaller dots, offset angle, shorter travel
final innerProgress = (progress * 1.15).clamp(0.0, 1.0);
for (var i = 0; i < _innerCount; i++) {
_drawDot(
canvas,
center,
angle: (i / _innerCount) * 2 * math.pi,
radius: innerProgress * maxRadius * 0.6,
opacity: (1.0 - innerProgress) * 0.5,
dotSize: (1.0 - innerProgress) * 2.0 + 0.5,
);
}
}
void _drawDot(
Canvas canvas,
Offset center, {
required double angle,
required double radius,
required double opacity,
required double dotSize,
}) {
if (opacity <= 0) return;
canvas.drawCircle(
Offset(
center.dx + math.cos(angle) * radius,
center.dy + math.sin(angle) * radius,
),
dotSize,
Paint()..color = color.withValues(alpha: opacity),
);
}
@override
bool shouldRepaint(_SparklePainter old) => old.progress != progress;
}

View file

@ -0,0 +1,86 @@
import 'package:flutter/material.dart';
import 'package:twonly/src/database/daos/contacts.dao.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
import 'package:twonly/src/visual/components/verification_success_animation.comp.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart';
/// A premium popup dialog shown when a contact's public key has been successfully verified.
class VerificationSuccessDialog extends StatelessWidget {
const VerificationSuccessDialog({
required this.contact,
this.message,
super.key,
});
final Contact contact;
final String? message;
/// Utility method to easily present this dialog.
static Future<void> show(BuildContext context, Contact contact, {String? message}) {
return showDialog<void>(
context: context,
barrierDismissible: false,
builder: (context) => VerificationSuccessDialog(contact: contact, message: message),
);
}
@override
Widget build(BuildContext context) {
final displayName = getContactDisplayName(contact);
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
backgroundColor: Theme.of(context).colorScheme.surface,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 28,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AvatarIcon(
contactId: contact.userId,
fontSize: 16,
),
const SizedBox(width: 12),
Flexible(
child: Text(
displayName,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18,
),
),
),
],
),
const SizedBox(height: 24),
const VerificationSuccessAnimation(
size: 160,
),
const SizedBox(height: 24),
Text(
message ?? context.lang.verifiedPublicKey(displayName),
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 16,
),
),
const SizedBox(height: 28),
MyButton(
onPressed: () => Navigator.pop(context),
child: Text(context.lang.close),
),
],
),
),
);
}
}

View file

@ -9,11 +9,13 @@ class ContextMenu extends StatefulWidget {
const ContextMenu({ const ContextMenu({
required this.child, required this.child,
required this.items, required this.items,
this.minWidth,
super.key, super.key,
}); });
final List<ContextMenuItem> items; final List<ContextMenuItem> items;
final Widget child; final Widget child;
final double? minWidth;
@override @override
State<ContextMenu> createState() => _ContextMenuState(); State<ContextMenu> createState() => _ContextMenuState();
@ -116,17 +118,26 @@ class _ContextMenuState extends State<ContextMenu>
), ),
items: <PopupMenuEntry<int>>[ items: <PopupMenuEntry<int>>[
...widget.items.map( ...widget.items.map(
(item) => PopupMenuItem( (item) {
padding: const EdgeInsets.only(right: 4), Widget child = ListTile(
child: ListTile(
title: Text(item.title), title: Text(item.title),
onTap: () async { onTap: () async {
if (mounted) Navigator.pop(context); if (mounted) Navigator.pop(context);
await item.onTap(); await item.onTap();
}, },
leading: _getIcon(item.icon), leading: _getIcon(item.icon),
), );
), if (widget.minWidth != null) {
child = ConstrainedBox(
constraints: BoxConstraints(minWidth: widget.minWidth!),
child: child,
);
}
return PopupMenuItem(
padding: const EdgeInsets.only(right: 4),
child: child,
);
},
), ),
], ],
position: RelativeRect.fromRect( position: RelativeRect.fromRect(

View file

@ -78,12 +78,13 @@ class GroupContextMenu extends StatelessWidget {
if (group.isDirectChat) { if (group.isDirectChat) {
await twonlyDB.groupsDao.deleteGroup(group.groupId); await twonlyDB.groupsDao.deleteGroup(group.groupId);
} else { } else {
await twonlyDB.groupsDao.updateGroup( await twonlyDB.groupsDao.deleteGroup(group.groupId);
group.groupId, // await twonlyDB.groupsDao.updateGroup(
const GroupsCompanion( // group.groupId,
deletedContent: Value(true), // const GroupsCompanion(
), // deletedContent: Value(true),
); // ),
// );
} }
} }
}, },

View file

@ -18,6 +18,7 @@ class UserContextMenu extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ContextMenu( return ContextMenu(
minWidth: 150,
items: [ items: [
ContextMenuItem( ContextMenuItem(
title: context.lang.contextMenuUserProfile, title: context.lang.contextMenuUserProfile,

View file

@ -41,7 +41,7 @@ class BetterText extends StatelessWidget {
url!.startsWith('http') ? url : 'http://$url', url!.startsWith('http') ? url : 'http://$url',
); );
try { try {
await launchUrl(lUrl); await launchUrl(lUrl, mode: LaunchMode.externalApplication);
} catch (e) { } catch (e) {
Log.error('Could not launch $e'); Log.error('Could not launch $e');
} }

View file

@ -10,6 +10,7 @@ enum MyButtonVariant {
primaryMiddle, primaryMiddle,
primaryDense, primaryDense,
secondaryDense, secondaryDense,
error,
} }
class MyButton extends StatefulWidget { class MyButton extends StatefulWidget {
@ -211,6 +212,25 @@ class _MyButtonState extends State<MyButton>
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
); );
case MyButtonVariant.error:
buttonStyle = FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.errorContainer,
foregroundColor: Theme.of(context).colorScheme.onErrorContainer,
disabledBackgroundColor: disabledBgColor,
disabledForegroundColor: disabledFgColor,
minimumSize: const Size(0, 40),
padding: const EdgeInsets.symmetric(
horizontal: 16,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 0,
textStyle: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
);
} }
final childButton = widget.variant == MyButtonVariant.text final childButton = widget.variant == MyButtonVariant.text

View file

@ -0,0 +1,145 @@
import 'package:flutter/material.dart';
import 'package:flutter/physics.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/themes/light.dart';
enum MyIconButtonVariant {
primary,
secondary,
}
class MyIconButton extends StatefulWidget {
const MyIconButton({
required this.icon,
required this.onPressed,
this.onLongPress,
this.variant = MyIconButtonVariant.primary,
super.key,
});
final Widget icon;
final VoidCallback? onPressed;
final VoidCallback? onLongPress;
final MyIconButtonVariant variant;
@override
State<MyIconButton> createState() => _MyIconButtonState();
}
class _MyIconButtonState extends State<MyIconButton>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
lowerBound: double.negativeInfinity,
upperBound: double.infinity,
value: 0,
)..addListener(() {
setState(() {});
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _onTapDown(TapDownDetails details) {
if (widget.onPressed != null || widget.onLongPress != null) {
_controller.animateTo(
1,
duration: const Duration(milliseconds: 60),
curve: Curves.easeOut,
);
}
}
void _onTapUp(TapUpDetails details) {
if (widget.onPressed != null || widget.onLongPress != null) {
_bounce();
}
}
void _onTapCancel() {
if (widget.onPressed != null || widget.onLongPress != null) {
_bounce();
}
}
void _bounce() {
const spring = SpringDescription(
mass: 1,
stiffness: 400,
damping: 15,
);
final simulation = SpringSimulation(
spring,
_controller.value,
0,
_controller.velocity,
);
_controller.animateWith(simulation);
}
@override
Widget build(BuildContext context) {
final scale = 1.0 - (_controller.value * 0.02);
final isEnabled = widget.onPressed != null || widget.onLongPress != null;
final isDark = isDarkMode(context);
final disabledBgColor = isDark
? const Color(0xFF353535)
: const Color(0xFFE0E0E0);
final disabledFgColor = isDark
? const Color(0xFF757575)
: const Color(0xFF9E9E9E);
late final Color bgColor;
late final Color fgColor;
if (widget.variant == MyIconButtonVariant.primary) {
bgColor = primaryColor;
fgColor = Colors.black87;
} else {
bgColor = isDark ? Colors.grey[800]! : Colors.grey[200]!;
fgColor = isDark ? Colors.white : Colors.black87;
}
final childButton = FilledButton(
style: FilledButton.styleFrom(
backgroundColor: bgColor,
foregroundColor: fgColor,
disabledBackgroundColor: disabledBgColor,
disabledForegroundColor: disabledFgColor,
minimumSize: const Size(72, 52),
fixedSize: const Size(72, 52),
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
),
elevation: 0,
),
onPressed: isEnabled ? () {} : null,
child: widget.icon,
);
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: isEnabled ? _onTapDown : null,
onTapUp: isEnabled ? _onTapUp : null,
onTapCancel: isEnabled ? _onTapCancel : null,
onTap: widget.onPressed,
onLongPress: widget.onLongPress,
child: Transform.scale(
scale: scale,
child: AbsorbPointer(
child: childButton,
),
),
);
}
}

View file

@ -17,6 +17,7 @@ class MyInput extends StatefulWidget {
this.autofocus = false, this.autofocus = false,
this.errorText, this.errorText,
this.obscureText = false, this.obscureText = false,
this.dense = false,
super.key, super.key,
}); });
@ -31,6 +32,7 @@ class MyInput extends StatefulWidget {
final bool autofocus; final bool autofocus;
final String? errorText; final String? errorText;
final bool obscureText; final bool obscureText;
final bool dense;
@override @override
State<MyInput> createState() => _MyInputState(); State<MyInput> createState() => _MyInputState();
@ -102,8 +104,8 @@ class _MyInputState extends State<MyInput> with SingleTickerProviderStateMixin {
: Colors.black.withValues(alpha: 0.05); : Colors.black.withValues(alpha: 0.05);
final inputBorderColor = isDark final inputBorderColor = isDark
? Colors.white.withValues(alpha: 0.15) ? Colors.white.withValues(alpha: 0.05)
: Colors.black.withValues(alpha: 0.15); : Colors.black.withValues(alpha: 0.05);
final inputHintColor = isDark final inputHintColor = isDark
? Colors.white.withValues(alpha: 0.5) ? Colors.white.withValues(alpha: 0.5)
@ -160,48 +162,50 @@ class _MyInputState extends State<MyInput> with SingleTickerProviderStateMixin {
autofocus: widget.autofocus, autofocus: widget.autofocus,
obscureText: widget.obscureText, obscureText: widget.obscureText,
style: TextStyle( style: TextStyle(
fontSize: 18, fontSize: widget.dense ? 16 : 18,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: isDark ? Colors.white : Colors.black87, color: isDark ? Colors.white : Colors.black87,
), ),
decoration: InputDecoration( decoration: InputDecoration(
isDense: widget.dense,
hintText: widget.hintText, hintText: widget.hintText,
hintStyle: TextStyle( hintStyle: TextStyle(
color: inputHintColor, color: inputHintColor,
fontSize: widget.dense ? 16 : 18,
), ),
filled: true, filled: true,
fillColor: inputFillColor, fillColor: inputFillColor,
contentPadding: const EdgeInsets.symmetric( contentPadding: EdgeInsets.symmetric(
vertical: 18, vertical: widget.dense ? 13 : 18,
horizontal: 24, horizontal: widget.dense ? 13 : 24,
), ),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(18), borderRadius: BorderRadius.circular(widget.dense ? 12 : 18),
borderSide: BorderSide( borderSide: BorderSide(
color: inputBorderColor, color: inputBorderColor,
), ),
), ),
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(18), borderRadius: BorderRadius.circular(widget.dense ? 12 : 18),
borderSide: BorderSide( borderSide: BorderSide(
color: inputBorderColor, color: inputBorderColor,
), ),
), ),
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(18), borderRadius: BorderRadius.circular(widget.dense ? 12 : 18),
borderSide: BorderSide( borderSide: BorderSide(
color: isDark ? Colors.white : Colors.black87, color: isDark ? Colors.white : Colors.black87,
width: 2, width: 2,
), ),
), ),
errorBorder: OutlineInputBorder( errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(18), borderRadius: BorderRadius.circular(widget.dense ? 12 : 18),
borderSide: const BorderSide( borderSide: const BorderSide(
color: Colors.redAccent, color: Colors.redAccent,
), ),
), ),
focusedErrorBorder: OutlineInputBorder( focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(18), borderRadius: BorderRadius.circular(widget.dense ? 12 : 18),
borderSide: const BorderSide( borderSide: const BorderSide(
color: Colors.redAccent, color: Colors.redAccent,
width: 2, width: 2,

View file

@ -1,15 +1,17 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_svg/flutter_svg.dart';
import 'package:vector_graphics/vector_graphics.dart';
class SvgIcons { class SvgIcons {
static const String verifiedGreen = 'assets/icons/verified_badge_green.svg'; static const String verifiedGreen =
static const String verifiedRed = 'assets/icons/verified_badge_red.svg'; 'assets/icons/verified_badge_green.svg.vec';
static const String verifiedRed = 'assets/icons/verified_badge_red.svg.vec';
static String verifiedNumeric(int value) { static String verifiedNumeric(int value) {
if (value >= 4) { if (value >= 4) {
return verifiedGreen; return verifiedGreen;
} }
return 'assets/icons/verification_badge_numeric/verified_badge_$value.svg'; return 'assets/icons/verification_badge_numeric/verified_badge_$value.svg.vec';
} }
} }
@ -26,13 +28,24 @@ class SvgIcon extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final filter = color != null
? ColorFilter.mode(color!, BlendMode.srcIn)
: null;
if (assetPath.endsWith('.vec')) {
return SvgPicture(
AssetBytesLoader(assetPath),
width: size,
height: size,
colorFilter: filter,
);
}
return SvgPicture.asset( return SvgPicture.asset(
assetPath, assetPath,
width: size, width: size,
height: size, height: size,
colorFilter: color != null colorFilter: filter,
? ColorFilter.mode(color!, BlendMode.srcIn)
: null,
); );
} }
} }

View file

@ -1,13 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:lottie/lottie.dart';
import 'package:twonly/src/database/daos/contacts.dao.dart'; import 'package:twonly/src/database/daos/contacts.dao.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/utils/qr.utils.dart';
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
import 'package:twonly/src/visual/components/snackbar.dart';
import 'package:twonly/src/visual/views/camera/camera_preview_components/main_camera_controller.dart'; import 'package:twonly/src/visual/views/camera/camera_preview_components/main_camera_controller.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher.dart';
class CameraScannedOverlay extends StatelessWidget { class CameraScannedOverlay extends StatelessWidget {
const CameraScannedOverlay({ const CameraScannedOverlay({
@ -27,117 +23,21 @@ class CameraScannedOverlay extends StatelessWidget {
width: 150, width: 150,
child: ListView( child: ListView(
children: [ children: [
...mainController.scannedNewProfiles.values.map( if (mainController.scannedUrl != null)
(c) => _buildScannedProfileTile(context, c), _buildScannedUrlTile(context, mainController.scannedUrl!),
),
...mainController.contactsVerified.values.map(
(c) => _buildVerifiedContactTile(context, c),
),
if (mainController.scannedUrl != null) _buildScannedUrlTile(context, mainController.scannedUrl!),
], ],
), ),
), ),
); );
} }
Widget _buildScannedProfileTile(BuildContext context, ScannedNewProfile c) {
if (c.isLoading) return Container();
return GestureDetector(
onTap: () async {
c.isLoading = true;
mainController.setState?.call();
showSnackbar(
context,
context.lang.requestedUserToastText(c.profile.username),
level: SnackbarLevel.success,
);
if (await addNewContactFromPublicProfile(c.profile) && context.mounted) {
// showSnackbar(
// context,
// context.lang.requestedUserToastText(c.profile.username),
// level: SnackbarLevel.success,
// );
}
},
child: Container(
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.only(bottom: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: context.color.surfaceContainer,
),
child: Row(
children: [
Text(c.profile.username),
Expanded(child: Container()),
if (c.isLoading)
const SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator.adaptive(strokeWidth: 2),
)
else
ColoredBox(
color: Colors.transparent,
child: FaIcon(
FontAwesomeIcons.userPlus,
color: isDarkMode(context) ? Colors.white : Colors.black,
size: 17,
),
),
],
),
),
);
}
Widget _buildVerifiedContactTile(
BuildContext context,
ScannedVerifiedContact c,
) {
return Container(
padding: const EdgeInsets.all(8),
margin: const EdgeInsets.only(bottom: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: context.color.surfaceContainer,
),
child: Row(
children: [
AvatarIcon(
contactId: c.contact.userId,
fontSize: 14,
),
const SizedBox(width: 10),
Text(
getContactDisplayName(c.contact, maxLength: 9),
),
Expanded(child: Container()),
ColoredBox(
color: Colors.transparent,
child: SizedBox(
width: 30,
child: Lottie.asset(
c.verificationOk ? 'assets/animations/success.lottie' : 'assets/animations/failed.lottie',
repeat: false,
onLoaded: (p0) {
Future.delayed(const Duration(seconds: 4), () {
mainController.setState?.call();
});
},
),
),
),
],
),
);
}
Widget _buildScannedUrlTile(BuildContext context, String url) { Widget _buildScannedUrlTile(BuildContext context, String url) {
return GestureDetector( return GestureDetector(
onTap: () { onTap: () {
launchUrlString(url); launchUrl(
Uri.parse(url),
mode: LaunchMode.externalApplication,
);
}, },
child: Container( child: Container(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),

View file

@ -742,9 +742,10 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
), ),
if (!mc.isSharePreviewIsShown && if (!mc.isSharePreviewIsShown &&
mc.sharedLinkForPreview != null && mc.sharedLinkForPreview != null &&
mc.sharedLinkForPreview!.shouldGeneratePreview &&
!mc.isVideoRecording) !mc.isVideoRecording)
ShowTitleText( ShowTitleText(
title: mc.sharedLinkForPreview?.host ?? '', title: mc.sharedLinkForPreview!.url.host,
desc: 'Link', desc: 'Link',
isLink: true, isLink: true,
), ),

View file

@ -12,13 +12,14 @@ import 'package:google_mlkit_face_detection/google_mlkit_face_detection.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:twonly/globals.dart'; import 'package:twonly/globals.dart';
import 'package:twonly/locator.dart'; import 'package:twonly/locator.dart';
import 'package:twonly/src/database/daos/contacts.dao.dart';
import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/protobuf/client/generated/qr.pb.dart'; import 'package:twonly/src/model/protobuf/client/generated/qr.pb.dart';
import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/utils/qr.utils.dart'; import 'package:twonly/src/utils/qr.utils.dart';
import 'package:twonly/src/visual/components/add_contact_dialog.comp.dart';
import 'package:twonly/src/visual/components/snackbar.dart'; import 'package:twonly/src/visual/components/snackbar.dart';
import 'package:twonly/src/visual/components/verification_success_dialog.comp.dart';
import 'package:twonly/src/visual/helpers/screenshot.helper.dart'; import 'package:twonly/src/visual/helpers/screenshot.helper.dart';
import 'package:twonly/src/visual/views/camera/camera_preview_components/camera_preview_controller_view.dart'; import 'package:twonly/src/visual/views/camera/camera_preview_components/camera_preview_controller_view.dart';
import 'package:twonly/src/visual/views/camera/camera_preview_components/face_filters.dart'; import 'package:twonly/src/visual/views/camera/camera_preview_components/face_filters.dart';
@ -27,6 +28,16 @@ import 'package:twonly/src/visual/views/camera/camera_preview_components/painter
import 'package:twonly/src/visual/views/camera/camera_preview_components/painters/face_filters/dog_filter_painter.dart'; import 'package:twonly/src/visual/views/camera/camera_preview_components/painters/face_filters/dog_filter_painter.dart';
import 'package:twonly/src/visual/views/camera/camera_preview_components/painters/face_filters/face_filter_painter.dart'; import 'package:twonly/src/visual/views/camera/camera_preview_components/painters/face_filters/face_filter_painter.dart';
class PreviewLink {
const PreviewLink({
required this.url,
required this.shouldGeneratePreview,
});
final Uri url;
final bool shouldGeneratePreview;
}
class ScannedVerifiedContact { class ScannedVerifiedContact {
ScannedVerifiedContact({required this.contact, required this.verificationOk}); ScannedVerifiedContact({required this.contact, required this.verificationOk});
Contact contact; Contact contact;
@ -57,10 +68,12 @@ class MainCameraController {
bool isVideoRecording = false; bool isVideoRecording = false;
DateTime? timeSharedLinkWasSetWithQr; DateTime? timeSharedLinkWasSetWithQr;
Uri? sharedLinkForPreview; PreviewLink? sharedLinkForPreview;
void setSharedLinkForPreview(Uri? url) { void setSharedLinkForPreview(Uri? url, {bool generatePreview = true}) {
sharedLinkForPreview = url; sharedLinkForPreview = url == null
? null
: PreviewLink(url: url, shouldGeneratePreview: generatePreview);
setState?.call(); setState?.call();
} }
@ -141,7 +154,8 @@ class MainCameraController {
if (init) { if (init) {
for (; cameraId < AppEnvironment.cameras.length; cameraId++) { for (; cameraId < AppEnvironment.cameras.length; cameraId++) {
if (AppEnvironment.cameras[cameraId].lensDirection == CameraLensDirection.back) { if (AppEnvironment.cameras[cameraId].lensDirection ==
CameraLensDirection.back) {
break; break;
} }
} }
@ -157,7 +171,9 @@ class MainCameraController {
AppEnvironment.cameras[cameraId], AppEnvironment.cameras[cameraId],
ResolutionPreset.high, ResolutionPreset.high,
enableAudio: hasMic, enableAudio: hasMic,
imageFormatGroup: Platform.isAndroid ? ImageFormatGroup.nv21 : ImageFormatGroup.bgra8888, imageFormatGroup: Platform.isAndroid
? ImageFormatGroup.nv21
: ImageFormatGroup.bgra8888,
); );
try { try {
_initializeFuture = cameraController?.initialize(); _initializeFuture = cameraController?.initialize();
@ -210,10 +226,14 @@ class MainCameraController {
selectedCameraDetails.isFlashOn ? FlashMode.always : FlashMode.off, selectedCameraDetails.isFlashOn ? FlashMode.always : FlashMode.off,
); );
if (cameraController == null) return; if (cameraController == null) return;
selectedCameraDetails.maxAvailableZoom = await cameraController?.getMaxZoomLevel() ?? 1; selectedCameraDetails.maxAvailableZoom =
selectedCameraDetails.minAvailableZoom = await cameraController?.getMinZoomLevel() ?? 1; await cameraController?.getMaxZoomLevel() ?? 1;
selectedCameraDetails.minAvailableZoom =
await cameraController?.getMinZoomLevel() ?? 1;
selectedCameraDetails selectedCameraDetails
..isZoomAble = selectedCameraDetails.maxAvailableZoom != selectedCameraDetails.minAvailableZoom ..isZoomAble =
selectedCameraDetails.maxAvailableZoom !=
selectedCameraDetails.minAvailableZoom
..cameraLoaded = true ..cameraLoaded = true
..cameraId = cameraId; ..cameraId = cameraId;
@ -235,7 +255,8 @@ class MainCameraController {
} }
Future<void> onTapDown(TapDownDetails details) async { Future<void> onTapDown(TapDownDetails details) async {
final box = cameraPreviewKey.currentContext?.findRenderObject() as RenderBox?; final box =
cameraPreviewKey.currentContext?.findRenderObject() as RenderBox?;
if (box == null) return; if (box == null) return;
final localPosition = box.globalToLocal(details.globalPosition); final localPosition = box.globalToLocal(details.globalPosition);
@ -251,7 +272,8 @@ class MainCameraController {
await cameraController?.setFocusPoint(Offset(dx, dy)); await cameraController?.setFocusPoint(Offset(dx, dy));
await cameraController?.setFocusMode(FocusMode.auto); await cameraController?.setFocusMode(FocusMode.auto);
} catch (e) { } catch (e) {
if (e is CameraException && (e.code == 'setFocusPointFailed' || e.code == 'setFocusModeFailed')) { if (e is CameraException &&
(e.code == 'setFocusPointFailed' || e.code == 'setFocusModeFailed')) {
Log.info('Focus point or mode not supported on this device'); Log.info('Focus point or mode not supported on this device');
} else { } else {
Log.warn(e); Log.warn(e);
@ -292,7 +314,8 @@ class MainCameraController {
if (inputImage == null) return; if (inputImage == null) return;
_processBarcode(inputImage); _processBarcode(inputImage);
// check if front camera is selected // check if front camera is selected
if (cameraController?.description.lensDirection == CameraLensDirection.front) { if (cameraController?.description.lensDirection ==
CameraLensDirection.front) {
if (_currentFilterType != FaceFilterType.none) { if (_currentFilterType != FaceFilterType.none) {
_processFaces(inputImage); _processFaces(inputImage);
} }
@ -311,14 +334,16 @@ class MainCameraController {
if (Platform.isIOS) { if (Platform.isIOS) {
rotation = InputImageRotationValue.fromRawValue(sensorOrientation); rotation = InputImageRotationValue.fromRawValue(sensorOrientation);
} else if (Platform.isAndroid) { } else if (Platform.isAndroid) {
var rotationCompensation = _orientations[cameraController!.value.deviceOrientation]; var rotationCompensation =
_orientations[cameraController!.value.deviceOrientation];
if (rotationCompensation == null) return null; if (rotationCompensation == null) return null;
if (camera.lensDirection == CameraLensDirection.front) { if (camera.lensDirection == CameraLensDirection.front) {
// front-facing // front-facing
rotationCompensation = (sensorOrientation + rotationCompensation) % 360; rotationCompensation = (sensorOrientation + rotationCompensation) % 360;
} else { } else {
// back-facing // back-facing
rotationCompensation = (sensorOrientation - rotationCompensation + 360) % 360; rotationCompensation =
(sensorOrientation - rotationCompensation + 360) % 360;
} }
rotation = InputImageRotationValue.fromRawValue(rotationCompensation); rotation = InputImageRotationValue.fromRawValue(rotationCompensation);
} }
@ -360,7 +385,9 @@ class MainCameraController {
if (_isBusy) return; if (_isBusy) return;
_isBusy = true; _isBusy = true;
final barcodes = await _barcodeScanner.processImage(inputImage); final barcodes = await _barcodeScanner.processImage(inputImage);
if (inputImage.metadata?.size != null && inputImage.metadata?.rotation != null && cameraController != null) { if (inputImage.metadata?.size != null &&
inputImage.metadata?.rotation != null &&
cameraController != null) {
final painter = BarcodeDetectorPainter( final painter = BarcodeDetectorPainter(
barcodes, barcodes,
inputImage.metadata!.size, inputImage.metadata!.size,
@ -397,11 +424,21 @@ class MainCameraController {
} }
if (contact == null || contact.deletedByUser) { if (contact == null || contact.deletedByUser) {
if (scannedNewProfiles[profile.userId.toInt()] == null) { final context = cameraPreviewKey.currentContext;
await HapticFeedback.heavyImpact(); if (context != null && context.mounted) {
scannedNewProfiles[profile.userId.toInt()] = ScannedNewProfile( unawaited(HapticFeedback.heavyImpact());
profile: profile, final shouldRequest = await AddContactDialog.show(
context,
profile.username,
); );
if (shouldRequest == true && context.mounted) {
showSnackbar(
context,
context.lang.requestedUserToastText(profile.username),
level: SnackbarLevel.success,
);
await addNewContactFromPublicProfile(profile);
}
} }
continue; continue;
} }
@ -412,16 +449,10 @@ class MainCameraController {
verificationOk: verificationOk, verificationOk: verificationOk,
); );
await HapticFeedback.heavyImpact(); unawaited(HapticFeedback.heavyImpact());
final context = cameraPreviewKey.currentContext; final context = cameraPreviewKey.currentContext;
if (verificationOk && context != null && context.mounted) { if (verificationOk && context != null && context.mounted) {
showSnackbar( await VerificationSuccessDialog.show(context, contact);
context,
context.lang.verifiedPublicKey(
getContactDisplayName(contact),
),
level: SnackbarLevel.success,
);
} }
} }
continue; continue;
@ -431,7 +462,7 @@ class MainCameraController {
scannedUrl = link; scannedUrl = link;
if (sharedLinkForPreview == null) { if (sharedLinkForPreview == null) {
timeSharedLinkWasSetWithQr = clock.now(); timeSharedLinkWasSetWithQr = clock.now();
setSharedLinkForPreview(Uri.parse(scannedUrl!)); setSharedLinkForPreview(Uri.parse(scannedUrl!), generatePreview: false);
} }
} }
} }
@ -444,7 +475,9 @@ class MainCameraController {
if (_isBusyFaces) return; if (_isBusyFaces) return;
_isBusyFaces = true; _isBusyFaces = true;
final faces = await _faceDetector.processImage(inputImage); final faces = await _faceDetector.processImage(inputImage);
if (inputImage.metadata?.size != null && inputImage.metadata?.rotation != null && cameraController != null) { if (inputImage.metadata?.size != null &&
inputImage.metadata?.rotation != null &&
cameraController != null) {
if (faces.isNotEmpty) { if (faces.isNotEmpty) {
CustomPainter? painter; CustomPainter? painter;
switch (_currentFilterType) { switch (_currentFilterType) {

View file

@ -51,7 +51,7 @@ class ShareImageEditorView extends StatefulWidget {
final bool sharedFromGallery; final bool sharedFromGallery;
final MediaFileService mediaFileService; final MediaFileService mediaFileService;
final MainCameraController? mainCameraController; final MainCameraController? mainCameraController;
final Uri? previewLink; final PreviewLink? previewLink;
@override @override
State<ShareImageEditorView> createState() => _ShareImageEditorView(); State<ShareImageEditorView> createState() => _ShareImageEditorView();
} }
@ -81,9 +81,9 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
layers.add(FilterLayerData(key: GlobalKey())); layers.add(FilterLayerData(key: GlobalKey()));
} }
if (widget.previewLink != null) { if (widget.previewLink != null && widget.previewLink!.shouldGeneratePreview) {
layers.add( layers.add(
LinkPreviewLayerData(key: GlobalKey(), link: widget.previewLink!), LinkPreviewLayerData(key: GlobalKey(), link: widget.previewLink!.url),
); );
} }
@ -635,7 +635,7 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
if (widget.previewLink != null) { if (widget.previewLink != null) {
additionalData = AdditionalMessageData( additionalData = AdditionalMessageData(
type: AdditionalMessageData_Type.LINK, type: AdditionalMessageData_Type.LINK,
link: widget.previewLink.toString(), link: widget.previewLink!.url.toString(),
); );
} }
return additionalData; return additionalData;

View file

@ -11,8 +11,10 @@ import 'package:twonly/locator.dart';
import 'package:twonly/src/constants/routes.keys.dart'; import 'package:twonly/src/constants/routes.keys.dart';
import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/providers/purchases.provider.dart'; import 'package:twonly/src/providers/purchases.provider.dart';
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/services/subscription.service.dart'; import 'package:twonly/src/services/subscription.service.dart';
import 'package:twonly/src/services/user.service.dart'; import 'package:twonly/src/services/user.service.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/avatar_icon.comp.dart'; import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
import 'package:twonly/src/visual/components/connection_status.comp.dart'; import 'package:twonly/src/visual/components/connection_status.comp.dart';
@ -34,6 +36,8 @@ class _ChatListViewState extends State<ChatListView> {
StreamSubscription<void>? _userSub; StreamSubscription<void>? _userSub;
StreamSubscription<List<Group>>? _contactsSub; StreamSubscription<List<Group>>? _contactsSub;
StreamSubscription<List<Contact>>? _contactsCountSub; StreamSubscription<List<Contact>>? _contactsCountSub;
StreamSubscription<List<MediaFile>>? _precacheSub;
final Set<String> _precachedMediaIds = {};
List<Group> _groupsNotPinned = []; List<Group> _groupsNotPinned = [];
List<Group> _groupsPinned = []; List<Group> _groupsPinned = [];
List<Group> _groupsArchived = []; List<Group> _groupsArchived = [];
@ -105,6 +109,24 @@ class _ChatListViewState extends State<ChatListView> {
}); });
}); });
_precacheSub = twonlyDB.messagesDao.watchUnopenedMediaFiles().listen((mediaFiles) {
if (!mounted) return;
for (final media in mediaFiles) {
if (!_precachedMediaIds.contains(media.mediaId)) {
_precachedMediaIds.add(media.mediaId);
final fileService = MediaFileService(media);
if (fileService.tempPath.existsSync()) {
precacheImage(
FileImage(fileService.tempPath),
context,
).catchError((Object e, StackTrace st) {
Log.error('Failed to precache image in ChatListView: $e\n$st');
});
}
}
}
});
WidgetsBinding.instance.addPostFrameCallback((_) async { WidgetsBinding.instance.addPostFrameCallback((_) async {
final changeLog = await rootBundle.loadString('CHANGELOG.md'); final changeLog = await rootBundle.loadString('CHANGELOG.md');
final changeLogHash = (await compute( final changeLogHash = (await compute(
@ -137,6 +159,7 @@ class _ChatListViewState extends State<ChatListView> {
_countContactRequestStream.cancel(); _countContactRequestStream.cancel();
_countAnnouncedStream.cancel(); _countAnnouncedStream.cancel();
_userSub?.cancel(); _userSub?.cancel();
_precacheSub?.cancel();
super.dispose(); super.dispose();
} }

View file

@ -1,5 +1,7 @@
import 'package:drift/drift.dart' show Value;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/services/api/messages.api.dart'; import 'package:twonly/src/services/api/messages.api.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
@ -42,6 +44,17 @@ class _ShareAdditionalViewState extends State<ShareAdditionalView> {
widget.group.groupId, widget.group.groupId,
selectedContacts, selectedContacts,
); );
if (widget.group.isDirectChat) {
final members = await twonlyDB.groupsDao.getGroupContact(
widget.group.groupId,
);
if (members.isNotEmpty) {
await twonlyDB.contactsDao.updateContact(
members.first.userId,
const ContactsCompanion(askForFriendPromotions: Value(false)),
);
}
}
} }
if (mounted) { if (mounted) {
Navigator.pop(context); Navigator.pop(context);

View file

@ -256,8 +256,8 @@ class _ChatAskAFriendEntryState extends State<ChatAskAFriendEntry> {
] else ...[ ] else ...[
StreamBuilder<Contact?>( StreamBuilder<Contact?>(
stream: twonlyDB.contactsDao.watchContact(userId), stream: twonlyDB.contactsDao.watchContact(userId),
builder: (context, snapshot) { builder: (context, contactSnapshot) {
final contactInDb = snapshot.data; final contactInDb = contactSnapshot.data;
if (contactInDb != null) { if (contactInDb != null) {
return Text( return Text(
context.lang.chatAskAFriendAddedDescription, context.lang.chatAskAFriendAddedDescription,
@ -268,6 +268,15 @@ class _ChatAskAFriendEntryState extends State<ChatAskAFriendEntry> {
); );
} }
return StreamBuilder<UserDiscoveryAnnouncedUser?>(
stream:
twonlyDB.userDiscoveryDao.watchAnnouncedUser(userId),
builder: (context, userSnapshot) {
final announcedUser = userSnapshot.data;
if (announcedUser != null && announcedUser.isHidden) {
return const SizedBox.shrink();
}
return Row( return Row(
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
@ -314,6 +323,8 @@ class _ChatAskAFriendEntryState extends State<ChatAskAFriendEntry> {
], ],
); );
}, },
);
},
), ),
], ],
], ],

View file

@ -1,17 +1,17 @@
import 'dart:convert'; import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:drift/drift.dart' show Value; import 'package:drift/drift.dart' show Value;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:twonly/locator.dart'; import 'package:twonly/locator.dart';
import 'package:twonly/src/constants/routes.keys.dart'; import 'package:twonly/src/constants/routes.keys.dart';
import 'package:twonly/src/database/tables/contacts.table.dart';
import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/protobuf/client/generated/data.pb.dart'; import 'package:twonly/src/model/protobuf/client/generated/data.pb.dart';
import 'package:twonly/src/services/api/utils.api.dart'; import 'package:twonly/src/services/api/utils.api.dart';
import 'package:twonly/src/services/key_verification.service.dart';
import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/visual/components/add_contact_dialog.comp.dart';
import 'package:twonly/src/visual/elements/better_text.element.dart'; import 'package:twonly/src/visual/elements/better_text.element.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/common.dart'; import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/common.dart';
@ -117,9 +117,23 @@ class _ContactRowState extends State<_ContactRow> {
); );
if (userdata == null) return; if (userdata == null) return;
final username = utf8.decode(userdata.username);
setState(() {
_isLoading = false;
});
if (!mounted) return;
final shouldRequest = await AddContactDialog.show(context, username);
if (shouldRequest != true) return;
setState(() {
_isLoading = true;
});
final added = await twonlyDB.contactsDao.insertOnConflictUpdate( final added = await twonlyDB.contactsDao.insertOnConflictUpdate(
ContactsCompanion( ContactsCompanion(
username: Value(utf8.decode(userdata.username)), username: Value(username),
userId: Value(userdata.userId.toInt()), userId: Value(userdata.userId.toInt()),
requested: const Value(false), requested: const Value(false),
blocked: const Value(false), blocked: const Value(false),
@ -127,21 +141,13 @@ class _ContactRowState extends State<_ContactRow> {
), ),
); );
if (userdata.publicIdentityKey.equals(widget.contact.publicIdentityKey)) {
final verified = await twonlyDB.keyVerificationDao.isContactVerified(
widget.message.senderId!,
);
if (verified) {
Log.info('Verified a user which was shared by a verified contact');
await twonlyDB.keyVerificationDao.addKeyVerification(
userdata.userId.toInt(),
VerificationType.contactSharedByVerified,
);
}
}
if (added > 0) await importSignalContactAndCreateRequest(userdata); if (added > 0) await importSignalContactAndCreateRequest(userdata);
await KeyVerificationService.verifySharedContact(
contactId: userdata.userId.toInt(),
sharedPublicIdentityKey: widget.contact.publicIdentityKey,
senderId: widget.message.senderId!,
);
} catch (e) { } catch (e) {
Log.error(e); Log.error(e);
} finally { } finally {
@ -170,7 +176,6 @@ class _ContactRowState extends State<_ContactRow> {
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 8), padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 8),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min,
children: [ children: [
const FaIcon( const FaIcon(
FontAwesomeIcons.user, FontAwesomeIcons.user,

View file

@ -14,6 +14,7 @@ import 'package:twonly/src/services/api/mediafiles/download.api.dart'
as received; as received;
import 'package:twonly/src/services/api/messages.api.dart'; import 'package:twonly/src/services/api/messages.api.dart';
import 'package:twonly/src/services/mediafiles/mediafile.service.dart'; import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/elements/better_text.element.dart'; import 'package:twonly/src/visual/elements/better_text.element.dart';
import 'package:twonly/src/visual/themes/colors.dart'; import 'package:twonly/src/visual/themes/colors.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/common.dart'; import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/common.dart';
@ -93,16 +94,8 @@ class _ChatMediaEntryState extends State<ChatMediaEntry> {
if ((widget.mediaService.mediaFile.downloadState == DownloadState.ready) && if ((widget.mediaService.mediaFile.downloadState == DownloadState.ready) &&
widget.message.openedAt == null) { widget.message.openedAt == null) {
if (!mounted) return; if (!mounted) return;
await Navigator.push( await context.navPush(
context, MediaViewerView(widget.group, initialMessage: widget.message),
MaterialPageRoute(
builder: (context) {
return MediaViewerView(
widget.group,
initialMessage: widget.message,
);
},
),
); );
} else if (widget.mediaService.mediaFile.downloadState == } else if (widget.mediaService.mediaFile.downloadState ==
DownloadState.pending) { DownloadState.pending) {

View file

@ -19,8 +19,10 @@ import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/views/camera/camera_send_to.view.dart'; import 'package:twonly/src/visual/views/camera/camera_send_to.view.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/bottom_sheets/share_additional.bottom_sheet.dart'; import 'package:twonly/src/visual/views/chats/chat_messages_components/bottom_sheets/share_additional.bottom_sheet.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/chat_audio_entry.dart'; import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/chat_audio_entry.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/unverified_contact_warning.comp.dart'; import 'package:twonly/src/visual/views/chats/chat_messages_components/message_input_components/ask_for_friend_promotions.comp.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/user_discovery_manual_approval.comp.dart'; import 'package:twonly/src/visual/views/chats/chat_messages_components/message_input_components/sparks.comp.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/message_input_components/unverified_contact_warning.comp.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/message_input_components/user_discovery_manual_approval.comp.dart';
import 'package:twonly/src/visual/views/contact/contact_components/restore_flame.comp.dart'; import 'package:twonly/src/visual/views/contact/contact_components/restore_flame.comp.dart';
class MessageInput extends StatefulWidget { class MessageInput extends StatefulWidget {
@ -48,6 +50,7 @@ class _MessageInputState extends State<MessageInput> {
late final RecorderController recorderController; late final RecorderController recorderController;
final bool isApple = Platform.isIOS; final bool isApple = Platform.isIOS;
bool _emojiShowing = false; bool _emojiShowing = false;
bool _showSparks = false;
bool _audioRecordingLock = false; bool _audioRecordingLock = false;
int _currentDuration = 0; int _currentDuration = 0;
double _cancelSlideOffset = 0; double _cancelSlideOffset = 0;
@ -74,6 +77,7 @@ class _MessageInputState extends State<MessageInput> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_textFieldController = TextEditingController(); _textFieldController = TextEditingController();
_textFieldController.addListener(_handleTextChange); _textFieldController.addListener(_handleTextChange);
if (widget.group.draftMessage != null) { if (widget.group.draftMessage != null) {
@ -103,6 +107,19 @@ class _MessageInputState extends State<MessageInput> {
widget.textFieldFocus.dispose(); widget.textFieldFocus.dispose();
recorderController.dispose(); recorderController.dispose();
_nextTypingIndicator?.cancel(); _nextTypingIndicator?.cancel();
// Persist draft message on close
final draftText = _textFieldController.text;
unawaited(
twonlyDB.groupsDao.updateGroup(
widget.group.groupId,
GroupsCompanion(
draftMessage: Value(draftText.isEmpty ? null : draftText),
),
),
);
_textFieldController.dispose();
super.dispose(); super.dispose();
} }
@ -197,6 +214,23 @@ class _MessageInputState extends State<MessageInput> {
} }
Future<void> _showAdditionalShareModal(BuildContext context) async { Future<void> _showAdditionalShareModal(BuildContext context) async {
setState(() {
_showSparks = false;
});
if (widget.group.isDirectChat) {
final members = await twonlyDB.groupsDao.getGroupContact(
widget.group.groupId,
);
if (members.isNotEmpty) {
await twonlyDB.contactsDao.updateContact(
members.first.userId,
const ContactsCompanion(
askForFriendPromotions: Value(false),
),
);
}
}
if (!context.mounted) return;
// ignore: inference_failure_on_function_invocation // ignore: inference_failure_on_function_invocation
await showModalBottomSheet( await showModalBottomSheet(
context: context, context: context,
@ -227,9 +261,18 @@ class _MessageInputState extends State<MessageInput> {
return Column( return Column(
children: [ children: [
UserDiscoveryManualApprovalComp(group: widget.group), UserDiscoveryManualApprovalComp(group: widget.group),
AskForFriendPromotionsComp(
group: widget.group,
onHighlightChanged: (highlight) {
if (!mounted) return;
setState(() {
_showSparks = highlight;
});
},
),
if (_contactId != null) if (_contactId != null)
Container( Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: context.color.surfaceContainerHighest, color: context.color.surfaceContainerHighest,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
@ -248,9 +291,6 @@ class _MessageInputState extends State<MessageInput> {
children: [ children: [
Expanded( Expanded(
child: Container( child: Container(
// padding: const EdgeInsets.symmetric(
// horizontal: 3,
// ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: context.color.surfaceContainer, color: context.color.surfaceContainer,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
@ -293,22 +333,15 @@ class _MessageInputState extends State<MessageInput> {
TextField( TextField(
controller: _textFieldController, controller: _textFieldController,
focusNode: widget.textFieldFocus, focusNode: widget.textFieldFocus,
textCapitalization: TextCapitalization.sentences, textCapitalization:
TextCapitalization.sentences,
keyboardType: TextInputType.multiline, keyboardType: TextInputType.multiline,
showCursor: showCursor:
_recordingState != RecordingState.recording, _recordingState != RecordingState.recording,
maxLines: 4, maxLines: 4,
minLines: 1, minLines: 1,
onChanged: (value) async { onChanged: (value) {
setState(() {}); setState(() {});
await twonlyDB.groupsDao.updateGroup(
widget.group.groupId,
GroupsCompanion(
draftMessage: Value(
_textFieldController.text,
),
),
);
}, },
onSubmitted: (_) { onSubmitted: (_) {
_sendMessage(); _sendMessage();
@ -545,11 +578,14 @@ class _MessageInputState extends State<MessageInput> {
: _sendMessage, : _sendMessage,
) )
else else
IconButton( SparksWidget(
animate: _showSparks,
child: IconButton(
icon: const FaIcon(FontAwesomeIcons.plus), icon: const FaIcon(FontAwesomeIcons.plus),
padding: const EdgeInsets.all(15), padding: const EdgeInsets.all(15),
onPressed: () => _showAdditionalShareModal(context), onPressed: () => _showAdditionalShareModal(context),
), ),
),
], ],
), ),
), ),

View file

@ -0,0 +1,184 @@
import 'dart:async';
import 'dart:math' show pi;
import 'package:drift/drift.dart' show Value;
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/utils/misc.dart';
class AskForFriendPromotionsComp extends StatefulWidget {
const AskForFriendPromotionsComp({
required this.group,
this.onHighlightChanged,
super.key,
});
final Group group;
final ValueChanged<bool>? onHighlightChanged;
@override
State<AskForFriendPromotionsComp> createState() =>
_AskForFriendPromotionsCompState();
}
class _AskForFriendPromotionsCompState extends State<AskForFriendPromotionsComp>
with SingleTickerProviderStateMixin {
Contact? _contact;
StreamSubscription<Contact?>? _subscription;
late final AnimationController _arrowController;
late final Animation<double> _arrowAnimation;
@override
void initState() {
super.initState();
_initContactWatcher();
_arrowController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1000),
)..repeat(reverse: true);
_arrowAnimation = Tween<double>(begin: -2, end: 3).animate(
CurvedAnimation(parent: _arrowController, curve: Curves.easeInOut),
);
}
@override
void didUpdateWidget(AskForFriendPromotionsComp oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.group.groupId != widget.group.groupId) {
_initContactWatcher();
}
}
@override
void dispose() {
_subscription?.cancel();
_arrowController.dispose();
// Schedule state callback outside of dispose to avoid setState call during build/dispose issues
final callback = widget.onHighlightChanged;
if (callback != null) {
scheduleMicrotask(() => callback(false));
}
super.dispose();
}
Future<void> _initContactWatcher() async {
await _subscription?.cancel();
if (!widget.group.isDirectChat) {
setState(() {
_contact = null;
});
widget.onHighlightChanged?.call(false);
return;
}
final members = await twonlyDB.groupsDao.getGroupContact(
widget.group.groupId,
);
if (members.isEmpty) {
setState(() {
_contact = null;
});
widget.onHighlightChanged?.call(false);
return;
}
final contactId = members.first.userId;
_subscription = twonlyDB.contactsDao.watchContact(contactId).listen((
contact,
) {
if (mounted) {
setState(() {
_contact = contact;
});
final shouldHighlight = contact?.askForFriendPromotions == true;
widget.onHighlightChanged?.call(shouldHighlight);
}
});
}
@override
Widget build(BuildContext context) {
if (_contact == null || _contact!.askForFriendPromotions != true) {
return const SizedBox.shrink();
}
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer.withAlpha(40),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: Theme.of(context).colorScheme.primary.withAlpha(60),
width: 0.8,
),
),
child: Row(
children: [
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () async {
await twonlyDB.contactsDao.updateContact(
_contact!.userId,
const ContactsCompanion(
askForFriendPromotions: Value(false),
),
);
},
child: Padding(
padding: const EdgeInsets.all(4),
child: FaIcon(
FontAwesomeIcons.xmark,
size: 13,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant.withAlpha(150),
),
),
),
const SizedBox(width: 8),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Text(
context.lang.askForFriendPromotionsPrompt(
_contact!.displayName ?? _contact!.username,
),
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 12,
fontWeight: FontWeight.w500,
height: 1.35,
),
),
),
),
const SizedBox(width: 8),
Padding(
padding: const EdgeInsets.only(right: 4),
child: AnimatedBuilder(
animation: _arrowAnimation,
builder: (context, child) {
return Transform.rotate(
angle: -pi / 10,
child: Transform.translate(
offset: Offset(0, _arrowAnimation.value),
child: child,
),
);
},
child: FaIcon(
FontAwesomeIcons.anglesDown,
color: Theme.of(context).colorScheme.primary,
size: 15,
),
),
),
],
),
);
}
}

View file

@ -0,0 +1,150 @@
import 'dart:math';
import 'package:flutter/material.dart';
class SparksWidget extends StatefulWidget {
const SparksWidget({
required this.child,
required this.animate,
super.key,
});
final Widget child;
final bool animate;
@override
State<SparksWidget> createState() => _SparksWidgetState();
}
class _SparksWidgetState extends State<SparksWidget>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
final List<Spark> _sparks = [];
final _random = Random();
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 2000),
);
if (widget.animate) {
_controller.repeat();
_startSparksGenerator();
}
}
@override
void didUpdateWidget(SparksWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.animate != oldWidget.animate) {
if (widget.animate) {
_controller.repeat();
_startSparksGenerator();
} else {
_controller.stop();
_sparks.clear();
}
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _startSparksGenerator() {
_controller.addListener(_generateAndUpdateSparks);
}
void _generateAndUpdateSparks() {
if (!mounted || !widget.animate) return;
// Update existing sparks
setState(() {
_sparks.removeWhere((s) => s.progress >= 1.0);
for (final spark in _sparks) {
spark.progress += 0.005;
}
// Generate new spark occasionally
if (_sparks.length < 15 && _random.nextDouble() < 0.15) {
final angle = -pi * 0.58 + _random.nextDouble() * pi * 0.16;
final speed = 60 + _random.nextDouble() * 60;
final color = _random.nextBool()
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.secondary;
_sparks.add(
Spark(
angle: angle,
speed: speed,
color: color,
size: 2 + _random.nextDouble() * 3,
progress: 0,
),
);
}
});
}
@override
Widget build(BuildContext context) {
if (!widget.animate) {
return widget.child;
}
return Stack(
alignment: Alignment.center,
clipBehavior: Clip.none,
children: [
CustomPaint(
painter: SparksPainter(sparks: _sparks),
),
widget.child,
],
);
}
}
class Spark {
Spark({
required this.angle,
required this.speed,
required this.color,
required this.size,
required this.progress,
});
final double angle;
final double speed;
final Color color;
final double size;
double progress;
}
class SparksPainter extends CustomPainter {
SparksPainter({required this.sparks});
final List<Spark> sparks;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint();
for (final spark in sparks) {
final distance = spark.speed * spark.progress;
final dx = cos(spark.angle) * distance;
final dy = sin(spark.angle) * distance;
final alpha = (220 * (1 - spark.progress)).toInt();
paint.color = spark.color.withAlpha(alpha);
canvas.drawCircle(Offset(dx, dy), spark.size, paint);
}
}
@override
bool shouldRepaint(covariant SparksPainter oldDelegate) => true;
}

View file

@ -45,6 +45,7 @@ class _TypingIndicatorState extends State<TypingIndicator> {
StreamSubscription<List<(Contact, GroupMember)>>? membersSub; StreamSubscription<List<(Contact, GroupMember)>>? membersSub;
Timer? _periodicUpdate; Timer? _periodicUpdate;
double _wasShownOnce = 0;
@override @override
void initState() { void initState() {
@ -79,23 +80,35 @@ class _TypingIndicatorState extends State<TypingIndicator> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (_groupMembers.isEmpty) return Container(); if (_groupMembers.isEmpty) {
return SizedBox(height: _wasShownOnce);
}
return Align( final height =
(widget.group.isDirectChat ? 20 : 24) * _groupMembers.length.toDouble();
_wasShownOnce = height;
return SizedBox(
height: height,
child: Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: Padding( child: Padding(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.only(left: 12),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: _groupMembers children: _groupMembers
.map( .map(
(member) => Padding( (member) => Padding(
key: Key('typing_indicator_${member.contactId}'), key: Key('typing_indicator_${member.contactId}'),
padding: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.only(right: 8),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (!widget.group.isDirectChat) if (!widget.group.isDirectChat)
GestureDetector( Padding(
padding: const EdgeInsets.only(right: 6),
child: GestureDetector(
onTap: () => context.push( onTap: () => context.push(
Routes.profileContact(member.contactId), Routes.profileContact(member.contactId),
), ),
@ -104,8 +117,12 @@ class _TypingIndicatorState extends State<TypingIndicator> {
fontSize: 12, fontSize: 12,
), ),
), ),
),
Container( Container(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.symmetric(
horizontal: 7,
vertical: 6,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: getMessageColor(true), color: getMessageColor(true),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
@ -114,7 +131,6 @@ class _TypingIndicatorState extends State<TypingIndicator> {
isTyping: isTyping(member), isTyping: isTyping(member),
), ),
), ),
Expanded(child: Container()),
], ],
), ),
), ),
@ -122,6 +138,7 @@ class _TypingIndicatorState extends State<TypingIndicator> {
.toList(), .toList(),
), ),
), ),
),
); );
} }
} }

View file

@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'dart:collection'; import 'dart:collection';
import 'dart:math';
import 'package:clock/clock.dart'; import 'package:clock/clock.dart';
import 'package:drift/drift.dart' show Value; import 'package:drift/drift.dart' show Value;
@ -27,7 +28,8 @@ import 'package:twonly/src/services/notifications/background.notifications.dart'
import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/animate_icon.comp.dart'; import 'package:twonly/src/visual/components/animate_icon.comp.dart';
import 'package:twonly/src/visual/decorations/input_text.decoration.dart'; import 'package:twonly/src/visual/elements/my_icon_button.element.dart';
import 'package:twonly/src/visual/elements/my_input.element.dart';
import 'package:twonly/src/visual/helpers/media_view_sizing.helper.dart'; import 'package:twonly/src/visual/helpers/media_view_sizing.helper.dart';
import 'package:twonly/src/visual/loader/three_rotating_dots.loader.dart'; import 'package:twonly/src/visual/loader/three_rotating_dots.loader.dart';
import 'package:twonly/src/visual/views/camera/camera_send_to.view.dart'; import 'package:twonly/src/visual/views/camera/camera_send_to.view.dart';
@ -40,7 +42,6 @@ class MediaViewerView extends StatefulWidget {
final Group group; final Group group;
final Message? initialMessage; final Message? initialMessage;
@override @override
State<MediaViewerView> createState() => _MediaViewerViewState(); State<MediaViewerView> createState() => _MediaViewerViewState();
} }
@ -81,6 +82,9 @@ class _MediaViewerViewState extends State<MediaViewerView> {
final HashSet<String> _alreadyOpenedMediaIds = HashSet(); final HashSet<String> _alreadyOpenedMediaIds = HashSet();
bool _isTransitioning = false; bool _isTransitioning = false;
bool _isZoomed = false;
late PageController _verticalPager;
final ValueNotifier<double> _backdropOpacityNotifier = ValueNotifier(1);
@override @override
void initState() { void initState() {
@ -91,6 +95,11 @@ class _MediaViewerViewState extends State<MediaViewerView> {
allMediaFiles = [widget.initialMessage!]; allMediaFiles = [widget.initialMessage!];
} }
_verticalPager = PageController(initialPage: 1);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _verticalPager.addListener(_onVerticalScrollUpdated);
});
asyncLoadNextMedia(true); asyncLoadNextMedia(true);
} }
@ -106,9 +115,33 @@ class _MediaViewerViewState extends State<MediaViewerView> {
_disposeVideoController(); _disposeVideoController();
// Persist draft message on close
final draftText = textMessageController.text;
unawaited(
twonlyDB.groupsDao.updateGroup(
widget.group.groupId,
GroupsCompanion(
draftMessage: Value(draftText.isEmpty ? null : draftText),
),
),
);
textMessageController.dispose();
_verticalPager
..removeListener(_onVerticalScrollUpdated)
..dispose();
_backdropOpacityNotifier.dispose();
super.dispose(); super.dispose();
} }
void _onVerticalScrollUpdated() {
if (!_verticalPager.hasClients) return;
final page = _verticalPager.page ?? 1.0;
final linearFraction = min(1, max(0, page)).toDouble();
_backdropOpacityNotifier.value = linearFraction * linearFraction;
}
void _disposeVideoController() { void _disposeVideoController() {
final listener = _videoListener; final listener = _videoListener;
final controller = videoController; final controller = videoController;
@ -537,30 +570,18 @@ class _MediaViewerViewState extends State<MediaViewerView> {
if (currentMedia != null && if (currentMedia != null &&
!currentMedia!.mediaFile.requiresAuthentication && !currentMedia!.mediaFile.requiresAuthentication &&
currentMedia!.mediaFile.displayLimitInMilliseconds == null) currentMedia!.mediaFile.displayLimitInMilliseconds == null)
OutlinedButton( MyIconButton(
style: OutlinedButton.styleFrom( variant: MyIconButtonVariant.secondary,
iconColor: imageSaved
? Theme.of(context).colorScheme.outline
: Theme.of(context).colorScheme.primary,
foregroundColor: imageSaved
? Theme.of(context).colorScheme.outline
: Theme.of(context).colorScheme.primary,
),
onPressed: (currentMedia == null) ? null : onPressedSaveToGallery, onPressed: (currentMedia == null) ? null : onPressedSaveToGallery,
child: Row( icon: imageSaving
children: [ ? const SizedBox(
if (imageSaving) width: 16,
const SizedBox( height: 16,
width: 10, child: CircularProgressIndicator.adaptive(strokeWidth: 2),
height: 10,
child: CircularProgressIndicator.adaptive(strokeWidth: 1),
) )
else : imageSaved
imageSaved
? const Icon(Icons.check) ? const Icon(Icons.check)
: const FaIcon(FontAwesomeIcons.floppyDisk), : const FaIcon(FontAwesomeIcons.floppyDisk, size: 20),
],
),
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
IconButton( IconButton(
@ -602,23 +623,21 @@ class _MediaViewerViewState extends State<MediaViewerView> {
), ),
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
IconButton.outlined( MyIconButton(
icon: const FaIcon(FontAwesomeIcons.message), variant: MyIconButtonVariant.secondary,
onPressed: () async { onPressed: () async {
displayShortReactions(); displayShortReactions();
setState(() { setState(() {
showSendTextMessageInput = true; showSendTextMessageInput = true;
}); });
}, },
style: ButtonStyle( icon: const FaIcon(
padding: WidgetStateProperty.all<EdgeInsets>( FontAwesomeIcons.message,
const EdgeInsets.symmetric(vertical: 10, horizontal: 20), size: 20,
),
), ),
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
IconButton.outlined( MyIconButton(
icon: const FaIcon(FontAwesomeIcons.camera),
onPressed: () async { onPressed: () async {
nextMediaTimer?.cancel(); nextMediaTimer?.cancel();
progressTimer?.cancel(); progressTimer?.cancel();
@ -639,11 +658,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
await videoController?.play(); await videoController?.play();
} }
}, },
style: ButtonStyle( icon: const FaIcon(FontAwesomeIcons.camera, size: 24),
padding: WidgetStateProperty.all<EdgeInsets>(
const EdgeInsets.symmetric(vertical: 10, horizontal: 20),
),
),
), ),
], ],
); );
@ -676,6 +691,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: Colors.transparent,
body: SafeArea( body: SafeArea(
child: Stack( child: Stack(
fit: StackFit.expand, fit: StackFit.expand,
@ -696,6 +712,18 @@ class _MediaViewerViewState extends State<MediaViewerView> {
child: PhotoView.customChild( child: PhotoView.customChild(
initialScale: PhotoViewComputedScale.contained, initialScale: PhotoViewComputedScale.contained,
minScale: PhotoViewComputedScale.contained, minScale: PhotoViewComputedScale.contained,
backgroundDecoration: const BoxDecoration(
color: Colors.transparent,
),
scaleStateChangedCallback: (state) {
final zoomed =
state != PhotoViewScaleState.initial;
if (_isZoomed != zoomed) {
setState(() {
_isZoomed = zoomed;
});
}
},
child: VideoPlayer( child: VideoPlayer(
videoController!, videoController!,
), ),
@ -709,8 +737,21 @@ class _MediaViewerViewState extends State<MediaViewerView> {
imageProvider: FileImage( imageProvider: FileImage(
currentMedia!.tempPath, currentMedia!.tempPath,
), ),
loadingBuilder: (context, event) => _loader(),
backgroundDecoration: const BoxDecoration(
color: Colors.transparent,
),
initialScale: PhotoViewComputedScale.contained, initialScale: PhotoViewComputedScale.contained,
minScale: PhotoViewComputedScale.contained, minScale: PhotoViewComputedScale.contained,
scaleStateChangedCallback: (state) {
final zoomed =
state != PhotoViewScaleState.initial;
if (_isZoomed != zoomed) {
setState(() {
_isZoomed = zoomed;
});
}
},
errorBuilder: (context, error, stackTrace) { errorBuilder: (context, error, stackTrace) {
return const Center( return const Center(
child: Icon( child: Icon(
@ -739,25 +780,14 @@ class _MediaViewerViewState extends State<MediaViewerView> {
), ),
Container( Container(
padding: const EdgeInsets.only(bottom: 200), padding: const EdgeInsets.only(bottom: 200),
child: Text(context.lang.mediaViewerTwonlyTapToOpen), child: Text(
context.lang.mediaViewerTwonlyTapToOpen,
),
), ),
], ],
), ),
), ),
), ),
Positioned(
left: 10,
top: 10,
child: Row(
children: [
IconButton(
icon: const Icon(Icons.close, size: 30),
color: Colors.white,
onPressed: () => Navigator.pop(context),
),
],
),
),
if (currentMedia != null && if (currentMedia != null &&
currentMedia?.mediaFile.downloadState != DownloadState.ready) currentMedia?.mediaFile.downloadState != DownloadState.ready)
Positioned.fill(child: _loader()), Positioned.fill(child: _loader()),
@ -820,48 +850,37 @@ class _MediaViewerViewState extends State<MediaViewerView> {
), ),
child: Row( child: Row(
children: [ children: [
IconButton(
icon: const FaIcon(FontAwesomeIcons.xmark),
onPressed: () {
setState(() {
showShortReactions = false;
showSendTextMessageInput = false;
});
},
),
Expanded( Expanded(
child: TextField( child: MyInput(
dense: true,
autofocus: true, autofocus: true,
controller: textMessageController, controller: textMessageController,
textCapitalization: TextCapitalization.sentences, hintText: context.lang.chatListDetailInput,
onChanged: (value) async { onChanged: (value) {
await twonlyDB.groupsDao.updateGroup( setState(() {});
widget.group.groupId,
GroupsCompanion(
draftMessage: Value(textMessageController.text),
),
);
}, },
onEditingComplete: () { onSubmitted: (value) {
setState(() { setState(() {
showSendTextMessageInput = false; showSendTextMessageInput = false;
showShortReactions = false; showShortReactions = false;
}); });
}, },
decoration: inputTextMessageDeco(
context,
context.lang.chatListDetailInput,
), ),
), ),
const SizedBox(width: 10),
MyIconButton(
icon: const FaIcon(
FontAwesomeIcons.solidPaperPlane,
size: 20,
), ),
IconButton(
icon: const FaIcon(FontAwesomeIcons.solidPaperPlane),
onPressed: () async { onPressed: () async {
if (textMessageController.text.isNotEmpty) { if (textMessageController.text.isNotEmpty) {
await insertAndSendTextMessage( unawaited(
insertAndSendTextMessage(
widget.group.groupId, widget.group.groupId,
textMessageController.text, textMessageController.text,
currentMessage!.messageId, currentMessage!.messageId,
),
); );
textMessageController.clear(); textMessageController.clear();
} }

View file

@ -3,7 +3,7 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:twonly/src/database/daos/contacts.dao.dart'; import 'package:twonly/src/database/daos/contacts.dao.dart';
import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/protobuf/client/generated/data.pb.dart'; import 'package:twonly/src/model/protobuf/client/generated/data.pb.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher.dart';
class AdditionalMessageContent extends StatelessWidget { class AdditionalMessageContent extends StatelessWidget {
const AdditionalMessageContent(this.message, {super.key}); const AdditionalMessageContent(this.message, {super.key});
@ -33,7 +33,10 @@ class AdditionalMessageContent extends StatelessWidget {
children: [ children: [
FilledButton.icon( FilledButton.icon(
icon: const FaIcon(FontAwesomeIcons.shareFromSquare), icon: const FaIcon(FontAwesomeIcons.shareFromSquare),
onPressed: () => launchUrlString(data.link), onPressed: () => launchUrl(
Uri.parse(data.link),
mode: LaunchMode.externalApplication,
),
label: Text( label: Text(
substringBy( substringBy(
data.link data.link

View file

@ -56,17 +56,7 @@ class _ReactionButtonsState extends State<ReactionButtons> {
void didUpdateWidget(ReactionButtons oldWidget) { void didUpdateWidget(ReactionButtons oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (widget.show != oldWidget.show) { if (widget.show != oldWidget.show) {
if (widget.show) { _renderAnimations = widget.show;
_renderAnimations = true;
} else {
Future.delayed(const Duration(milliseconds: 150), () {
if (mounted && !widget.show) {
setState(() {
_renderAnimations = false;
});
}
});
}
} }
} }
@ -98,7 +88,7 @@ class _ReactionButtonsState extends State<ReactionButtons> {
ignoring: !widget.show, ignoring: !widget.show,
child: AnimatedOpacity( child: AnimatedOpacity(
opacity: widget.show ? 1.0 : 0.0, // Fade in/out opacity: widget.show ? 1.0 : 0.0, // Fade in/out
duration: const Duration(milliseconds: 150), duration: Duration(milliseconds: widget.show ? 150 : 50),
child: Container( child: Container(
color: widget.show ? Colors.black.withAlpha(0) : Colors.transparent, color: widget.show ? Colors.black.withAlpha(0) : Colors.transparent,
padding: const EdgeInsets.symmetric(vertical: 32), padding: const EdgeInsets.symmetric(vertical: 32),

View file

@ -29,10 +29,11 @@ class VerificationExpansionTileComp extends StatefulWidget {
class _VerificationExpansionTileCompState class _VerificationExpansionTileCompState
extends State<VerificationExpansionTileComp> { extends State<VerificationExpansionTileComp> {
List<KeyVerification> _keyVerifications = []; List<(KeyVerification, Contact?)> _keyVerifications = [];
List<(Contact, DateTime)> _transferredTrust = []; List<(Contact, DateTime)> _transferredTrust = [];
late StreamSubscription<List<KeyVerification>> _streamKeyVerifications; late StreamSubscription<List<(KeyVerification, Contact?)>>
_streamKeyVerifications;
late StreamSubscription<List<(Contact, DateTime)>> _streamTransferredTrust; late StreamSubscription<List<(Contact, DateTime)>> _streamTransferredTrust;
@override @override
@ -63,7 +64,11 @@ class _VerificationExpansionTileCompState
super.dispose(); super.dispose();
} }
String _verificationTypeLabel(BuildContext context, VerificationType type) { String _verificationTypeLabel(
BuildContext context,
VerificationType type,
Contact? verifier,
) {
return switch (type) { return switch (type) {
VerificationType.qrScanned => context.lang.verificationTypeQrScanned, VerificationType.qrScanned => context.lang.verificationTypeQrScanned,
VerificationType.secretQrToken => VerificationType.secretQrToken =>
@ -72,7 +77,9 @@ class _VerificationExpansionTileCompState
), ),
VerificationType.link => context.lang.verificationTypeLink, VerificationType.link => context.lang.verificationTypeLink,
VerificationType.contactSharedByVerified => VerificationType.contactSharedByVerified =>
context.lang.verificationTypeContactSharedByVerified, verifier != null
? context.lang.contactVerifiedBy(getContactDisplayName(verifier))
: context.lang.contactSharedByUnknown,
VerificationType.migratedFromOldVersion => VerificationType.migratedFromOldVersion =>
context.lang.verificationTypeMigratedFromOldVersion, context.lang.verificationTypeMigratedFromOldVersion,
}; };
@ -94,6 +101,17 @@ class _VerificationExpansionTileCompState
); );
} }
final sharedVerifierIds = _keyVerifications
.where((pair) =>
pair.$1.type == VerificationType.contactSharedByVerified &&
pair.$1.verifiedBy != null)
.map((pair) => pair.$1.verifiedBy!)
.toSet();
final filteredTransferredTrust = _transferredTrust
.where((tt) => !sharedVerifierIds.contains(tt.$1.userId))
.toList();
return ExpansionTile( return ExpansionTile(
shape: const RoundedRectangleBorder(), shape: const RoundedRectangleBorder(),
backgroundColor: context.color.surfaceContainer, backgroundColor: context.color.surfaceContainer,
@ -108,10 +126,17 @@ class _VerificationExpansionTileCompState
title: Text(context.lang.userVerifiedTitle), title: Text(context.lang.userVerifiedTitle),
children: [ children: [
..._keyVerifications.map( ..._keyVerifications.map(
(kv) => ListTile( (pair) {
final kv = pair.$1;
final verifier = pair.$2;
return ListTile(
dense: true, dense: true,
contentPadding: const EdgeInsets.only(left: 16), contentPadding: const EdgeInsets.only(left: 16),
title: Text(_verificationTypeLabel(context, kv.type)), title:
kv.type == VerificationType.contactSharedByVerified &&
verifier != null
? _VerifiedByContactRow(contact: verifier)
: Text(_verificationTypeLabel(context, kv.type, verifier)),
trailing: Row( trailing: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@ -150,23 +175,13 @@ class _VerificationExpansionTileCompState
), ),
], ],
), ),
);
},
), ),
), ...filteredTransferredTrust.map(
..._transferredTrust.map(
(tt) => ListTile( (tt) => ListTile(
dense: true, dense: true,
title: Row( title: _VerifiedByContactRow(contact: tt.$1),
children: [
Text(
context.lang.contactVerifiedBy(
getContactDisplayName(tt.$1),
),
),
VerificationBadgeComp(
contact: tt.$1,
),
],
),
trailing: Text( trailing: Text(
DateFormat.yMd( DateFormat.yMd(
Localizations.localeOf(context).toString(), Localizations.localeOf(context).toString(),
@ -182,3 +197,27 @@ class _VerificationExpansionTileCompState
); );
} }
} }
/// A reusable row that shows "Verified by [contact name]" with the contact's
/// [VerificationBadgeComp] and navigates to their profile on tap.
class _VerifiedByContactRow extends StatelessWidget {
const _VerifiedByContactRow({required this.contact});
final Contact contact;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => context.push(Routes.profileContact(contact.userId)),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
context.lang.contactVerifiedBy(getContactDisplayName(contact)),
),
VerificationBadgeComp(contact: contact),
],
),
);
}
}

View file

@ -82,7 +82,7 @@ class HomeViewState extends State<HomeView> with WidgetsBindingObserver {
if (response.payload != null && if (response.payload != null &&
response.payload!.startsWith(Routes.chats) && response.payload!.startsWith(Routes.chats) &&
response.payload! != Routes.chats) { response.payload! != Routes.chats) {
await routerProvider.push(response.payload!); routerProvider.go(response.payload!);
} }
streamHomeViewPageIndex.add(0); streamHomeViewPageIndex.add(0);
}); });
@ -155,7 +155,7 @@ class HomeViewState extends State<HomeView> with WidgetsBindingObserver {
if (payload != null && if (payload != null &&
payload.startsWith(Routes.chats) && payload.startsWith(Routes.chats) &&
payload != Routes.chats) { payload != Routes.chats) {
await routerProvider.push(payload); routerProvider.go(payload);
streamHomeViewPageIndex.add(0); streamHomeViewPageIndex.add(0);
} }
if (payload == Routes.chats) { if (payload == Routes.chats) {

View file

@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:twonly/locator.dart'; import 'package:twonly/locator.dart';
@ -8,6 +9,7 @@ import 'package:twonly/src/model/json/backup.model.dart';
import 'package:twonly/src/services/backup.service.dart'; import 'package:twonly/src/services/backup.service.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart'; import 'package:twonly/src/visual/elements/my_button.element.dart';
import 'package:twonly/src/visual/views/settings/backup/passwordless_recovery/setup.passwordless_recovery.view.dart';
class BackupView extends StatefulWidget { class BackupView extends StatefulWidget {
const BackupView({super.key}); const BackupView({super.key});
@ -176,9 +178,30 @@ class _BackupViewState extends State<BackupView> {
), ),
]), ]),
), ),
const SizedBox(height: 10), ],
MyButton( ),
if (userService.currentUser.passwordLessRecovery == null &&
kDebugMode) ...[
const SizedBox(height: 20),
Center(
child: MyButton(
variant: MyButtonVariant.primaryMiddle, variant: MyButtonVariant.primaryMiddle,
onPressed: () =>
context.navPush(const PasswordLessRecoverySetup()),
child: const Text('Setup Passwordless Recovery'),
),
),
],
const SizedBox(height: 20),
Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
if (userService.currentUser.isBackupEnabled) ...[
MyButton(
variant: MyButtonVariant.secondaryDense,
onPressed: _isLoading onPressed: _isLoading
? null ? null
: () async { : () async {
@ -192,20 +215,22 @@ class _BackupViewState extends State<BackupView> {
}, },
child: Text(context.lang.backupTwonlySaveNow), child: Text(context.lang.backupTwonlySaveNow),
), ),
const SizedBox(width: 12),
], ],
), MyButton(
const SizedBox(height: 32),
Center(
child: MyButton(
variant: MyButtonVariant.secondaryDense, variant: MyButtonVariant.secondaryDense,
onPressed: () => onPressed: () => context.push(
context.push(Routes.settingsBackupSetup, extra: true), Routes.settingsBackupSetup,
extra: true,
),
child: Text( child: Text(
!userService.currentUser.isBackupEnabled !userService.currentUser.isBackupEnabled
? context.lang.backupEnableBackup ? context.lang.backupEnableBackup
: context.lang.backupChangePassword, : context.lang.backupChangePassword,
), ),
), ),
],
),
), ),
], ],
), ),

View file

@ -0,0 +1,189 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:twonly/src/services/passwordless_recovery.service.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/elements/my_input.element.dart';
import 'package:twonly/src/visual/themes/light.dart';
class _FactorOption {
const _FactorOption({
required this.type,
required this.icon,
required this.label,
});
final SecondFactorType type;
final IconData icon;
final String label;
}
const _options = [
_FactorOption(
type: SecondFactorType.none,
icon: Icons.block_rounded,
label: 'None',
),
_FactorOption(
type: SecondFactorType.pin,
icon: Icons.dialpad_rounded,
label: 'PIN',
),
_FactorOption(
type: SecondFactorType.email,
icon: Icons.email_rounded,
label: 'Email',
),
];
class SecondFactorPicker extends StatelessWidget {
const SecondFactorPicker({
required this.selected,
required this.onChanged,
required this.pinController,
required this.emailController,
required this.onInputChanged,
super.key,
});
final SecondFactorType selected;
final ValueChanged<SecondFactorType> onChanged;
final TextEditingController pinController;
final TextEditingController emailController;
final VoidCallback onInputChanged;
@override
Widget build(BuildContext context) {
return Column(
children: [
const Text(
'Second factor method',
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
_buildSegmentedControl(context),
const SizedBox(height: 16),
_buildInputField(context),
],
);
}
Widget _buildSegmentedControl(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: context.color.surfaceContainerLow,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: context.color.outlineVariant.withValues(alpha: 0.3),
),
),
child: Row(
children: List.generate(_options.length * 2 - 1, (index) {
if (index.isOdd) {
return Container(
width: 1,
height: 40,
color: context.color.outlineVariant.withValues(alpha: 0.3),
);
}
final optionIndex = index ~/ 2;
final option = _options[optionIndex];
final isSelected = selected == option.type;
BorderRadius? borderRadius;
if (optionIndex == 0) {
borderRadius = const BorderRadius.horizontal(
left: Radius.circular(15),
);
} else if (optionIndex == _options.length - 1) {
borderRadius = const BorderRadius.horizontal(
right: Radius.circular(15),
);
}
return Expanded(
child: GestureDetector(
onTap: () => onChanged(option.type),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: isSelected ? primaryColor : Colors.transparent,
borderRadius: borderRadius,
),
child: Column(
children: [
Icon(
option.icon,
color: isSelected
? Colors.black87
: context.color.onSurfaceVariant,
),
const SizedBox(height: 6),
Text(
option.label,
style: TextStyle(
fontWeight: FontWeight.bold,
color: isSelected
? Colors.black87
: context.color.onSurfaceVariant,
),
),
],
),
),
),
);
}),
),
);
}
Widget _buildInputField(BuildContext context) {
return AnimatedSwitcher(
duration: const Duration(milliseconds: 250),
child: Column(
children: switch (selected) {
SecondFactorType.none => [
const Text(
'Without second-factor, your friends could collaborate to recover your account. Therefore, it is recommended to configure a second-factor.',
textAlign: TextAlign.center,
),
],
SecondFactorType.pin => [
MyInput(
key: const ValueKey('pin_input'),
controller: pinController,
hintText: 'Enter PIN',
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
onChanged: (_) => onInputChanged(),
),
],
SecondFactorType.email => [
MyInput(
key: const ValueKey('email_input'),
controller: emailController,
hintText: 'Enter recovery email address',
keyboardType: TextInputType.emailAddress,
onChanged: (_) => onInputChanged(),
),
const SizedBox(height: 6),
RichText(
text: TextSpan(
children: formattedText(
context,
'Your email address is *never stored on the server* and is only sent to it in the event of a recovery.',
),
style: TextStyle(
color: context.color.onSurface,
fontSize: 11,
),
),
textAlign: TextAlign.center,
),
],
},
),
);
}
}

View file

@ -0,0 +1,129 @@
import 'package:flutter/material.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/themes/light.dart';
class ThresholdPicker extends StatelessWidget {
const ThresholdPicker({
required this.contactCount,
required this.minThreshold,
required this.currentThreshold,
required this.onChanged,
super.key,
});
final int contactCount;
final int minThreshold;
final int currentThreshold;
final ValueChanged<int> onChanged;
/// Clamps [value] between [minThreshold] and contactCount - 2.
static int clampThreshold({
required int value,
required int minThreshold,
required int contactCount,
}) {
final maxT = (contactCount - 2) < minThreshold
? minThreshold
: (contactCount - 2);
if (value < minThreshold) return minThreshold;
if (value > maxT) return maxT;
return value;
}
@override
Widget build(BuildContext context) {
if (contactCount == 0) return const SizedBox.shrink();
final maxT = (contactCount - 2) < minThreshold
? minThreshold
: (contactCount - 2);
final options = List.generate(
maxT - minThreshold + 1,
(i) => minThreshold + i,
);
if (options.length == 1) {
return Text(
'To recover your account you need ${options.first} of your selected trusted friends.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: context.color.onSurfaceVariant,
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Required trusted friends for recovery',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: context.color.onSurfaceVariant,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Container(
decoration: BoxDecoration(
color: context.color.surfaceContainerLow,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: context.color.outlineVariant.withValues(alpha: 0.3),
),
),
child: Row(
children: List.generate(options.length * 2 - 1, (index) {
if (index.isOdd) {
return Container(
width: 1,
height: 40,
color: context.color.outlineVariant.withValues(alpha: 0.3),
);
}
final optionIndex = index ~/ 2;
final value = options[optionIndex];
final isSelected = value == currentThreshold;
BorderRadius? borderRadius;
if (optionIndex == 0) {
borderRadius = const BorderRadius.horizontal(
left: Radius.circular(15),
);
} else if (optionIndex == options.length - 1) {
borderRadius = const BorderRadius.horizontal(
right: Radius.circular(15),
);
}
return Expanded(
child: GestureDetector(
onTap: () => onChanged(value),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 14),
decoration: BoxDecoration(
color: isSelected ? primaryColor : Colors.transparent,
borderRadius: borderRadius,
),
child: Center(
child: Text(
'$value',
style: TextStyle(
fontWeight: FontWeight.bold,
color: isSelected
? Colors.black87
: context.color.onSurfaceVariant,
),
),
),
),
),
);
}),
),
),
],
);
}
}

View file

@ -0,0 +1,148 @@
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:twonly/src/database/daos/contacts.dao.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
import 'package:twonly/src/visual/components/verification_badge.comp.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart';
class TrustedFriendsCard extends StatelessWidget {
const TrustedFriendsCard({
required this.selectedContacts,
required this.needsMoreFriends,
required this.missingCount,
required this.onSelectFriends,
required this.onRemoveContact,
super.key,
});
final List<Contact> selectedContacts;
final bool needsMoreFriends;
final int missingCount;
final VoidCallback onSelectFriends;
final void Function(int userId) onRemoveContact;
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: context.color.surfaceContainerLow,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: context.color.outlineVariant.withValues(alpha: 0.5),
),
),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (selectedContacts.isEmpty)
_buildEmptyState(context)
else
Wrap(
spacing: 4,
alignment: WrapAlignment.center,
children: selectedContacts.map((contact) {
return _ContactChip(
key: ValueKey(contact.userId),
contact: contact,
onRemove: onRemoveContact,
);
}).toList(),
),
const SizedBox(height: 12),
MyButton(
variant: needsMoreFriends
? MyButtonVariant.error
: MyButtonVariant.secondaryDense,
onPressed: onSelectFriends,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.add_moderator_rounded, size: 18),
const SizedBox(width: 8),
Text(
needsMoreFriends
? 'Select friends ($missingCount more needed)'
: 'Select trusted friends',
),
],
),
),
],
),
);
}
Widget _buildEmptyState(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Column(
children: [
Icon(
Icons.people_outline_rounded,
size: 48,
color: context.color.onSurfaceVariant.withValues(alpha: 0.6),
),
const SizedBox(height: 12),
Text(
'No trusted friends selected yet',
style: TextStyle(
fontSize: 14,
color: context.color.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
],
),
);
}
}
class _ContactChip extends StatelessWidget {
const _ContactChip({
required this.contact,
required this.onRemove,
super.key,
});
final Contact contact;
final void Function(int) onRemove;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => onRemove(contact.userId),
child: Chip(
avatar: AvatarIcon(
contactId: contact.userId,
fontSize: 10,
),
label: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
getContactDisplayName(contact),
style: const TextStyle(fontSize: 14),
overflow: TextOverflow.ellipsis,
),
const SizedBox(width: 6),
VerificationBadgeComp(
contact: contact,
size: 12,
clickable: false,
),
const SizedBox(width: 15),
const FaIcon(
FontAwesomeIcons.xmark,
color: Colors.grey,
size: 12,
),
],
),
),
);
}
}

View file

@ -0,0 +1,257 @@
import 'dart:math';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/services/passwordless_recovery.service.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/snackbar.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart';
import 'package:twonly/src/visual/views/settings/backup/passwordless_recovery/components/second_factor_picker.comp.dart';
import 'package:twonly/src/visual/views/settings/backup/passwordless_recovery/components/threshold_picker.comp.dart';
import 'package:twonly/src/visual/views/settings/backup/passwordless_recovery/components/trusted_friends_card.comp.dart';
import 'package:twonly/src/visual/views/shared/select_contacts.view.dart';
class PasswordLessRecoverySetup extends StatefulWidget {
const PasswordLessRecoverySetup({super.key});
@override
State<PasswordLessRecoverySetup> createState() =>
_PasswordLessRecoverySetupState();
}
class _PasswordLessRecoverySetupState extends State<PasswordLessRecoverySetup> {
List<Contact> _selectedContacts = [];
SecondFactorType _secondFactor = SecondFactorType.pin;
final _pinController = TextEditingController();
final _emailController = TextEditingController();
bool _isLoading = false;
int _threshold = 2;
@override
void initState() {
super.initState();
initAsync();
}
Future<List<int>> _loadVerifiedContacts() async {
final kvs = await twonlyDB.select(twonlyDB.keyVerifications).get();
final urs = await (twonlyDB.select(
twonlyDB.userDiscoveryUserRelations,
)..where((u) => u.publicKeyVerifiedTimestamp.isNotNull())).get();
return [
...kvs.map((row) => row.contactId),
...urs.map((row) => row.announcedUserId),
];
}
Future<void> initAsync() async {
final contacts = await twonlyDB.contactsDao.getAllContacts();
if (!mounted) return;
final verified = await _loadVerifiedContacts();
contacts.sortBy((c) => c.mediaSendCounter);
final verifiedContacts = contacts
.where(
(c) =>
verified.contains(c.userId) &
c.accepted &
!c.blocked &
!c.accountDeleted &
!c.deletedByUser,
)
.toList();
setState(() {
_selectedContacts = verifiedContacts.sublist(
0,
min(8, verifiedContacts.length),
);
});
}
@override
void dispose() {
_pinController.dispose();
_emailController.dispose();
super.dispose();
}
// --- Threshold logic ---
int get _minThreshold => _secondFactor == SecondFactorType.none ? 4 : 2;
int get _validThreshold => ThresholdPicker.clampThreshold(
value: _threshold,
minThreshold: _minThreshold,
contactCount: _selectedContacts.length,
);
int get _minSelectedFriends => _validThreshold + 2;
// --- Validation ---
bool get _canEnable {
if (_selectedContacts.length < _minSelectedFriends) return false;
return switch (_secondFactor) {
SecondFactorType.none => true,
SecondFactorType.pin => _pinController.text.length >= 4,
SecondFactorType.email => RegExp(
r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$',
).hasMatch(_emailController.text),
};
}
// --- Actions ---
Future<void> _selectTrustedFriends() async {
final selectedIds = await Navigator.push<List<int>>(
context,
MaterialPageRoute(
builder: (_) => SelectContactsView(
text: SelectedContactView(
title: 'Trusted Friends',
submitButton: (selected, _) => 'Done ($selected)',
submitIcon: FontAwesomeIcons.check,
),
alreadySelected: _selectedContacts.map((c) => c.userId).toList(),
isAlreadySelectedLocked: false,
onlyVerified: true,
sortByMediaCount: true,
),
),
);
if (selectedIds == null) return;
final contacts = <Contact>[];
for (final id in selectedIds) {
final contact = await twonlyDB.contactsDao.getContactById(id);
if (contact != null) contacts.add(contact);
}
setState(() {
_selectedContacts = contacts;
_threshold = _validThreshold;
});
}
Future<void> _enable() async {
setState(() => _isLoading = true);
final secondFactorValue = switch (_secondFactor) {
SecondFactorType.none => '',
SecondFactorType.pin => _pinController.text,
SecondFactorType.email => _emailController.text,
};
final success =
await PasswordlessRecoveryService.enablePasswordlessRecovery(
trustedFriendIds: _selectedContacts.map((c) => c.userId).toList(),
secondFactorType: _secondFactor,
secondFactorValue: secondFactorValue,
threshold: _validThreshold,
);
if (!mounted) return;
setState(() => _isLoading = false);
if (success) {
showSnackbar(
context,
'Passwordless recovery enabled successfully!',
level: SnackbarLevel.success,
);
Navigator.pop(context);
}
}
// --- Build ---
@override
Widget build(BuildContext context) {
final needsMoreFriends = _selectedContacts.length < _minSelectedFriends;
return GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: Scaffold(
appBar: AppBar(
title: const Text('Passwordless Recovery'),
),
body: SafeArea(
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
children: [
Text(
'Recover your identity without a password.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: context.color.onSurfaceVariant,
),
),
const SizedBox(height: 24),
TrustedFriendsCard(
selectedContacts: _selectedContacts,
needsMoreFriends: needsMoreFriends,
missingCount: _minSelectedFriends - _selectedContacts.length,
onSelectFriends: _selectTrustedFriends,
onRemoveContact: (userId) => setState(() {
_selectedContacts.removeWhere((c) => c.userId == userId);
_threshold = _validThreshold;
}),
),
const SizedBox(height: 28),
SecondFactorPicker(
selected: _secondFactor,
onChanged: (type) => setState(() {
_secondFactor = type;
_threshold = _validThreshold;
}),
pinController: _pinController,
emailController: _emailController,
onInputChanged: () => setState(() {}),
),
const SizedBox(height: 28),
ThresholdPicker(
contactCount: _selectedContacts.length,
minThreshold: _minThreshold,
currentThreshold: _validThreshold,
onChanged: (value) => setState(() => _threshold = value),
),
const SizedBox(height: 28),
MyButton(
onPressed: (_canEnable && !_isLoading) ? _enable : null,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (_isLoading)
const SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator.adaptive(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
Colors.black87,
),
),
)
else
const Icon(Icons.check_circle_outline_rounded, size: 20),
const SizedBox(width: 8),
const Text('Enable Passwordless Recovery'),
],
),
),
],
),
),
),
);
}
}

View file

@ -27,7 +27,10 @@ class UrlListTitle extends StatelessWidget {
title: (title != null) ? Text(title!) : null, title: (title != null) ? Text(title!) : null,
subtitle: subtitle == null ? null : Text(subtitle!), subtitle: subtitle == null ? null : Text(subtitle!),
onTap: () async { onTap: () async {
await launchUrl(Uri.parse(url)); await launchUrl(
Uri.parse(url),
mode: LaunchMode.externalApplication,
);
}, },
trailing: const FaIcon(FontAwesomeIcons.arrowUpRightFromSquare, size: 15), trailing: const FaIcon(FontAwesomeIcons.arrowUpRightFromSquare, size: 15),
); );

View file

@ -96,6 +96,7 @@ class _HelpViewState extends State<HelpView> {
title: const Text('Open Source'), title: const Text('Open Source'),
onTap: () => launchUrl( onTap: () => launchUrl(
Uri.parse('https://github.com/twonlyapp/twonly-app'), Uri.parse('https://github.com/twonlyapp/twonly-app'),
mode: LaunchMode.externalApplication,
), ),
trailing: const FaIcon( trailing: const FaIcon(
FontAwesomeIcons.arrowUpRightFromSquare, FontAwesomeIcons.arrowUpRightFromSquare,
@ -104,8 +105,10 @@ class _HelpViewState extends State<HelpView> {
), ),
ListTile( ListTile(
title: Text(context.lang.settingsHelpImprint), title: Text(context.lang.settingsHelpImprint),
onTap: () => onTap: () => launchUrl(
launchUrl(Uri.parse('https://twonly.eu/de/legal/')), Uri.parse('https://twonly.eu/de/legal/'),
mode: LaunchMode.externalApplication,
),
trailing: const FaIcon( trailing: const FaIcon(
FontAwesomeIcons.arrowUpRightFromSquare, FontAwesomeIcons.arrowUpRightFromSquare,
size: 15, size: 15,
@ -113,8 +116,10 @@ class _HelpViewState extends State<HelpView> {
), ),
ListTile( ListTile(
title: Text(context.lang.settingsHelpTerms), title: Text(context.lang.settingsHelpTerms),
onTap: () => onTap: () => launchUrl(
launchUrl(Uri.parse('https://twonly.eu/de/legal/agb.html')), Uri.parse('https://twonly.eu/de/legal/agb.html'),
mode: LaunchMode.externalApplication,
),
trailing: const FaIcon( trailing: const FaIcon(
FontAwesomeIcons.arrowUpRightFromSquare, FontAwesomeIcons.arrowUpRightFromSquare,
size: 15, size: 15,

View file

@ -5,6 +5,8 @@ import 'package:twonly/src/constants/routes.keys.dart';
import 'package:twonly/src/services/profile.service.dart'; import 'package:twonly/src/services/profile.service.dart';
import 'package:twonly/src/services/user.service.dart'; import 'package:twonly/src/services/user.service.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/verification_badge_info.comp.dart';
import 'package:twonly/src/visual/elements/svg_icon.element.dart';
class PrivacyView extends StatefulWidget { class PrivacyView extends StatefulWidget {
const PrivacyView({super.key}); const PrivacyView({super.key});
@ -68,6 +70,7 @@ class _PrivacyViewState extends State<PrivacyView> {
ListTile( ListTile(
title: Text(context.lang.contactVerifyNumberTitle), title: Text(context.lang.contactVerifyNumberTitle),
subtitle: Text(context.lang.contactVerifyNumberSubtitle), subtitle: Text(context.lang.contactVerifyNumberSubtitle),
trailing: const _VerificationBadgeTriangle(),
onTap: () async { onTap: () async {
await context.push(Routes.settingsHelpFaqVerifyBadge); await context.push(Routes.settingsHelpFaqVerifyBadge);
setState(() {}); setState(() {});
@ -117,3 +120,44 @@ class _PrivacyViewState extends State<PrivacyView> {
); );
} }
} }
class _VerificationBadgeTriangle extends StatelessWidget {
const _VerificationBadgeTriangle();
@override
Widget build(BuildContext context) {
return const SizedBox(
width: 30,
height: 30,
child: Stack(
children: [
Positioned(
top: 0,
left: 9,
child: SvgIcon(
assetPath: SvgIcons.verifiedGreen,
size: 14,
),
),
Positioned(
bottom: 0,
left: 0,
child: SvgIcon(
assetPath: SvgIcons.verifiedGreen,
size: 14,
color: colorVerificationBadgeYellow,
),
),
Positioned(
bottom: 0,
right: 0,
child: SvgIcon(
assetPath: SvgIcons.verifiedRed,
size: 14,
),
),
],
),
);
}
}

View file

@ -148,7 +148,10 @@ class _SubscriptionViewState extends State<SubscriptionView> {
size: 15, size: 15,
), ),
onTap: () async { onTap: () async {
await launchUrl(Uri.parse('https://twonly.eu/de/legal/agb.html')); await launchUrl(
Uri.parse('https://twonly.eu/de/legal/agb.html'),
mode: LaunchMode.externalApplication,
);
}, },
), ),
BetterListTile( BetterListTile(
@ -164,6 +167,7 @@ class _SubscriptionViewState extends State<SubscriptionView> {
onTap: () async { onTap: () async {
await launchUrl( await launchUrl(
Uri.parse('https://twonly.eu/de/legal/privacy.html'), Uri.parse('https://twonly.eu/de/legal/privacy.html'),
mode: LaunchMode.externalApplication,
); );
}, },
), ),
@ -330,7 +334,9 @@ class _PlanCardState extends State<PlanCard> {
? const SizedBox( ? const SizedBox(
width: 10, width: 10,
height: 10, height: 10,
child: CircularProgressIndicator.adaptive(strokeWidth: 1), child: CircularProgressIndicator.adaptive(
strokeWidth: 1,
),
) )
: null, : null,
label: Text( label: Text(
@ -350,7 +356,9 @@ class _PlanCardState extends State<PlanCard> {
? const SizedBox( ? const SizedBox(
width: 10, width: 10,
height: 10, height: 10,
child: CircularProgressIndicator.adaptive(strokeWidth: 1), child: CircularProgressIndicator.adaptive(
strokeWidth: 1,
),
) )
: null, : null,
label: Text( label: Text(

View file

@ -11,6 +11,7 @@ import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/avatar_icon.comp.dart'; import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
import 'package:twonly/src/visual/components/flame_counter.comp.dart'; import 'package:twonly/src/visual/components/flame_counter.comp.dart';
import 'package:twonly/src/visual/components/verification_badge.comp.dart';
import 'package:twonly/src/visual/context_menu/user.context_menu.dart'; import 'package:twonly/src/visual/context_menu/user.context_menu.dart';
import 'package:twonly/src/visual/decorations/input_text.decoration.dart'; import 'package:twonly/src/visual/decorations/input_text.decoration.dart';
@ -19,10 +20,12 @@ class SelectedContactView {
required this.title, required this.title,
required this.submitButton, required this.submitButton,
required this.submitIcon, required this.submitIcon,
this.alreadySelectedSubtitle,
}); });
final String title; final String title;
final String Function(int selected, int? limit) submitButton; final String Function(int selected, int? limit) submitButton;
final FaIconData submitIcon; final FaIconData submitIcon;
final String? alreadySelectedSubtitle;
} }
class SelectContactsView extends StatefulWidget { class SelectContactsView extends StatefulWidget {
@ -30,11 +33,18 @@ class SelectContactsView extends StatefulWidget {
required this.text, required this.text,
this.alreadySelected, this.alreadySelected,
this.limit, this.limit,
this.isAlreadySelectedLocked = true,
this.onlyVerified = false,
this.sortByMediaCount = false,
super.key, super.key,
}); });
final SelectedContactView text; final SelectedContactView text;
final List<int>? alreadySelected; final List<int>? alreadySelected;
final int? limit; final int? limit;
final bool isAlreadySelectedLocked;
final bool onlyVerified;
final bool sortByMediaCount;
@override @override
State<SelectContactsView> createState() => _SelectAdditionalUsers(); State<SelectContactsView> createState() => _SelectAdditionalUsers();
} }
@ -47,12 +57,17 @@ class _SelectAdditionalUsers extends State<SelectContactsView> {
final HashSet<int> selectedUsers = HashSet(); final HashSet<int> selectedUsers = HashSet();
late HashSet<int> _alreadySelected; late HashSet<int> _alreadySelected;
final HashSet<int> verifiedUserIds = HashSet();
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_alreadySelected = HashSet.from(widget.alreadySelected ?? []); _alreadySelected = HashSet.from(widget.alreadySelected ?? []);
if (!widget.isAlreadySelectedLocked) {
selectedUsers.addAll(_alreadySelected);
_alreadySelected.clear();
}
final stream = twonlyDB.contactsDao.watchAllAcceptedContacts(); final stream = twonlyDB.contactsDao.watchAllAcceptedContacts();
@ -65,6 +80,23 @@ class _SelectAdditionalUsers extends State<SelectContactsView> {
}); });
await filterUsers(); await filterUsers();
}); });
_loadVerifiedContacts();
}
Future<void> _loadVerifiedContacts() async {
final kvs = await twonlyDB.select(twonlyDB.keyVerifications).get();
final urs = await (twonlyDB.select(twonlyDB.userDiscoveryUserRelations)
..where((u) => u.publicKeyVerifiedTimestamp.isNotNull()))
.get();
if (!mounted) return;
setState(() {
verifiedUserIds
..addAll(kvs.map((row) => row.contactId))
..addAll(urs.map((row) => row.announcedUserId));
});
await filterUsers();
} }
@override @override
@ -74,21 +106,37 @@ class _SelectAdditionalUsers extends State<SelectContactsView> {
} }
Future<void> filterUsers() async { Future<void> filterUsers() async {
if (searchUserName.value.text.isEmpty) { var filtered = allContacts;
setState(() { if (searchUserName.value.text.isNotEmpty) {
contacts = allContacts; filtered = filtered
});
return;
}
final usersFiltered = allContacts
.where( .where(
(user) => getContactDisplayName( (user) => getContactDisplayName(
user, user,
).toLowerCase().contains(searchUserName.value.text.toLowerCase()), ).toLowerCase().contains(searchUserName.value.text.toLowerCase()),
) )
.toList(); .toList();
}
if (widget.sortByMediaCount) {
filtered.sort((a, b) {
final aVerified = verifiedUserIds.contains(a.userId);
final bVerified = verifiedUserIds.contains(b.userId);
if (aVerified && !bVerified) return -1;
if (!aVerified && bVerified) return 1;
final cmp = b.mediaSendCounter.compareTo(a.mediaSendCounter);
if (cmp != 0) return cmp;
return getContactDisplayName(a).compareTo(getContactDisplayName(b));
});
} else {
filtered.sort(
(a, b) => getContactDisplayName(a).compareTo(getContactDisplayName(b)),
);
}
setState(() { setState(() {
contacts = usersFiltered; contacts = filtered;
}); });
} }
@ -119,7 +167,7 @@ class _SelectAdditionalUsers extends State<SelectContactsView> {
: () => Navigator.pop(context, selectedUsers.toList()), : () => Navigator.pop(context, selectedUsers.toList()),
label: Text( label: Text(
widget.text.submitButton( widget.text.submitButton(
selectedUsers.length + (widget.alreadySelected?.length ?? 0), selectedUsers.length + _alreadySelected.length,
widget.limit, widget.limit,
), ),
), ),
@ -169,10 +217,15 @@ class _SelectAdditionalUsers extends State<SelectContactsView> {
return Wrap( return Wrap(
spacing: 8, spacing: 8,
children: selected.map((w) { children: selected.map((w) {
final contact = allContacts
.where((t) => t.userId == w)
.firstOrNull;
if (contact == null) {
return const SizedBox.shrink();
}
return _Chip( return _Chip(
contact: allContacts.firstWhere( key: ValueKey(contact.userId),
(t) => t.userId == w, contact: contact,
),
onTap: toggleSelectedUser, onTap: toggleSelectedUser,
); );
}).toList(), }).toList(),
@ -188,13 +241,27 @@ class _SelectAdditionalUsers extends State<SelectContactsView> {
i -= 2; i -= 2;
} }
final user = contacts[i]; final user = contacts[i];
final isVerified = verifiedUserIds.contains(user.userId);
final isSelectionDisabled = widget.onlyVerified && !isVerified;
return UserContextMenu( return UserContextMenu(
key: ValueKey(user.userId), key: ValueKey(user.userId),
contact: user, contact: user,
child: ListTile( child: ListTile(
enabled: !isSelectionDisabled,
title: Row( title: Row(
children: [ children: [
Text(getContactDisplayName(user)), Flexible(
child: Text(
getContactDisplayName(user),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 4),
VerificationBadgeComp(
contact: user,
size: 14,
clickable: false,
),
FlameCounterWidget( FlameCounterWidget(
contactId: user.userId, contactId: user.userId,
prefix: true, prefix: true,
@ -202,8 +269,17 @@ class _SelectAdditionalUsers extends State<SelectContactsView> {
], ],
), ),
subtitle: (_alreadySelected.contains(user.userId)) subtitle: (_alreadySelected.contains(user.userId))
? Text(context.lang.alreadyInGroup) ? (widget.text.alreadySelectedSubtitle != null
: null, ? Text(widget.text.alreadySelectedSubtitle!)
: Text(context.lang.alreadyInGroup))
: (isSelectionDisabled
? Text(
context.lang.contactNotVerified,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
)
: null),
leading: AvatarIcon( leading: AvatarIcon(
contactId: user.userId, contactId: user.userId,
fontSize: 13, fontSize: 13,
@ -222,11 +298,15 @@ class _SelectAdditionalUsers extends State<SelectContactsView> {
); );
}, },
), ),
onChanged: (value) { onChanged: isSelectionDisabled
? null
: (value) {
toggleSelectedUser(user.userId); toggleSelectedUser(user.userId);
}, },
), ),
onTap: () { onTap: isSelectionDisabled
? null
: () {
toggleSelectedUser(user.userId); toggleSelectedUser(user.userId);
}, },
), ),
@ -247,6 +327,7 @@ class _Chip extends StatelessWidget {
const _Chip({ const _Chip({
required this.contact, required this.contact,
required this.onTap, required this.onTap,
super.key,
}); });
final Contact contact; final Contact contact;
final void Function(int) onTap; final void Function(int) onTap;
@ -268,6 +349,12 @@ class _Chip extends StatelessWidget {
style: const TextStyle(fontSize: 14), style: const TextStyle(fontSize: 14),
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
const SizedBox(width: 4),
VerificationBadgeComp(
contact: contact,
size: 12,
clickable: false,
),
const SizedBox(width: 15), const SizedBox(width: 15),
const FaIcon( const FaIcon(
FontAwesomeIcons.xmark, FontAwesomeIcons.xmark,

View file

@ -65,7 +65,7 @@ packages:
source: hosted source: hosted
version: "1.0.4" version: "1.0.4"
archive: archive:
dependency: "direct main" dependency: transitive
description: description:
name: archive name: archive
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
@ -389,18 +389,18 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: device_info_plus name: device_info_plus
sha256: b4fed1b2835da9d670d7bed7db79ae2a94b0f5ad6312268158a9b5479abbacdd sha256: "6a642e1daa10190af89ba6cb6386c0df7d071a3592080bfe1e44faa63ae1df65"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "12.4.0" version: "13.1.0"
device_info_plus_platform_interface: device_info_plus_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: device_info_plus_platform_interface name: device_info_plus_platform_interface
sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f sha256: "04b173a92e2d9161dfead145667037c8d834db725ce2e7b942bfe18fd2f45a46"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.0.3" version: "8.1.0"
dots_indicator: dots_indicator:
dependency: "direct overridden" dependency: "direct overridden"
description: description:
@ -471,6 +471,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.0" version: "2.2.0"
ffi_leak_tracker:
dependency: transitive
description:
name: ffi_leak_tracker
sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97"
url: "https://pub.dev"
source: hosted
version: "0.1.2"
file: file:
dependency: transitive dependency: transitive
description: description:
@ -479,14 +487,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.0.1" version: "7.0.1"
file_picker:
dependency: "direct main"
description:
name: file_picker
sha256: "57d9a1dd5063f85fa3107fb42d1faffda52fdc948cefd5fe5ea85267a5fc7343"
url: "https://pub.dev"
source: hosted
version: "10.3.10"
file_selector_linux: file_selector_linux:
dependency: transitive dependency: transitive
description: description:
@ -834,10 +834,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: flutter_secure_storage_windows name: flutter_secure_storage_windows
sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613" sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.1.0" version: "4.2.2"
flutter_sharing_intent: flutter_sharing_intent:
dependency: "direct main" dependency: "direct main"
description: description:
@ -892,14 +892,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.2" version: "2.3.2"
get:
dependency: "direct main"
description:
name: get
sha256: "5ed34a7925b85336e15d472cc4cfe7d9ebf4ab8e8b9f688585bf6b50f4c3d79a"
url: "https://pub.dev"
source: hosted
version: "4.7.3"
get_it: get_it:
dependency: "direct main" dependency: "direct main"
description: description:
@ -1037,10 +1029,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: image_picker name: image_picker
sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320" sha256: "91c025426c2881c551100bce834e201c835a170151545f58d17da5180ca7d9ac"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.2.1" version: "1.2.2"
image_picker_android: image_picker_android:
dependency: transitive dependency: transitive
description: description:
@ -1381,18 +1373,18 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: package_info_plus name: package_info_plus
sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20" sha256: "4bf625947f6c7713ee242296a682e23e44823c09cf9d79e4f1238923c92db852"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "9.0.1" version: "10.1.0"
package_info_plus_platform_interface: package_info_plus_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: package_info_plus_platform_interface name: package_info_plus_platform_interface
sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.2.1" version: "4.1.0"
path: path:
dependency: "direct main" dependency: "direct main"
description: description:
@ -1694,18 +1686,18 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: share_plus name: share_plus
sha256: "223873d106614442ea6f20db5a038685cc5b32a2fba81cdecaefbbae0523f7fa" sha256: a857d8b1479250aff6b57a51b2c02d31ca05848d441817c43f1640c885c286c0
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "12.0.2" version: "13.1.0"
share_plus_platform_interface: share_plus_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: share_plus_platform_interface name: share_plus_platform_interface
sha256: "88023e53a13429bd65d8e85e11a9b484f49d4c190abbd96c7932b74d6927cc9a" sha256: "7f7ae28cf400d13f811e297ff37742dba83b79e0a6f5dce14eec0248274e6ce9"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.0" version: "7.1.0"
shared_preferences: shared_preferences:
dependency: transitive dependency: transitive
description: description:
@ -2055,7 +2047,7 @@ packages:
source: hosted source: hosted
version: "1.1.13" version: "1.1.13"
vector_graphics_compiler: vector_graphics_compiler:
dependency: transitive dependency: "direct dev"
description: description:
name: vector_graphics_compiler name: vector_graphics_compiler
sha256: "5a88dd14c0954a5398af544651c7fb51b457a2a556949bfb25369b210ef73a74" sha256: "5a88dd14c0954a5398af544651c7fb51b457a2a556949bfb25369b210ef73a74"
@ -2170,18 +2162,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: win32 name: win32
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.15.0" version: "6.3.0"
win32_registry: win32_registry:
dependency: transitive dependency: transitive
description: description:
name: win32_registry name: win32_registry
sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae" sha256: "73b1d78920a9d6e03f8b4e43e612b87bf3152a0e5c5e5150267762b7c4116904"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.0" version: "3.0.3"
workmanager: workmanager:
dependency: "direct main" dependency: "direct main"
description: description:

View file

@ -3,7 +3,7 @@ description: "twonly, a privacy-friendly way to connect with friends through sec
publish_to: 'none' publish_to: 'none'
version: 0.3.0+139 version: 0.3.1+145
environment: environment:
sdk: ^3.11.0 sdk: ^3.11.0
@ -17,8 +17,8 @@ dependencies:
# Trusted published dart.dev or tools.dart.dev # Trusted published dart.dev or tools.dart.dev
collection: ^1.18.0 collection: ^1.18.0
fixnum: ^1.1.1 fixnum: ^1.1.1
meta: ^1.17.0 meta: ^1.17.0 # used by overwritten dependencies...
http: ^1.3.0 http: ^1.6.0
intl: ^0.20.2 intl: ^0.20.2
path: ^1.9.0 path: ^1.9.0
logging: ^1.3.0 logging: ^1.3.0
@ -32,7 +32,7 @@ dependencies:
# Trusted publisher flutter.dev # Trusted publisher flutter.dev
camera: ^0.12.0+1 camera: ^0.12.0+1
flutter_svg: ^2.0.17 flutter_svg: ^2.0.17
image_picker: ^1.1.2 image_picker: ^1.2.2
local_auth: ^3.0.0 local_auth: ^3.0.0
path_provider: ^2.1.5 path_provider: ^2.1.5
url_launcher: ^6.3.2 url_launcher: ^6.3.2
@ -44,10 +44,10 @@ dependencies:
# Trusted publisher fluttercommunity.dev # Trusted publisher fluttercommunity.dev
connectivity_plus: ^7.0.0 connectivity_plus: ^7.0.0
device_info_plus: ^12.1.0 device_info_plus: ^13.1.0
font_awesome_flutter: ^11.0.0 font_awesome_flutter: ^11.0.0
share_plus: ^12.0.0 share_plus: ^13.1.0
package_info_plus: ^9.0.0 package_info_plus: ^10.1.0
workmanager: ^0.9.0+3 workmanager: ^0.9.0+3
@ -91,14 +91,11 @@ dependencies:
# With high download. (But should be checked nonetheless.) # With high download. (But should be checked nonetheless.)
app_links: ^7.0.0 # 1.6 mio app_links: ^7.0.0 # 1.6 mio
image: ^4.3.0 # 3.3 mio image: ^4.3.0 # 3.3 mio
archive: ^4.0.7 # 6.5 mio
file_picker: ^10.3.6 # 2 mio
get: ^4.7.2 # 740 k
flutter_secure_storage: ^10.3.1 # 1.85 mio flutter_secure_storage: ^10.3.1 # 1.85 mio
permission_handler: ^12.0.0+1 # 2 mio permission_handler: ^12.0.0+1 # 2 mio
# Not yet checked # Not yet checked
audio_waveforms: ^2.0.0 audio_waveforms: ^2.0.2
avatar_maker: ^0.4.0 avatar_maker: ^0.4.0
background_downloader: ^9.4.0 background_downloader: ^9.4.0
cached_network_image: ^3.4.1 cached_network_image: ^3.4.1
@ -184,6 +181,7 @@ dev_dependencies:
drift_dev: ^2.25.2 drift_dev: ^2.25.2
flutter_launcher_icons: ^0.14.1 flutter_launcher_icons: ^0.14.1
flutter_lints: ^6.0.0 flutter_lints: ^6.0.0
vector_graphics_compiler: ^1.2.0
flutter_test: flutter_test:
sdk: flutter sdk: flutter
json_serializable: ^6.8.0 json_serializable: ^6.8.0

View file

@ -1,5 +1,6 @@
use std::io::Result; use std::io::Result;
fn main() -> Result<()> { fn main() -> Result<()> {
prost_build::compile_protos(&["src/user_discovery/types.proto"], &["src/"])?; prost_build::compile_protos(&["src/user_discovery/types.proto"], &["src/"])?;
prost_build::compile_protos(&["src/passwordless_recovery/types.proto"], &["src/"])?;
Ok(()) Ok(())
} }

View file

@ -1 +1,12 @@
mod traits; include!(concat!(env!("OUT_DIR"), "/passwordless_recovery.rs"));
struct PasswordLessRecovery {}
impl PasswordLessRecovery {
pub(crate) fn create_shared_secret_data(
total_shares: i64,
threshold: i64,
pin_unlock_token: Option<Vec<u8>>,
) {
}
}

Some files were not shown because too many files have changed in this diff Show more