mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-06-25 07:04:08 +00:00
commit
c7db5b7bdc
110 changed files with 49115 additions and 1158 deletions
12
CHANGELOG.md
12
CHANGELOG.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
BIN
assets/icons/verification_badge_numeric/verified_badge_1.svg.vec
Normal file
BIN
assets/icons/verification_badge_numeric/verified_badge_1.svg.vec
Normal file
Binary file not shown.
BIN
assets/icons/verification_badge_numeric/verified_badge_2.svg.vec
Normal file
BIN
assets/icons/verification_badge_numeric/verified_badge_2.svg.vec
Normal file
Binary file not shown.
BIN
assets/icons/verification_badge_numeric/verified_badge_3.svg.vec
Normal file
BIN
assets/icons/verification_badge_numeric/verified_badge_3.svg.vec
Normal file
Binary file not shown.
BIN
assets/icons/verified_badge_green.svg.vec
Normal file
BIN
assets/icons/verified_badge_green.svg.vec
Normal file
Binary file not shown.
BIN
assets/icons/verified_badge_red.svg.vec
Normal file
BIN
assets/icons/verified_badge_red.svg.vec
Normal file
Binary file not shown.
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
||||||
3047
lib/src/database/schemas/twonly_db/drift_schema_v18.json
Normal file
3047
lib/src/database/schemas/twonly_db/drift_schema_v18.json
Normal file
File diff suppressed because it is too large
Load diff
3073
lib/src/database/schemas/twonly_db/drift_schema_v19.json
Normal file
3073
lib/src/database/schemas/twonly_db/drift_schema_v19.json
Normal file
File diff suppressed because it is too large
Load diff
3107
lib/src/database/schemas/twonly_db/drift_schema_v20.json
Normal file
3107
lib/src/database/schemas/twonly_db/drift_schema_v20.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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)();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -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 =
|
||||||
|
|
|
||||||
|
|
@ -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];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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');
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -70,8 +72,9 @@ message EncryptedContent {
|
||||||
|
|
||||||
message GroupCreate {
|
message GroupCreate {
|
||||||
// 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 {
|
||||||
|
|
|
||||||
|
|
@ -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}',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -227,46 +227,54 @@ 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.
|
||||||
continue;
|
try {
|
||||||
}
|
if (!service.tempPath.existsSync()) {
|
||||||
if (mediaFile.reuploadRequestedBy != null) {
|
service.storedPath.copySync(service.tempPath.path);
|
||||||
Log.warn(
|
}
|
||||||
'Reupload requested for ${mediaFile.mediaId} but files are missing. Cancelling reupload but keeping record.',
|
} catch (e) {
|
||||||
);
|
Log.error('Error recovering tempPath from storedPath: $e');
|
||||||
await twonlyDB.mediaFilesDao.updateMedia(
|
continue;
|
||||||
mediaFile.mediaId,
|
}
|
||||||
const MediaFilesCompanion(
|
|
||||||
uploadState: Value(UploadState.uploaded),
|
|
||||||
reuploadRequestedBy: Value(null),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
final messages = await twonlyDB.messagesDao.getMessagesByMediaId(
|
|
||||||
mediaFile.mediaId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (messages.isEmpty) {
|
|
||||||
Log.info(
|
|
||||||
'Deleted media files ${mediaFile.mediaId} as originalPath and uploadRequestPath both do not exists and no messages reference it.',
|
|
||||||
);
|
|
||||||
// the file does not exists anymore and no messages reference it.
|
|
||||||
await twonlyDB.mediaFilesDao.deleteMediaFile(mediaFile.mediaId);
|
|
||||||
} else {
|
} else {
|
||||||
Log.warn(
|
if (mediaFile.reuploadRequestedBy != null) {
|
||||||
'Media files ${mediaFile.mediaId} missing but messages still reference it. Keeping record to avoid broken chat history.',
|
Log.warn(
|
||||||
);
|
'Reupload requested for ${mediaFile.mediaId} but files are missing. Cancelling reupload but keeping record.',
|
||||||
// Just mark as uploaded to stop preprocessing attempts
|
);
|
||||||
await twonlyDB.mediaFilesDao.updateMedia(
|
await twonlyDB.mediaFilesDao.updateMedia(
|
||||||
|
mediaFile.mediaId,
|
||||||
|
const MediaFilesCompanion(
|
||||||
|
uploadState: Value(UploadState.uploaded),
|
||||||
|
reuploadRequestedBy: Value(null),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final messages = await twonlyDB.messagesDao.getMessagesByMediaId(
|
||||||
mediaFile.mediaId,
|
mediaFile.mediaId,
|
||||||
const MediaFilesCompanion(
|
|
||||||
uploadState: Value(UploadState.uploaded),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (messages.isEmpty) {
|
||||||
|
Log.info(
|
||||||
|
'Deleted media files ${mediaFile.mediaId} as originalPath and uploadRequestPath both do not exists and no messages reference it.',
|
||||||
|
);
|
||||||
|
// the file does not exists anymore and no messages reference it.
|
||||||
|
await twonlyDB.mediaFilesDao.deleteMediaFile(mediaFile.mediaId);
|
||||||
|
} else {
|
||||||
|
Log.warn(
|
||||||
|
'Media files ${mediaFile.mediaId} missing but messages still reference it. Keeping record to avoid broken chat history.',
|
||||||
|
);
|
||||||
|
// Just mark as uploaded to stop preprocessing attempts
|
||||||
|
await twonlyDB.mediaFilesDao.updateMedia(
|
||||||
|
mediaFile.mediaId,
|
||||||
|
const MediaFilesCompanion(
|
||||||
|
uploadState: Value(UploadState.uploaded),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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()) {
|
||||||
await mediaService.compressMedia();
|
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();
|
||||||
|
}
|
||||||
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,12 +454,26 @@ Future<(Uint8List, Uint8List?)?> sendCipherText(
|
||||||
userService.currentUser.avatarCounter,
|
userService.currentUser.avatarCounter,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (userService.currentUser.isUserDiscoveryEnabled && messageId != null) {
|
{
|
||||||
final contact = await twonlyDB.contactsDao.getContactById(contactId);
|
if (userService.currentUser.askForFriendPromotions) {
|
||||||
if (UserDiscoveryService.isContactAllowed(contact)) {
|
final contacts = await twonlyDB.contactsDao.getAllContacts();
|
||||||
final version = await UserDiscoveryService.getCurrentVersion();
|
final contactCount = contacts.where((c) => c.accepted).length;
|
||||||
if (version != null) {
|
if (contactCount > 5) {
|
||||||
encryptedContent.senderUserDiscoveryVersion = version;
|
await UserService.update((u) {
|
||||||
|
u.askForFriendPromotions = false;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
encryptedContent.askForFriendPromotions = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userService.currentUser.isUserDiscoveryEnabled && messageId != null) {
|
||||||
|
final contact = await twonlyDB.contactsDao.getContactById(contactId);
|
||||||
|
if (UserDiscoveryService.isContactAllowed(contact)) {
|
||||||
|
final version = await UserDiscoveryService.getCurrentVersion();
|
||||||
|
if (version != null) {
|
||||||
|
encryptedContent.senderUserDiscoveryVersion = version;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
context,
|
VerificationSuccessDialog.show(
|
||||||
context.lang.secretQrTokenVerifiedSnackbar(
|
context,
|
||||||
getContactDisplayName(contact),
|
contact,
|
||||||
|
message: context.lang.secretQrTokenVerifiedSnackbar(
|
||||||
|
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(
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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 == '') {
|
||||||
|
|
|
||||||
115
lib/src/services/passwordless_recovery.service.dart
Normal file
115
lib/src/services/passwordless_recovery.service.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,28 +17,35 @@ 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) {
|
||||||
if (contact.avatarSvgCompressed == null) continue;
|
try {
|
||||||
|
if (contact.avatarSvgCompressed == null) continue;
|
||||||
|
|
||||||
if (forceForUserId == null) {
|
if (forceForUserId == null) {
|
||||||
if (avatarPNGFile(contact.userId).existsSync()) {
|
if (avatarPNGFile(contact.userId).existsSync()) {
|
||||||
continue; // only create the avatar in case no avatar exists yet fot this user
|
continue; // only create the avatar in case no avatar exists yet fot this user
|
||||||
|
}
|
||||||
|
} else if (contact.userId != forceForUserId) {
|
||||||
|
// only update the avatar for this specified contact
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
} else if (contact.userId != forceForUserId) {
|
|
||||||
// only update the avatar for this specified contact
|
final avatarSvg = getAvatarSvg(contact.avatarSvgCompressed!);
|
||||||
continue;
|
|
||||||
|
final pictureInfo = await vg.loadPicture(
|
||||||
|
SvgStringLoader(avatarSvg),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
final image = await pictureInfo.picture.toImage(270, 300);
|
||||||
|
|
||||||
|
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
|
||||||
|
final pngBytes = byteData!.buffer.asUint8List();
|
||||||
|
|
||||||
|
await avatarPNGFile(contact.userId).writeAsBytes(pngBytes);
|
||||||
|
pictureInfo.picture.dispose();
|
||||||
|
} catch (e) {
|
||||||
|
Log.error(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
final avatarSvg = getAvatarSvg(contact.avatarSvgCompressed!);
|
|
||||||
|
|
||||||
final pictureInfo = await vg.loadPicture(SvgStringLoader(avatarSvg), null);
|
|
||||||
|
|
||||||
final image = await pictureInfo.picture.toImage(270, 300);
|
|
||||||
|
|
||||||
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
|
|
||||||
final pngBytes = byteData!.buffer.asUint8List();
|
|
||||||
|
|
||||||
await avatarPNGFile(contact.userId).writeAsBytes(pngBytes);
|
|
||||||
pictureInfo.picture.dispose();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
87
lib/src/visual/components/add_contact_dialog.comp.dart
Normal file
87
lib/src/visual/components/add_contact_dialog.comp.dart
Normal 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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 = [];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
if (userService.currentUser.avatarSvg != null) {
|
unawaited(_updateMyAvatar());
|
||||||
_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(
|
||||||
errorBuilder: errorBuilder,
|
File(_myAvatarPath!),
|
||||||
);
|
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) {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -23,10 +23,17 @@ class VerificationBadgeInfo extends StatelessWidget {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
RichText(
|
||||||
context.lang.verificationBadgeGeneralDesc,
|
text: TextSpan(
|
||||||
|
children: formattedText(
|
||||||
|
context,
|
||||||
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
);
|
// ),
|
||||||
|
// );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
145
lib/src/visual/elements/my_icon_button.element.dart
Normal file
145
lib/src/visual/elements/my_icon_button.element.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,50 +268,61 @@ class _ChatAskAFriendEntryState extends State<ChatAskAFriendEntry> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Row(
|
return StreamBuilder<UserDiscoveryAnnouncedUser?>(
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
stream:
|
||||||
children: [
|
twonlyDB.userDiscoveryDao.watchAnnouncedUser(userId),
|
||||||
TextButton(
|
builder: (context, userSnapshot) {
|
||||||
onPressed: _isLoading ? null : _hideUser,
|
final announcedUser = userSnapshot.data;
|
||||||
style: TextButton.styleFrom(
|
if (announcedUser != null && announcedUser.isHidden) {
|
||||||
minimumSize: Size.zero,
|
return const SizedBox.shrink();
|
||||||
padding: const EdgeInsets.symmetric(
|
}
|
||||||
horizontal: 12,
|
|
||||||
vertical: 6,
|
return Row(
|
||||||
),
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
),
|
children: [
|
||||||
child: Text(
|
TextButton(
|
||||||
context.lang.chatAskAFriendHide,
|
onPressed: _isLoading ? null : _hideUser,
|
||||||
style: TextStyle(
|
style: TextButton.styleFrom(
|
||||||
fontSize: 12,
|
minimumSize: Size.zero,
|
||||||
color: widget.info.textColor,
|
padding: const EdgeInsets.symmetric(
|
||||||
),
|
horizontal: 12,
|
||||||
),
|
vertical: 6,
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
FilledButton(
|
|
||||||
style: FilledButton.styleFrom(
|
|
||||||
minimumSize: Size.zero,
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 12,
|
|
||||||
vertical: 6,
|
|
||||||
),
|
|
||||||
).merge(secondaryGreyButtonStyle(context)),
|
|
||||||
onPressed: _isLoading ? null : _requestUser,
|
|
||||||
child: _isLoading
|
|
||||||
? const SizedBox(
|
|
||||||
width: 12,
|
|
||||||
height: 12,
|
|
||||||
child: CircularProgressIndicator.adaptive(
|
|
||||||
strokeWidth: 2,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: Text(
|
|
||||||
context.lang.chatAskAFriendRequest,
|
|
||||||
style: const TextStyle(fontSize: 12),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
child: Text(
|
||||||
|
context.lang.chatAskAFriendHide,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: widget.info.textColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
FilledButton(
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
minimumSize: Size.zero,
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 6,
|
||||||
|
),
|
||||||
|
).merge(secondaryGreyButtonStyle(context)),
|
||||||
|
onPressed: _isLoading ? null : _requestUser,
|
||||||
|
child: _isLoading
|
||||||
|
? const SizedBox(
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
child: CircularProgressIndicator.adaptive(
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
|
context.lang.chatAskAFriendRequest,
|
||||||
|
style: const TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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,10 +578,13 @@ class _MessageInputState extends State<MessageInput> {
|
||||||
: _sendMessage,
|
: _sendMessage,
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
IconButton(
|
SparksWidget(
|
||||||
icon: const FaIcon(FontAwesomeIcons.plus),
|
animate: _showSparks,
|
||||||
padding: const EdgeInsets.all(15),
|
child: IconButton(
|
||||||
onPressed: () => _showAdditionalShareModal(context),
|
icon: const FaIcon(FontAwesomeIcons.plus),
|
||||||
|
padding: const EdgeInsets.all(15),
|
||||||
|
onPressed: () => _showAdditionalShareModal(context),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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,47 +80,63 @@ 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 =
|
||||||
alignment: Alignment.centerLeft,
|
(widget.group.isDirectChat ? 20 : 24) * _groupMembers.length.toDouble();
|
||||||
child: Padding(
|
_wasShownOnce = height;
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
child: Column(
|
return SizedBox(
|
||||||
children: _groupMembers
|
height: height,
|
||||||
.map(
|
child: Align(
|
||||||
(member) => Padding(
|
alignment: Alignment.centerLeft,
|
||||||
key: Key('typing_indicator_${member.contactId}'),
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
padding: const EdgeInsets.only(left: 12),
|
||||||
child: Row(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
if (!widget.group.isDirectChat)
|
children: _groupMembers
|
||||||
GestureDetector(
|
.map(
|
||||||
onTap: () => context.push(
|
(member) => Padding(
|
||||||
Routes.profileContact(member.contactId),
|
key: Key('typing_indicator_${member.contactId}'),
|
||||||
|
padding: const EdgeInsets.only(right: 8),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (!widget.group.isDirectChat)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 6),
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () => context.push(
|
||||||
|
Routes.profileContact(member.contactId),
|
||||||
|
),
|
||||||
|
child: AvatarIcon(
|
||||||
|
contactId: member.contactId,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: AvatarIcon(
|
Container(
|
||||||
contactId: member.contactId,
|
padding: const EdgeInsets.symmetric(
|
||||||
fontSize: 12,
|
horizontal: 7,
|
||||||
|
vertical: 6,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: getMessageColor(true),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: AnimatedTypingDots(
|
||||||
|
isTyping: isTyping(member),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Container(
|
],
|
||||||
padding: const EdgeInsets.all(8),
|
),
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: getMessageColor(true),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
child: AnimatedTypingDots(
|
|
||||||
isTyping: isTyping(member),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(child: Container()),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
)
|
||||||
)
|
.toList(),
|
||||||
.toList(),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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, size: 20),
|
||||||
: const FaIcon(FontAwesomeIcons.floppyDisk),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
IconButton(
|
const SizedBox(width: 10),
|
||||||
icon: const FaIcon(FontAwesomeIcons.solidPaperPlane),
|
MyIconButton(
|
||||||
|
icon: const FaIcon(
|
||||||
|
FontAwesomeIcons.solidPaperPlane,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
if (textMessageController.text.isNotEmpty) {
|
if (textMessageController.text.isNotEmpty) {
|
||||||
await insertAndSendTextMessage(
|
unawaited(
|
||||||
widget.group.groupId,
|
insertAndSendTextMessage(
|
||||||
textMessageController.text,
|
widget.group.groupId,
|
||||||
currentMessage!.messageId,
|
textMessageController.text,
|
||||||
|
currentMessage!.messageId,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
textMessageController.clear();
|
textMessageController.clear();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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,65 +126,62 @@ class _VerificationExpansionTileCompState
|
||||||
title: Text(context.lang.userVerifiedTitle),
|
title: Text(context.lang.userVerifiedTitle),
|
||||||
children: [
|
children: [
|
||||||
..._keyVerifications.map(
|
..._keyVerifications.map(
|
||||||
(kv) => ListTile(
|
(pair) {
|
||||||
dense: true,
|
final kv = pair.$1;
|
||||||
contentPadding: const EdgeInsets.only(left: 16),
|
final verifier = pair.$2;
|
||||||
title: Text(_verificationTypeLabel(context, kv.type)),
|
return ListTile(
|
||||||
trailing: Row(
|
dense: true,
|
||||||
mainAxisSize: MainAxisSize.min,
|
contentPadding: const EdgeInsets.only(left: 16),
|
||||||
children: [
|
title:
|
||||||
Text(
|
kv.type == VerificationType.contactSharedByVerified &&
|
||||||
DateFormat.yMd(
|
verifier != null
|
||||||
Localizations.localeOf(context).toString(),
|
? _VerifiedByContactRow(contact: verifier)
|
||||||
).format(kv.createdAt),
|
: Text(_verificationTypeLabel(context, kv.type, verifier)),
|
||||||
style: TextStyle(
|
trailing: Row(
|
||||||
color: context.color.onSurfaceVariant,
|
mainAxisSize: MainAxisSize.min,
|
||||||
fontSize: 13,
|
children: [
|
||||||
|
Text(
|
||||||
|
DateFormat.yMd(
|
||||||
|
Localizations.localeOf(context).toString(),
|
||||||
|
).format(kv.createdAt),
|
||||||
|
style: TextStyle(
|
||||||
|
color: context.color.onSurfaceVariant,
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
IconButton(
|
||||||
IconButton(
|
padding: EdgeInsets.zero,
|
||||||
padding: EdgeInsets.zero,
|
constraints: const BoxConstraints(),
|
||||||
constraints: const BoxConstraints(),
|
iconSize: 8,
|
||||||
iconSize: 8,
|
icon: FaIcon(
|
||||||
icon: FaIcon(
|
FontAwesomeIcons.trash,
|
||||||
FontAwesomeIcons.trash,
|
size: 8,
|
||||||
size: 8,
|
color: context.color.onSurfaceVariant,
|
||||||
color: context.color.onSurfaceVariant,
|
),
|
||||||
|
onPressed: () async {
|
||||||
|
final confirm = await showAlertDialog(
|
||||||
|
context,
|
||||||
|
context.lang.deleteVerificationTitle,
|
||||||
|
context.lang.deleteVerificationBody,
|
||||||
|
);
|
||||||
|
if (confirm) {
|
||||||
|
await twonlyDB.keyVerificationDao
|
||||||
|
.deleteKeyVerificationById(
|
||||||
|
kv.verificationId,
|
||||||
|
widget.contact.userId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
onPressed: () async {
|
],
|
||||||
final confirm = await showAlertDialog(
|
),
|
||||||
context,
|
);
|
||||||
context.lang.deleteVerificationTitle,
|
},
|
||||||
context.lang.deleteVerificationBody,
|
|
||||||
);
|
|
||||||
if (confirm) {
|
|
||||||
await twonlyDB.keyVerificationDao
|
|
||||||
.deleteKeyVerificationById(
|
|
||||||
kv.verificationId,
|
|
||||||
widget.contact.userId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
..._transferredTrust.map(
|
...filteredTransferredTrust.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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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,36 +178,59 @@ class _BackupViewState extends State<BackupView> {
|
||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
|
||||||
MyButton(
|
|
||||||
variant: MyButtonVariant.primaryMiddle,
|
|
||||||
onPressed: _isLoading
|
|
||||||
? null
|
|
||||||
: () async {
|
|
||||||
setState(() {
|
|
||||||
_isLoading = true;
|
|
||||||
});
|
|
||||||
await BackupService.makeBackup(force: true);
|
|
||||||
setState(() {
|
|
||||||
_isLoading = false;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
child: Text(context.lang.backupTwonlySaveNow),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
|
||||||
Center(
|
if (userService.currentUser.passwordLessRecovery == null &&
|
||||||
child: MyButton(
|
kDebugMode) ...[
|
||||||
variant: MyButtonVariant.secondaryDense,
|
const SizedBox(height: 20),
|
||||||
onPressed: () =>
|
Center(
|
||||||
context.push(Routes.settingsBackupSetup, extra: true),
|
child: MyButton(
|
||||||
child: Text(
|
variant: MyButtonVariant.primaryMiddle,
|
||||||
!userService.currentUser.isBackupEnabled
|
onPressed: () =>
|
||||||
? context.lang.backupEnableBackup
|
context.navPush(const PasswordLessRecoverySetup()),
|
||||||
: context.lang.backupChangePassword,
|
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
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
});
|
||||||
|
await BackupService.makeBackup(force: true);
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: Text(context.lang.backupTwonlySaveNow),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
],
|
||||||
|
MyButton(
|
||||||
|
variant: MyButtonVariant.secondaryDense,
|
||||||
|
onPressed: () => context.push(
|
||||||
|
Routes.settingsBackupSetup,
|
||||||
|
extra: true,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
!userService.currentUser.isBackupEnabled
|
||||||
|
? context.lang.backupEnableBackup
|
||||||
|
: context.lang.backupChangePassword,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
});
|
.where(
|
||||||
return;
|
(user) => getContactDisplayName(
|
||||||
|
user,
|
||||||
|
).toLowerCase().contains(searchUserName.value.text.toLowerCase()),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
}
|
}
|
||||||
final usersFiltered = allContacts
|
|
||||||
.where(
|
if (widget.sortByMediaCount) {
|
||||||
(user) => getContactDisplayName(
|
filtered.sort((a, b) {
|
||||||
user,
|
final aVerified = verifiedUserIds.contains(a.userId);
|
||||||
).toLowerCase().contains(searchUserName.value.text.toLowerCase()),
|
final bVerified = verifiedUserIds.contains(b.userId);
|
||||||
)
|
if (aVerified && !bVerified) return -1;
|
||||||
.toList();
|
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,13 +298,17 @@ class _SelectAdditionalUsers extends State<SelectContactsView> {
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
onChanged: (value) {
|
onChanged: isSelectionDisabled
|
||||||
toggleSelectedUser(user.userId);
|
? null
|
||||||
},
|
: (value) {
|
||||||
|
toggleSelectedUser(user.userId);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
onTap: () {
|
onTap: isSelectionDisabled
|
||||||
toggleSelectedUser(user.userId);
|
? null
|
||||||
},
|
: () {
|
||||||
|
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,
|
||||||
|
|
|
||||||
68
pubspec.lock
68
pubspec.lock
|
|
@ -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:
|
||||||
|
|
|
||||||
20
pubspec.yaml
20
pubspec.yaml
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
Loading…
Reference in a new issue