Fix: Shared contacts now correctly show the blue verification badge
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run

This commit is contained in:
otsmr 2026-06-16 11:32:39 +02:00
parent 9daf275310
commit 96eddd7480
23 changed files with 15511 additions and 241 deletions

View file

@ -5,6 +5,7 @@
- New: Promotion of sharing contacts when contact is new to twonly - New: Promotion of sharing contacts when contact is new to twonly
- Improve: Onboarding of new users to the verification badges - Improve: Onboarding of new users to the verification badges
- Improve: Better feedback when a QR code is scanned - 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: Suppressed link previews for scanned QR codes
- Fix: Black screen on iOS when a link is clicked - Fix: Black screen on iOS when a link is clicked
- Fix: Fixed size of the typing indicator to prevent the chat from glitching - Fix: Fixed size of the typing indicator to prevent the chat from glitching

View file

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

File diff suppressed because it is too large Load diff

View file

@ -59,6 +59,11 @@ class KeyVerifications extends Table {
onDelete: KeyAction.cascade, onDelete: KeyAction.cascade,
)(); )();
TextColumn get type => textEnum<VerificationType>()(); TextColumn get type => textEnum<VerificationType>()();
IntColumn get verifiedBy => integer().nullable().references(
Contacts,
#userId,
onDelete: KeyAction.cascade,
)();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
} }

View file

@ -82,7 +82,7 @@ class TwonlyDB extends _$TwonlyDB {
TwonlyDB.forTesting(DatabaseConnection super.connection); TwonlyDB.forTesting(DatabaseConnection super.connection);
@override @override
int get schemaVersion => 18; int get schemaVersion => 19;
static QueryExecutor _openConnection() { static QueryExecutor _openConnection() {
final connection = driftDatabase( final connection = driftDatabase(
@ -239,6 +239,12 @@ class TwonlyDB extends _$TwonlyDB {
schema.contacts.askForFriendPromotions, schema.contacts.askForFriendPromotions,
); );
}, },
from18To19: (m, schema) async {
await m.addColumn(
schema.keyVerifications,
schema.keyVerifications.verifiedBy,
);
},
)(m, from, to); )(m, from, to);
}, },
); );

View file

@ -9766,6 +9766,20 @@ class $KeyVerificationsTable extends KeyVerifications
type: DriftSqlType.string, type: DriftSqlType.string,
requiredDuringInsert: true, requiredDuringInsert: true,
).withConverter<VerificationType>($KeyVerificationsTable.$convertertype); ).withConverter<VerificationType>($KeyVerificationsTable.$convertertype);
static const VerificationMeta _verifiedByMeta = const VerificationMeta(
'verifiedBy',
);
@override
late final GeneratedColumn<int> verifiedBy = GeneratedColumn<int>(
'verified_by',
aliasedName,
true,
type: DriftSqlType.int,
requiredDuringInsert: false,
defaultConstraints: GeneratedColumn.constraintIsAlways(
'REFERENCES contacts (user_id) ON DELETE CASCADE',
),
);
static const VerificationMeta _createdAtMeta = const VerificationMeta( static const VerificationMeta _createdAtMeta = const VerificationMeta(
'createdAt', 'createdAt',
); );
@ -9783,6 +9797,7 @@ class $KeyVerificationsTable extends KeyVerifications
verificationId, verificationId,
contactId, contactId,
type, type,
verifiedBy,
createdAt, createdAt,
]; ];
@override @override
@ -9814,6 +9829,12 @@ class $KeyVerificationsTable extends KeyVerifications
} else if (isInserting) { } else if (isInserting) {
context.missing(_contactIdMeta); context.missing(_contactIdMeta);
} }
if (data.containsKey('verified_by')) {
context.handle(
_verifiedByMeta,
verifiedBy.isAcceptableOrUnknown(data['verified_by']!, _verifiedByMeta),
);
}
if (data.containsKey('created_at')) { if (data.containsKey('created_at')) {
context.handle( context.handle(
_createdAtMeta, _createdAtMeta,
@ -9843,6 +9864,10 @@ class $KeyVerificationsTable extends KeyVerifications
data['${effectivePrefix}type'], data['${effectivePrefix}type'],
)!, )!,
), ),
verifiedBy: attachedDatabase.typeMapping.read(
DriftSqlType.int,
data['${effectivePrefix}verified_by'],
),
createdAt: attachedDatabase.typeMapping.read( createdAt: attachedDatabase.typeMapping.read(
DriftSqlType.dateTime, DriftSqlType.dateTime,
data['${effectivePrefix}created_at'], data['${effectivePrefix}created_at'],
@ -9863,11 +9888,13 @@ class KeyVerification extends DataClass implements Insertable<KeyVerification> {
final int verificationId; final int verificationId;
final int contactId; final int contactId;
final VerificationType type; final VerificationType type;
final int? verifiedBy;
final DateTime createdAt; final DateTime createdAt;
const KeyVerification({ const KeyVerification({
required this.verificationId, required this.verificationId,
required this.contactId, required this.contactId,
required this.type, required this.type,
this.verifiedBy,
required this.createdAt, required this.createdAt,
}); });
@override @override
@ -9880,6 +9907,9 @@ class KeyVerification extends DataClass implements Insertable<KeyVerification> {
$KeyVerificationsTable.$convertertype.toSql(type), $KeyVerificationsTable.$convertertype.toSql(type),
); );
} }
if (!nullToAbsent || verifiedBy != null) {
map['verified_by'] = Variable<int>(verifiedBy);
}
map['created_at'] = Variable<DateTime>(createdAt); map['created_at'] = Variable<DateTime>(createdAt);
return map; return map;
} }
@ -9889,6 +9919,9 @@ class KeyVerification extends DataClass implements Insertable<KeyVerification> {
verificationId: Value(verificationId), verificationId: Value(verificationId),
contactId: Value(contactId), contactId: Value(contactId),
type: Value(type), type: Value(type),
verifiedBy: verifiedBy == null && nullToAbsent
? const Value.absent()
: Value(verifiedBy),
createdAt: Value(createdAt), createdAt: Value(createdAt),
); );
} }
@ -9904,6 +9937,7 @@ class KeyVerification extends DataClass implements Insertable<KeyVerification> {
type: $KeyVerificationsTable.$convertertype.fromJson( type: $KeyVerificationsTable.$convertertype.fromJson(
serializer.fromJson<String>(json['type']), serializer.fromJson<String>(json['type']),
), ),
verifiedBy: serializer.fromJson<int?>(json['verifiedBy']),
createdAt: serializer.fromJson<DateTime>(json['createdAt']), createdAt: serializer.fromJson<DateTime>(json['createdAt']),
); );
} }
@ -9916,6 +9950,7 @@ class KeyVerification extends DataClass implements Insertable<KeyVerification> {
'type': serializer.toJson<String>( 'type': serializer.toJson<String>(
$KeyVerificationsTable.$convertertype.toJson(type), $KeyVerificationsTable.$convertertype.toJson(type),
), ),
'verifiedBy': serializer.toJson<int?>(verifiedBy),
'createdAt': serializer.toJson<DateTime>(createdAt), 'createdAt': serializer.toJson<DateTime>(createdAt),
}; };
} }
@ -9924,11 +9959,13 @@ class KeyVerification extends DataClass implements Insertable<KeyVerification> {
int? verificationId, int? verificationId,
int? contactId, int? contactId,
VerificationType? type, VerificationType? type,
Value<int?> verifiedBy = const Value.absent(),
DateTime? createdAt, DateTime? createdAt,
}) => KeyVerification( }) => KeyVerification(
verificationId: verificationId ?? this.verificationId, verificationId: verificationId ?? this.verificationId,
contactId: contactId ?? this.contactId, contactId: contactId ?? this.contactId,
type: type ?? this.type, type: type ?? this.type,
verifiedBy: verifiedBy.present ? verifiedBy.value : this.verifiedBy,
createdAt: createdAt ?? this.createdAt, createdAt: createdAt ?? this.createdAt,
); );
KeyVerification copyWithCompanion(KeyVerificationsCompanion data) { KeyVerification copyWithCompanion(KeyVerificationsCompanion data) {
@ -9938,6 +9975,9 @@ class KeyVerification extends DataClass implements Insertable<KeyVerification> {
: this.verificationId, : this.verificationId,
contactId: data.contactId.present ? data.contactId.value : this.contactId, contactId: data.contactId.present ? data.contactId.value : this.contactId,
type: data.type.present ? data.type.value : this.type, type: data.type.present ? data.type.value : this.type,
verifiedBy: data.verifiedBy.present
? data.verifiedBy.value
: this.verifiedBy,
createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt,
); );
} }
@ -9948,13 +9988,15 @@ class KeyVerification extends DataClass implements Insertable<KeyVerification> {
..write('verificationId: $verificationId, ') ..write('verificationId: $verificationId, ')
..write('contactId: $contactId, ') ..write('contactId: $contactId, ')
..write('type: $type, ') ..write('type: $type, ')
..write('verifiedBy: $verifiedBy, ')
..write('createdAt: $createdAt') ..write('createdAt: $createdAt')
..write(')')) ..write(')'))
.toString(); .toString();
} }
@override @override
int get hashCode => Object.hash(verificationId, contactId, type, createdAt); int get hashCode =>
Object.hash(verificationId, contactId, type, verifiedBy, createdAt);
@override @override
bool operator ==(Object other) => bool operator ==(Object other) =>
identical(this, other) || identical(this, other) ||
@ -9962,6 +10004,7 @@ class KeyVerification extends DataClass implements Insertable<KeyVerification> {
other.verificationId == this.verificationId && other.verificationId == this.verificationId &&
other.contactId == this.contactId && other.contactId == this.contactId &&
other.type == this.type && other.type == this.type &&
other.verifiedBy == this.verifiedBy &&
other.createdAt == this.createdAt); other.createdAt == this.createdAt);
} }
@ -9969,17 +10012,20 @@ class KeyVerificationsCompanion extends UpdateCompanion<KeyVerification> {
final Value<int> verificationId; final Value<int> verificationId;
final Value<int> contactId; final Value<int> contactId;
final Value<VerificationType> type; final Value<VerificationType> type;
final Value<int?> verifiedBy;
final Value<DateTime> createdAt; final Value<DateTime> createdAt;
const KeyVerificationsCompanion({ const KeyVerificationsCompanion({
this.verificationId = const Value.absent(), this.verificationId = const Value.absent(),
this.contactId = const Value.absent(), this.contactId = const Value.absent(),
this.type = const Value.absent(), this.type = const Value.absent(),
this.verifiedBy = const Value.absent(),
this.createdAt = const Value.absent(), this.createdAt = const Value.absent(),
}); });
KeyVerificationsCompanion.insert({ KeyVerificationsCompanion.insert({
this.verificationId = const Value.absent(), this.verificationId = const Value.absent(),
required int contactId, required int contactId,
required VerificationType type, required VerificationType type,
this.verifiedBy = const Value.absent(),
this.createdAt = const Value.absent(), this.createdAt = const Value.absent(),
}) : contactId = Value(contactId), }) : contactId = Value(contactId),
type = Value(type); type = Value(type);
@ -9987,12 +10033,14 @@ class KeyVerificationsCompanion extends UpdateCompanion<KeyVerification> {
Expression<int>? verificationId, Expression<int>? verificationId,
Expression<int>? contactId, Expression<int>? contactId,
Expression<String>? type, Expression<String>? type,
Expression<int>? verifiedBy,
Expression<DateTime>? createdAt, Expression<DateTime>? createdAt,
}) { }) {
return RawValuesInsertable({ return RawValuesInsertable({
if (verificationId != null) 'verification_id': verificationId, if (verificationId != null) 'verification_id': verificationId,
if (contactId != null) 'contact_id': contactId, if (contactId != null) 'contact_id': contactId,
if (type != null) 'type': type, if (type != null) 'type': type,
if (verifiedBy != null) 'verified_by': verifiedBy,
if (createdAt != null) 'created_at': createdAt, if (createdAt != null) 'created_at': createdAt,
}); });
} }
@ -10001,12 +10049,14 @@ class KeyVerificationsCompanion extends UpdateCompanion<KeyVerification> {
Value<int>? verificationId, Value<int>? verificationId,
Value<int>? contactId, Value<int>? contactId,
Value<VerificationType>? type, Value<VerificationType>? type,
Value<int?>? verifiedBy,
Value<DateTime>? createdAt, Value<DateTime>? createdAt,
}) { }) {
return KeyVerificationsCompanion( return KeyVerificationsCompanion(
verificationId: verificationId ?? this.verificationId, verificationId: verificationId ?? this.verificationId,
contactId: contactId ?? this.contactId, contactId: contactId ?? this.contactId,
type: type ?? this.type, type: type ?? this.type,
verifiedBy: verifiedBy ?? this.verifiedBy,
createdAt: createdAt ?? this.createdAt, createdAt: createdAt ?? this.createdAt,
); );
} }
@ -10025,6 +10075,9 @@ class KeyVerificationsCompanion extends UpdateCompanion<KeyVerification> {
$KeyVerificationsTable.$convertertype.toSql(type.value), $KeyVerificationsTable.$convertertype.toSql(type.value),
); );
} }
if (verifiedBy.present) {
map['verified_by'] = Variable<int>(verifiedBy.value);
}
if (createdAt.present) { if (createdAt.present) {
map['created_at'] = Variable<DateTime>(createdAt.value); map['created_at'] = Variable<DateTime>(createdAt.value);
} }
@ -10037,6 +10090,7 @@ class KeyVerificationsCompanion extends UpdateCompanion<KeyVerification> {
..write('verificationId: $verificationId, ') ..write('verificationId: $verificationId, ')
..write('contactId: $contactId, ') ..write('contactId: $contactId, ')
..write('type: $type, ') ..write('type: $type, ')
..write('verifiedBy: $verifiedBy, ')
..write('createdAt: $createdAt') ..write('createdAt: $createdAt')
..write(')')) ..write(')'))
.toString(); .toString();
@ -12786,6 +12840,13 @@ abstract class _$TwonlyDB extends GeneratedDatabase {
), ),
result: [TableUpdate('key_verifications', kind: UpdateKind.delete)], result: [TableUpdate('key_verifications', kind: UpdateKind.delete)],
), ),
WritePropagation(
on: TableUpdateQuery.onTableName(
'contacts',
limitUpdateKind: UpdateKind.delete,
),
result: [TableUpdate('key_verifications', kind: UpdateKind.delete)],
),
WritePropagation( WritePropagation(
on: TableUpdateQuery.onTableName( on: TableUpdateQuery.onTableName(
'user_discovery_announced_users', 'user_discovery_announced_users',
@ -13036,29 +13097,6 @@ final class $$ContactsTableReferences
); );
} }
static MultiTypedResultKey<$KeyVerificationsTable, List<KeyVerification>>
_keyVerificationsRefsTable(_$TwonlyDB db) => MultiTypedResultKey.fromTable(
db.keyVerifications,
aliasName: $_aliasNameGenerator(
db.contacts.userId,
db.keyVerifications.contactId,
),
);
$$KeyVerificationsTableProcessedTableManager get keyVerificationsRefs {
final manager =
$$KeyVerificationsTableTableManager($_db, $_db.keyVerifications).filter(
(f) => f.contactId.userId.sqlEquals($_itemColumn<int>('user_id')!),
);
final cache = $_typedResult.readTableOrNull(
_keyVerificationsRefsTable($_db),
);
return ProcessedTableManager(
manager.$state.copyWith(prefetchedData: cache),
);
}
static MultiTypedResultKey< static MultiTypedResultKey<
$UserDiscoveryUserRelationsTable, $UserDiscoveryUserRelationsTable,
List<UserDiscoveryUserRelation> List<UserDiscoveryUserRelation>
@ -13463,31 +13501,6 @@ class $$ContactsTableFilterComposer
return f(composer); return f(composer);
} }
Expression<bool> keyVerificationsRefs(
Expression<bool> Function($$KeyVerificationsTableFilterComposer f) f,
) {
final $$KeyVerificationsTableFilterComposer composer = $composerBuilder(
composer: this,
getCurrentColumn: (t) => t.userId,
referencedTable: $db.keyVerifications,
getReferencedColumn: (t) => t.contactId,
builder:
(
joinBuilder, {
$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer,
}) => $$KeyVerificationsTableFilterComposer(
$db: $db,
$table: $db.keyVerifications,
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
),
);
return f(composer);
}
Expression<bool> userDiscoveryUserRelationsRefs( Expression<bool> userDiscoveryUserRelationsRefs(
Expression<bool> Function($$UserDiscoveryUserRelationsTableFilterComposer f) Expression<bool> Function($$UserDiscoveryUserRelationsTableFilterComposer f)
f, f,
@ -13965,31 +13978,6 @@ class $$ContactsTableAnnotationComposer
return f(composer); return f(composer);
} }
Expression<T> keyVerificationsRefs<T extends Object>(
Expression<T> Function($$KeyVerificationsTableAnnotationComposer a) f,
) {
final $$KeyVerificationsTableAnnotationComposer composer = $composerBuilder(
composer: this,
getCurrentColumn: (t) => t.userId,
referencedTable: $db.keyVerifications,
getReferencedColumn: (t) => t.contactId,
builder:
(
joinBuilder, {
$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer,
}) => $$KeyVerificationsTableAnnotationComposer(
$db: $db,
$table: $db.keyVerifications,
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
),
);
return f(composer);
}
Expression<T> userDiscoveryUserRelationsRefs<T extends Object>( Expression<T> userDiscoveryUserRelationsRefs<T extends Object>(
Expression<T> Function( Expression<T> Function(
$$UserDiscoveryUserRelationsTableAnnotationComposer a, $$UserDiscoveryUserRelationsTableAnnotationComposer a,
@ -14125,7 +14113,6 @@ class $$ContactsTableTableManager
bool receiptsRefs, bool receiptsRefs,
bool messageActionsRefs, bool messageActionsRefs,
bool groupHistoriesRefs, bool groupHistoriesRefs,
bool keyVerificationsRefs,
bool userDiscoveryUserRelationsRefs, bool userDiscoveryUserRelationsRefs,
bool userDiscoveryOtherPromotionsRefs, bool userDiscoveryOtherPromotionsRefs,
bool userDiscoveryOwnPromotionsRefs, bool userDiscoveryOwnPromotionsRefs,
@ -14244,7 +14231,6 @@ class $$ContactsTableTableManager
receiptsRefs = false, receiptsRefs = false,
messageActionsRefs = false, messageActionsRefs = false,
groupHistoriesRefs = false, groupHistoriesRefs = false,
keyVerificationsRefs = false,
userDiscoveryUserRelationsRefs = false, userDiscoveryUserRelationsRefs = false,
userDiscoveryOtherPromotionsRefs = false, userDiscoveryOtherPromotionsRefs = false,
userDiscoveryOwnPromotionsRefs = false, userDiscoveryOwnPromotionsRefs = false,
@ -14260,7 +14246,6 @@ class $$ContactsTableTableManager
if (receiptsRefs) db.receipts, if (receiptsRefs) db.receipts,
if (messageActionsRefs) db.messageActions, if (messageActionsRefs) db.messageActions,
if (groupHistoriesRefs) db.groupHistories, if (groupHistoriesRefs) db.groupHistories,
if (keyVerificationsRefs) db.keyVerifications,
if (userDiscoveryUserRelationsRefs) if (userDiscoveryUserRelationsRefs)
db.userDiscoveryUserRelations, db.userDiscoveryUserRelations,
if (userDiscoveryOtherPromotionsRefs) if (userDiscoveryOtherPromotionsRefs)
@ -14419,27 +14404,6 @@ class $$ContactsTableTableManager
), ),
typedResults: items, typedResults: items,
), ),
if (keyVerificationsRefs)
await $_getPrefetchedData<
Contact,
$ContactsTable,
KeyVerification
>(
currentTable: table,
referencedTable: $$ContactsTableReferences
._keyVerificationsRefsTable(db),
managerFromTypedResult: (p0) =>
$$ContactsTableReferences(
db,
table,
p0,
).keyVerificationsRefs,
referencedItemsForCurrentItem:
(item, referencedItems) => referencedItems.where(
(e) => e.contactId == item.userId,
),
typedResults: items,
),
if (userDiscoveryUserRelationsRefs) if (userDiscoveryUserRelationsRefs)
await $_getPrefetchedData< await $_getPrefetchedData<
Contact, Contact,
@ -14552,7 +14516,6 @@ typedef $$ContactsTableProcessedTableManager =
bool receiptsRefs, bool receiptsRefs,
bool messageActionsRefs, bool messageActionsRefs,
bool groupHistoriesRefs, bool groupHistoriesRefs,
bool keyVerificationsRefs,
bool userDiscoveryUserRelationsRefs, bool userDiscoveryUserRelationsRefs,
bool userDiscoveryOtherPromotionsRefs, bool userDiscoveryOtherPromotionsRefs,
bool userDiscoveryOwnPromotionsRefs, bool userDiscoveryOwnPromotionsRefs,
@ -21202,6 +21165,7 @@ typedef $$KeyVerificationsTableCreateCompanionBuilder =
Value<int> verificationId, Value<int> verificationId,
required int contactId, required int contactId,
required VerificationType type, required VerificationType type,
Value<int?> verifiedBy,
Value<DateTime> createdAt, Value<DateTime> createdAt,
}); });
typedef $$KeyVerificationsTableUpdateCompanionBuilder = typedef $$KeyVerificationsTableUpdateCompanionBuilder =
@ -21209,6 +21173,7 @@ typedef $$KeyVerificationsTableUpdateCompanionBuilder =
Value<int> verificationId, Value<int> verificationId,
Value<int> contactId, Value<int> contactId,
Value<VerificationType> type, Value<VerificationType> type,
Value<int?> verifiedBy,
Value<DateTime> createdAt, Value<DateTime> createdAt,
}); });
@ -21239,6 +21204,28 @@ final class $$KeyVerificationsTableReferences
manager.$state.copyWith(prefetchedData: [item]), manager.$state.copyWith(prefetchedData: [item]),
); );
} }
static $ContactsTable _verifiedByTable(_$TwonlyDB db) =>
db.contacts.createAlias(
$_aliasNameGenerator(
db.keyVerifications.verifiedBy,
db.contacts.userId,
),
);
$$ContactsTableProcessedTableManager? get verifiedBy {
final $_column = $_itemColumn<int>('verified_by');
if ($_column == null) return null;
final manager = $$ContactsTableTableManager(
$_db,
$_db.contacts,
).filter((f) => f.userId.sqlEquals($_column));
final item = $_typedResult.readTableOrNull(_verifiedByTable($_db));
if (item == null) return manager;
return ProcessedTableManager(
manager.$state.copyWith(prefetchedData: [item]),
);
}
} }
class $$KeyVerificationsTableFilterComposer class $$KeyVerificationsTableFilterComposer
@ -21288,6 +21275,29 @@ class $$KeyVerificationsTableFilterComposer
); );
return composer; return composer;
} }
$$ContactsTableFilterComposer get verifiedBy {
final $$ContactsTableFilterComposer composer = $composerBuilder(
composer: this,
getCurrentColumn: (t) => t.verifiedBy,
referencedTable: $db.contacts,
getReferencedColumn: (t) => t.userId,
builder:
(
joinBuilder, {
$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer,
}) => $$ContactsTableFilterComposer(
$db: $db,
$table: $db.contacts,
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
),
);
return composer;
}
} }
class $$KeyVerificationsTableOrderingComposer class $$KeyVerificationsTableOrderingComposer
@ -21336,6 +21346,29 @@ class $$KeyVerificationsTableOrderingComposer
); );
return composer; return composer;
} }
$$ContactsTableOrderingComposer get verifiedBy {
final $$ContactsTableOrderingComposer composer = $composerBuilder(
composer: this,
getCurrentColumn: (t) => t.verifiedBy,
referencedTable: $db.contacts,
getReferencedColumn: (t) => t.userId,
builder:
(
joinBuilder, {
$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer,
}) => $$ContactsTableOrderingComposer(
$db: $db,
$table: $db.contacts,
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
),
);
return composer;
}
} }
class $$KeyVerificationsTableAnnotationComposer class $$KeyVerificationsTableAnnotationComposer
@ -21380,6 +21413,29 @@ class $$KeyVerificationsTableAnnotationComposer
); );
return composer; return composer;
} }
$$ContactsTableAnnotationComposer get verifiedBy {
final $$ContactsTableAnnotationComposer composer = $composerBuilder(
composer: this,
getCurrentColumn: (t) => t.verifiedBy,
referencedTable: $db.contacts,
getReferencedColumn: (t) => t.userId,
builder:
(
joinBuilder, {
$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer,
}) => $$ContactsTableAnnotationComposer(
$db: $db,
$table: $db.contacts,
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
),
);
return composer;
}
} }
class $$KeyVerificationsTableTableManager class $$KeyVerificationsTableTableManager
@ -21395,7 +21451,7 @@ class $$KeyVerificationsTableTableManager
$$KeyVerificationsTableUpdateCompanionBuilder, $$KeyVerificationsTableUpdateCompanionBuilder,
(KeyVerification, $$KeyVerificationsTableReferences), (KeyVerification, $$KeyVerificationsTableReferences),
KeyVerification, KeyVerification,
PrefetchHooks Function({bool contactId}) PrefetchHooks Function({bool contactId, bool verifiedBy})
> { > {
$$KeyVerificationsTableTableManager( $$KeyVerificationsTableTableManager(
_$TwonlyDB db, _$TwonlyDB db,
@ -21415,11 +21471,13 @@ class $$KeyVerificationsTableTableManager
Value<int> verificationId = const Value.absent(), Value<int> verificationId = const Value.absent(),
Value<int> contactId = const Value.absent(), Value<int> contactId = const Value.absent(),
Value<VerificationType> type = const Value.absent(), Value<VerificationType> type = const Value.absent(),
Value<int?> verifiedBy = const Value.absent(),
Value<DateTime> createdAt = const Value.absent(), Value<DateTime> createdAt = const Value.absent(),
}) => KeyVerificationsCompanion( }) => KeyVerificationsCompanion(
verificationId: verificationId, verificationId: verificationId,
contactId: contactId, contactId: contactId,
type: type, type: type,
verifiedBy: verifiedBy,
createdAt: createdAt, createdAt: createdAt,
), ),
createCompanionCallback: createCompanionCallback:
@ -21427,11 +21485,13 @@ class $$KeyVerificationsTableTableManager
Value<int> verificationId = const Value.absent(), Value<int> verificationId = const Value.absent(),
required int contactId, required int contactId,
required VerificationType type, required VerificationType type,
Value<int?> verifiedBy = const Value.absent(),
Value<DateTime> createdAt = const Value.absent(), Value<DateTime> createdAt = const Value.absent(),
}) => KeyVerificationsCompanion.insert( }) => KeyVerificationsCompanion.insert(
verificationId: verificationId, verificationId: verificationId,
contactId: contactId, contactId: contactId,
type: type, type: type,
verifiedBy: verifiedBy,
createdAt: createdAt, createdAt: createdAt,
), ),
withReferenceMapper: (p0) => p0 withReferenceMapper: (p0) => p0
@ -21442,7 +21502,7 @@ class $$KeyVerificationsTableTableManager
), ),
) )
.toList(), .toList(),
prefetchHooksCallback: ({contactId = false}) { prefetchHooksCallback: ({contactId = false, verifiedBy = false}) {
return PrefetchHooks( return PrefetchHooks(
db: db, db: db,
explicitlyWatchedTables: [], explicitlyWatchedTables: [],
@ -21477,6 +21537,21 @@ class $$KeyVerificationsTableTableManager
) )
as T; as T;
} }
if (verifiedBy) {
state =
state.withJoin(
currentTable: table,
currentColumn: table.verifiedBy,
referencedTable:
$$KeyVerificationsTableReferences
._verifiedByTable(db),
referencedColumn:
$$KeyVerificationsTableReferences
._verifiedByTable(db)
.userId,
)
as T;
}
return state; return state;
}, },
@ -21501,7 +21576,7 @@ typedef $$KeyVerificationsTableProcessedTableManager =
$$KeyVerificationsTableUpdateCompanionBuilder, $$KeyVerificationsTableUpdateCompanionBuilder,
(KeyVerification, $$KeyVerificationsTableReferences), (KeyVerification, $$KeyVerificationsTableReferences),
KeyVerification, KeyVerification,
PrefetchHooks Function({bool contactId}) PrefetchHooks Function({bool contactId, bool verifiedBy})
>; >;
typedef $$VerificationTokensTableCreateCompanionBuilder = typedef $$VerificationTokensTableCreateCompanionBuilder =
VerificationTokensCompanion Function({ VerificationTokensCompanion Function({

View file

@ -9524,6 +9524,483 @@ i1.GeneratedColumn<int> _column_247(String aliasedName) =>
type: i1.DriftSqlType.int, type: i1.DriftSqlType.int,
$customConstraints: 'NULL CHECK (ask_for_friend_promotions IN (0, 1))', $customConstraints: 'NULL CHECK (ask_for_friend_promotions IN (0, 1))',
); );
final class Schema19 extends i0.VersionedSchema {
Schema19({required super.database}) : super(version: 19);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
contacts,
groups,
mediaFiles,
messages,
messageHistories,
reactions,
groupMembers,
receipts,
receivedReceipts,
signalIdentityKeyStores,
signalPreKeyStores,
signalSenderKeyStores,
signalSessionStores,
signalSignedPreKeyStores,
messageActions,
groupHistories,
keyVerifications,
verificationTokens,
userDiscoveryAnnouncedUsers,
userDiscoveryUserRelations,
userDiscoveryOtherPromotions,
userDiscoveryOwnPromotions,
userDiscoveryShares,
shortcuts,
shortcutMembers,
];
late final Shape53 contacts = Shape53(
source: i0.VersionedTable(
entityName: 'contacts',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(user_id)'],
columns: [
_column_106,
_column_107,
_column_108,
_column_109,
_column_110,
_column_111,
_column_112,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_211,
_column_212,
_column_247,
_column_213,
_column_214,
_column_215,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape23 groups = Shape23(
source: i0.VersionedTable(
entityName: 'groups',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(group_id)'],
columns: [
_column_119,
_column_120,
_column_121,
_column_122,
_column_123,
_column_124,
_column_125,
_column_126,
_column_127,
_column_128,
_column_129,
_column_130,
_column_131,
_column_132,
_column_133,
_column_134,
_column_118,
_column_135,
_column_136,
_column_137,
_column_138,
_column_139,
_column_140,
_column_141,
_column_142,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape51 mediaFiles = Shape51(
source: i0.VersionedTable(
entityName: 'media_files',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(media_id)'],
columns: [
_column_143,
_column_144,
_column_145,
_column_146,
_column_147,
_column_148,
_column_149,
_column_239,
_column_240,
_column_207,
_column_150,
_column_151,
_column_152,
_column_153,
_column_154,
_column_155,
_column_156,
_column_157,
_column_244,
_column_245,
_column_118,
_column_241,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape25 messages = Shape25(
source: i0.VersionedTable(
entityName: 'messages',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(message_id)'],
columns: [
_column_158,
_column_159,
_column_160,
_column_144,
_column_161,
_column_162,
_column_163,
_column_164,
_column_165,
_column_153,
_column_166,
_column_167,
_column_168,
_column_169,
_column_118,
_column_170,
_column_171,
_column_172,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape26 messageHistories = Shape26(
source: i0.VersionedTable(
entityName: 'message_histories',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_173,
_column_174,
_column_175,
_column_161,
_column_118,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape27 reactions = Shape27(
source: i0.VersionedTable(
entityName: 'reactions',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(message_id, sender_id, emoji)'],
columns: [_column_174, _column_176, _column_177, _column_118],
attachedDatabase: database,
),
alias: null,
);
late final Shape38 groupMembers = Shape38(
source: i0.VersionedTable(
entityName: 'group_members',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(group_id, contact_id)'],
columns: [
_column_158,
_column_178,
_column_179,
_column_180,
_column_209,
_column_210,
_column_181,
_column_118,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape37 receipts = Shape37(
source: i0.VersionedTable(
entityName: 'receipts',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(receipt_id)'],
columns: [
_column_182,
_column_183,
_column_184,
_column_185,
_column_186,
_column_208,
_column_187,
_column_188,
_column_189,
_column_190,
_column_191,
_column_118,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape30 receivedReceipts = Shape30(
source: i0.VersionedTable(
entityName: 'received_receipts',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(receipt_id)'],
columns: [_column_182, _column_118],
attachedDatabase: database,
),
alias: null,
);
late final Shape31 signalIdentityKeyStores = Shape31(
source: i0.VersionedTable(
entityName: 'signal_identity_key_stores',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(device_id, name)'],
columns: [_column_192, _column_193, _column_194, _column_118],
attachedDatabase: database,
),
alias: null,
);
late final Shape32 signalPreKeyStores = Shape32(
source: i0.VersionedTable(
entityName: 'signal_pre_key_stores',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(pre_key_id)'],
columns: [_column_195, _column_196, _column_118],
attachedDatabase: database,
),
alias: null,
);
late final Shape11 signalSenderKeyStores = Shape11(
source: i0.VersionedTable(
entityName: 'signal_sender_key_stores',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(sender_key_name)'],
columns: [_column_197, _column_198],
attachedDatabase: database,
),
alias: null,
);
late final Shape33 signalSessionStores = Shape33(
source: i0.VersionedTable(
entityName: 'signal_session_stores',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(device_id, name)'],
columns: [_column_192, _column_193, _column_199, _column_118],
attachedDatabase: database,
),
alias: null,
);
late final Shape50 signalSignedPreKeyStores = Shape50(
source: i0.VersionedTable(
entityName: 'signal_signed_pre_key_stores',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(signed_pre_key_id)'],
columns: [_column_242, _column_243, _column_118],
attachedDatabase: database,
),
alias: null,
);
late final Shape34 messageActions = Shape34(
source: i0.VersionedTable(
entityName: 'message_actions',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(message_id, contact_id, type)'],
columns: [_column_174, _column_183, _column_144, _column_200],
attachedDatabase: database,
),
alias: null,
);
late final Shape35 groupHistories = Shape35(
source: i0.VersionedTable(
entityName: 'group_histories',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(group_history_id)'],
columns: [
_column_201,
_column_158,
_column_202,
_column_203,
_column_204,
_column_205,
_column_206,
_column_144,
_column_200,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape54 keyVerifications = Shape54(
source: i0.VersionedTable(
entityName: 'key_verifications',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_216,
_column_183,
_column_144,
_column_248,
_column_118,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape41 verificationTokens = Shape41(
source: i0.VersionedTable(
entityName: 'verification_tokens',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [_column_217, _column_218, _column_118],
attachedDatabase: database,
),
alias: null,
);
late final Shape52 userDiscoveryAnnouncedUsers = Shape52(
source: i0.VersionedTable(
entityName: 'user_discovery_announced_users',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(announced_user_id)'],
columns: [
_column_219,
_column_220,
_column_221,
_column_222,
_column_223,
_column_224,
_column_246,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape43 userDiscoveryUserRelations = Shape43(
source: i0.VersionedTable(
entityName: 'user_discovery_user_relations',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(announced_user_id, from_contact_id)'],
columns: [_column_225, _column_226, _column_227],
attachedDatabase: database,
),
alias: null,
);
late final Shape44 userDiscoveryOtherPromotions = Shape44(
source: i0.VersionedTable(
entityName: 'user_discovery_other_promotions',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(from_contact_id, public_id)'],
columns: [
_column_226,
_column_228,
_column_229,
_column_230,
_column_231,
_column_227,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape45 userDiscoveryOwnPromotions = Shape45(
source: i0.VersionedTable(
entityName: 'user_discovery_own_promotions',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [_column_232, _column_183, _column_233],
attachedDatabase: database,
),
alias: null,
);
late final Shape46 userDiscoveryShares = Shape46(
source: i0.VersionedTable(
entityName: 'user_discovery_shares',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [_column_234, _column_235, _column_175],
attachedDatabase: database,
),
alias: null,
);
late final Shape47 shortcuts = Shape47(
source: i0.VersionedTable(
entityName: 'shortcuts',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [_column_173, _column_236, _column_237],
attachedDatabase: database,
),
alias: null,
);
late final Shape48 shortcutMembers = Shape48(
source: i0.VersionedTable(
entityName: 'shortcut_members',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(shortcut_id, group_id)'],
columns: [_column_238, _column_158],
attachedDatabase: database,
),
alias: null,
);
}
class Shape54 extends i0.VersionedTable {
Shape54({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<int> get verificationId =>
columnsByName['verification_id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get contactId =>
columnsByName['contact_id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get type =>
columnsByName['type']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get verifiedBy =>
columnsByName['verified_by']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get createdAt =>
columnsByName['created_at']! as i1.GeneratedColumn<int>;
}
i1.GeneratedColumn<int> _column_248(String aliasedName) =>
i1.GeneratedColumn<int>(
'verified_by',
aliasedName,
true,
type: i1.DriftSqlType.int,
$customConstraints: 'NULL REFERENCES contacts(user_id)ON DELETE CASCADE',
);
i0.MigrationStepWithVersion migrationSteps({ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2, required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3, required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
@ -9542,6 +10019,7 @@ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema16 schema) from15To16, required Future<void> Function(i1.Migrator m, Schema16 schema) from15To16,
required Future<void> Function(i1.Migrator m, Schema17 schema) from16To17, required Future<void> Function(i1.Migrator m, Schema17 schema) from16To17,
required Future<void> Function(i1.Migrator m, Schema18 schema) from17To18, required Future<void> Function(i1.Migrator m, Schema18 schema) from17To18,
required Future<void> Function(i1.Migrator m, Schema19 schema) from18To19,
}) { }) {
return (currentVersion, database) async { return (currentVersion, database) async {
switch (currentVersion) { switch (currentVersion) {
@ -9630,6 +10108,11 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema); final migrator = i1.Migrator(database, schema);
await from17To18(migrator, schema); await from17To18(migrator, schema);
return 18; return 18;
case 18:
final schema = Schema19(database: database);
final migrator = i1.Migrator(database, schema);
await from18To19(migrator, schema);
return 19;
default: default:
throw ArgumentError.value('Unknown migration from $currentVersion'); throw ArgumentError.value('Unknown migration from $currentVersion');
} }
@ -9654,6 +10137,7 @@ i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema16 schema) from15To16, required Future<void> Function(i1.Migrator m, Schema16 schema) from15To16,
required Future<void> Function(i1.Migrator m, Schema17 schema) from16To17, required Future<void> Function(i1.Migrator m, Schema17 schema) from16To17,
required Future<void> Function(i1.Migrator m, Schema18 schema) from17To18, required Future<void> Function(i1.Migrator m, Schema18 schema) from17To18,
required Future<void> Function(i1.Migrator m, Schema19 schema) from18To19,
}) => i0.VersionedSchema.stepByStepHelper( }) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps( step: migrationSteps(
from1To2: from1To2, from1To2: from1To2,
@ -9673,5 +10157,6 @@ i1.OnUpgrade stepByStep({
from15To16: from15To16, from15To16: from15To16,
from16To17: from16To17, from16To17: from16To17,
from17To18: from17To18, from17To18: from17To18,
from18To19: from18To19,
), ),
); );

View file

@ -914,6 +914,12 @@ abstract class AppLocalizations {
/// **'Verified by {username}'** /// **'Verified by {username}'**
String contactVerifiedBy(Object username); String contactVerifiedBy(Object username);
/// No description provided for @contactSharedByUnknown.
///
/// In en, this message translates to:
/// **'Shared by a verified contact (username not available)'**
String get contactSharedByUnknown;
/// No description provided for @verificationTypeQrScanned. /// No description provided for @verificationTypeQrScanned.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -447,6 +447,10 @@ class AppLocalizationsDe extends AppLocalizations {
return 'Verifiziert von $username'; return 'Verifiziert von $username';
} }
@override
String get contactSharedByUnknown =>
'Geteilt von einem verifizierten Kontakt (Benutzername nicht verfügbar)';
@override @override
String get verificationTypeQrScanned => 'Du hast den QR-Code gescannt.'; String get verificationTypeQrScanned => 'Du hast den QR-Code gescannt.';

View file

@ -443,6 +443,10 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Verified by $username'; return 'Verified by $username';
} }
@override
String get contactSharedByUnknown =>
'Shared by a verified contact (username not available)';
@override @override
String get verificationTypeQrScanned => 'You scanned their QR code.'; String get verificationTypeQrScanned => 'You scanned their QR code.';

@ -1 +1 @@
Subproject commit b508873f4a46cda607d03ee91c66a7907e22bf0a Subproject commit 7686a412d9911bafe7585007b4eb867270e4fde4

View file

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

View file

@ -94,6 +94,29 @@ class KeyVerificationService {
Log.error('No valid secret token could be found...'); Log.error('No valid secret token could be found...');
} }
static Future<void> verifySharedContact({
required int contactId,
required List<int> sharedPublicIdentityKey,
required int senderId,
}) async {
final publicIdentityKey = await getPublicKeyFromContact(contactId);
if (publicIdentityKey == null) {
Log.info('No public key stored for contact $contactId');
return;
}
if (publicIdentityKey.equals(sharedPublicIdentityKey)) {
Log.info('Verified a user which was shared by a contact');
await twonlyDB.keyVerificationDao.addKeyVerification(
contactId,
VerificationType.contactSharedByVerified,
verifiedBy: senderId,
);
} else {
Log.error('Public identity keys do not match for contact $contactId');
}
}
} }
Future<List<int>> _createVerificationBytes( Future<List<int>> _createVerificationBytes(

View file

@ -1,26 +1,21 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:twonly/src/model/protobuf/client/generated/qr.pb.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/elements/my_button.element.dart'; import 'package:twonly/src/visual/elements/my_button.element.dart';
/// A premium popup dialog shown when a new user profile is scanned via QR code.
/// Allows the user to request connection ("Anfragen") or cancel ("Abbrechen").
class AddContactDialog extends StatelessWidget { class AddContactDialog extends StatelessWidget {
const AddContactDialog({ const AddContactDialog({
required this.profile, required this.username,
super.key, super.key,
}); });
final PublicProfile profile; final String username;
/// Utility method to easily present this dialog. static Future<bool?> show(BuildContext context, String username) {
/// Returns `true` if the user chose to request the contact, `false` otherwise.
static Future<bool?> show(BuildContext context, PublicProfile profile) {
return showDialog<bool>( return showDialog<bool>(
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
builder: (context) => AddContactDialog(profile: profile), builder: (context) => AddContactDialog(username: username),
); );
} }
@ -48,7 +43,7 @@ class AddContactDialog extends StatelessWidget {
const SizedBox(width: 12), const SizedBox(width: 12),
Flexible( Flexible(
child: Text( child: Text(
profile.username, username,
style: const TextStyle( style: const TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 18, fontSize: 18,
@ -59,7 +54,7 @@ class AddContactDialog extends StatelessWidget {
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
Text( Text(
context.lang.userFoundBody(profile.username), context.lang.userFoundBody(username),
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: const TextStyle( style: const TextStyle(
fontSize: 16, fontSize: 16,

View file

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

View file

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

View file

@ -429,7 +429,7 @@ class MainCameraController {
unawaited(HapticFeedback.heavyImpact()); unawaited(HapticFeedback.heavyImpact());
final shouldRequest = await AddContactDialog.show( final shouldRequest = await AddContactDialog.show(
context, context,
profile, profile.username,
); );
if (shouldRequest == true && context.mounted) { if (shouldRequest == true && context.mounted) {
showSnackbar( showSnackbar(

View file

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

View file

@ -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),
],
),
);
}
}

View file

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

View file

@ -22,6 +22,7 @@ import 'schema_v15.dart' as v15;
import 'schema_v16.dart' as v16; import 'schema_v16.dart' as v16;
import 'schema_v17.dart' as v17; import 'schema_v17.dart' as v17;
import 'schema_v18.dart' as v18; import 'schema_v18.dart' as v18;
import 'schema_v19.dart' as v19;
class GeneratedHelper implements SchemaInstantiationHelper { class GeneratedHelper implements SchemaInstantiationHelper {
@override @override
@ -63,6 +64,8 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v17.DatabaseAtV17(db); return v17.DatabaseAtV17(db);
case 18: case 18:
return v18.DatabaseAtV18(db); return v18.DatabaseAtV18(db);
case 19:
return v19.DatabaseAtV19(db);
default: default:
throw MissingSchemaException(version, versions); throw MissingSchemaException(version, versions);
} }
@ -87,5 +90,6 @@ class GeneratedHelper implements SchemaInstantiationHelper {
16, 16,
17, 17,
18, 18,
19,
]; ];
} }

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,693 @@
import 'dart:io';
import 'package:drift/drift.dart' hide isNotNull, isNull;
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/locator.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/model/json/userdata.model.dart';
import 'package:twonly/src/services/api.service.dart';
import 'package:twonly/src/services/user.service.dart';
void main() {
if (!Platform.isMacOS) {
return;
}
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() async {
await locator.reset();
locator
..registerSingleton<TwonlyDB>(
TwonlyDB.forTesting(
DatabaseConnection(
NativeDatabase.memory(),
closeStreamsSynchronously: true,
),
),
)
..registerSingleton<UserService>(UserService())
..registerSingleton<ApiService>(ApiService());
// isUserDiscoveryEnabled defaults to false, so no Rust bridge calls happen
// in addKeyVerification / deleteKeyVerification.
userService.currentUser = UserData(
userId: 1,
username: 'me',
displayName: 'Me',
subscriptionPlan: 'Free',
currentSetupPage: null,
appVersion: 100,
);
userService.isUserCreated = true;
AppEnvironment.initTesting();
});
tearDown(() async {
await twonlyDB.close();
});
// Helpers
Future<void> insertContact(int userId, {String? username}) async {
await twonlyDB.contactsDao.insertContact(
ContactsCompanion.insert(
userId: Value(userId),
username: username ?? 'user$userId',
),
);
}
Future<void> addDirectVerification(
int contactId,
VerificationType type,
) async {
await twonlyDB.keyVerificationDao.addKeyVerification(contactId, type);
}
Future<void> addSharedVerification(
int contactId,
int sharedByContactId,
) async {
await twonlyDB.keyVerificationDao.addKeyVerification(
contactId,
VerificationType.contactSharedByVerified,
verifiedBy: sharedByContactId,
);
}
// Verification Tokens
group('KeyVerificationDao Verification Tokens', () {
test(
'insertVerificationToken stores token; getRecentVerificationTokens returns it',
() async {
final token = Uint8List.fromList([1, 2, 3, 4, 5, 6, 7, 8]);
await twonlyDB.keyVerificationDao.insertVerificationToken(token);
final tokens = await twonlyDB.keyVerificationDao
.getRecentVerificationTokens();
expect(tokens.length, 1);
expect(tokens.first.token, token);
},
);
test('multiple tokens are all returned when recent', () async {
final t1 = Uint8List.fromList(List.generate(16, (i) => i));
final t2 = Uint8List.fromList(List.generate(16, (i) => i + 16));
await twonlyDB.keyVerificationDao.insertVerificationToken(t1);
await twonlyDB.keyVerificationDao.insertVerificationToken(t2);
final tokens = await twonlyDB.keyVerificationDao
.getRecentVerificationTokens();
expect(tokens.length, 2);
});
});
// Direct Verification
group('KeyVerificationDao Direct Verification', () {
test(
'addKeyVerification stores an entry; getContactVerification returns it',
() async {
await insertContact(10);
await addDirectVerification(10, VerificationType.secretQrToken);
final verifications = await twonlyDB.keyVerificationDao
.getContactVerification(10);
expect(verifications.length, 1);
expect(verifications.first.type, VerificationType.secretQrToken);
expect(verifications.first.contactId, 10);
expect(verifications.first.verifiedBy, isNull);
},
);
test('isContactVerified returns true for secretQrToken', () async {
await insertContact(10);
await addDirectVerification(10, VerificationType.secretQrToken);
expect(await twonlyDB.keyVerificationDao.isContactVerified(10), true);
});
test('isContactVerified returns true for qrScanned', () async {
await insertContact(10);
await addDirectVerification(10, VerificationType.qrScanned);
expect(await twonlyDB.keyVerificationDao.isContactVerified(10), true);
});
test('isContactVerified returns true for link', () async {
await insertContact(10);
await addDirectVerification(10, VerificationType.link);
expect(await twonlyDB.keyVerificationDao.isContactVerified(10), true);
});
test(
'isContactVerified returns false when no verification exists',
() async {
await insertContact(10);
expect(await twonlyDB.keyVerificationDao.isContactVerified(10), false);
},
);
test('multiple direct verifications are stored independently', () async {
await insertContact(10);
await addDirectVerification(10, VerificationType.secretQrToken);
await addDirectVerification(10, VerificationType.link);
final verifications = await twonlyDB.keyVerificationDao
.getContactVerification(10);
expect(verifications.length, 2);
});
});
// Shared / Transitive Verification (Blue Badge)
group('KeyVerificationDao Transitive Trust (Blue Badge)', () {
// Scenario setup:
// alice (userId=2) may or may not be directly verified
// bob (userId=3) shared by alice (contactSharedByVerified, verifiedBy=2)
//
// Rule: bob's shared verification only "counts" if alice is herself verified.
test(
'isContactVerified is false when sharer (alice) is NOT verified',
() async {
await insertContact(2, username: 'alice');
await insertContact(3, username: 'bob');
// Bob shared by Alice, but Alice is not verified
await addSharedVerification(3, 2);
expect(await twonlyDB.keyVerificationDao.isContactVerified(3), false);
},
);
test(
'isContactVerified is true (blue badge) when sharer (alice) IS verified',
() async {
await insertContact(2, username: 'alice');
await insertContact(3, username: 'bob');
// Alice verified directly
await addDirectVerification(2, VerificationType.secretQrToken);
// Bob shared by verified Alice
await addSharedVerification(3, 2);
expect(await twonlyDB.keyVerificationDao.isContactVerified(3), true);
},
);
test(
'isContactVerified updates reactively when sharer becomes verified',
() async {
await insertContact(2, username: 'alice');
await insertContact(3, username: 'bob');
await addSharedVerification(3, 2);
// Bob not yet verified (alice not verified)
expect(await twonlyDB.keyVerificationDao.isContactVerified(3), false);
// Alice gets verified
await addDirectVerification(2, VerificationType.qrScanned);
// Now Bob is transitively verified
expect(await twonlyDB.keyVerificationDao.isContactVerified(3), true);
},
);
test(
'direct verification takes precedence regardless of sharer state',
() async {
await insertContact(2, username: 'alice');
await insertContact(3, username: 'bob');
// Bob has both a direct and a shared (unverified sharer) verification
await addDirectVerification(3, VerificationType.link);
await addSharedVerification(3, 2); // alice not verified
// Direct verification makes bob verified
expect(await twonlyDB.keyVerificationDao.isContactVerified(3), true);
},
);
});
// watchContactVerification
group('KeyVerificationDao watchContactVerification', () {
test(
'emits the direct verification entry with no verifier contact',
() async {
await insertContact(10, username: 'alice');
await addDirectVerification(10, VerificationType.secretQrToken);
final entries = await twonlyDB.keyVerificationDao
.watchContactVerification(10)
.first;
expect(entries.length, 1);
final (kv, verifierContact) = entries.first;
expect(kv.type, VerificationType.secretQrToken);
expect(verifierContact, isNull);
},
);
test(
'filters out shared verification when sharer is NOT verified',
() async {
await insertContact(2, username: 'alice');
await insertContact(3, username: 'bob');
await addSharedVerification(3, 2); // alice not verified
final entries = await twonlyDB.keyVerificationDao
.watchContactVerification(3)
.first;
expect(entries, isEmpty);
},
);
test(
'returns shared verification entry with verifier contact when sharer IS verified',
() async {
await insertContact(2, username: 'alice');
await insertContact(3, username: 'bob');
await addDirectVerification(2, VerificationType.secretQrToken);
await addSharedVerification(3, 2);
final entries = await twonlyDB.keyVerificationDao
.watchContactVerification(3)
.first;
expect(entries.length, 1);
final (kv, verifierContact) = entries.first;
expect(kv.type, VerificationType.contactSharedByVerified);
expect(verifierContact, isNotNull);
expect(verifierContact!.username, 'alice');
},
);
test(
'emits mixed entries: direct (no filter) + shared (filtered by verifier state)',
() async {
await insertContact(2, username: 'alice');
await insertContact(3, username: 'charlie');
await insertContact(4, username: 'bob');
// Bob has a direct verification
await addDirectVerification(4, VerificationType.link);
// Bob is also shared by Alice (unverified) should NOT appear
await addSharedVerification(4, 2);
// Bob is also shared by Charlie (verified) SHOULD appear
await addDirectVerification(3, VerificationType.qrScanned);
await addSharedVerification(4, 3);
final entries = await twonlyDB.keyVerificationDao
.watchContactVerification(4)
.first;
// Expect: direct + shared-by-charlie (2 entries, shared-by-alice filtered)
expect(entries.length, 2);
final types = entries.map((e) => e.$1.type).toSet();
expect(types, contains(VerificationType.link));
expect(types, contains(VerificationType.contactSharedByVerified));
},
);
test('emits empty list for contact with no verifications', () async {
await insertContact(10);
final entries = await twonlyDB.keyVerificationDao
.watchContactVerification(10)
.first;
expect(entries, isEmpty);
});
});
// getFirstVerificationTypeByContacts
group('KeyVerificationDao getFirstVerificationTypeByContacts', () {
test(
'returns a map with the earliest verification type per contact',
() async {
await insertContact(10);
await insertContact(20);
await addDirectVerification(10, VerificationType.secretQrToken);
await addDirectVerification(20, VerificationType.link);
final map = await twonlyDB.keyVerificationDao
.getFirstVerificationTypeByContacts();
expect(map[10], VerificationType.secretQrToken);
expect(map[20], VerificationType.link);
},
);
test('returns an empty map when no verifications exist', () async {
final map = await twonlyDB.keyVerificationDao
.getFirstVerificationTypeByContacts();
expect(map, isEmpty);
});
test(
'returns only the first verification type when multiple exist',
() async {
await insertContact(10);
// Insert two types for the same contact
await addDirectVerification(10, VerificationType.secretQrToken);
await addDirectVerification(10, VerificationType.link);
final map = await twonlyDB.keyVerificationDao
.getFirstVerificationTypeByContacts();
// Only the first-inserted (earliest createdAt) should be in the map
expect(map.length, 1);
expect(map.containsKey(10), true);
// The first inserted was secretQrToken
expect(map[10], VerificationType.secretQrToken);
},
);
});
// deleteKeyVerification
group('KeyVerificationDao deleteKeyVerification', () {
test('removes all verifications for a contact', () async {
await insertContact(10);
await addDirectVerification(10, VerificationType.secretQrToken);
await addDirectVerification(10, VerificationType.link);
expect(
await twonlyDB.keyVerificationDao.getContactVerification(10),
hasLength(2),
);
await twonlyDB.keyVerificationDao.deleteKeyVerification(10);
expect(
await twonlyDB.keyVerificationDao.getContactVerification(10),
isEmpty,
);
});
test(
'isContactVerified returns false after deleteKeyVerification',
() async {
await insertContact(10);
await addDirectVerification(10, VerificationType.secretQrToken);
expect(await twonlyDB.keyVerificationDao.isContactVerified(10), true);
await twonlyDB.keyVerificationDao.deleteKeyVerification(10);
expect(await twonlyDB.keyVerificationDao.isContactVerified(10), false);
},
);
test('deleting one contact does not affect other contacts', () async {
await insertContact(10);
await insertContact(20);
await addDirectVerification(10, VerificationType.secretQrToken);
await addDirectVerification(20, VerificationType.qrScanned);
await twonlyDB.keyVerificationDao.deleteKeyVerification(10);
expect(await twonlyDB.keyVerificationDao.isContactVerified(10), false);
expect(await twonlyDB.keyVerificationDao.isContactVerified(20), true);
});
test(
'deleting sharer invalidates transitive trust for shared contacts',
() async {
await insertContact(2, username: 'alice');
await insertContact(3, username: 'bob');
await addDirectVerification(2, VerificationType.secretQrToken);
await addSharedVerification(3, 2);
expect(await twonlyDB.keyVerificationDao.isContactVerified(3), true);
// Remove Alice's verification
await twonlyDB.keyVerificationDao.deleteKeyVerification(2);
// Bob's transitive trust should now be invalid
expect(await twonlyDB.keyVerificationDao.isContactVerified(3), false);
},
);
});
// deleteKeyVerificationById
group('KeyVerificationDao deleteKeyVerificationById', () {
test('removes only the specified verification entry', () async {
await insertContact(10);
await addDirectVerification(10, VerificationType.secretQrToken);
await addDirectVerification(10, VerificationType.link);
var verifications = await twonlyDB.keyVerificationDao
.getContactVerification(10);
expect(verifications.length, 2);
final idToDelete = verifications.first.verificationId;
await twonlyDB.keyVerificationDao.deleteKeyVerificationById(
idToDelete,
10,
);
verifications = await twonlyDB.keyVerificationDao.getContactVerification(
10,
);
expect(verifications.length, 1);
expect(verifications.first.verificationId, isNot(idToDelete));
});
test(
'isContactVerified remains true if another direct verification exists',
() async {
await insertContact(10);
await addDirectVerification(10, VerificationType.secretQrToken);
await addDirectVerification(10, VerificationType.link);
final verifications = await twonlyDB.keyVerificationDao
.getContactVerification(10);
await twonlyDB.keyVerificationDao.deleteKeyVerificationById(
verifications.first.verificationId,
10,
);
expect(await twonlyDB.keyVerificationDao.isContactVerified(10), true);
},
);
test(
'isContactVerified returns false after deleting the last verification',
() async {
await insertContact(10);
await addDirectVerification(10, VerificationType.secretQrToken);
final verifications = await twonlyDB.keyVerificationDao
.getContactVerification(10);
await twonlyDB.keyVerificationDao.deleteKeyVerificationById(
verifications.first.verificationId,
10,
);
expect(await twonlyDB.keyVerificationDao.isContactVerified(10), false);
},
);
});
// watchAllGroupMembersVerified
group('KeyVerificationDao watchAllGroupMembersVerified', () {
const groupId = 'test-group-abc';
Future<void> setupGroup(List<int> memberIds) async {
await twonlyDB.groupsDao.createNewGroup(
GroupsCompanion.insert(
groupId: groupId,
groupName: 'Trust Test Group',
),
);
for (final id in memberIds) {
await twonlyDB.groupsDao.insertOrUpdateGroupMember(
GroupMembersCompanion.insert(groupId: groupId, contactId: id),
);
}
}
test('notTrusted when no member is verified', () async {
await insertContact(10);
await insertContact(20);
await setupGroup([10, 20]);
final status = await twonlyDB.keyVerificationDao
.watchAllGroupMembersVerified(groupId)
.first;
expect(status, VerificationStatus.notTrusted);
});
test('trusted when all members are directly verified', () async {
await insertContact(10);
await insertContact(20);
await addDirectVerification(10, VerificationType.secretQrToken);
await addDirectVerification(20, VerificationType.qrScanned);
await setupGroup([10, 20]);
final status = await twonlyDB.keyVerificationDao
.watchAllGroupMembersVerified(groupId)
.first;
expect(status, VerificationStatus.trusted);
});
test('notTrusted for empty group', () async {
await twonlyDB.groupsDao.createNewGroup(
GroupsCompanion.insert(groupId: groupId, groupName: 'Empty Group'),
);
final status = await twonlyDB.keyVerificationDao
.watchAllGroupMembersVerified(groupId)
.first;
expect(status, VerificationStatus.notTrusted);
});
test(
'single member group trusted when that member is verified',
() async {
await insertContact(10);
await addDirectVerification(10, VerificationType.link);
await setupGroup([10]);
final status = await twonlyDB.keyVerificationDao
.watchAllGroupMembersVerified(groupId)
.first;
expect(status, VerificationStatus.trusted);
},
);
});
// Full Transitive Trust Scenario
group('KeyVerificationService Full Transitive Trust Scenario', () {
// Simulates the complete "blue badge" flow:
// 1. Alice (2) is directly verified by me via QR code.
// 2. Alice shares Bob (3) addKeyVerification(3, contactSharedByVerified, verifiedBy: 2)
// 3. Bob should receive the blue verification badge (isContactVerified = true)
// because Alice (the sharer) is herself verified.
//
// 4. Charlie (4) is shared by Dave (5) who is NOT verified.
// 5. Charlie should NOT receive a badge.
//
// 6. Eve (6) is shared by both Dave (5, unverified) and Alice (2, verified).
// 7. Eve should receive a badge (at least one verified sharer).
test('bob gets blue badge because alice (sharer) is verified', () async {
await insertContact(2, username: 'alice');
await insertContact(3, username: 'bob');
await addDirectVerification(2, VerificationType.secretQrToken);
await addSharedVerification(3, 2);
expect(await twonlyDB.keyVerificationDao.isContactVerified(3), true);
});
test(
'charlie gets no badge because dave (sharer) is NOT verified',
() async {
await insertContact(4, username: 'charlie');
await insertContact(5, username: 'dave');
await addSharedVerification(4, 5);
expect(await twonlyDB.keyVerificationDao.isContactVerified(4), false);
},
);
test(
'eve gets blue badge because alice (one of her sharers) is verified',
() async {
await insertContact(2, username: 'alice');
await insertContact(5, username: 'dave');
await insertContact(6, username: 'eve');
await addDirectVerification(2, VerificationType.secretQrToken);
// Dave is NOT verified
await addSharedVerification(6, 5); // dave shares eve (does not count)
await addSharedVerification(6, 2); // alice shares eve (counts!)
expect(await twonlyDB.keyVerificationDao.isContactVerified(6), true);
},
);
test(
'watchContactVerification shows alice as verifier for bob (blue badge)',
() async {
await insertContact(2, username: 'alice');
await insertContact(3, username: 'bob');
await addDirectVerification(2, VerificationType.secretQrToken);
await addSharedVerification(3, 2);
final entries = await twonlyDB.keyVerificationDao
.watchContactVerification(3)
.first;
expect(entries.length, 1);
final (kv, verifierContact) = entries.first;
expect(kv.type, VerificationType.contactSharedByVerified);
expect(verifierContact?.username, 'alice');
},
);
test(
'watchContactVerification shows no entries for charlie (sharer unverified)',
() async {
await insertContact(4, username: 'charlie');
await insertContact(5, username: 'dave');
await addSharedVerification(4, 5);
final entries = await twonlyDB.keyVerificationDao
.watchContactVerification(4)
.first;
expect(entries, isEmpty);
},
);
test('removing alice revokes bob blue badge transitively', () async {
await insertContact(2, username: 'alice');
await insertContact(3, username: 'bob');
await addDirectVerification(2, VerificationType.secretQrToken);
await addSharedVerification(3, 2);
// Confirm bob is verified
expect(await twonlyDB.keyVerificationDao.isContactVerified(3), true);
// Revoke alice's verification
await twonlyDB.keyVerificationDao.deleteKeyVerification(2);
// Bob should lose the blue badge
expect(await twonlyDB.keyVerificationDao.isContactVerified(3), false);
// watchContactVerification should also be empty for bob
final entries = await twonlyDB.keyVerificationDao
.watchContactVerification(3)
.first;
expect(entries, isEmpty);
});
});
}