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
- 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

View file

@ -57,18 +57,70 @@ class KeyVerificationDao extends DatabaseAccessor<TwonlyDB>
}
Future<bool> isContactVerified(int contactId) async {
final row =
await (select(keyVerifications)
..where((kv) => kv.contactId.equals(contactId))
..limit(1))
.getSingleOrNull();
return row != null;
final verifierKv = alias(keyVerifications, 'verifierKv');
final query = select(keyVerifications).join([
leftOuterJoin(
verifierKv,
verifierKv.contactId.equalsExp(keyVerifications.verifiedBy),
),
])..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) {
return (select(
keyVerifications,
)..where((kv) => kv.contactId.equals(contactId))).watch();
Stream<List<(KeyVerification, Contact?)>> watchContactVerification(
int contactId,
) {
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 {
@ -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 {
await into(keyVerifications).insertOnConflictUpdate(
KeyVerificationsCompanion(
contactId: Value(contactId),
type: Value(type),
verifiedBy: Value(verifiedBy),
),
);
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,
)();
TextColumn get type => textEnum<VerificationType>()();
IntColumn get verifiedBy => integer().nullable().references(
Contacts,
#userId,
onDelete: KeyAction.cascade,
)();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
}

View file

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

View file

@ -9766,6 +9766,20 @@ class $KeyVerificationsTable extends KeyVerifications
type: DriftSqlType.string,
requiredDuringInsert: true,
).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(
'createdAt',
);
@ -9783,6 +9797,7 @@ class $KeyVerificationsTable extends KeyVerifications
verificationId,
contactId,
type,
verifiedBy,
createdAt,
];
@override
@ -9814,6 +9829,12 @@ class $KeyVerificationsTable extends KeyVerifications
} else if (isInserting) {
context.missing(_contactIdMeta);
}
if (data.containsKey('verified_by')) {
context.handle(
_verifiedByMeta,
verifiedBy.isAcceptableOrUnknown(data['verified_by']!, _verifiedByMeta),
);
}
if (data.containsKey('created_at')) {
context.handle(
_createdAtMeta,
@ -9843,6 +9864,10 @@ class $KeyVerificationsTable extends KeyVerifications
data['${effectivePrefix}type'],
)!,
),
verifiedBy: attachedDatabase.typeMapping.read(
DriftSqlType.int,
data['${effectivePrefix}verified_by'],
),
createdAt: attachedDatabase.typeMapping.read(
DriftSqlType.dateTime,
data['${effectivePrefix}created_at'],
@ -9863,11 +9888,13 @@ class KeyVerification extends DataClass implements Insertable<KeyVerification> {
final int verificationId;
final int contactId;
final VerificationType type;
final int? verifiedBy;
final DateTime createdAt;
const KeyVerification({
required this.verificationId,
required this.contactId,
required this.type,
this.verifiedBy,
required this.createdAt,
});
@override
@ -9880,6 +9907,9 @@ class KeyVerification extends DataClass implements Insertable<KeyVerification> {
$KeyVerificationsTable.$convertertype.toSql(type),
);
}
if (!nullToAbsent || verifiedBy != null) {
map['verified_by'] = Variable<int>(verifiedBy);
}
map['created_at'] = Variable<DateTime>(createdAt);
return map;
}
@ -9889,6 +9919,9 @@ class KeyVerification extends DataClass implements Insertable<KeyVerification> {
verificationId: Value(verificationId),
contactId: Value(contactId),
type: Value(type),
verifiedBy: verifiedBy == null && nullToAbsent
? const Value.absent()
: Value(verifiedBy),
createdAt: Value(createdAt),
);
}
@ -9904,6 +9937,7 @@ class KeyVerification extends DataClass implements Insertable<KeyVerification> {
type: $KeyVerificationsTable.$convertertype.fromJson(
serializer.fromJson<String>(json['type']),
),
verifiedBy: serializer.fromJson<int?>(json['verifiedBy']),
createdAt: serializer.fromJson<DateTime>(json['createdAt']),
);
}
@ -9916,6 +9950,7 @@ class KeyVerification extends DataClass implements Insertable<KeyVerification> {
'type': serializer.toJson<String>(
$KeyVerificationsTable.$convertertype.toJson(type),
),
'verifiedBy': serializer.toJson<int?>(verifiedBy),
'createdAt': serializer.toJson<DateTime>(createdAt),
};
}
@ -9924,11 +9959,13 @@ class KeyVerification extends DataClass implements Insertable<KeyVerification> {
int? verificationId,
int? contactId,
VerificationType? type,
Value<int?> verifiedBy = const Value.absent(),
DateTime? createdAt,
}) => KeyVerification(
verificationId: verificationId ?? this.verificationId,
contactId: contactId ?? this.contactId,
type: type ?? this.type,
verifiedBy: verifiedBy.present ? verifiedBy.value : this.verifiedBy,
createdAt: createdAt ?? this.createdAt,
);
KeyVerification copyWithCompanion(KeyVerificationsCompanion data) {
@ -9938,6 +9975,9 @@ class KeyVerification extends DataClass implements Insertable<KeyVerification> {
: this.verificationId,
contactId: data.contactId.present ? data.contactId.value : this.contactId,
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,
);
}
@ -9948,13 +9988,15 @@ class KeyVerification extends DataClass implements Insertable<KeyVerification> {
..write('verificationId: $verificationId, ')
..write('contactId: $contactId, ')
..write('type: $type, ')
..write('verifiedBy: $verifiedBy, ')
..write('createdAt: $createdAt')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(verificationId, contactId, type, createdAt);
int get hashCode =>
Object.hash(verificationId, contactId, type, verifiedBy, createdAt);
@override
bool operator ==(Object other) =>
identical(this, other) ||
@ -9962,6 +10004,7 @@ class KeyVerification extends DataClass implements Insertable<KeyVerification> {
other.verificationId == this.verificationId &&
other.contactId == this.contactId &&
other.type == this.type &&
other.verifiedBy == this.verifiedBy &&
other.createdAt == this.createdAt);
}
@ -9969,17 +10012,20 @@ class KeyVerificationsCompanion extends UpdateCompanion<KeyVerification> {
final Value<int> verificationId;
final Value<int> contactId;
final Value<VerificationType> type;
final Value<int?> verifiedBy;
final Value<DateTime> createdAt;
const KeyVerificationsCompanion({
this.verificationId = const Value.absent(),
this.contactId = const Value.absent(),
this.type = const Value.absent(),
this.verifiedBy = const Value.absent(),
this.createdAt = const Value.absent(),
});
KeyVerificationsCompanion.insert({
this.verificationId = const Value.absent(),
required int contactId,
required VerificationType type,
this.verifiedBy = const Value.absent(),
this.createdAt = const Value.absent(),
}) : contactId = Value(contactId),
type = Value(type);
@ -9987,12 +10033,14 @@ class KeyVerificationsCompanion extends UpdateCompanion<KeyVerification> {
Expression<int>? verificationId,
Expression<int>? contactId,
Expression<String>? type,
Expression<int>? verifiedBy,
Expression<DateTime>? createdAt,
}) {
return RawValuesInsertable({
if (verificationId != null) 'verification_id': verificationId,
if (contactId != null) 'contact_id': contactId,
if (type != null) 'type': type,
if (verifiedBy != null) 'verified_by': verifiedBy,
if (createdAt != null) 'created_at': createdAt,
});
}
@ -10001,12 +10049,14 @@ class KeyVerificationsCompanion extends UpdateCompanion<KeyVerification> {
Value<int>? verificationId,
Value<int>? contactId,
Value<VerificationType>? type,
Value<int?>? verifiedBy,
Value<DateTime>? createdAt,
}) {
return KeyVerificationsCompanion(
verificationId: verificationId ?? this.verificationId,
contactId: contactId ?? this.contactId,
type: type ?? this.type,
verifiedBy: verifiedBy ?? this.verifiedBy,
createdAt: createdAt ?? this.createdAt,
);
}
@ -10025,6 +10075,9 @@ class KeyVerificationsCompanion extends UpdateCompanion<KeyVerification> {
$KeyVerificationsTable.$convertertype.toSql(type.value),
);
}
if (verifiedBy.present) {
map['verified_by'] = Variable<int>(verifiedBy.value);
}
if (createdAt.present) {
map['created_at'] = Variable<DateTime>(createdAt.value);
}
@ -10037,6 +10090,7 @@ class KeyVerificationsCompanion extends UpdateCompanion<KeyVerification> {
..write('verificationId: $verificationId, ')
..write('contactId: $contactId, ')
..write('type: $type, ')
..write('verifiedBy: $verifiedBy, ')
..write('createdAt: $createdAt')
..write(')'))
.toString();
@ -12786,6 +12840,13 @@ abstract class _$TwonlyDB extends GeneratedDatabase {
),
result: [TableUpdate('key_verifications', kind: UpdateKind.delete)],
),
WritePropagation(
on: TableUpdateQuery.onTableName(
'contacts',
limitUpdateKind: UpdateKind.delete,
),
result: [TableUpdate('key_verifications', kind: UpdateKind.delete)],
),
WritePropagation(
on: TableUpdateQuery.onTableName(
'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<
$UserDiscoveryUserRelationsTable,
List<UserDiscoveryUserRelation>
@ -13463,31 +13501,6 @@ class $$ContactsTableFilterComposer
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> Function($$UserDiscoveryUserRelationsTableFilterComposer f)
f,
@ -13965,31 +13978,6 @@ class $$ContactsTableAnnotationComposer
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> Function(
$$UserDiscoveryUserRelationsTableAnnotationComposer a,
@ -14125,7 +14113,6 @@ class $$ContactsTableTableManager
bool receiptsRefs,
bool messageActionsRefs,
bool groupHistoriesRefs,
bool keyVerificationsRefs,
bool userDiscoveryUserRelationsRefs,
bool userDiscoveryOtherPromotionsRefs,
bool userDiscoveryOwnPromotionsRefs,
@ -14244,7 +14231,6 @@ class $$ContactsTableTableManager
receiptsRefs = false,
messageActionsRefs = false,
groupHistoriesRefs = false,
keyVerificationsRefs = false,
userDiscoveryUserRelationsRefs = false,
userDiscoveryOtherPromotionsRefs = false,
userDiscoveryOwnPromotionsRefs = false,
@ -14260,7 +14246,6 @@ class $$ContactsTableTableManager
if (receiptsRefs) db.receipts,
if (messageActionsRefs) db.messageActions,
if (groupHistoriesRefs) db.groupHistories,
if (keyVerificationsRefs) db.keyVerifications,
if (userDiscoveryUserRelationsRefs)
db.userDiscoveryUserRelations,
if (userDiscoveryOtherPromotionsRefs)
@ -14419,27 +14404,6 @@ class $$ContactsTableTableManager
),
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)
await $_getPrefetchedData<
Contact,
@ -14552,7 +14516,6 @@ typedef $$ContactsTableProcessedTableManager =
bool receiptsRefs,
bool messageActionsRefs,
bool groupHistoriesRefs,
bool keyVerificationsRefs,
bool userDiscoveryUserRelationsRefs,
bool userDiscoveryOtherPromotionsRefs,
bool userDiscoveryOwnPromotionsRefs,
@ -21202,6 +21165,7 @@ typedef $$KeyVerificationsTableCreateCompanionBuilder =
Value<int> verificationId,
required int contactId,
required VerificationType type,
Value<int?> verifiedBy,
Value<DateTime> createdAt,
});
typedef $$KeyVerificationsTableUpdateCompanionBuilder =
@ -21209,6 +21173,7 @@ typedef $$KeyVerificationsTableUpdateCompanionBuilder =
Value<int> verificationId,
Value<int> contactId,
Value<VerificationType> type,
Value<int?> verifiedBy,
Value<DateTime> createdAt,
});
@ -21239,6 +21204,28 @@ final class $$KeyVerificationsTableReferences
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
@ -21288,6 +21275,29 @@ class $$KeyVerificationsTableFilterComposer
);
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
@ -21336,6 +21346,29 @@ class $$KeyVerificationsTableOrderingComposer
);
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
@ -21380,6 +21413,29 @@ class $$KeyVerificationsTableAnnotationComposer
);
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
@ -21395,7 +21451,7 @@ class $$KeyVerificationsTableTableManager
$$KeyVerificationsTableUpdateCompanionBuilder,
(KeyVerification, $$KeyVerificationsTableReferences),
KeyVerification,
PrefetchHooks Function({bool contactId})
PrefetchHooks Function({bool contactId, bool verifiedBy})
> {
$$KeyVerificationsTableTableManager(
_$TwonlyDB db,
@ -21415,11 +21471,13 @@ class $$KeyVerificationsTableTableManager
Value<int> verificationId = const Value.absent(),
Value<int> contactId = const Value.absent(),
Value<VerificationType> type = const Value.absent(),
Value<int?> verifiedBy = const Value.absent(),
Value<DateTime> createdAt = const Value.absent(),
}) => KeyVerificationsCompanion(
verificationId: verificationId,
contactId: contactId,
type: type,
verifiedBy: verifiedBy,
createdAt: createdAt,
),
createCompanionCallback:
@ -21427,11 +21485,13 @@ class $$KeyVerificationsTableTableManager
Value<int> verificationId = const Value.absent(),
required int contactId,
required VerificationType type,
Value<int?> verifiedBy = const Value.absent(),
Value<DateTime> createdAt = const Value.absent(),
}) => KeyVerificationsCompanion.insert(
verificationId: verificationId,
contactId: contactId,
type: type,
verifiedBy: verifiedBy,
createdAt: createdAt,
),
withReferenceMapper: (p0) => p0
@ -21442,7 +21502,7 @@ class $$KeyVerificationsTableTableManager
),
)
.toList(),
prefetchHooksCallback: ({contactId = false}) {
prefetchHooksCallback: ({contactId = false, verifiedBy = false}) {
return PrefetchHooks(
db: db,
explicitlyWatchedTables: [],
@ -21477,6 +21537,21 @@ class $$KeyVerificationsTableTableManager
)
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;
},
@ -21501,7 +21576,7 @@ typedef $$KeyVerificationsTableProcessedTableManager =
$$KeyVerificationsTableUpdateCompanionBuilder,
(KeyVerification, $$KeyVerificationsTableReferences),
KeyVerification,
PrefetchHooks Function({bool contactId})
PrefetchHooks Function({bool contactId, bool verifiedBy})
>;
typedef $$VerificationTokensTableCreateCompanionBuilder =
VerificationTokensCompanion Function({

View file

@ -9524,6 +9524,483 @@ i1.GeneratedColumn<int> _column_247(String aliasedName) =>
type: i1.DriftSqlType.int,
$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({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
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, Schema17 schema) from16To17,
required Future<void> Function(i1.Migrator m, Schema18 schema) from17To18,
required Future<void> Function(i1.Migrator m, Schema19 schema) from18To19,
}) {
return (currentVersion, database) async {
switch (currentVersion) {
@ -9630,6 +10108,11 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema);
await from17To18(migrator, schema);
return 18;
case 18:
final schema = Schema19(database: database);
final migrator = i1.Migrator(database, schema);
await from18To19(migrator, schema);
return 19;
default:
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, Schema17 schema) from16To17,
required Future<void> Function(i1.Migrator m, Schema18 schema) from17To18,
required Future<void> Function(i1.Migrator m, Schema19 schema) from18To19,
}) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps(
from1To2: from1To2,
@ -9673,5 +10157,6 @@ i1.OnUpgrade stepByStep({
from15To16: from15To16,
from16To17: from16To17,
from17To18: from17To18,
from18To19: from18To19,
),
);

View file

@ -914,6 +914,12 @@ abstract class AppLocalizations {
/// **'Verified by {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.
///
/// In en, this message translates to:

View file

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

View file

@ -443,6 +443,10 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Verified by $username';
}
@override
String get contactSharedByUnknown =>
'Shared by a verified contact (username not available)';
@override
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:twonly/locator.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/services/api/utils.api.dart';
import 'package:twonly/src/services/key_verification.service.dart';
import 'package:twonly/src/utils/log.dart';
Future<void> handleAdditionalDataMessage(
@ -28,6 +31,25 @@ Future<void> handleAdditionalDataMessage(
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(
MessagesCompanion(
messageId: Value(message.senderMessageId),
@ -46,6 +68,8 @@ Future<void> handleAdditionalDataMessage(
fromTimestamp(message.timestamp),
);
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...');
}
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(

View file

@ -1,26 +1,21 @@
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/visual/components/avatar_icon.comp.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 {
const AddContactDialog({
required this.profile,
required this.username,
super.key,
});
final PublicProfile profile;
final String username;
/// Utility method to easily present this dialog.
/// Returns `true` if the user chose to request the contact, `false` otherwise.
static Future<bool?> show(BuildContext context, PublicProfile profile) {
static Future<bool?> show(BuildContext context, String username) {
return showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (context) => AddContactDialog(profile: profile),
builder: (context) => AddContactDialog(username: username),
);
}
@ -48,7 +43,7 @@ class AddContactDialog extends StatelessWidget {
const SizedBox(width: 12),
Flexible(
child: Text(
profile.username,
username,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18,
@ -59,7 +54,7 @@ class AddContactDialog extends StatelessWidget {
),
const SizedBox(height: 24),
Text(
context.lang.userFoundBody(profile.username),
context.lang.userFoundBody(username),
textAlign: TextAlign.center,
style: const TextStyle(
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/src/constants/routes.keys.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/visual/components/verification_badge_info.comp.dart';
import 'package:twonly/src/visual/elements/svg_icon.element.dart';
@ -33,10 +34,17 @@ class VerificationBadgeComp extends StatefulWidget {
class _VerificationBadgeCompState extends State<VerificationBadgeComp> {
bool _isVerified = false;
bool _isSharedVerified = false;
int _verifiedByTransferredTrustCount = 0;
int _sharedByVerifiedCount = 0;
int _transferredTrustBaseCount = 0;
List<(KeyVerification, Contact?)> _keyVerifications = [];
List<(Contact, DateTime)> _transferredTrust = [];
StreamSubscription<VerificationStatus>? _streamAllVerified;
StreamSubscription<List<KeyVerification>>? _streamContactVerification;
StreamSubscription<List<(KeyVerification, Contact?)>>?
_streamContactVerification;
StreamSubscription<List<(Contact, DateTime)>>? _streamTransferredTrust;
@override
@ -45,6 +53,33 @@ class _VerificationBadgeCompState extends State<VerificationBadgeComp> {
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 {
var group = widget.group;
var contact = widget.contact;
@ -64,6 +99,7 @@ class _VerificationBadgeCompState extends State<VerificationBadgeComp> {
if (!mounted) return;
setState(() {
_isVerified = false;
_isSharedVerified = false;
_verifiedByTransferredTrustCount = 0;
if (update == VerificationStatus.trusted) {
_isVerified = true;
@ -78,18 +114,16 @@ class _VerificationBadgeCompState extends State<VerificationBadgeComp> {
.watchContactVerification(contact.userId)
.listen((update) {
if (!mounted) return;
setState(() {
_isVerified = update.isNotEmpty;
});
_keyVerifications = update;
_updateVerificationCounts();
});
_streamTransferredTrust = twonlyDB.keyVerificationDao
.watchTransferredTrustVerifications(contact.userId)
.listen((update) {
if (!mounted) return;
setState(() {
_verifiedByTransferredTrustCount = update.length;
});
_transferredTrust = update;
_updateVerificationCounts();
});
} else if (widget.isVerifiedByTransferredTrust != null) {
setState(() {
@ -109,6 +143,7 @@ class _VerificationBadgeCompState extends State<VerificationBadgeComp> {
@override
Widget build(BuildContext context) {
if (!_isVerified &&
!_isSharedVerified &&
_verifiedByTransferredTrustCount == 0 &&
widget.showOnlyIfVerified) {
return Container();

View file

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

View file

@ -1,17 +1,17 @@
import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:drift/drift.dart' show Value;
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:go_router/go_router.dart';
import 'package:twonly/locator.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/model/protobuf/client/generated/data.pb.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/visual/components/add_contact_dialog.comp.dart';
import 'package:twonly/src/visual/elements/better_text.element.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;
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(
ContactsCompanion(
username: Value(utf8.decode(userdata.username)),
username: Value(username),
userId: Value(userdata.userId.toInt()),
requested: const Value(false),
blocked: const Value(false),
@ -127,19 +141,11 @@ 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,
);
}
}
await KeyVerificationService.verifySharedContact(
contactId: userdata.userId.toInt(),
sharedPublicIdentityKey: widget.contact.publicIdentityKey,
senderId: widget.message.senderId!,
);
if (added > 0) await importSignalContactAndCreateRequest(userdata);
} catch (e) {
@ -170,7 +176,6 @@ class _ContactRowState extends State<_ContactRow> {
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const FaIcon(
FontAwesomeIcons.user,

View file

@ -29,10 +29,11 @@ class VerificationExpansionTileComp extends StatefulWidget {
class _VerificationExpansionTileCompState
extends State<VerificationExpansionTileComp> {
List<KeyVerification> _keyVerifications = [];
List<(KeyVerification, Contact?)> _keyVerifications = [];
List<(Contact, DateTime)> _transferredTrust = [];
late StreamSubscription<List<KeyVerification>> _streamKeyVerifications;
late StreamSubscription<List<(KeyVerification, Contact?)>>
_streamKeyVerifications;
late StreamSubscription<List<(Contact, DateTime)>> _streamTransferredTrust;
@override
@ -63,7 +64,11 @@ class _VerificationExpansionTileCompState
super.dispose();
}
String _verificationTypeLabel(BuildContext context, VerificationType type) {
String _verificationTypeLabel(
BuildContext context,
VerificationType type,
Contact? verifier,
) {
return switch (type) {
VerificationType.qrScanned => context.lang.verificationTypeQrScanned,
VerificationType.secretQrToken =>
@ -72,7 +77,9 @@ class _VerificationExpansionTileCompState
),
VerificationType.link => context.lang.verificationTypeLink,
VerificationType.contactSharedByVerified =>
context.lang.verificationTypeContactSharedByVerified,
verifier != null
? context.lang.contactVerifiedBy(getContactDisplayName(verifier))
: context.lang.contactSharedByUnknown,
VerificationType.migratedFromOldVersion =>
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(
shape: const RoundedRectangleBorder(),
backgroundColor: context.color.surfaceContainer,
@ -108,65 +126,62 @@ class _VerificationExpansionTileCompState
title: Text(context.lang.userVerifiedTitle),
children: [
..._keyVerifications.map(
(kv) => ListTile(
dense: true,
contentPadding: const EdgeInsets.only(left: 16),
title: Text(_verificationTypeLabel(context, kv.type)),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
DateFormat.yMd(
Localizations.localeOf(context).toString(),
).format(kv.createdAt),
style: TextStyle(
color: context.color.onSurfaceVariant,
fontSize: 13,
(pair) {
final kv = pair.$1;
final verifier = pair.$2;
return ListTile(
dense: true,
contentPadding: const EdgeInsets.only(left: 16),
title:
kv.type == VerificationType.contactSharedByVerified &&
verifier != null
? _VerifiedByContactRow(contact: verifier)
: Text(_verificationTypeLabel(context, kv.type, verifier)),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
DateFormat.yMd(
Localizations.localeOf(context).toString(),
).format(kv.createdAt),
style: TextStyle(
color: context.color.onSurfaceVariant,
fontSize: 13,
),
),
),
IconButton(
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
iconSize: 8,
icon: FaIcon(
FontAwesomeIcons.trash,
size: 8,
color: context.color.onSurfaceVariant,
IconButton(
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
iconSize: 8,
icon: FaIcon(
FontAwesomeIcons.trash,
size: 8,
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(
dense: true,
title: Row(
children: [
Text(
context.lang.contactVerifiedBy(
getContactDisplayName(tt.$1),
),
),
VerificationBadgeComp(
contact: tt.$1,
),
],
),
title: _VerifiedByContactRow(contact: tt.$1),
trailing: Text(
DateFormat.yMd(
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/user.service.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 {
const PrivacyView({super.key});
@ -68,6 +70,7 @@ class _PrivacyViewState extends State<PrivacyView> {
ListTile(
title: Text(context.lang.contactVerifyNumberTitle),
subtitle: Text(context.lang.contactVerifyNumberSubtitle),
trailing: const _VerificationBadgeTriangle(),
onTap: () async {
await context.push(Routes.settingsHelpFaqVerifyBadge);
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_v17.dart' as v17;
import 'schema_v18.dart' as v18;
import 'schema_v19.dart' as v19;
class GeneratedHelper implements SchemaInstantiationHelper {
@override
@ -63,6 +64,8 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v17.DatabaseAtV17(db);
case 18:
return v18.DatabaseAtV18(db);
case 19:
return v19.DatabaseAtV19(db);
default:
throw MissingSchemaException(version, versions);
}
@ -87,5 +90,6 @@ class GeneratedHelper implements SchemaInstantiationHelper {
16,
17,
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);
});
});
}