move ownership of the database to rust

This commit is contained in:
otsmr 2026-08-24 00:31:59 +02:00
parent 1fb6b3c94a
commit 74c20389c9
94 changed files with 7866 additions and 7329 deletions

2
.gitignore vendored
View file

@ -57,3 +57,5 @@ rust/target
rust_dependencies/target rust_dependencies/target
fastlane/repo/status/running.json fastlane/repo/status/running.json
.cache/ .cache/
# Widget Preview related
.widget_preview/

View file

@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.5-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip

View file

@ -18,11 +18,11 @@ pluginManagement {
plugins { plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0" id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version '8.11.1' apply false id "com.android.application" version '9.0.1' apply false
// START: FlutterFire Configuration // START: FlutterFire Configuration
id "com.google.gms.google-services" version "4.3.15" apply false id "com.google.gms.google-services" version "4.3.15" apply false
// END: FlutterFire Configuration // END: FlutterFire Configuration
id "org.jetbrains.kotlin.android" version "2.2.20" apply false id "org.jetbrains.kotlin.android" version "2.3.20" apply false
} }
include ":app" include ":app"

103
lib/core/app_database.dart Normal file
View file

@ -0,0 +1,103 @@
// This file is automatically generated, so please do not edit it.
// @generated by `flutter_rust_bridge`@ 2.12.0.
// ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import
import 'frb_generated.dart';
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
class SqlExecutionResult {
final PlatformInt64 affectedRows;
final PlatformInt64 lastInsertRowId;
const SqlExecutionResult({
required this.affectedRows,
required this.lastInsertRowId,
});
@override
int get hashCode => affectedRows.hashCode ^ lastInsertRowId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is SqlExecutionResult &&
runtimeType == other.runtimeType &&
affectedRows == other.affectedRows &&
lastInsertRowId == other.lastInsertRowId;
}
class SqlRow {
final List<SqlValue> values;
const SqlRow({
required this.values,
});
@override
int get hashCode => values.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is SqlRow &&
runtimeType == other.runtimeType &&
values == other.values;
}
class SqlRows {
final List<String> columns;
final List<SqlRow> rows;
const SqlRows({
required this.columns,
required this.rows,
});
@override
int get hashCode => columns.hashCode ^ rows.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is SqlRows &&
runtimeType == other.runtimeType &&
columns == other.columns &&
rows == other.rows;
}
class SqlValue {
/// 0 = null, 1 = integer, 2 = real, 3 = text, 4 = blob.
final int kind;
final PlatformInt64? integerValue;
final double? realValue;
final String? textValue;
final Uint8List? blobValue;
const SqlValue({
required this.kind,
this.integerValue,
this.realValue,
this.textValue,
this.blobValue,
});
@override
int get hashCode =>
kind.hashCode ^
integerValue.hashCode ^
realValue.hashCode ^
textValue.hashCode ^
blobValue.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is SqlValue &&
runtimeType == other.runtimeType &&
kind == other.kind &&
integerValue == other.integerValue &&
realValue == other.realValue &&
textValue == other.textValue &&
blobValue == other.blobValue;
}

View file

@ -7,35 +7,11 @@ import 'frb_generated.dart';
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
// These functions are ignored because they are not marked as `pub`: `get_twonly_flutter` // These functions are ignored because they are not marked as `pub`: `get_twonly_flutter`
// These types are ignored because they are neither used by any `pub` functions nor (for structs and enums) marked `#[frb(unignore)]`: `TwonlyFlutter` // These types are ignored because they are neither used by any `pub` functions nor (for structs and enums) marked `#[frb(unignore)]`: `AnnouncedUser`, `OtherPromotion`, `TwonlyFlutter`
Future<void> initializeTwonlyFlutter({required InitConfig config}) => Future<void> initializeTwonlyFlutter({required InitConfig config}) =>
RustLib.instance.api.crateBridgeInitializeTwonlyFlutter(config: config); RustLib.instance.api.crateBridgeInitializeTwonlyFlutter(config: config);
class AnnouncedUser {
final PlatformInt64 userId;
final Uint8List publicKey;
final PlatformInt64 publicId;
const AnnouncedUser({
required this.userId,
required this.publicKey,
required this.publicId,
});
@override
int get hashCode => userId.hashCode ^ publicKey.hashCode ^ publicId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is AnnouncedUser &&
runtimeType == other.runtimeType &&
userId == other.userId &&
publicKey == other.publicKey &&
publicId == other.publicId;
}
class InitConfig { class InitConfig {
final String databaseDir; final String databaseDir;
final String dataDir; final String dataDir;
@ -56,42 +32,3 @@ class InitConfig {
databaseDir == other.databaseDir && databaseDir == other.databaseDir &&
dataDir == other.dataDir; dataDir == other.dataDir;
} }
class OtherPromotion {
final int promotionId;
final PlatformInt64 publicId;
final PlatformInt64 fromContactId;
final int threshold;
final Uint8List announcementShare;
final PlatformInt64? publicKeyVerifiedTimestamp;
const OtherPromotion({
required this.promotionId,
required this.publicId,
required this.fromContactId,
required this.threshold,
required this.announcementShare,
this.publicKeyVerifiedTimestamp,
});
@override
int get hashCode =>
promotionId.hashCode ^
publicId.hashCode ^
fromContactId.hashCode ^
threshold.hashCode ^
announcementShare.hashCode ^
publicKeyVerifiedTimestamp.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is OtherPromotion &&
runtimeType == other.runtimeType &&
promotionId == other.promotionId &&
publicId == other.publicId &&
fromContactId == other.fromContactId &&
threshold == other.threshold &&
announcementShare == other.announcementShare &&
publicKeyVerifiedTimestamp == other.publicKeyVerifiedTimestamp;
}

View file

@ -3,62 +3,17 @@
// ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import // ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import
import '../bridge.dart';
import '../frb_generated.dart'; import '../frb_generated.dart';
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
// These functions are ignored because they are not marked as `pub`: `get_callbacks` // These functions are ignored because they are not marked as `pub`: `get_callbacks`
// These types are ignored because they are neither used by any `pub` functions nor (for structs and enums) marked `#[frb(unignore)]`: `FlutterCallbacks`, `Logging`, `UserDiscoveryCallbacks` // These types are ignored because they are neither used by any `pub` functions nor (for structs and enums) marked `#[frb(unignore)]`: `FlutterCallbacks`, `Logging`
// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `clone`, `clone`, `clone` // These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `clone`, `clone`
Future<void> initFlutterCallbacks({ Future<void> initFlutterCallbacks({
required int callbackId, required int callbackId,
required FutureOr<RustStreamSink<String>> Function() loggingGetStreamSink, required FutureOr<RustStreamSink<String>> Function() loggingGetStreamSink,
required FutureOr<Uint8List?> Function(Uint8List) userDiscoverySignData,
required FutureOr<bool> Function(Uint8List, Uint8List, Uint8List)
userDiscoveryVerifySignature,
required FutureOr<bool> Function(PlatformInt64, Uint8List)
userDiscoveryVerifyStoredPubkey,
required FutureOr<bool> Function(List<Uint8List>) userDiscoverySetShares,
required FutureOr<Uint8List?> Function(PlatformInt64)
userDiscoveryGetShareForContact,
required FutureOr<bool> Function(PlatformInt64, PlatformInt64, Uint8List)
userDiscoveryPushOwnPromotionAndClearOldVersion,
required FutureOr<List<Uint8List>?> Function(PlatformInt64)
userDiscoveryGetOwnPromotionsAfterVersion,
required FutureOr<bool> Function(OtherPromotion)
userDiscoveryStoreOtherPromotion,
required FutureOr<List<OtherPromotion>?> Function(PlatformInt64)
userDiscoveryGetOtherPromotionsByPublicId,
required FutureOr<AnnouncedUser?> Function(PlatformInt64)
userDiscoveryGetAnnouncedUserByPublicId,
required FutureOr<Uint8List?> Function(PlatformInt64)
userDiscoveryGetContactVersion,
required FutureOr<bool> Function(PlatformInt64, Uint8List)
userDiscoverySetContactVersion,
required FutureOr<bool> Function(PlatformInt64, AnnouncedUser, PlatformInt64?)
userDiscoveryPushNewUserRelation,
required FutureOr<Uint8List?> Function(PlatformInt64)
userDiscoveryGetContactPromotion,
}) => RustLib.instance.api.crateBridgeCallbacksInitFlutterCallbacks( }) => RustLib.instance.api.crateBridgeCallbacksInitFlutterCallbacks(
callbackId: callbackId, callbackId: callbackId,
loggingGetStreamSink: loggingGetStreamSink, loggingGetStreamSink: loggingGetStreamSink,
userDiscoverySignData: userDiscoverySignData,
userDiscoveryVerifySignature: userDiscoveryVerifySignature,
userDiscoveryVerifyStoredPubkey: userDiscoveryVerifyStoredPubkey,
userDiscoverySetShares: userDiscoverySetShares,
userDiscoveryGetShareForContact: userDiscoveryGetShareForContact,
userDiscoveryPushOwnPromotionAndClearOldVersion:
userDiscoveryPushOwnPromotionAndClearOldVersion,
userDiscoveryGetOwnPromotionsAfterVersion:
userDiscoveryGetOwnPromotionsAfterVersion,
userDiscoveryStoreOtherPromotion: userDiscoveryStoreOtherPromotion,
userDiscoveryGetOtherPromotionsByPublicId:
userDiscoveryGetOtherPromotionsByPublicId,
userDiscoveryGetAnnouncedUserByPublicId:
userDiscoveryGetAnnouncedUserByPublicId,
userDiscoveryGetContactVersion: userDiscoveryGetContactVersion,
userDiscoverySetContactVersion: userDiscoverySetContactVersion,
userDiscoveryPushNewUserRelation: userDiscoveryPushNewUserRelation,
userDiscoveryGetContactPromotion: userDiscoveryGetContactPromotion,
); );

View file

@ -0,0 +1,89 @@
// This file is automatically generated, so please do not edit it.
// @generated by `flutter_rust_bridge`@ 2.12.0.
// ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import
import '../../database/app.dart';
import '../../frb_generated.dart';
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
class LegacyMigrationReport {
final PlatformInt64 legacyVersion;
final List<LegacyTableMigrationCount> tables;
const LegacyMigrationReport({
required this.legacyVersion,
required this.tables,
});
@override
int get hashCode => legacyVersion.hashCode ^ tables.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is LegacyMigrationReport &&
runtimeType == other.runtimeType &&
legacyVersion == other.legacyVersion &&
tables == other.tables;
}
class LegacyTableMigrationCount {
final String table;
final PlatformInt64 rows;
const LegacyTableMigrationCount({
required this.table,
required this.rows,
});
@override
int get hashCode => table.hashCode ^ rows.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is LegacyTableMigrationCount &&
runtimeType == other.runtimeType &&
table == other.table &&
rows == other.rows;
}
class RustAppDatabase {
const RustAppDatabase();
static Future<SqlExecutionResult> execute({
required String statement,
required List<SqlValue> arguments,
}) =>
RustLib.instance.api.crateBridgeWrapperAppDatabaseRustAppDatabaseExecute(
statement: statement,
arguments: arguments,
);
static Future<bool> legacyImportComplete() => RustLib.instance.api
.crateBridgeWrapperAppDatabaseRustAppDatabaseLegacyImportComplete();
/// Imports the non-Signal tables from a Drift v25 database. The import is
/// transactional and idempotent. Drift must be closed while this runs.
static Future<LegacyMigrationReport> migrateLegacyDatabase() => RustLib
.instance
.api
.crateBridgeWrapperAppDatabaseRustAppDatabaseMigrateLegacyDatabase();
static Future<SqlRows> select({
required String statement,
required List<SqlValue> arguments,
}) => RustLib.instance.api.crateBridgeWrapperAppDatabaseRustAppDatabaseSelect(
statement: statement,
arguments: arguments,
);
@override
int get hashCode => 0;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is RustAppDatabase && runtimeType == other.runtimeType;
}

103
lib/core/database/app.dart Normal file
View file

@ -0,0 +1,103 @@
// This file is automatically generated, so please do not edit it.
// @generated by `flutter_rust_bridge`@ 2.12.0.
// ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import
import '../frb_generated.dart';
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
class SqlExecutionResult {
final PlatformInt64 affectedRows;
final PlatformInt64 lastInsertRowId;
const SqlExecutionResult({
required this.affectedRows,
required this.lastInsertRowId,
});
@override
int get hashCode => affectedRows.hashCode ^ lastInsertRowId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is SqlExecutionResult &&
runtimeType == other.runtimeType &&
affectedRows == other.affectedRows &&
lastInsertRowId == other.lastInsertRowId;
}
class SqlRow {
final List<SqlValue> values;
const SqlRow({
required this.values,
});
@override
int get hashCode => values.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is SqlRow &&
runtimeType == other.runtimeType &&
values == other.values;
}
class SqlRows {
final List<String> columns;
final List<SqlRow> rows;
const SqlRows({
required this.columns,
required this.rows,
});
@override
int get hashCode => columns.hashCode ^ rows.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is SqlRows &&
runtimeType == other.runtimeType &&
columns == other.columns &&
rows == other.rows;
}
class SqlValue {
/// 0 = null, 1 = integer, 2 = real, 3 = text, 4 = blob.
final int kind;
final PlatformInt64? integerValue;
final double? realValue;
final String? textValue;
final Uint8List? blobValue;
const SqlValue({
required this.kind,
this.integerValue,
this.realValue,
this.textValue,
this.blobValue,
});
@override
int get hashCode =>
kind.hashCode ^
integerValue.hashCode ^
realValue.hashCode ^
textValue.hashCode ^
blobValue.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is SqlValue &&
runtimeType == other.runtimeType &&
kind == other.kind &&
integerValue == other.integerValue &&
realValue == other.realValue &&
textValue == other.textValue &&
blobValue == other.blobValue;
}

File diff suppressed because it is too large Load diff

View file

@ -5,8 +5,8 @@
import 'bridge.dart'; import 'bridge.dart';
import 'bridge/callbacks.dart'; import 'bridge/callbacks.dart';
import 'bridge/callbacks/user_discovery.dart';
import 'bridge/wrapper.dart'; import 'bridge/wrapper.dart';
import 'bridge/wrapper/app_database.dart';
import 'bridge/wrapper/backup.dart'; import 'bridge/wrapper/backup.dart';
import 'bridge/wrapper/key_manager.dart'; import 'bridge/wrapper/key_manager.dart';
import 'bridge/wrapper/signal.dart'; import 'bridge/wrapper/signal.dart';
@ -14,6 +14,7 @@ import 'bridge/wrapper/user_discovery.dart';
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:ffi' as ffi; import 'dart:ffi' as ffi;
import 'database/app.dart';
import 'frb_generated.dart'; import 'frb_generated.dart';
import 'keys/backup_password_keys.dart'; import 'keys/backup_password_keys.dart';
import 'lib.dart'; import 'lib.dart';
@ -37,72 +38,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
dynamic raw, dynamic raw,
); );
@protected
FutureOr<AnnouncedUser?> Function(PlatformInt64)
dco_decode_DartFn_Inputs_i_64_Output_opt_box_autoadd_announced_user_AnyhowException(
dynamic raw,
);
@protected
FutureOr<List<Uint8List>?> Function(PlatformInt64)
dco_decode_DartFn_Inputs_i_64_Output_opt_list_list_prim_u_8_strict_AnyhowException(
dynamic raw,
);
@protected
FutureOr<List<OtherPromotion>?> Function(PlatformInt64)
dco_decode_DartFn_Inputs_i_64_Output_opt_list_other_promotion_AnyhowException(
dynamic raw,
);
@protected
FutureOr<Uint8List?> Function(PlatformInt64)
dco_decode_DartFn_Inputs_i_64_Output_opt_list_prim_u_8_strict_AnyhowException(
dynamic raw,
);
@protected
FutureOr<bool> Function(PlatformInt64, AnnouncedUser, PlatformInt64?)
dco_decode_DartFn_Inputs_i_64_announced_user_opt_box_autoadd_i_64_Output_bool_AnyhowException(
dynamic raw,
);
@protected
FutureOr<bool> Function(PlatformInt64, PlatformInt64, Uint8List)
dco_decode_DartFn_Inputs_i_64_i_64_list_prim_u_8_strict_Output_bool_AnyhowException(
dynamic raw,
);
@protected
FutureOr<bool> Function(PlatformInt64, Uint8List)
dco_decode_DartFn_Inputs_i_64_list_prim_u_8_strict_Output_bool_AnyhowException(
dynamic raw,
);
@protected
FutureOr<bool> Function(List<Uint8List>)
dco_decode_DartFn_Inputs_list_list_prim_u_8_strict_Output_bool_AnyhowException(
dynamic raw,
);
@protected
FutureOr<Uint8List?> Function(Uint8List)
dco_decode_DartFn_Inputs_list_prim_u_8_strict_Output_opt_list_prim_u_8_strict_AnyhowException(
dynamic raw,
);
@protected
FutureOr<bool> Function(Uint8List, Uint8List, Uint8List)
dco_decode_DartFn_Inputs_list_prim_u_8_strict_list_prim_u_8_strict_list_prim_u_8_strict_Output_bool_AnyhowException(
dynamic raw,
);
@protected
FutureOr<bool> Function(OtherPromotion)
dco_decode_DartFn_Inputs_other_promotion_Output_bool_AnyhowException(
dynamic raw,
);
@protected @protected
Object dco_decode_DartOpaque(dynamic raw); Object dco_decode_DartOpaque(dynamic raw);
@ -117,9 +52,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected @protected
String dco_decode_String(dynamic raw); String dco_decode_String(dynamic raw);
@protected
AnnouncedUser dco_decode_announced_user(dynamic raw);
@protected @protected
BackupPasswordKeys dco_decode_backup_password_keys(dynamic raw); BackupPasswordKeys dco_decode_backup_password_keys(dynamic raw);
@ -127,10 +59,10 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
bool dco_decode_bool(dynamic raw); bool dco_decode_bool(dynamic raw);
@protected @protected
AnnouncedUser dco_decode_box_autoadd_announced_user(dynamic raw); BackupPasswordKeys dco_decode_box_autoadd_backup_password_keys(dynamic raw);
@protected @protected
BackupPasswordKeys dco_decode_box_autoadd_backup_password_keys(dynamic raw); double dco_decode_box_autoadd_f_64(dynamic raw);
@protected @protected
FrbPreKeyBundle dco_decode_box_autoadd_frb_pre_key_bundle(dynamic raw); FrbPreKeyBundle dco_decode_box_autoadd_frb_pre_key_bundle(dynamic raw);
@ -141,21 +73,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected @protected
InitConfig dco_decode_box_autoadd_init_config(dynamic raw); InitConfig dco_decode_box_autoadd_init_config(dynamic raw);
@protected
OtherPromotion dco_decode_box_autoadd_other_promotion(dynamic raw);
@protected @protected
int dco_decode_box_autoadd_u_32(dynamic raw); int dco_decode_box_autoadd_u_32(dynamic raw);
@protected @protected
UserDiscoveryStoreFlutter dco_decode_box_autoadd_user_discovery_store_flutter( double dco_decode_f_64(dynamic raw);
dynamic raw,
);
@protected
UserDiscoveryUtilsFlutter dco_decode_box_autoadd_user_discovery_utils_flutter(
dynamic raw,
);
@protected @protected
FlutterUserDiscovery dco_decode_flutter_user_discovery(dynamic raw); FlutterUserDiscovery dco_decode_flutter_user_discovery(dynamic raw);
@ -175,14 +97,27 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected @protected
PlatformInt64 dco_decode_isize(dynamic raw); PlatformInt64 dco_decode_isize(dynamic raw);
@protected
LegacyMigrationReport dco_decode_legacy_migration_report(dynamic raw);
@protected
LegacyTableMigrationCount dco_decode_legacy_table_migration_count(
dynamic raw,
);
@protected
List<String> dco_decode_list_String(dynamic raw);
@protected @protected
List<FrbPqcPreKey> dco_decode_list_frb_pqc_pre_key(dynamic raw); List<FrbPqcPreKey> dco_decode_list_frb_pqc_pre_key(dynamic raw);
@protected @protected
List<Uint8List> dco_decode_list_list_prim_u_8_strict(dynamic raw); List<LegacyTableMigrationCount> dco_decode_list_legacy_table_migration_count(
dynamic raw,
);
@protected @protected
List<OtherPromotion> dco_decode_list_other_promotion(dynamic raw); List<Uint8List> dco_decode_list_list_prim_u_8_strict(dynamic raw);
@protected @protected
List<int> dco_decode_list_prim_u_8_loose(dynamic raw); List<int> dco_decode_list_prim_u_8_loose(dynamic raw);
@ -194,11 +129,17 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
List<(PlatformInt64, Uint8List)> List<(PlatformInt64, Uint8List)>
dco_decode_list_record_i_64_list_prim_u_8_strict(dynamic raw); dco_decode_list_record_i_64_list_prim_u_8_strict(dynamic raw);
@protected
List<SqlRow> dco_decode_list_sql_row(dynamic raw);
@protected
List<SqlValue> dco_decode_list_sql_value(dynamic raw);
@protected @protected
String? dco_decode_opt_String(dynamic raw); String? dco_decode_opt_String(dynamic raw);
@protected @protected
AnnouncedUser? dco_decode_opt_box_autoadd_announced_user(dynamic raw); double? dco_decode_opt_box_autoadd_f_64(dynamic raw);
@protected @protected
PlatformInt64? dco_decode_opt_box_autoadd_i_64(dynamic raw); PlatformInt64? dco_decode_opt_box_autoadd_i_64(dynamic raw);
@ -206,18 +147,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected @protected
int? dco_decode_opt_box_autoadd_u_32(dynamic raw); int? dco_decode_opt_box_autoadd_u_32(dynamic raw);
@protected
List<Uint8List>? dco_decode_opt_list_list_prim_u_8_strict(dynamic raw);
@protected
List<OtherPromotion>? dco_decode_opt_list_other_promotion(dynamic raw);
@protected @protected
Uint8List? dco_decode_opt_list_prim_u_8_strict(dynamic raw); Uint8List? dco_decode_opt_list_prim_u_8_strict(dynamic raw);
@protected
OtherPromotion dco_decode_other_promotion(dynamic raw);
@protected @protected
(PlatformInt64, Uint8List) dco_decode_record_i_64_list_prim_u_8_strict( (PlatformInt64, Uint8List) dco_decode_record_i_64_list_prim_u_8_strict(
dynamic raw, dynamic raw,
@ -231,6 +163,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected @protected
(String, String) dco_decode_record_string_string(dynamic raw); (String, String) dco_decode_record_string_string(dynamic raw);
@protected
RustAppDatabase dco_decode_rust_app_database(dynamic raw);
@protected @protected
RustBackupArchive dco_decode_rust_backup_archive(dynamic raw); RustBackupArchive dco_decode_rust_backup_archive(dynamic raw);
@ -246,6 +181,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected @protected
RustUtils dco_decode_rust_utils(dynamic raw); RustUtils dco_decode_rust_utils(dynamic raw);
@protected
SqlExecutionResult dco_decode_sql_execution_result(dynamic raw);
@protected
SqlRow dco_decode_sql_row(dynamic raw);
@protected
SqlRows dco_decode_sql_rows(dynamic raw);
@protected
SqlValue dco_decode_sql_value(dynamic raw);
@protected @protected
int dco_decode_u_32(dynamic raw); int dco_decode_u_32(dynamic raw);
@ -258,16 +205,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected @protected
void dco_decode_unit(dynamic raw); void dco_decode_unit(dynamic raw);
@protected
UserDiscoveryStoreFlutter dco_decode_user_discovery_store_flutter(
dynamic raw,
);
@protected
UserDiscoveryUtilsFlutter dco_decode_user_discovery_utils_flutter(
dynamic raw,
);
@protected @protected
BigInt dco_decode_usize(dynamic raw); BigInt dco_decode_usize(dynamic raw);
@ -290,9 +227,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected @protected
String sse_decode_String(SseDeserializer deserializer); String sse_decode_String(SseDeserializer deserializer);
@protected
AnnouncedUser sse_decode_announced_user(SseDeserializer deserializer);
@protected @protected
BackupPasswordKeys sse_decode_backup_password_keys( BackupPasswordKeys sse_decode_backup_password_keys(
SseDeserializer deserializer, SseDeserializer deserializer,
@ -302,14 +236,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
bool sse_decode_bool(SseDeserializer deserializer); bool sse_decode_bool(SseDeserializer deserializer);
@protected @protected
AnnouncedUser sse_decode_box_autoadd_announced_user( BackupPasswordKeys sse_decode_box_autoadd_backup_password_keys(
SseDeserializer deserializer, SseDeserializer deserializer,
); );
@protected @protected
BackupPasswordKeys sse_decode_box_autoadd_backup_password_keys( double sse_decode_box_autoadd_f_64(SseDeserializer deserializer);
SseDeserializer deserializer,
);
@protected @protected
FrbPreKeyBundle sse_decode_box_autoadd_frb_pre_key_bundle( FrbPreKeyBundle sse_decode_box_autoadd_frb_pre_key_bundle(
@ -322,23 +254,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected @protected
InitConfig sse_decode_box_autoadd_init_config(SseDeserializer deserializer); InitConfig sse_decode_box_autoadd_init_config(SseDeserializer deserializer);
@protected
OtherPromotion sse_decode_box_autoadd_other_promotion(
SseDeserializer deserializer,
);
@protected @protected
int sse_decode_box_autoadd_u_32(SseDeserializer deserializer); int sse_decode_box_autoadd_u_32(SseDeserializer deserializer);
@protected @protected
UserDiscoveryStoreFlutter sse_decode_box_autoadd_user_discovery_store_flutter( double sse_decode_f_64(SseDeserializer deserializer);
SseDeserializer deserializer,
);
@protected
UserDiscoveryUtilsFlutter sse_decode_box_autoadd_user_discovery_utils_flutter(
SseDeserializer deserializer,
);
@protected @protected
FlutterUserDiscovery sse_decode_flutter_user_discovery( FlutterUserDiscovery sse_decode_flutter_user_discovery(
@ -360,18 +280,31 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected @protected
PlatformInt64 sse_decode_isize(SseDeserializer deserializer); PlatformInt64 sse_decode_isize(SseDeserializer deserializer);
@protected
LegacyMigrationReport sse_decode_legacy_migration_report(
SseDeserializer deserializer,
);
@protected
LegacyTableMigrationCount sse_decode_legacy_table_migration_count(
SseDeserializer deserializer,
);
@protected
List<String> sse_decode_list_String(SseDeserializer deserializer);
@protected @protected
List<FrbPqcPreKey> sse_decode_list_frb_pqc_pre_key( List<FrbPqcPreKey> sse_decode_list_frb_pqc_pre_key(
SseDeserializer deserializer, SseDeserializer deserializer,
); );
@protected @protected
List<Uint8List> sse_decode_list_list_prim_u_8_strict( List<LegacyTableMigrationCount> sse_decode_list_legacy_table_migration_count(
SseDeserializer deserializer, SseDeserializer deserializer,
); );
@protected @protected
List<OtherPromotion> sse_decode_list_other_promotion( List<Uint8List> sse_decode_list_list_prim_u_8_strict(
SseDeserializer deserializer, SseDeserializer deserializer,
); );
@ -387,13 +320,17 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer, SseDeserializer deserializer,
); );
@protected
List<SqlRow> sse_decode_list_sql_row(SseDeserializer deserializer);
@protected
List<SqlValue> sse_decode_list_sql_value(SseDeserializer deserializer);
@protected @protected
String? sse_decode_opt_String(SseDeserializer deserializer); String? sse_decode_opt_String(SseDeserializer deserializer);
@protected @protected
AnnouncedUser? sse_decode_opt_box_autoadd_announced_user( double? sse_decode_opt_box_autoadd_f_64(SseDeserializer deserializer);
SseDeserializer deserializer,
);
@protected @protected
PlatformInt64? sse_decode_opt_box_autoadd_i_64(SseDeserializer deserializer); PlatformInt64? sse_decode_opt_box_autoadd_i_64(SseDeserializer deserializer);
@ -401,22 +338,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected @protected
int? sse_decode_opt_box_autoadd_u_32(SseDeserializer deserializer); int? sse_decode_opt_box_autoadd_u_32(SseDeserializer deserializer);
@protected
List<Uint8List>? sse_decode_opt_list_list_prim_u_8_strict(
SseDeserializer deserializer,
);
@protected
List<OtherPromotion>? sse_decode_opt_list_other_promotion(
SseDeserializer deserializer,
);
@protected @protected
Uint8List? sse_decode_opt_list_prim_u_8_strict(SseDeserializer deserializer); Uint8List? sse_decode_opt_list_prim_u_8_strict(SseDeserializer deserializer);
@protected
OtherPromotion sse_decode_other_promotion(SseDeserializer deserializer);
@protected @protected
(PlatformInt64, Uint8List) sse_decode_record_i_64_list_prim_u_8_strict( (PlatformInt64, Uint8List) sse_decode_record_i_64_list_prim_u_8_strict(
SseDeserializer deserializer, SseDeserializer deserializer,
@ -432,6 +356,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer, SseDeserializer deserializer,
); );
@protected
RustAppDatabase sse_decode_rust_app_database(SseDeserializer deserializer);
@protected @protected
RustBackupArchive sse_decode_rust_backup_archive( RustBackupArchive sse_decode_rust_backup_archive(
SseDeserializer deserializer, SseDeserializer deserializer,
@ -451,6 +378,20 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected @protected
RustUtils sse_decode_rust_utils(SseDeserializer deserializer); RustUtils sse_decode_rust_utils(SseDeserializer deserializer);
@protected
SqlExecutionResult sse_decode_sql_execution_result(
SseDeserializer deserializer,
);
@protected
SqlRow sse_decode_sql_row(SseDeserializer deserializer);
@protected
SqlRows sse_decode_sql_rows(SseDeserializer deserializer);
@protected
SqlValue sse_decode_sql_value(SseDeserializer deserializer);
@protected @protected
int sse_decode_u_32(SseDeserializer deserializer); int sse_decode_u_32(SseDeserializer deserializer);
@ -463,16 +404,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected @protected
void sse_decode_unit(SseDeserializer deserializer); void sse_decode_unit(SseDeserializer deserializer);
@protected
UserDiscoveryStoreFlutter sse_decode_user_discovery_store_flutter(
SseDeserializer deserializer,
);
@protected
UserDiscoveryUtilsFlutter sse_decode_user_discovery_utils_flutter(
SseDeserializer deserializer,
);
@protected @protected
BigInt sse_decode_usize(SseDeserializer deserializer); BigInt sse_decode_usize(SseDeserializer deserializer);
@ -491,82 +422,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer, SseSerializer serializer,
); );
@protected
void
sse_encode_DartFn_Inputs_i_64_Output_opt_box_autoadd_announced_user_AnyhowException(
FutureOr<AnnouncedUser?> Function(PlatformInt64) self,
SseSerializer serializer,
);
@protected
void
sse_encode_DartFn_Inputs_i_64_Output_opt_list_list_prim_u_8_strict_AnyhowException(
FutureOr<List<Uint8List>?> Function(PlatformInt64) self,
SseSerializer serializer,
);
@protected
void
sse_encode_DartFn_Inputs_i_64_Output_opt_list_other_promotion_AnyhowException(
FutureOr<List<OtherPromotion>?> Function(PlatformInt64) self,
SseSerializer serializer,
);
@protected
void
sse_encode_DartFn_Inputs_i_64_Output_opt_list_prim_u_8_strict_AnyhowException(
FutureOr<Uint8List?> Function(PlatformInt64) self,
SseSerializer serializer,
);
@protected
void
sse_encode_DartFn_Inputs_i_64_announced_user_opt_box_autoadd_i_64_Output_bool_AnyhowException(
FutureOr<bool> Function(PlatformInt64, AnnouncedUser, PlatformInt64?) self,
SseSerializer serializer,
);
@protected
void
sse_encode_DartFn_Inputs_i_64_i_64_list_prim_u_8_strict_Output_bool_AnyhowException(
FutureOr<bool> Function(PlatformInt64, PlatformInt64, Uint8List) self,
SseSerializer serializer,
);
@protected
void
sse_encode_DartFn_Inputs_i_64_list_prim_u_8_strict_Output_bool_AnyhowException(
FutureOr<bool> Function(PlatformInt64, Uint8List) self,
SseSerializer serializer,
);
@protected
void
sse_encode_DartFn_Inputs_list_list_prim_u_8_strict_Output_bool_AnyhowException(
FutureOr<bool> Function(List<Uint8List>) self,
SseSerializer serializer,
);
@protected
void
sse_encode_DartFn_Inputs_list_prim_u_8_strict_Output_opt_list_prim_u_8_strict_AnyhowException(
FutureOr<Uint8List?> Function(Uint8List) self,
SseSerializer serializer,
);
@protected
void
sse_encode_DartFn_Inputs_list_prim_u_8_strict_list_prim_u_8_strict_list_prim_u_8_strict_Output_bool_AnyhowException(
FutureOr<bool> Function(Uint8List, Uint8List, Uint8List) self,
SseSerializer serializer,
);
@protected
void sse_encode_DartFn_Inputs_other_promotion_Output_bool_AnyhowException(
FutureOr<bool> Function(OtherPromotion) self,
SseSerializer serializer,
);
@protected @protected
void sse_encode_DartOpaque(Object self, SseSerializer serializer); void sse_encode_DartOpaque(Object self, SseSerializer serializer);
@ -585,9 +440,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected @protected
void sse_encode_String(String self, SseSerializer serializer); void sse_encode_String(String self, SseSerializer serializer);
@protected
void sse_encode_announced_user(AnnouncedUser self, SseSerializer serializer);
@protected @protected
void sse_encode_backup_password_keys( void sse_encode_backup_password_keys(
BackupPasswordKeys self, BackupPasswordKeys self,
@ -597,18 +449,15 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected @protected
void sse_encode_bool(bool self, SseSerializer serializer); void sse_encode_bool(bool self, SseSerializer serializer);
@protected
void sse_encode_box_autoadd_announced_user(
AnnouncedUser self,
SseSerializer serializer,
);
@protected @protected
void sse_encode_box_autoadd_backup_password_keys( void sse_encode_box_autoadd_backup_password_keys(
BackupPasswordKeys self, BackupPasswordKeys self,
SseSerializer serializer, SseSerializer serializer,
); );
@protected
void sse_encode_box_autoadd_f_64(double self, SseSerializer serializer);
@protected @protected
void sse_encode_box_autoadd_frb_pre_key_bundle( void sse_encode_box_autoadd_frb_pre_key_bundle(
FrbPreKeyBundle self, FrbPreKeyBundle self,
@ -627,26 +476,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer, SseSerializer serializer,
); );
@protected
void sse_encode_box_autoadd_other_promotion(
OtherPromotion self,
SseSerializer serializer,
);
@protected @protected
void sse_encode_box_autoadd_u_32(int self, SseSerializer serializer); void sse_encode_box_autoadd_u_32(int self, SseSerializer serializer);
@protected @protected
void sse_encode_box_autoadd_user_discovery_store_flutter( void sse_encode_f_64(double self, SseSerializer serializer);
UserDiscoveryStoreFlutter self,
SseSerializer serializer,
);
@protected
void sse_encode_box_autoadd_user_discovery_utils_flutter(
UserDiscoveryUtilsFlutter self,
SseSerializer serializer,
);
@protected @protected
void sse_encode_flutter_user_discovery( void sse_encode_flutter_user_discovery(
@ -672,6 +506,21 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected @protected
void sse_encode_isize(PlatformInt64 self, SseSerializer serializer); void sse_encode_isize(PlatformInt64 self, SseSerializer serializer);
@protected
void sse_encode_legacy_migration_report(
LegacyMigrationReport self,
SseSerializer serializer,
);
@protected
void sse_encode_legacy_table_migration_count(
LegacyTableMigrationCount self,
SseSerializer serializer,
);
@protected
void sse_encode_list_String(List<String> self, SseSerializer serializer);
@protected @protected
void sse_encode_list_frb_pqc_pre_key( void sse_encode_list_frb_pqc_pre_key(
List<FrbPqcPreKey> self, List<FrbPqcPreKey> self,
@ -679,14 +528,14 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
); );
@protected @protected
void sse_encode_list_list_prim_u_8_strict( void sse_encode_list_legacy_table_migration_count(
List<Uint8List> self, List<LegacyTableMigrationCount> self,
SseSerializer serializer, SseSerializer serializer,
); );
@protected @protected
void sse_encode_list_other_promotion( void sse_encode_list_list_prim_u_8_strict(
List<OtherPromotion> self, List<Uint8List> self,
SseSerializer serializer, SseSerializer serializer,
); );
@ -705,14 +554,17 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer, SseSerializer serializer,
); );
@protected
void sse_encode_list_sql_row(List<SqlRow> self, SseSerializer serializer);
@protected
void sse_encode_list_sql_value(List<SqlValue> self, SseSerializer serializer);
@protected @protected
void sse_encode_opt_String(String? self, SseSerializer serializer); void sse_encode_opt_String(String? self, SseSerializer serializer);
@protected @protected
void sse_encode_opt_box_autoadd_announced_user( void sse_encode_opt_box_autoadd_f_64(double? self, SseSerializer serializer);
AnnouncedUser? self,
SseSerializer serializer,
);
@protected @protected
void sse_encode_opt_box_autoadd_i_64( void sse_encode_opt_box_autoadd_i_64(
@ -723,30 +575,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected @protected
void sse_encode_opt_box_autoadd_u_32(int? self, SseSerializer serializer); void sse_encode_opt_box_autoadd_u_32(int? self, SseSerializer serializer);
@protected
void sse_encode_opt_list_list_prim_u_8_strict(
List<Uint8List>? self,
SseSerializer serializer,
);
@protected
void sse_encode_opt_list_other_promotion(
List<OtherPromotion>? self,
SseSerializer serializer,
);
@protected @protected
void sse_encode_opt_list_prim_u_8_strict( void sse_encode_opt_list_prim_u_8_strict(
Uint8List? self, Uint8List? self,
SseSerializer serializer, SseSerializer serializer,
); );
@protected
void sse_encode_other_promotion(
OtherPromotion self,
SseSerializer serializer,
);
@protected @protected
void sse_encode_record_i_64_list_prim_u_8_strict( void sse_encode_record_i_64_list_prim_u_8_strict(
(PlatformInt64, Uint8List) self, (PlatformInt64, Uint8List) self,
@ -765,6 +599,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer, SseSerializer serializer,
); );
@protected
void sse_encode_rust_app_database(
RustAppDatabase self,
SseSerializer serializer,
);
@protected @protected
void sse_encode_rust_backup_archive( void sse_encode_rust_backup_archive(
RustBackupArchive self, RustBackupArchive self,
@ -789,6 +629,21 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected @protected
void sse_encode_rust_utils(RustUtils self, SseSerializer serializer); void sse_encode_rust_utils(RustUtils self, SseSerializer serializer);
@protected
void sse_encode_sql_execution_result(
SqlExecutionResult self,
SseSerializer serializer,
);
@protected
void sse_encode_sql_row(SqlRow self, SseSerializer serializer);
@protected
void sse_encode_sql_rows(SqlRows self, SseSerializer serializer);
@protected
void sse_encode_sql_value(SqlValue self, SseSerializer serializer);
@protected @protected
void sse_encode_u_32(int self, SseSerializer serializer); void sse_encode_u_32(int self, SseSerializer serializer);
@ -801,18 +656,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected @protected
void sse_encode_unit(void self, SseSerializer serializer); void sse_encode_unit(void self, SseSerializer serializer);
@protected
void sse_encode_user_discovery_store_flutter(
UserDiscoveryStoreFlutter self,
SseSerializer serializer,
);
@protected
void sse_encode_user_discovery_utils_flutter(
UserDiscoveryUtilsFlutter self,
SseSerializer serializer,
);
@protected @protected
void sse_encode_usize(BigInt self, SseSerializer serializer); void sse_encode_usize(BigInt self, SseSerializer serializer);

View file

@ -8,14 +8,15 @@
import 'bridge.dart'; import 'bridge.dart';
import 'bridge/callbacks.dart'; import 'bridge/callbacks.dart';
import 'bridge/callbacks/user_discovery.dart';
import 'bridge/wrapper.dart'; import 'bridge/wrapper.dart';
import 'bridge/wrapper/app_database.dart';
import 'bridge/wrapper/backup.dart'; import 'bridge/wrapper/backup.dart';
import 'bridge/wrapper/key_manager.dart'; import 'bridge/wrapper/key_manager.dart';
import 'bridge/wrapper/signal.dart'; import 'bridge/wrapper/signal.dart';
import 'bridge/wrapper/user_discovery.dart'; import 'bridge/wrapper/user_discovery.dart';
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'database/app.dart';
import 'frb_generated.dart'; import 'frb_generated.dart';
import 'keys/backup_password_keys.dart'; import 'keys/backup_password_keys.dart';
import 'lib.dart'; import 'lib.dart';
@ -39,72 +40,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
dynamic raw, dynamic raw,
); );
@protected
FutureOr<AnnouncedUser?> Function(PlatformInt64)
dco_decode_DartFn_Inputs_i_64_Output_opt_box_autoadd_announced_user_AnyhowException(
dynamic raw,
);
@protected
FutureOr<List<Uint8List>?> Function(PlatformInt64)
dco_decode_DartFn_Inputs_i_64_Output_opt_list_list_prim_u_8_strict_AnyhowException(
dynamic raw,
);
@protected
FutureOr<List<OtherPromotion>?> Function(PlatformInt64)
dco_decode_DartFn_Inputs_i_64_Output_opt_list_other_promotion_AnyhowException(
dynamic raw,
);
@protected
FutureOr<Uint8List?> Function(PlatformInt64)
dco_decode_DartFn_Inputs_i_64_Output_opt_list_prim_u_8_strict_AnyhowException(
dynamic raw,
);
@protected
FutureOr<bool> Function(PlatformInt64, AnnouncedUser, PlatformInt64?)
dco_decode_DartFn_Inputs_i_64_announced_user_opt_box_autoadd_i_64_Output_bool_AnyhowException(
dynamic raw,
);
@protected
FutureOr<bool> Function(PlatformInt64, PlatformInt64, Uint8List)
dco_decode_DartFn_Inputs_i_64_i_64_list_prim_u_8_strict_Output_bool_AnyhowException(
dynamic raw,
);
@protected
FutureOr<bool> Function(PlatformInt64, Uint8List)
dco_decode_DartFn_Inputs_i_64_list_prim_u_8_strict_Output_bool_AnyhowException(
dynamic raw,
);
@protected
FutureOr<bool> Function(List<Uint8List>)
dco_decode_DartFn_Inputs_list_list_prim_u_8_strict_Output_bool_AnyhowException(
dynamic raw,
);
@protected
FutureOr<Uint8List?> Function(Uint8List)
dco_decode_DartFn_Inputs_list_prim_u_8_strict_Output_opt_list_prim_u_8_strict_AnyhowException(
dynamic raw,
);
@protected
FutureOr<bool> Function(Uint8List, Uint8List, Uint8List)
dco_decode_DartFn_Inputs_list_prim_u_8_strict_list_prim_u_8_strict_list_prim_u_8_strict_Output_bool_AnyhowException(
dynamic raw,
);
@protected
FutureOr<bool> Function(OtherPromotion)
dco_decode_DartFn_Inputs_other_promotion_Output_bool_AnyhowException(
dynamic raw,
);
@protected @protected
Object dco_decode_DartOpaque(dynamic raw); Object dco_decode_DartOpaque(dynamic raw);
@ -119,9 +54,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected @protected
String dco_decode_String(dynamic raw); String dco_decode_String(dynamic raw);
@protected
AnnouncedUser dco_decode_announced_user(dynamic raw);
@protected @protected
BackupPasswordKeys dco_decode_backup_password_keys(dynamic raw); BackupPasswordKeys dco_decode_backup_password_keys(dynamic raw);
@ -129,10 +61,10 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
bool dco_decode_bool(dynamic raw); bool dco_decode_bool(dynamic raw);
@protected @protected
AnnouncedUser dco_decode_box_autoadd_announced_user(dynamic raw); BackupPasswordKeys dco_decode_box_autoadd_backup_password_keys(dynamic raw);
@protected @protected
BackupPasswordKeys dco_decode_box_autoadd_backup_password_keys(dynamic raw); double dco_decode_box_autoadd_f_64(dynamic raw);
@protected @protected
FrbPreKeyBundle dco_decode_box_autoadd_frb_pre_key_bundle(dynamic raw); FrbPreKeyBundle dco_decode_box_autoadd_frb_pre_key_bundle(dynamic raw);
@ -143,21 +75,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected @protected
InitConfig dco_decode_box_autoadd_init_config(dynamic raw); InitConfig dco_decode_box_autoadd_init_config(dynamic raw);
@protected
OtherPromotion dco_decode_box_autoadd_other_promotion(dynamic raw);
@protected @protected
int dco_decode_box_autoadd_u_32(dynamic raw); int dco_decode_box_autoadd_u_32(dynamic raw);
@protected @protected
UserDiscoveryStoreFlutter dco_decode_box_autoadd_user_discovery_store_flutter( double dco_decode_f_64(dynamic raw);
dynamic raw,
);
@protected
UserDiscoveryUtilsFlutter dco_decode_box_autoadd_user_discovery_utils_flutter(
dynamic raw,
);
@protected @protected
FlutterUserDiscovery dco_decode_flutter_user_discovery(dynamic raw); FlutterUserDiscovery dco_decode_flutter_user_discovery(dynamic raw);
@ -177,14 +99,27 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected @protected
PlatformInt64 dco_decode_isize(dynamic raw); PlatformInt64 dco_decode_isize(dynamic raw);
@protected
LegacyMigrationReport dco_decode_legacy_migration_report(dynamic raw);
@protected
LegacyTableMigrationCount dco_decode_legacy_table_migration_count(
dynamic raw,
);
@protected
List<String> dco_decode_list_String(dynamic raw);
@protected @protected
List<FrbPqcPreKey> dco_decode_list_frb_pqc_pre_key(dynamic raw); List<FrbPqcPreKey> dco_decode_list_frb_pqc_pre_key(dynamic raw);
@protected @protected
List<Uint8List> dco_decode_list_list_prim_u_8_strict(dynamic raw); List<LegacyTableMigrationCount> dco_decode_list_legacy_table_migration_count(
dynamic raw,
);
@protected @protected
List<OtherPromotion> dco_decode_list_other_promotion(dynamic raw); List<Uint8List> dco_decode_list_list_prim_u_8_strict(dynamic raw);
@protected @protected
List<int> dco_decode_list_prim_u_8_loose(dynamic raw); List<int> dco_decode_list_prim_u_8_loose(dynamic raw);
@ -196,11 +131,17 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
List<(PlatformInt64, Uint8List)> List<(PlatformInt64, Uint8List)>
dco_decode_list_record_i_64_list_prim_u_8_strict(dynamic raw); dco_decode_list_record_i_64_list_prim_u_8_strict(dynamic raw);
@protected
List<SqlRow> dco_decode_list_sql_row(dynamic raw);
@protected
List<SqlValue> dco_decode_list_sql_value(dynamic raw);
@protected @protected
String? dco_decode_opt_String(dynamic raw); String? dco_decode_opt_String(dynamic raw);
@protected @protected
AnnouncedUser? dco_decode_opt_box_autoadd_announced_user(dynamic raw); double? dco_decode_opt_box_autoadd_f_64(dynamic raw);
@protected @protected
PlatformInt64? dco_decode_opt_box_autoadd_i_64(dynamic raw); PlatformInt64? dco_decode_opt_box_autoadd_i_64(dynamic raw);
@ -208,18 +149,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected @protected
int? dco_decode_opt_box_autoadd_u_32(dynamic raw); int? dco_decode_opt_box_autoadd_u_32(dynamic raw);
@protected
List<Uint8List>? dco_decode_opt_list_list_prim_u_8_strict(dynamic raw);
@protected
List<OtherPromotion>? dco_decode_opt_list_other_promotion(dynamic raw);
@protected @protected
Uint8List? dco_decode_opt_list_prim_u_8_strict(dynamic raw); Uint8List? dco_decode_opt_list_prim_u_8_strict(dynamic raw);
@protected
OtherPromotion dco_decode_other_promotion(dynamic raw);
@protected @protected
(PlatformInt64, Uint8List) dco_decode_record_i_64_list_prim_u_8_strict( (PlatformInt64, Uint8List) dco_decode_record_i_64_list_prim_u_8_strict(
dynamic raw, dynamic raw,
@ -233,6 +165,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected @protected
(String, String) dco_decode_record_string_string(dynamic raw); (String, String) dco_decode_record_string_string(dynamic raw);
@protected
RustAppDatabase dco_decode_rust_app_database(dynamic raw);
@protected @protected
RustBackupArchive dco_decode_rust_backup_archive(dynamic raw); RustBackupArchive dco_decode_rust_backup_archive(dynamic raw);
@ -248,6 +183,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected @protected
RustUtils dco_decode_rust_utils(dynamic raw); RustUtils dco_decode_rust_utils(dynamic raw);
@protected
SqlExecutionResult dco_decode_sql_execution_result(dynamic raw);
@protected
SqlRow dco_decode_sql_row(dynamic raw);
@protected
SqlRows dco_decode_sql_rows(dynamic raw);
@protected
SqlValue dco_decode_sql_value(dynamic raw);
@protected @protected
int dco_decode_u_32(dynamic raw); int dco_decode_u_32(dynamic raw);
@ -260,16 +207,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected @protected
void dco_decode_unit(dynamic raw); void dco_decode_unit(dynamic raw);
@protected
UserDiscoveryStoreFlutter dco_decode_user_discovery_store_flutter(
dynamic raw,
);
@protected
UserDiscoveryUtilsFlutter dco_decode_user_discovery_utils_flutter(
dynamic raw,
);
@protected @protected
BigInt dco_decode_usize(dynamic raw); BigInt dco_decode_usize(dynamic raw);
@ -292,9 +229,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected @protected
String sse_decode_String(SseDeserializer deserializer); String sse_decode_String(SseDeserializer deserializer);
@protected
AnnouncedUser sse_decode_announced_user(SseDeserializer deserializer);
@protected @protected
BackupPasswordKeys sse_decode_backup_password_keys( BackupPasswordKeys sse_decode_backup_password_keys(
SseDeserializer deserializer, SseDeserializer deserializer,
@ -304,14 +238,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
bool sse_decode_bool(SseDeserializer deserializer); bool sse_decode_bool(SseDeserializer deserializer);
@protected @protected
AnnouncedUser sse_decode_box_autoadd_announced_user( BackupPasswordKeys sse_decode_box_autoadd_backup_password_keys(
SseDeserializer deserializer, SseDeserializer deserializer,
); );
@protected @protected
BackupPasswordKeys sse_decode_box_autoadd_backup_password_keys( double sse_decode_box_autoadd_f_64(SseDeserializer deserializer);
SseDeserializer deserializer,
);
@protected @protected
FrbPreKeyBundle sse_decode_box_autoadd_frb_pre_key_bundle( FrbPreKeyBundle sse_decode_box_autoadd_frb_pre_key_bundle(
@ -324,23 +256,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected @protected
InitConfig sse_decode_box_autoadd_init_config(SseDeserializer deserializer); InitConfig sse_decode_box_autoadd_init_config(SseDeserializer deserializer);
@protected
OtherPromotion sse_decode_box_autoadd_other_promotion(
SseDeserializer deserializer,
);
@protected @protected
int sse_decode_box_autoadd_u_32(SseDeserializer deserializer); int sse_decode_box_autoadd_u_32(SseDeserializer deserializer);
@protected @protected
UserDiscoveryStoreFlutter sse_decode_box_autoadd_user_discovery_store_flutter( double sse_decode_f_64(SseDeserializer deserializer);
SseDeserializer deserializer,
);
@protected
UserDiscoveryUtilsFlutter sse_decode_box_autoadd_user_discovery_utils_flutter(
SseDeserializer deserializer,
);
@protected @protected
FlutterUserDiscovery sse_decode_flutter_user_discovery( FlutterUserDiscovery sse_decode_flutter_user_discovery(
@ -362,18 +282,31 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected @protected
PlatformInt64 sse_decode_isize(SseDeserializer deserializer); PlatformInt64 sse_decode_isize(SseDeserializer deserializer);
@protected
LegacyMigrationReport sse_decode_legacy_migration_report(
SseDeserializer deserializer,
);
@protected
LegacyTableMigrationCount sse_decode_legacy_table_migration_count(
SseDeserializer deserializer,
);
@protected
List<String> sse_decode_list_String(SseDeserializer deserializer);
@protected @protected
List<FrbPqcPreKey> sse_decode_list_frb_pqc_pre_key( List<FrbPqcPreKey> sse_decode_list_frb_pqc_pre_key(
SseDeserializer deserializer, SseDeserializer deserializer,
); );
@protected @protected
List<Uint8List> sse_decode_list_list_prim_u_8_strict( List<LegacyTableMigrationCount> sse_decode_list_legacy_table_migration_count(
SseDeserializer deserializer, SseDeserializer deserializer,
); );
@protected @protected
List<OtherPromotion> sse_decode_list_other_promotion( List<Uint8List> sse_decode_list_list_prim_u_8_strict(
SseDeserializer deserializer, SseDeserializer deserializer,
); );
@ -389,13 +322,17 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer, SseDeserializer deserializer,
); );
@protected
List<SqlRow> sse_decode_list_sql_row(SseDeserializer deserializer);
@protected
List<SqlValue> sse_decode_list_sql_value(SseDeserializer deserializer);
@protected @protected
String? sse_decode_opt_String(SseDeserializer deserializer); String? sse_decode_opt_String(SseDeserializer deserializer);
@protected @protected
AnnouncedUser? sse_decode_opt_box_autoadd_announced_user( double? sse_decode_opt_box_autoadd_f_64(SseDeserializer deserializer);
SseDeserializer deserializer,
);
@protected @protected
PlatformInt64? sse_decode_opt_box_autoadd_i_64(SseDeserializer deserializer); PlatformInt64? sse_decode_opt_box_autoadd_i_64(SseDeserializer deserializer);
@ -403,22 +340,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected @protected
int? sse_decode_opt_box_autoadd_u_32(SseDeserializer deserializer); int? sse_decode_opt_box_autoadd_u_32(SseDeserializer deserializer);
@protected
List<Uint8List>? sse_decode_opt_list_list_prim_u_8_strict(
SseDeserializer deserializer,
);
@protected
List<OtherPromotion>? sse_decode_opt_list_other_promotion(
SseDeserializer deserializer,
);
@protected @protected
Uint8List? sse_decode_opt_list_prim_u_8_strict(SseDeserializer deserializer); Uint8List? sse_decode_opt_list_prim_u_8_strict(SseDeserializer deserializer);
@protected
OtherPromotion sse_decode_other_promotion(SseDeserializer deserializer);
@protected @protected
(PlatformInt64, Uint8List) sse_decode_record_i_64_list_prim_u_8_strict( (PlatformInt64, Uint8List) sse_decode_record_i_64_list_prim_u_8_strict(
SseDeserializer deserializer, SseDeserializer deserializer,
@ -434,6 +358,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer, SseDeserializer deserializer,
); );
@protected
RustAppDatabase sse_decode_rust_app_database(SseDeserializer deserializer);
@protected @protected
RustBackupArchive sse_decode_rust_backup_archive( RustBackupArchive sse_decode_rust_backup_archive(
SseDeserializer deserializer, SseDeserializer deserializer,
@ -453,6 +380,20 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected @protected
RustUtils sse_decode_rust_utils(SseDeserializer deserializer); RustUtils sse_decode_rust_utils(SseDeserializer deserializer);
@protected
SqlExecutionResult sse_decode_sql_execution_result(
SseDeserializer deserializer,
);
@protected
SqlRow sse_decode_sql_row(SseDeserializer deserializer);
@protected
SqlRows sse_decode_sql_rows(SseDeserializer deserializer);
@protected
SqlValue sse_decode_sql_value(SseDeserializer deserializer);
@protected @protected
int sse_decode_u_32(SseDeserializer deserializer); int sse_decode_u_32(SseDeserializer deserializer);
@ -465,16 +406,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected @protected
void sse_decode_unit(SseDeserializer deserializer); void sse_decode_unit(SseDeserializer deserializer);
@protected
UserDiscoveryStoreFlutter sse_decode_user_discovery_store_flutter(
SseDeserializer deserializer,
);
@protected
UserDiscoveryUtilsFlutter sse_decode_user_discovery_utils_flutter(
SseDeserializer deserializer,
);
@protected @protected
BigInt sse_decode_usize(SseDeserializer deserializer); BigInt sse_decode_usize(SseDeserializer deserializer);
@ -493,82 +424,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer, SseSerializer serializer,
); );
@protected
void
sse_encode_DartFn_Inputs_i_64_Output_opt_box_autoadd_announced_user_AnyhowException(
FutureOr<AnnouncedUser?> Function(PlatformInt64) self,
SseSerializer serializer,
);
@protected
void
sse_encode_DartFn_Inputs_i_64_Output_opt_list_list_prim_u_8_strict_AnyhowException(
FutureOr<List<Uint8List>?> Function(PlatformInt64) self,
SseSerializer serializer,
);
@protected
void
sse_encode_DartFn_Inputs_i_64_Output_opt_list_other_promotion_AnyhowException(
FutureOr<List<OtherPromotion>?> Function(PlatformInt64) self,
SseSerializer serializer,
);
@protected
void
sse_encode_DartFn_Inputs_i_64_Output_opt_list_prim_u_8_strict_AnyhowException(
FutureOr<Uint8List?> Function(PlatformInt64) self,
SseSerializer serializer,
);
@protected
void
sse_encode_DartFn_Inputs_i_64_announced_user_opt_box_autoadd_i_64_Output_bool_AnyhowException(
FutureOr<bool> Function(PlatformInt64, AnnouncedUser, PlatformInt64?) self,
SseSerializer serializer,
);
@protected
void
sse_encode_DartFn_Inputs_i_64_i_64_list_prim_u_8_strict_Output_bool_AnyhowException(
FutureOr<bool> Function(PlatformInt64, PlatformInt64, Uint8List) self,
SseSerializer serializer,
);
@protected
void
sse_encode_DartFn_Inputs_i_64_list_prim_u_8_strict_Output_bool_AnyhowException(
FutureOr<bool> Function(PlatformInt64, Uint8List) self,
SseSerializer serializer,
);
@protected
void
sse_encode_DartFn_Inputs_list_list_prim_u_8_strict_Output_bool_AnyhowException(
FutureOr<bool> Function(List<Uint8List>) self,
SseSerializer serializer,
);
@protected
void
sse_encode_DartFn_Inputs_list_prim_u_8_strict_Output_opt_list_prim_u_8_strict_AnyhowException(
FutureOr<Uint8List?> Function(Uint8List) self,
SseSerializer serializer,
);
@protected
void
sse_encode_DartFn_Inputs_list_prim_u_8_strict_list_prim_u_8_strict_list_prim_u_8_strict_Output_bool_AnyhowException(
FutureOr<bool> Function(Uint8List, Uint8List, Uint8List) self,
SseSerializer serializer,
);
@protected
void sse_encode_DartFn_Inputs_other_promotion_Output_bool_AnyhowException(
FutureOr<bool> Function(OtherPromotion) self,
SseSerializer serializer,
);
@protected @protected
void sse_encode_DartOpaque(Object self, SseSerializer serializer); void sse_encode_DartOpaque(Object self, SseSerializer serializer);
@ -587,9 +442,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected @protected
void sse_encode_String(String self, SseSerializer serializer); void sse_encode_String(String self, SseSerializer serializer);
@protected
void sse_encode_announced_user(AnnouncedUser self, SseSerializer serializer);
@protected @protected
void sse_encode_backup_password_keys( void sse_encode_backup_password_keys(
BackupPasswordKeys self, BackupPasswordKeys self,
@ -599,18 +451,15 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected @protected
void sse_encode_bool(bool self, SseSerializer serializer); void sse_encode_bool(bool self, SseSerializer serializer);
@protected
void sse_encode_box_autoadd_announced_user(
AnnouncedUser self,
SseSerializer serializer,
);
@protected @protected
void sse_encode_box_autoadd_backup_password_keys( void sse_encode_box_autoadd_backup_password_keys(
BackupPasswordKeys self, BackupPasswordKeys self,
SseSerializer serializer, SseSerializer serializer,
); );
@protected
void sse_encode_box_autoadd_f_64(double self, SseSerializer serializer);
@protected @protected
void sse_encode_box_autoadd_frb_pre_key_bundle( void sse_encode_box_autoadd_frb_pre_key_bundle(
FrbPreKeyBundle self, FrbPreKeyBundle self,
@ -629,26 +478,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer, SseSerializer serializer,
); );
@protected
void sse_encode_box_autoadd_other_promotion(
OtherPromotion self,
SseSerializer serializer,
);
@protected @protected
void sse_encode_box_autoadd_u_32(int self, SseSerializer serializer); void sse_encode_box_autoadd_u_32(int self, SseSerializer serializer);
@protected @protected
void sse_encode_box_autoadd_user_discovery_store_flutter( void sse_encode_f_64(double self, SseSerializer serializer);
UserDiscoveryStoreFlutter self,
SseSerializer serializer,
);
@protected
void sse_encode_box_autoadd_user_discovery_utils_flutter(
UserDiscoveryUtilsFlutter self,
SseSerializer serializer,
);
@protected @protected
void sse_encode_flutter_user_discovery( void sse_encode_flutter_user_discovery(
@ -674,6 +508,21 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected @protected
void sse_encode_isize(PlatformInt64 self, SseSerializer serializer); void sse_encode_isize(PlatformInt64 self, SseSerializer serializer);
@protected
void sse_encode_legacy_migration_report(
LegacyMigrationReport self,
SseSerializer serializer,
);
@protected
void sse_encode_legacy_table_migration_count(
LegacyTableMigrationCount self,
SseSerializer serializer,
);
@protected
void sse_encode_list_String(List<String> self, SseSerializer serializer);
@protected @protected
void sse_encode_list_frb_pqc_pre_key( void sse_encode_list_frb_pqc_pre_key(
List<FrbPqcPreKey> self, List<FrbPqcPreKey> self,
@ -681,14 +530,14 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
); );
@protected @protected
void sse_encode_list_list_prim_u_8_strict( void sse_encode_list_legacy_table_migration_count(
List<Uint8List> self, List<LegacyTableMigrationCount> self,
SseSerializer serializer, SseSerializer serializer,
); );
@protected @protected
void sse_encode_list_other_promotion( void sse_encode_list_list_prim_u_8_strict(
List<OtherPromotion> self, List<Uint8List> self,
SseSerializer serializer, SseSerializer serializer,
); );
@ -707,14 +556,17 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer, SseSerializer serializer,
); );
@protected
void sse_encode_list_sql_row(List<SqlRow> self, SseSerializer serializer);
@protected
void sse_encode_list_sql_value(List<SqlValue> self, SseSerializer serializer);
@protected @protected
void sse_encode_opt_String(String? self, SseSerializer serializer); void sse_encode_opt_String(String? self, SseSerializer serializer);
@protected @protected
void sse_encode_opt_box_autoadd_announced_user( void sse_encode_opt_box_autoadd_f_64(double? self, SseSerializer serializer);
AnnouncedUser? self,
SseSerializer serializer,
);
@protected @protected
void sse_encode_opt_box_autoadd_i_64( void sse_encode_opt_box_autoadd_i_64(
@ -725,30 +577,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected @protected
void sse_encode_opt_box_autoadd_u_32(int? self, SseSerializer serializer); void sse_encode_opt_box_autoadd_u_32(int? self, SseSerializer serializer);
@protected
void sse_encode_opt_list_list_prim_u_8_strict(
List<Uint8List>? self,
SseSerializer serializer,
);
@protected
void sse_encode_opt_list_other_promotion(
List<OtherPromotion>? self,
SseSerializer serializer,
);
@protected @protected
void sse_encode_opt_list_prim_u_8_strict( void sse_encode_opt_list_prim_u_8_strict(
Uint8List? self, Uint8List? self,
SseSerializer serializer, SseSerializer serializer,
); );
@protected
void sse_encode_other_promotion(
OtherPromotion self,
SseSerializer serializer,
);
@protected @protected
void sse_encode_record_i_64_list_prim_u_8_strict( void sse_encode_record_i_64_list_prim_u_8_strict(
(PlatformInt64, Uint8List) self, (PlatformInt64, Uint8List) self,
@ -767,6 +601,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer, SseSerializer serializer,
); );
@protected
void sse_encode_rust_app_database(
RustAppDatabase self,
SseSerializer serializer,
);
@protected @protected
void sse_encode_rust_backup_archive( void sse_encode_rust_backup_archive(
RustBackupArchive self, RustBackupArchive self,
@ -791,6 +631,21 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected @protected
void sse_encode_rust_utils(RustUtils self, SseSerializer serializer); void sse_encode_rust_utils(RustUtils self, SseSerializer serializer);
@protected
void sse_encode_sql_execution_result(
SqlExecutionResult self,
SseSerializer serializer,
);
@protected
void sse_encode_sql_row(SqlRow self, SseSerializer serializer);
@protected
void sse_encode_sql_rows(SqlRows self, SseSerializer serializer);
@protected
void sse_encode_sql_value(SqlValue self, SseSerializer serializer);
@protected @protected
void sse_encode_u_32(int self, SseSerializer serializer); void sse_encode_u_32(int self, SseSerializer serializer);
@ -803,18 +658,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected @protected
void sse_encode_unit(void self, SseSerializer serializer); void sse_encode_unit(void self, SseSerializer serializer);
@protected
void sse_encode_user_discovery_store_flutter(
UserDiscoveryStoreFlutter self,
SseSerializer serializer,
);
@protected
void sse_encode_user_discovery_utils_flutter(
UserDiscoveryUtilsFlutter self,
SseSerializer serializer,
);
@protected @protected
void sse_encode_usize(BigInt self, SseSerializer serializer); void sse_encode_usize(BigInt self, SseSerializer serializer);

View file

@ -1,4 +1,5 @@
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:twonly/src/database/signal.db.dart';
import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/services/api/api.service.dart'; import 'package:twonly/src/services/api/api.service.dart';
import 'package:twonly/src/services/news.service.dart'; import 'package:twonly/src/services/news.service.dart';
@ -11,10 +12,12 @@ void setupLocator() {
..registerLazySingleton<UserService>(UserService.new) ..registerLazySingleton<UserService>(UserService.new)
..registerLazySingleton<ApiService>(ApiService.new) ..registerLazySingleton<ApiService>(ApiService.new)
..registerLazySingleton<TwonlyDB>(TwonlyDB.new) ..registerLazySingleton<TwonlyDB>(TwonlyDB.new)
..registerLazySingleton<SignalDB>(SignalDB.new)
..registerLazySingleton<NewsService>(NewsService.new); ..registerLazySingleton<NewsService>(NewsService.new);
} }
UserService get userService => locator<UserService>(); UserService get userService => locator<UserService>();
ApiService get apiService => locator<ApiService>(); ApiService get apiService => locator<ApiService>();
TwonlyDB get twonlyDB => locator<TwonlyDB>(); TwonlyDB get twonlyDB => locator<TwonlyDB>();
SignalDB get signalDB => locator<SignalDB>();
NewsService get newsService => locator<NewsService>(); NewsService get newsService => locator<NewsService>();

View file

@ -1,5 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:camera/camera.dart'; import 'package:camera/camera.dart';
import 'package:drift/native.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:mutex/mutex.dart'; import 'package:mutex/mutex.dart';
@ -7,11 +9,13 @@ import 'package:provider/provider.dart';
import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:twonly/app.dart'; import 'package:twonly/app.dart';
import 'package:twonly/core/bridge.dart' as bridge; import 'package:twonly/core/bridge.dart' as bridge;
import 'package:twonly/core/bridge/wrapper/app_database.dart';
import 'package:twonly/core/bridge/wrapper/key_manager.dart'; import 'package:twonly/core/bridge/wrapper/key_manager.dart';
import 'package:twonly/core/frb_generated.dart'; import 'package:twonly/core/frb_generated.dart';
import 'package:twonly/globals.dart'; import 'package:twonly/globals.dart';
import 'package:twonly/locator.dart'; import 'package:twonly/locator.dart';
import 'package:twonly/src/callbacks/callbacks.dart'; import 'package:twonly/src/callbacks/callbacks.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/providers/connection.provider.dart'; import 'package:twonly/src/providers/connection.provider.dart';
import 'package:twonly/src/providers/image_editor.provider.dart'; import 'package:twonly/src/providers/image_editor.provider.dart';
import 'package:twonly/src/providers/purchases.provider.dart'; import 'package:twonly/src/providers/purchases.provider.dart';
@ -55,6 +59,19 @@ Future<bool> twonlyMinimumInitialization() async {
dataDir: AppEnvironment.supportDir, dataDir: AppEnvironment.supportDir,
), ),
); );
if (!await RustAppDatabase.legacyImportComplete()) {
final legacyFile = File(
'${AppEnvironment.supportDir}/twonly.sqlite',
);
if (legacyFile.existsSync()) {
final legacyDatabase = TwonlyDB(NativeDatabase(legacyFile));
// Opening the database applies every existing Drift migration up
// to v25 before Rust copies the application tables.
await legacyDatabase.customSelect('PRAGMA user_version').getSingle();
await legacyDatabase.close();
}
await RustAppDatabase.migrateLegacyDatabase();
}
} catch (e) { } catch (e) {
Log.error(e); Log.error(e);
return true; return true;

View file

@ -1,33 +1,10 @@
import 'package:twonly/core/bridge/callbacks.dart'; import 'package:twonly/core/bridge/callbacks.dart';
import 'package:twonly/globals.dart'; import 'package:twonly/globals.dart';
import 'package:twonly/src/callbacks/logging.callbacks.dart'; import 'package:twonly/src/callbacks/logging.callbacks.dart';
import 'package:twonly/src/callbacks/user_discovery.callbacks.dart';
Future<void> initFlutterCallbacksForRust() async { Future<void> initFlutterCallbacksForRust() async {
await initFlutterCallbacks( await initFlutterCallbacks(
callbackId: isolateCallbackId, callbackId: isolateCallbackId,
loggingGetStreamSink: LoggingCallbacks.getStreamSink, loggingGetStreamSink: LoggingCallbacks.getStreamSink,
userDiscoverySetShares: UserDiscoveryCallbacks.setShares,
userDiscoveryGetShareForContact:
UserDiscoveryCallbacks.userDiscoveryGetShareForContact,
userDiscoveryPushOwnPromotionAndClearOldVersion:
UserDiscoveryCallbacks.userDiscoveryPushOwnPromotionAndClearOldVersion,
userDiscoveryPushNewUserRelation:
UserDiscoveryCallbacks.pushNewUserRelation,
userDiscoveryGetOwnPromotionsAfterVersion:
UserDiscoveryCallbacks.getOwnPromotionsAfterVersion,
userDiscoveryStoreOtherPromotion:
UserDiscoveryCallbacks.storeOtherPromotion,
userDiscoveryGetOtherPromotionsByPublicId:
UserDiscoveryCallbacks.getOtherPromotionsByPublicId,
userDiscoveryGetAnnouncedUserByPublicId:
UserDiscoveryCallbacks.getAnnouncedUserByPublicId,
userDiscoveryGetContactVersion: UserDiscoveryCallbacks.getContactVersion,
userDiscoverySetContactVersion: UserDiscoveryCallbacks.setContactVersion,
userDiscoverySignData: UserDiscoveryCallbacks.signData,
userDiscoveryVerifySignature: UserDiscoveryCallbacks.verifySignature,
userDiscoveryVerifyStoredPubkey: UserDiscoveryCallbacks.verifyStoredPubKey,
userDiscoveryGetContactPromotion:
UserDiscoveryCallbacks.getContactPromotion,
); );
} }

View file

@ -1,328 +0,0 @@
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:libsignal_protocol_dart/libsignal_protocol_dart.dart'
show Curve, IdentityKey;
// ignore: implementation_imports
import 'package:libsignal_protocol_dart/src/ecc/ed25519.dart';
import 'package:twonly/core/bridge.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/services/signal/identity.signal.dart';
import 'package:twonly/src/services/signal/session.signal.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart';
class UserDiscoveryCallbacks {
static Future<Uint8List?> signData(
Uint8List inputData,
) async {
Log.info('UserDiscoveryCallbacks: signData started');
var privKey = (await getSignalIdentityKeyPair())?.getPrivateKey();
if (privKey == null) {
Log.error('UserDiscoveryCallbacks: signData failed, privKey is null');
return null;
}
final random = getRandomUint8List(32);
final signature = sign(
privKey.serialize(),
inputData,
random,
);
privKey = null;
Log.info('UserDiscoveryCallbacks: signData finished');
return signature;
}
static Future<bool> verifySignature(
Uint8List inputData,
Uint8List pubKey,
Uint8List signature,
) async {
try {
return Curve.verifySignature(
IdentityKey.fromBytes(pubKey, 0).publicKey,
inputData,
signature,
);
} catch (_) {
return false;
}
}
static Future<bool> verifyStoredPubKey(
int contactId,
Uint8List pubKey,
) async {
try {
final storedPublicKey = await getPublicKeyFromContact(contactId);
if (storedPublicKey != null) {
return storedPublicKey.equals(pubKey);
} else {
return false;
}
} catch (_) {
return false;
}
}
static Future<bool> setShares(List<Uint8List> shares) async {
try {
// First remove all old shares then insert all the new shares
await twonlyDB.delete(twonlyDB.userDiscoveryShares).go();
await twonlyDB.batch((b) {
b.insertAll(
twonlyDB.userDiscoveryShares,
shares
.map((s) => UserDiscoverySharesCompanion(share: Value(s)))
.toList(),
);
});
return true;
} catch (e) {
Log.error(e);
return false;
}
}
static Future<Uint8List?> userDiscoveryGetShareForContact(
int contactId,
) async {
return twonlyDB.transaction(() async {
// 1. Check if this contact already has a share assigned
final existing =
await (twonlyDB.select(twonlyDB.userDiscoveryShares)
..where((tbl) => tbl.contactId.equals(contactId))
..limit(1))
.getSingleOrNull();
if (existing != null) {
return existing.share;
}
// 2. No share found. Find an available one (where contactId is null)
final available =
await (twonlyDB.select(twonlyDB.userDiscoveryShares)
..where((tbl) => tbl.contactId.isNull())
..limit(1))
.getSingleOrNull();
if (available != null) {
// 3. Assign the contactId to this available share
await (twonlyDB.update(
twonlyDB.userDiscoveryShares,
)..where((tbl) => tbl.shareId.equals(available.shareId))).write(
UserDiscoverySharesCompanion(
contactId: Value(contactId),
),
);
return available.share;
}
return null; // 4. No existing or available shares found
});
}
static Future<bool> userDiscoveryPushOwnPromotionAndClearOldVersion(
int contactId,
int version,
Uint8List promotion,
) async {
try {
// Old promotions from this users should be removed...
await (twonlyDB.update(
twonlyDB.userDiscoveryOwnPromotions,
)..where((t) => t.contactId.equals(contactId))).write(
UserDiscoveryOwnPromotionsCompanion(promotion: Value(Uint8List(0))),
);
await twonlyDB
.into(twonlyDB.userDiscoveryOwnPromotions)
.insert(
UserDiscoveryOwnPromotionsCompanion.insert(
contactId: contactId,
promotion: promotion,
),
);
return true;
} catch (e) {
Log.error(e);
return false;
}
}
static Future<List<Uint8List>> getOwnPromotionsAfterVersion(
int version,
) async {
final query = twonlyDB.select(twonlyDB.userDiscoveryOwnPromotions)
..where((tbl) => tbl.versionId.isBiggerThanValue(version));
final rows = await query.get();
return rows.map((r) => r.promotion).toList();
}
static Future<bool> storeOtherPromotion(
OtherPromotion promotion,
) async {
try {
await twonlyDB
.into(twonlyDB.userDiscoveryOtherPromotions)
.insertOnConflictUpdate(
UserDiscoveryOtherPromotionsCompanion(
promotionId: Value(promotion.promotionId),
publicId: Value(promotion.publicId),
fromContactId: Value(promotion.fromContactId),
threshold: Value(promotion.threshold),
announcementShare: Value(promotion.announcementShare),
publicKeyVerifiedTimestamp: Value(
promotion.publicKeyVerifiedTimestamp == null
? null
: DateTime.fromMillisecondsSinceEpoch(
promotion.publicKeyVerifiedTimestamp!,
),
),
),
);
return true;
} catch (e) {
Log.error(e);
return false;
}
}
static Future<List<OtherPromotion>> getOtherPromotionsByPublicId(
int publicId,
) async {
final rows = await (twonlyDB.select(
twonlyDB.userDiscoveryOtherPromotions,
)..where((tbl) => tbl.publicId.equals(publicId))).get();
return rows
.map(
(row) => OtherPromotion(
promotionId: row.promotionId,
publicId: row.publicId,
fromContactId: row.fromContactId,
threshold: row.threshold,
announcementShare: row.announcementShare,
publicKeyVerifiedTimestamp:
row.publicKeyVerifiedTimestamp?.millisecondsSinceEpoch,
),
)
.toList();
}
static Future<AnnouncedUser?> getAnnouncedUserByPublicId(
int publicId,
) async {
final row = await (twonlyDB.select(
twonlyDB.userDiscoveryAnnouncedUsers,
)..where((tbl) => tbl.publicId.equals(publicId))).getSingleOrNull();
if (row == null) return null;
return AnnouncedUser(
userId: row.announcedUserId,
publicKey: row.announcedPublicKey,
publicId: row.publicId,
);
}
static Future<bool> pushNewUserRelation(
int fromContactId,
AnnouncedUser announcedUser,
int? publicKeyVerifiedTimestamp,
) async {
try {
await twonlyDB.transaction(() async {
// 1. Ensure the user exists in the AnnouncedUsers table
await twonlyDB
.into(twonlyDB.userDiscoveryAnnouncedUsers)
.insertOnConflictUpdate(
UserDiscoveryAnnouncedUsersCompanion(
announcedUserId: Value(announcedUser.userId),
announcedPublicKey: Value(announcedUser.publicKey),
publicId: Value(announcedUser.publicId),
),
);
// 2. Insert or update the relation
await twonlyDB
.into(twonlyDB.userDiscoveryUserRelations)
.insertOnConflictUpdate(
UserDiscoveryUserRelationsCompanion.insert(
announcedUserId: announcedUser.userId,
fromContactId: fromContactId,
publicKeyVerifiedTimestamp: Value(
publicKeyVerifiedTimestamp != null
? DateTime.fromMillisecondsSinceEpoch(
publicKeyVerifiedTimestamp,
)
: null,
),
),
);
});
return true;
} catch (e) {
Log.error(e);
return false;
}
}
// static Future<Map<AnnouncedUser, List<(int, DateTime?)>>>
// getAllAnnouncedUsers() async {
// final query = twonlyDB.select(twonlyDB.userDiscoveryAnnouncedUsers).join([
// innerJoin(
// twonlyDB.userDiscoveryUserRelations,
// twonlyDB.userDiscoveryUserRelations.announcedUserId.equalsExp(
// twonlyDB.userDiscoveryAnnouncedUsers.announcedUserId,
// ),
// ),
// ]);
// final results = await query.get();
// final map = <UserDiscoveryAnnouncedUser, List<(int, DateTime?)>>{};
// for (final row in results) {
// final user = row.readTable(twonlyDB.userDiscoveryAnnouncedUsers);
// final relation = row.readTable(twonlyDB.userDiscoveryUserRelations);
// map.putIfAbsent(user, () => []).add(
// (relation.fromContactId, relation.publicKeyVerifiedTimestamp),
// );
// }
// return map;
// }
static Future<Uint8List?> getContactVersion(int contactId) async {
final row = await (twonlyDB.select(
twonlyDB.contacts,
)..where((tbl) => tbl.userId.equals(contactId))).getSingleOrNull();
return row?.userDiscoveryVersion;
}
static Future<bool> setContactVersion(int contactId, Uint8List update) async {
try {
await (twonlyDB.update(twonlyDB.contacts)
..where((tbl) => tbl.userId.equals(contactId)))
.write(ContactsCompanion(userDiscoveryVersion: Value(update)));
return true;
} catch (e) {
Log.error(e);
return false;
}
}
static Future<Uint8List?> getContactPromotion(int contactId) async {
try {
final query = twonlyDB.select(twonlyDB.userDiscoveryOwnPromotions)
..where((tbl) => tbl.contactId.equals(contactId))
..orderBy([(tbl) => OrderingTerm.desc(tbl.versionId)])
..limit(1);
final row = await query.getSingleOrNull();
return row?.promotion;
} catch (e) {
Log.error(e);
return null;
}
}
}

View file

@ -188,6 +188,15 @@ class GroupsDao extends DatabaseAccessor<TwonlyDB> with _$GroupsDaoMixin {
.watch(); .watch();
} }
Stream<List<(Contact, GroupMember)>> watchAllGroupMembers() {
final query = select(groupMembers).join([
innerJoin(contacts, contacts.userId.equalsExp(groupMembers.contactId)),
]);
return query
.map((row) => (row.readTable(contacts), row.readTable(groupMembers)))
.watch();
}
Stream<List<Group>> watchGroupsForShareImage() { Stream<List<Group>> watchGroupsForShareImage() {
return (select(groups)..where( return (select(groups)..where(
(g) => g.leftGroup.equals(false) & g.deletedContent.equals(false), (g) => g.leftGroup.equals(false) & g.deletedContent.equals(false),
@ -202,6 +211,12 @@ class GroupsDao extends DatabaseAccessor<TwonlyDB> with _$GroupsDaoMixin {
.watch(); .watch();
} }
Stream<List<GroupMember>> watchTypingGroupMembers() {
return (select(
groupMembers,
)..where((member) => member.lastTypeIndicator.isNotNull())).watch();
}
Stream<Group?> watchGroup(String groupId) { Stream<Group?> watchGroup(String groupId) {
return (select( return (select(
groups, groups,

View file

@ -259,6 +259,51 @@ class KeyVerificationDao extends DatabaseAccessor<TwonlyDB>
}); });
} }
Stream<Map<String, VerificationStatus>> watchAllGroupsVerificationStatus() {
final gm = groupMembers;
final directKv = alias(keyVerifications, 'allGroupsDirectKv');
final ur = userDiscoveryUserRelations;
final verifierKv = alias(keyVerifications, 'allGroupsVerifierKv');
final query = select(gm).join([
leftOuterJoin(directKv, directKv.contactId.equalsExp(gm.contactId)),
leftOuterJoin(
ur,
ur.announcedUserId.equalsExp(gm.contactId) &
ur.publicKeyVerifiedTimestamp.isNotNull() &
ur.fromContactId.equalsExp(gm.contactId).not(),
),
leftOuterJoin(
verifierKv,
verifierKv.contactId.equalsExp(ur.fromContactId),
),
]);
return query.watch().map((rows) {
final groups = <String, Map<int, ({bool direct, bool partial})>>{};
for (final row in rows) {
final member = row.readTable(gm);
final members = groups.putIfAbsent(member.groupId, () => {});
final current =
members[member.contactId] ?? (direct: false, partial: false);
members[member.contactId] = (
direct: current.direct || row.readTableOrNull(directKv) != null,
partial: current.partial || row.readTableOrNull(verifierKv) != null,
);
}
return {
for (final entry in groups.entries)
entry.key: entry.value.values.every((member) => member.direct)
? VerificationStatus.trusted
: entry.value.values.every(
(member) => member.direct || member.partial,
)
? VerificationStatus.partialTrusted
: VerificationStatus.notTrusted,
};
});
}
Stream<int> watchUnverifiedGroupMembersCount(String groupId) { Stream<int> watchUnverifiedGroupMembersCount(String groupId) {
final gm = groupMembers; final gm = groupMembers;
final directKv = alias(keyVerifications, 'directKv'); final directKv = alias(keyVerifications, 'directKv');

View file

@ -14,11 +14,15 @@ class LabelsDao extends DatabaseAccessor<TwonlyDB> with _$LabelsDaoMixin {
LabelsDao(super.db); LabelsDao(super.db);
Stream<List<Label>> watchAllLabels() { Stream<List<Label>> watchAllLabels() {
return (select(labels)..orderBy([(t) => OrderingTerm(expression: t.name)])).watch(); return (select(
labels,
)..orderBy([(t) => OrderingTerm(expression: t.name)])).watch();
} }
Future<List<Label>> getAllLabels() { Future<List<Label>> getAllLabels() {
return (select(labels)..orderBy([(t) => OrderingTerm(expression: t.name)])).get(); return (select(
labels,
)..orderBy([(t) => OrderingTerm(expression: t.name)])).get();
} }
Stream<List<Label>> watchContactLabels(int contactId) { Stream<List<Label>> watchContactLabels(int contactId) {
@ -27,8 +31,24 @@ class LabelsDao extends DatabaseAccessor<TwonlyDB> with _$LabelsDaoMixin {
])..where(contactLabels.contactId.equals(contactId)); ])..where(contactLabels.contactId.equals(contactId));
return query.watch().map( return query.watch().map(
(rows) => rows.map((row) => row.readTable(labels)).toList(), (rows) => rows.map((row) => row.readTable(labels)).toList(),
); );
}
Stream<List<(int, Label)>> watchAllContactLabels() {
final query = select(contactLabels).join([
innerJoin(labels, labels.id.equalsExp(contactLabels.labelId)),
]);
return query.watch().map(
(rows) => rows
.map(
(row) => (
row.readTable(contactLabels).contactId,
row.readTable(labels),
),
)
.toList(),
);
} }
Future<List<Label>> getContactLabels(int contactId) { Future<List<Label>> getContactLabels(int contactId) {
@ -37,14 +57,16 @@ class LabelsDao extends DatabaseAccessor<TwonlyDB> with _$LabelsDaoMixin {
])..where(contactLabels.contactId.equals(contactId)); ])..where(contactLabels.contactId.equals(contactId));
return query.get().then( return query.get().then(
(rows) => rows.map((row) => row.readTable(labels)).toList(), (rows) => rows.map((row) => row.readTable(labels)).toList(),
); );
} }
Future<void> setContactLabels(int contactId, List<int> labelIds) async { Future<void> setContactLabels(int contactId, List<int> labelIds) async {
final sanitizedLabelIds = labelIds.take(3).toList(); final sanitizedLabelIds = labelIds.take(3).toList();
await transaction(() async { await transaction(() async {
await (delete(contactLabels)..where((t) => t.contactId.equals(contactId))).go(); await (delete(
contactLabels,
)..where((t) => t.contactId.equals(contactId))).go();
if (sanitizedLabelIds.isNotEmpty) { if (sanitizedLabelIds.isNotEmpty) {
await batch((b) { await batch((b) {
b.insertAll( b.insertAll(
@ -72,7 +94,12 @@ class LabelsDao extends DatabaseAccessor<TwonlyDB> with _$LabelsDaoMixin {
); );
} }
Future<bool> updateLabel(int id, String name, int textColor, int backgroundColor) { Future<bool> updateLabel(
int id,
String name,
int textColor,
int backgroundColor,
) {
final sanitizedName = name.length > 8 ? name.substring(0, 8) : name; final sanitizedName = name.length > 8 ? name.substring(0, 8) : name;
return (update(labels)..where((t) => t.id.equals(id))) return (update(labels)..where((t) => t.id.equals(id)))
.write( .write(

View file

@ -177,6 +177,32 @@ class MediaFilesDao extends DatabaseAccessor<TwonlyDB>
return query.map((row) => row.readTable(mediaFiles)).watch(); return query.map((row) => row.readTable(mediaFiles)).watch();
} }
Stream<List<MediaFile>> watchChatListMediaFiles() {
return customSelect(
'''
WITH ranked_messages AS (
SELECT messages.media_id,
messages.opened_at,
ROW_NUMBER() OVER (
PARTITION BY messages.group_id
ORDER BY messages.created_at DESC
) AS message_rank
FROM messages
WHERE messages.media_id IS NOT NULL
)
SELECT DISTINCT media_files.*
FROM media_files
INNER JOIN ranked_messages
ON ranked_messages.media_id = media_files.media_id
WHERE ranked_messages.message_rank = 1
OR ranked_messages.opened_at IS NULL
''',
readsFrom: {mediaFiles, db.messages},
).watch().map(
(rows) => rows.map((row) => mediaFiles.map(row.data)).toList(),
);
}
Future<void> updateAllRetransmissionUploadingState() async { Future<void> updateAllRetransmissionUploadingState() async {
await (update(mediaFiles)..where( await (update(mediaFiles)..where(
(t) => (t) =>

View file

@ -53,6 +53,27 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
return query.map((row) => row.readTable(messages)).watch(); return query.map((row) => row.readTable(messages)).watch();
} }
Stream<List<Message>> watchAllMessagesNotOpened() {
final query =
select(messages).join([
leftOuterJoin(
mediaFiles,
mediaFiles.mediaId.equalsExp(messages.mediaId),
),
])
..where(
messages.openedAt.isNull() &
messages.isDeletedFromSender.equals(false) &
(messages.mediaId.isNull() |
mediaFiles.downloadState.isNull() |
mediaFiles.downloadState
.equals(DownloadState.reuploadRequested.name)
.not()),
)
..orderBy([OrderingTerm.desc(messages.createdAt)]);
return query.map((row) => row.readTable(messages)).watch();
}
Stream<List<Message>> watchMediaNotOpened(String groupId) { Stream<List<Message>> watchMediaNotOpened(String groupId) {
final query = final query =
select(messages).join([ select(messages).join([
@ -127,6 +148,38 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
return query.map((row) => row.readTable(messages)).watchSingleOrNull(); return query.map((row) => row.readTable(messages)).watchSingleOrNull();
} }
Stream<List<Message>> watchLatestMessagesByGroup() {
return customSelect(
'''
SELECT message_rows.*
FROM (
SELECT messages.*,
ROW_NUMBER() OVER (
PARTITION BY messages.group_id
ORDER BY messages.created_at DESC
) AS message_rank
FROM messages
INNER JOIN groups ON groups.group_id = messages.group_id
LEFT JOIN media_files ON media_files.media_id = messages.media_id
WHERE (
messages.opened_at IS NULL OR
messages.media_stored = 1 OR
messages.opened_at > CAST(strftime('%s', 'now') AS INTEGER) -
(groups.delete_messages_after_milliseconds / 1000)
)
AND (
media_files.download_state IS NULL OR
media_files.download_state != 'reuploadRequested'
)
) AS message_rows
WHERE message_rank = 1
''',
readsFrom: {messages, groups, mediaFiles},
).watch().map(
(rows) => rows.map((row) => messages.map(row.data)).toList(),
);
}
Future<Stream<List<Message>>> watchByGroupId(String groupId) async { Future<Stream<List<Message>>> watchByGroupId(String groupId) async {
final group = await twonlyDB.groupsDao.getGroup(groupId); final group = await twonlyDB.groupsDao.getGroup(groupId);
final deletionTime = clock.now().subtract( final deletionTime = clock.now().subtract(
@ -575,6 +628,19 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
.watch(); .watch();
} }
Stream<List<MessageAction>> watchMessageActionsForGroup(
String groupId,
) {
final query = select(messageActions).join([
innerJoin(
messages,
messages.messageId.equalsExp(messageActions.messageId),
useColumns: false,
),
])..where(messages.groupId.equals(groupId));
return query.map((row) => row.readTable(messageActions)).watch();
}
Stream<List<MessageHistory>> watchMessageHistory(String messageId) { Stream<List<MessageHistory>> watchMessageHistory(String messageId) {
return (select(messageHistories) return (select(messageHistories)
..where((t) => t.messageId.equals(messageId)) ..where((t) => t.messageId.equals(messageId))

View file

@ -108,6 +108,20 @@ class ReactionsDao extends DatabaseAccessor<TwonlyDB> with _$ReactionsDaoMixin {
.watch(); .watch();
} }
Stream<List<Reaction>> watchReactionsForGroup(String groupId) {
final query =
select(reactions).join([
innerJoin(
messages,
messages.messageId.equalsExp(reactions.messageId),
useColumns: false,
),
])
..where(messages.groupId.equals(groupId))
..orderBy([OrderingTerm.desc(reactions.createdAt)]);
return query.map((row) => row.readTable(reactions)).watch();
}
Stream<Reaction?> watchLastReactions(String groupId) { Stream<Reaction?> watchLastReactions(String groupId) {
final query = final query =
(select(reactions)).join( (select(reactions)).join(
@ -125,6 +139,35 @@ class ReactionsDao extends DatabaseAccessor<TwonlyDB> with _$ReactionsDaoMixin {
return query.map((row) => row.readTable(reactions)).watchSingleOrNull(); return query.map((row) => row.readTable(reactions)).watchSingleOrNull();
} }
Stream<List<(String, Reaction)>> watchLatestReactionsByGroup() {
return customSelect(
'''
SELECT reaction_rows.*
FROM (
SELECT reactions.*,
messages.group_id AS reaction_group_id,
ROW_NUMBER() OVER (
PARTITION BY messages.group_id
ORDER BY messages.created_at DESC, reactions.created_at DESC
) AS reaction_rank
FROM reactions
INNER JOIN messages ON messages.message_id = reactions.message_id
) AS reaction_rows
WHERE reaction_rank = 1
''',
readsFrom: {reactions, messages},
).watch().map(
(rows) => rows
.map(
(row) => (
row.read<String>('reaction_group_id'),
reactions.map(row.data),
),
)
.toList(),
);
}
Stream<List<(Reaction, Contact?)>> watchReactionWithContacts( Stream<List<(Reaction, Contact?)>> watchReactionWithContacts(
String messageId, String messageId,
) { ) {

View file

@ -0,0 +1,96 @@
import 'package:drift/backends.dart';
import 'package:drift/drift.dart';
import 'package:twonly/core/bridge/wrapper/app_database.dart';
import 'package:twonly/core/database/app.dart' as rust;
/// Drift compatibility executor backed by the Rust-owned SQLCipher database.
///
/// This keeps the existing generated models, DAO methods and query watches
/// while moving connection, encryption and storage ownership into Rust.
QueryExecutor openRustAppDatabase() => DelegatedDatabase(
_RustDatabaseDelegate(),
isSequential: true,
);
class _RustDatabaseDelegate extends DatabaseDelegate {
@override
Future<bool> get isOpen async => true;
@override
DbVersionDelegate get versionDelegate => const NoVersionDelegate();
@override
TransactionDelegate get transactionDelegate => const NoTransactionDelegate();
@override
Future<void> open(QueryExecutorUser db) async {}
@override
Future<void> close() async {
// The Rust application context owns the database lifecycle.
}
@override
Future<QueryResult> runSelect(String statement, List<Object?> args) async {
final result = await RustAppDatabase.select(
statement: statement,
arguments: args.map(_encode).toList(growable: false),
);
return QueryResult(
result.columns,
result.rows
.map((row) => row.values.map(_decode).toList(growable: false))
.toList(growable: false),
);
}
@override
Future<int> runInsert(String statement, List<Object?> args) async {
final result = await _execute(statement, args);
return result.lastInsertRowId;
}
@override
Future<int> runUpdate(String statement, List<Object?> args) async {
final result = await _execute(statement, args);
return result.affectedRows;
}
@override
Future<void> runCustom(String statement, List<Object?> args) async {
await _execute(statement, args);
}
Future<rust.SqlExecutionResult> _execute(
String statement,
List<Object?> args,
) {
return RustAppDatabase.execute(
statement: statement,
arguments: args.map(_encode).toList(growable: false),
);
}
}
rust.SqlValue _encode(Object? value) {
return switch (value) {
null => const rust.SqlValue(kind: 0),
final bool value => rust.SqlValue(kind: 1, integerValue: value ? 1 : 0),
final int value => rust.SqlValue(kind: 1, integerValue: value),
final double value => rust.SqlValue(kind: 2, realValue: value),
final String value => rust.SqlValue(kind: 3, textValue: value),
final Uint8List value => rust.SqlValue(kind: 4, blobValue: value),
_ => throw ArgumentError.value(value, 'value', 'Unsupported SQLite value'),
};
}
Object? _decode(rust.SqlValue value) {
return switch (value.kind) {
0 => null,
1 => value.integerValue,
2 => value.realValue,
3 => value.textValue,
4 => value.blobValue,
_ => throw StateError('Unknown SQLite value kind ${value.kind}'),
};
}

View file

@ -0,0 +1,47 @@
import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart'
show DriftNativeOptions, driftDatabase;
import 'package:path_provider/path_provider.dart';
import 'package:twonly/src/database/tables/signal_identity_key_store.table.dart';
import 'package:twonly/src/database/tables/signal_pre_key_store.table.dart';
import 'package:twonly/src/database/tables/signal_sender_key_store.table.dart';
import 'package:twonly/src/database/tables/signal_session_store.table.dart';
import 'package:twonly/src/database/tables/signal_signed_pre_key_store.table.dart';
part 'signal.db.g.dart';
@DriftDatabase(
tables: [
SignalIdentityKeyStores,
SignalPreKeyStores,
SignalSenderKeyStores,
SignalSessionStores,
SignalSignedPreKeyStores,
],
)
class SignalDB extends _$SignalDB {
SignalDB([QueryExecutor? executor]) : super(executor ?? _openConnection());
@override
// This database shares the legacy twonly.sqlite file, whose user_version is
// already 25. Keeping that version avoids a false downgrade while only the
// Signal tables remain owned by Drift.
int get schemaVersion => 25;
static QueryExecutor _openConnection() {
return driftDatabase(
name: 'twonly',
native: DriftNativeOptions(
databaseDirectory: getApplicationSupportDirectory,
shareAcrossIsolates: true,
setup: (database) {
database
..execute('PRAGMA journal_mode=DELETE;')
..execute('PRAGMA synchronous=FULL;')
..execute('PRAGMA busy_timeout=5000;')
..execute('PRAGMA foreign_keys=ON;');
},
),
);
}
}

File diff suppressed because it is too large Load diff

View file

@ -2,7 +2,7 @@ import 'package:collection/collection.dart';
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:libsignal_protocol_dart/libsignal_protocol_dart.dart'; import 'package:libsignal_protocol_dart/libsignal_protocol_dart.dart';
import 'package:twonly/locator.dart'; import 'package:twonly/locator.dart';
import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/database/signal.db.dart';
class SignalIdentityKeyStore extends IdentityKeyStore { class SignalIdentityKeyStore extends IdentityKeyStore {
SignalIdentityKeyStore(this.identityKeyPair, this.localRegistrationId); SignalIdentityKeyStore(this.identityKeyPair, this.localRegistrationId);
@ -13,7 +13,7 @@ class SignalIdentityKeyStore extends IdentityKeyStore {
@override @override
Future<IdentityKey?> getIdentity(SignalProtocolAddress address) async { Future<IdentityKey?> getIdentity(SignalProtocolAddress address) async {
final identity = final identity =
await (twonlyDB.select(twonlyDB.signalIdentityKeyStores)..where( await (signalDB.select(signalDB.signalIdentityKeyStores)..where(
(t) => (t) =>
t.deviceId.equals(address.getDeviceId()) & t.deviceId.equals(address.getDeviceId()) &
t.name.equals(address.getName()), t.name.equals(address.getName()),
@ -55,8 +55,8 @@ class SignalIdentityKeyStore extends IdentityKeyStore {
return false; return false;
} }
if (await getIdentity(address) == null) { if (await getIdentity(address) == null) {
await twonlyDB await signalDB
.into(twonlyDB.signalIdentityKeyStores) .into(signalDB.signalIdentityKeyStores)
.insert( .insert(
SignalIdentityKeyStoresCompanion( SignalIdentityKeyStoresCompanion(
deviceId: Value(address.getDeviceId()), deviceId: Value(address.getDeviceId()),
@ -65,7 +65,7 @@ class SignalIdentityKeyStore extends IdentityKeyStore {
), ),
); );
} else { } else {
await (twonlyDB.update(twonlyDB.signalIdentityKeyStores)..where( await (signalDB.update(signalDB.signalIdentityKeyStores)..where(
(t) => (t) =>
t.deviceId.equals(address.getDeviceId()) & t.deviceId.equals(address.getDeviceId()) &
t.name.equals(address.getName()), t.name.equals(address.getName()),

View file

@ -1,22 +1,22 @@
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:libsignal_protocol_dart/libsignal_protocol_dart.dart'; import 'package:libsignal_protocol_dart/libsignal_protocol_dart.dart';
import 'package:twonly/locator.dart'; import 'package:twonly/locator.dart';
import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/database/signal.db.dart';
import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/log.dart';
class SignalPreKeyStore extends PreKeyStore { class SignalPreKeyStore extends PreKeyStore {
@override @override
Future<bool> containsPreKey(int preKeyId) async { Future<bool> containsPreKey(int preKeyId) async {
final preKeyRecord = await (twonlyDB.select( final preKeyRecord = await (signalDB.select(
twonlyDB.signalPreKeyStores, signalDB.signalPreKeyStores,
)..where((tbl) => tbl.preKeyId.equals(preKeyId))).get(); )..where((tbl) => tbl.preKeyId.equals(preKeyId))).get();
return preKeyRecord.isNotEmpty; return preKeyRecord.isNotEmpty;
} }
@override @override
Future<PreKeyRecord> loadPreKey(int preKeyId) async { Future<PreKeyRecord> loadPreKey(int preKeyId) async {
final preKeyRecord = await (twonlyDB.select( final preKeyRecord = await (signalDB.select(
twonlyDB.signalPreKeyStores, signalDB.signalPreKeyStores,
)..where((tbl) => tbl.preKeyId.equals(preKeyId))).get(); )..where((tbl) => tbl.preKeyId.equals(preKeyId))).get();
if (preKeyRecord.isEmpty) { if (preKeyRecord.isEmpty) {
throw InvalidKeyIdException( throw InvalidKeyIdException(
@ -29,8 +29,8 @@ class SignalPreKeyStore extends PreKeyStore {
@override @override
Future<void> removePreKey(int preKeyId) async { Future<void> removePreKey(int preKeyId) async {
await (twonlyDB.delete( await (signalDB.delete(
twonlyDB.signalPreKeyStores, signalDB.signalPreKeyStores,
)..where((tbl) => tbl.preKeyId.equals(preKeyId))).go(); )..where((tbl) => tbl.preKeyId.equals(preKeyId))).go();
} }
@ -42,8 +42,8 @@ class SignalPreKeyStore extends PreKeyStore {
); );
try { try {
await twonlyDB await signalDB
.into(twonlyDB.signalPreKeyStores) .into(signalDB.signalPreKeyStores)
.insert(preKeyCompanion, mode: InsertMode.insertOrReplace); .insert(preKeyCompanion, mode: InsertMode.insertOrReplace);
} catch (e) { } catch (e) {
Log.error('$e'); Log.error('$e');

View file

@ -1,13 +1,13 @@
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:libsignal_protocol_dart/libsignal_protocol_dart.dart'; import 'package:libsignal_protocol_dart/libsignal_protocol_dart.dart';
import 'package:twonly/locator.dart'; import 'package:twonly/locator.dart';
import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/database/signal.db.dart';
class SignalSenderKeyStore extends SenderKeyStore { class SignalSenderKeyStore extends SenderKeyStore {
@override @override
Future<SenderKeyRecord> loadSenderKey(SenderKeyName senderKeyName) async { Future<SenderKeyRecord> loadSenderKey(SenderKeyName senderKeyName) async {
final identity = final identity =
await (twonlyDB.select(twonlyDB.signalSenderKeyStores) await (signalDB.select(signalDB.signalSenderKeyStores)
..where((t) => t.senderKeyName.equals(senderKeyName.serialize()))) ..where((t) => t.senderKeyName.equals(senderKeyName.serialize())))
.getSingleOrNull(); .getSingleOrNull();
if (identity == null) { if (identity == null) {
@ -23,8 +23,8 @@ class SignalSenderKeyStore extends SenderKeyStore {
SenderKeyName senderKeyName, SenderKeyName senderKeyName,
SenderKeyRecord record, SenderKeyRecord record,
) async { ) async {
await twonlyDB await signalDB
.into(twonlyDB.signalSenderKeyStores) .into(signalDB.signalSenderKeyStores)
.insert( .insert(
SignalSenderKeyStoresCompanion( SignalSenderKeyStoresCompanion(
senderKey: Value(record.serialize()), senderKey: Value(record.serialize()),

View file

@ -1,13 +1,13 @@
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:libsignal_protocol_dart/libsignal_protocol_dart.dart'; import 'package:libsignal_protocol_dart/libsignal_protocol_dart.dart';
import 'package:twonly/locator.dart'; import 'package:twonly/locator.dart';
import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/database/signal.db.dart';
class SignalSessionStore extends SessionStore { class SignalSessionStore extends SessionStore {
@override @override
Future<bool> containsSession(SignalProtocolAddress address) async { Future<bool> containsSession(SignalProtocolAddress address) async {
final sessions = final sessions =
await (twonlyDB.select(twonlyDB.signalSessionStores)..where( await (signalDB.select(signalDB.signalSessionStores)..where(
(tbl) => (tbl) =>
tbl.deviceId.equals(address.getDeviceId()) & tbl.deviceId.equals(address.getDeviceId()) &
tbl.name.equals(address.getName()), tbl.name.equals(address.getName()),
@ -18,14 +18,14 @@ class SignalSessionStore extends SessionStore {
@override @override
Future<void> deleteAllSessions(String name) async { Future<void> deleteAllSessions(String name) async {
await (twonlyDB.delete( await (signalDB.delete(
twonlyDB.signalSessionStores, signalDB.signalSessionStores,
)..where((tbl) => tbl.name.equals(name))).go(); )..where((tbl) => tbl.name.equals(name))).go();
} }
@override @override
Future<void> deleteSession(SignalProtocolAddress address) async { Future<void> deleteSession(SignalProtocolAddress address) async {
await (twonlyDB.delete(twonlyDB.signalSessionStores)..where( await (signalDB.delete(signalDB.signalSessionStores)..where(
(tbl) => (tbl) =>
tbl.deviceId.equals(address.getDeviceId()) & tbl.deviceId.equals(address.getDeviceId()) &
tbl.name.equals(address.getName()), tbl.name.equals(address.getName()),
@ -36,7 +36,7 @@ class SignalSessionStore extends SessionStore {
@override @override
Future<List<int>> getSubDeviceSessions(String name) async { Future<List<int>> getSubDeviceSessions(String name) async {
final deviceIds = final deviceIds =
await (twonlyDB.select(twonlyDB.signalSessionStores)..where( await (signalDB.select(signalDB.signalSessionStores)..where(
(tbl) => tbl.deviceId.equals(1).not() & tbl.name.equals(name), (tbl) => tbl.deviceId.equals(1).not() & tbl.name.equals(name),
)) ))
.get(); .get();
@ -46,7 +46,7 @@ class SignalSessionStore extends SessionStore {
@override @override
Future<SessionRecord> loadSession(SignalProtocolAddress address) async { Future<SessionRecord> loadSession(SignalProtocolAddress address) async {
final dbSession = final dbSession =
await (twonlyDB.select(twonlyDB.signalSessionStores)..where( await (signalDB.select(signalDB.signalSessionStores)..where(
(tbl) => (tbl) =>
tbl.deviceId.equals(address.getDeviceId()) & tbl.deviceId.equals(address.getDeviceId()) &
tbl.name.equals(address.getName()), tbl.name.equals(address.getName()),
@ -72,11 +72,11 @@ class SignalSessionStore extends SessionStore {
); );
if (!await containsSession(address)) { if (!await containsSession(address)) {
await twonlyDB await signalDB
.into(twonlyDB.signalSessionStores) .into(signalDB.signalSessionStores)
.insert(sessionCompanion); .insert(sessionCompanion);
} else { } else {
await (twonlyDB.update(twonlyDB.signalSessionStores)..where( await (signalDB.update(signalDB.signalSessionStores)..where(
(tbl) => (tbl) =>
tbl.deviceId.equals(address.getDeviceId()) & tbl.deviceId.equals(address.getDeviceId()) &
tbl.name.equals(address.getName()), tbl.name.equals(address.getName()),

View file

@ -3,7 +3,7 @@ import 'dart:convert';
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:libsignal_protocol_dart/libsignal_protocol_dart.dart'; import 'package:libsignal_protocol_dart/libsignal_protocol_dart.dart';
import 'package:twonly/locator.dart'; import 'package:twonly/locator.dart';
import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/database/signal.db.dart';
import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/secure_storage.dart'; import 'package:twonly/src/utils/secure_storage.dart';
@ -26,8 +26,8 @@ Future<HashMap<int, Uint8List>> getSignalSignedPreKeyStoreOld() async {
class SignalSignedPreKeyStore extends SignedPreKeyStore { class SignalSignedPreKeyStore extends SignedPreKeyStore {
@override @override
Future<SignedPreKeyRecord> loadSignedPreKey(int signedPreKeyId) async { Future<SignedPreKeyRecord> loadSignedPreKey(int signedPreKeyId) async {
final record = await (twonlyDB.select( final record = await (signalDB.select(
twonlyDB.signalSignedPreKeyStores, signalDB.signalSignedPreKeyStores,
)..where((tbl) => tbl.signedPreKeyId.equals(signedPreKeyId))).get(); )..where((tbl) => tbl.signedPreKeyId.equals(signedPreKeyId))).get();
if (record.isEmpty) { if (record.isEmpty) {
throw InvalidKeyIdException( throw InvalidKeyIdException(
@ -39,8 +39,8 @@ class SignalSignedPreKeyStore extends SignedPreKeyStore {
@override @override
Future<List<SignedPreKeyRecord>> loadSignedPreKeys() async { Future<List<SignedPreKeyRecord>> loadSignedPreKeys() async {
final records = await twonlyDB final records = await signalDB
.select(twonlyDB.signalSignedPreKeyStores) .select(signalDB.signalSignedPreKeyStores)
.get(); .get();
return records return records
.map((r) => SignedPreKeyRecord.fromSerialized(r.signedPreKey)) .map((r) => SignedPreKeyRecord.fromSerialized(r.signedPreKey))
@ -58,8 +58,8 @@ class SignalSignedPreKeyStore extends SignedPreKeyStore {
); );
try { try {
await twonlyDB await signalDB
.into(twonlyDB.signalSignedPreKeyStores) .into(signalDB.signalSignedPreKeyStores)
.insert(companion, mode: InsertMode.insertOrReplace); .insert(companion, mode: InsertMode.insertOrReplace);
} catch (e) { } catch (e) {
Log.error('$e'); Log.error('$e');
@ -69,16 +69,16 @@ class SignalSignedPreKeyStore extends SignedPreKeyStore {
@override @override
Future<bool> containsSignedPreKey(int signedPreKeyId) async { Future<bool> containsSignedPreKey(int signedPreKeyId) async {
final record = await (twonlyDB.select( final record = await (signalDB.select(
twonlyDB.signalSignedPreKeyStores, signalDB.signalSignedPreKeyStores,
)..where((tbl) => tbl.signedPreKeyId.equals(signedPreKeyId))).get(); )..where((tbl) => tbl.signedPreKeyId.equals(signedPreKeyId))).get();
return record.isNotEmpty; return record.isNotEmpty;
} }
@override @override
Future<void> removeSignedPreKey(int signedPreKeyId) async { Future<void> removeSignedPreKey(int signedPreKeyId) async {
await (twonlyDB.delete( await (signalDB.delete(
twonlyDB.signalSignedPreKeyStores, signalDB.signalSignedPreKeyStores,
)..where((tbl) => tbl.signedPreKeyId.equals(signedPreKeyId))).go(); )..where((tbl) => tbl.signedPreKeyId.equals(signedPreKeyId))).go();
} }
} }

View file

@ -1,8 +1,4 @@
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart'
show DriftNativeOptions, driftDatabase;
import 'package:path_provider/path_provider.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/database/daos/contacts.dao.dart'; import 'package:twonly/src/database/daos/contacts.dao.dart';
import 'package:twonly/src/database/daos/groups.dao.dart'; import 'package:twonly/src/database/daos/groups.dao.dart';
import 'package:twonly/src/database/daos/key_verification.dao.dart'; import 'package:twonly/src/database/daos/key_verification.dao.dart';
@ -13,7 +9,7 @@ import 'package:twonly/src/database/daos/reactions.dao.dart';
import 'package:twonly/src/database/daos/receipts.dao.dart'; import 'package:twonly/src/database/daos/receipts.dao.dart';
import 'package:twonly/src/database/daos/shortcuts.dao.dart'; import 'package:twonly/src/database/daos/shortcuts.dao.dart';
import 'package:twonly/src/database/daos/user_discovery.dao.dart'; import 'package:twonly/src/database/daos/user_discovery.dao.dart';
import 'package:twonly/src/database/drift_logging_interceptor.dart'; import 'package:twonly/src/database/rust_query_executor.dart';
import 'package:twonly/src/database/tables/contacts.table.dart'; import 'package:twonly/src/database/tables/contacts.table.dart';
import 'package:twonly/src/database/tables/groups.table.dart'; import 'package:twonly/src/database/tables/groups.table.dart';
import 'package:twonly/src/database/tables/labels.table.dart'; import 'package:twonly/src/database/tables/labels.table.dart';
@ -22,11 +18,6 @@ import 'package:twonly/src/database/tables/messages.table.dart';
import 'package:twonly/src/database/tables/reactions.table.dart'; import 'package:twonly/src/database/tables/reactions.table.dart';
import 'package:twonly/src/database/tables/receipts.table.dart'; import 'package:twonly/src/database/tables/receipts.table.dart';
import 'package:twonly/src/database/tables/shortcuts.table.dart'; import 'package:twonly/src/database/tables/shortcuts.table.dart';
import 'package:twonly/src/database/tables/signal_identity_key_store.table.dart';
import 'package:twonly/src/database/tables/signal_pre_key_store.table.dart';
import 'package:twonly/src/database/tables/signal_sender_key_store.table.dart';
import 'package:twonly/src/database/tables/signal_session_store.table.dart';
import 'package:twonly/src/database/tables/signal_signed_pre_key_store.table.dart';
import 'package:twonly/src/database/tables/user_discovery.table.dart'; import 'package:twonly/src/database/tables/user_discovery.table.dart';
import 'package:twonly/src/database/twonly.db.steps.dart'; import 'package:twonly/src/database/twonly.db.steps.dart';
import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/log.dart';
@ -45,11 +36,6 @@ part 'twonly.db.g.dart';
GroupMembers, GroupMembers,
Receipts, Receipts,
ReceivedReceipts, ReceivedReceipts,
SignalIdentityKeyStores,
SignalPreKeyStores,
SignalSenderKeyStores,
SignalSessionStores,
SignalSignedPreKeyStores,
MessageActions, MessageActions,
GroupHistories, GroupHistories,
KeyVerifications, KeyVerifications,
@ -80,7 +66,7 @@ part 'twonly.db.g.dart';
class TwonlyDB extends _$TwonlyDB { class TwonlyDB extends _$TwonlyDB {
TwonlyDB([QueryExecutor? e]) TwonlyDB([QueryExecutor? e])
: super( : super(
e ?? _openConnection(), e ?? openRustAppDatabase(),
); );
// ignore: matching_super_parameters // ignore: matching_super_parameters
@ -89,29 +75,6 @@ class TwonlyDB extends _$TwonlyDB {
@override @override
int get schemaVersion => 25; int get schemaVersion => 25;
static QueryExecutor _openConnection() {
final connection = driftDatabase(
name: 'twonly',
native: DriftNativeOptions(
databaseDirectory: getApplicationSupportDirectory,
shareAcrossIsolates: true,
setup: (rawDb) {
rawDb
..execute('PRAGMA journal_mode=DELETE;')
..execute('PRAGMA synchronous=FULL;')
..execute('PRAGMA busy_timeout=5000;');
},
),
);
try {
if (userService.isUserCreated &&
userService.currentUser.enableDatabaseLogging) {
return connection.interceptWith(DriftLoggingInterceptor());
}
} catch (_) {}
return connection;
}
@override @override
MigrationStrategy get migration { MigrationStrategy get migration {
return MigrationStrategy( return MigrationStrategy(

File diff suppressed because it is too large Load diff

View file

@ -111,15 +111,6 @@ UserData _$UserDataFromJson(Map<String, dynamic> json) =>
json['passwordLessRecovery'] as Map<String, dynamic>, json['passwordLessRecovery'] as Map<String, dynamic>,
) )
..fcmToken = json['fcmToken'] as String? ..fcmToken = json['fcmToken'] as String?
..askedForUserStudyPermission =
json['askedForUserStudyPermission'] as bool? ?? false
..userStudyParticipantsToken =
json['userStudyParticipantsToken'] as String?
..userStudyCountNewFriendsViaSuggestion =
(json['userStudyCountNewFriendsViaSuggestion'] as num?)?.toInt() ?? 0
..lastUserStudyDataUpload = json['lastUserStudyDataUpload'] == null
? null
: DateTime.parse(json['lastUserStudyDataUpload'] as String)
..skipSetupPages = json['skipSetupPages'] as bool? ?? false ..skipSetupPages = json['skipSetupPages'] as bool? ?? false
..hasZoomed = json['hasZoomed'] as bool? ?? false; ..hasZoomed = json['hasZoomed'] as bool? ?? false;
@ -184,12 +175,6 @@ Map<String, dynamic> _$UserDataToJson(UserData instance) => <String, dynamic>{
'isBackupEnabled': instance.isBackupEnabled, 'isBackupEnabled': instance.isBackupEnabled,
'passwordLessRecovery': instance.passwordLessRecovery, 'passwordLessRecovery': instance.passwordLessRecovery,
'fcmToken': instance.fcmToken, 'fcmToken': instance.fcmToken,
'askedForUserStudyPermission': instance.askedForUserStudyPermission,
'userStudyParticipantsToken': instance.userStudyParticipantsToken,
'userStudyCountNewFriendsViaSuggestion':
instance.userStudyCountNewFriendsViaSuggestion,
'lastUserStudyDataUpload': instance.lastUserStudyDataUpload
?.toIso8601String(),
'currentSetupPage': instance.currentSetupPage, 'currentSetupPage': instance.currentSetupPage,
'skipSetupPages': instance.skipSetupPages, 'skipSetupPages': instance.skipSetupPages,
'hasZoomed': instance.hasZoomed, 'hasZoomed': instance.hasZoomed,

View file

@ -7,6 +7,7 @@ import 'package:twonly/core/bridge/wrapper/key_manager.dart';
import 'package:twonly/globals.dart'; import 'package:twonly/globals.dart';
import 'package:twonly/locator.dart'; import 'package:twonly/locator.dart';
import 'package:twonly/src/constants/secure_storage.keys.dart'; import 'package:twonly/src/constants/secure_storage.keys.dart';
import 'package:twonly/src/database/signal.db.dart';
import 'package:twonly/src/database/signal/signal_signed_pre_key_store.dart' import 'package:twonly/src/database/signal/signal_signed_pre_key_store.dart'
show getSignalSignedPreKeyStoreOld; show getSignalSignedPreKeyStoreOld;
import 'package:twonly/src/database/tables/contacts.table.dart'; import 'package:twonly/src/database/tables/contacts.table.dart';
@ -129,8 +130,8 @@ Future<void> runMigrations() async {
signedPreKeyId: Value(entry.key), signedPreKeyId: Value(entry.key),
signedPreKey: Value(entry.value), signedPreKey: Value(entry.value),
); );
await twonlyDB await signalDB
.into(twonlyDB.signalSignedPreKeyStores) .into(signalDB.signalSignedPreKeyStores)
.insert( .insert(
companion, companion,
mode: InsertMode.insertOrReplace, mode: InsertMode.insertOrReplace,

View file

@ -13,6 +13,7 @@ class AvatarIcon extends StatefulWidget {
const AvatarIcon({ const AvatarIcon({
super.key, super.key,
this.group, this.group,
this.contacts,
this.contactId, this.contactId,
this.myAvatar = false, this.myAvatar = false,
this.fontSize = 20, this.fontSize = 20,
@ -20,6 +21,7 @@ class AvatarIcon extends StatefulWidget {
this.color, this.color,
}); });
final Group? group; final Group? group;
final List<Contact>? contacts;
final int? contactId; final int? contactId;
final bool myAvatar; final bool myAvatar;
final double? fontSize; final double? fontSize;
@ -45,6 +47,21 @@ class _AvatarIconState extends State<AvatarIcon> {
initAsync(); initAsync();
} }
@override
void didUpdateWidget(AvatarIcon oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.contacts != null && widget.contacts != oldWidget.contacts) {
_setAvatarContacts(widget.contacts!);
}
}
void _setAvatarContacts(List<Contact> contacts) {
_avatarContacts = contacts
.where((contact) => contact.avatarSvgCompressed != null)
.toList();
if (mounted) setState(() {});
}
@override @override
void dispose() { void dispose() {
groupStream?.cancel(); groupStream?.cancel();
@ -84,7 +101,9 @@ class _AvatarIconState extends State<AvatarIcon> {
} }
Future<void> initAsync() async { Future<void> initAsync() async {
if (widget.group != null) { if (widget.contacts != null) {
_setAvatarContacts(widget.contacts!);
} else if (widget.group != null) {
groupStream = twonlyDB.groupsDao groupStream = twonlyDB.groupsDao
.watchGroupContact(widget.group!.groupId) .watchGroupContact(widget.group!.groupId)
.listen((contacts) { .listen((contacts) {

View file

@ -11,6 +11,7 @@ class ContactLabels extends StatefulWidget {
this.padding = const EdgeInsets.symmetric(horizontal: 6, vertical: 2), this.padding = const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
this.emptyText, this.emptyText,
this.showEmptyText = false, this.showEmptyText = false,
this.labels,
super.key, super.key,
}); });
@ -19,6 +20,7 @@ class ContactLabels extends StatefulWidget {
final EdgeInsetsGeometry padding; final EdgeInsetsGeometry padding;
final String? emptyText; final String? emptyText;
final bool showEmptyText; final bool showEmptyText;
final List<Label>? labels;
@override @override
State<ContactLabels> createState() => _ContactLabelsState(); State<ContactLabels> createState() => _ContactLabelsState();
@ -31,20 +33,32 @@ class _ContactLabelsState extends State<ContactLabels> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_sub = twonlyDB.labelsDao.watchContactLabels(widget.contactId).listen(( if (widget.labels != null) {
labels, _labels = widget.labels!;
) { } else {
if (mounted) { _sub = twonlyDB.labelsDao.watchContactLabels(widget.contactId).listen((
setState(() { labels,
_labels = labels; ) {
}); if (mounted) {
} setState(() {
}); _labels = labels;
});
}
});
}
}
@override
void didUpdateWidget(ContactLabels oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.labels != null && widget.labels != oldWidget.labels) {
_labels = widget.labels!;
}
} }
@override @override
void dispose() { void dispose() {
_sub.cancel(); if (widget.labels == null) _sub.cancel();
super.dispose(); super.dispose();
} }
@ -101,8 +115,7 @@ Widget? buildContactLabelsSubtitle({
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
additionalSubtitle, additionalSubtitle,
if (labels.isNotEmpty) if (labels.isNotEmpty) ContactLabels(contactId: contactId),
ContactLabels(contactId: contactId),
], ],
); );
} }

View file

@ -3,17 +3,20 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.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/services/flame.service.dart';
import 'package:twonly/src/visual/components/animate_icon.comp.dart'; import 'package:twonly/src/visual/components/animate_icon.comp.dart';
class FlameCounterWidget extends StatefulWidget { class FlameCounterWidget extends StatefulWidget {
const FlameCounterWidget({ const FlameCounterWidget({
this.groupId, this.groupId,
this.contactId, this.contactId,
this.group,
this.prefix = false, this.prefix = false,
super.key, super.key,
}); });
final String? groupId; final String? groupId;
final int? contactId; final int? contactId;
final Group? group;
final bool prefix; final bool prefix;
@override @override
@ -33,6 +36,14 @@ class _FlameCounterWidgetState extends State<FlameCounterWidget> {
initAsync(); initAsync();
} }
@override
void didUpdateWidget(FlameCounterWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.group != null && widget.group != oldWidget.group) {
initAsync();
}
}
@override @override
void dispose() { void dispose() {
flameCounterSub?.cancel(); flameCounterSub?.cancel();
@ -41,8 +52,10 @@ class _FlameCounterWidgetState extends State<FlameCounterWidget> {
Future<void> initAsync() async { Future<void> initAsync() async {
var groupId = widget.groupId; var groupId = widget.groupId;
late Group? group; var group = widget.group;
if (widget.groupId == null && widget.contactId != null) { if (group != null) {
groupId = group.groupId;
} else if (widget.groupId == null && widget.contactId != null) {
group = await twonlyDB.groupsDao.getDirectChat(widget.contactId!); group = await twonlyDB.groupsDao.getDirectChat(widget.contactId!);
groupId = group?.groupId; groupId = group?.groupId;
} else if (groupId != null) { } else if (groupId != null) {
@ -52,6 +65,16 @@ class _FlameCounterWidgetState extends State<FlameCounterWidget> {
isBestFriend = isBestFriend =
userService.currentUser.myBestFriendGroupId == groupId && userService.currentUser.myBestFriendGroupId == groupId &&
group.alsoBestFriend; group.alsoBestFriend;
if (widget.group != null) {
final result = getFlameCounterFromGroup(group);
if (mounted) {
setState(() {
flameCounter = result.counter;
isExpiring = result.isExpiring;
});
}
return;
}
final stream = twonlyDB.groupsDao.watchFlameCounter(groupId); final stream = twonlyDB.groupsDao.watchFlameCounter(groupId);
flameCounterSub = stream.listen((result) { flameCounterSub = stream.listen((result) {
if (mounted) { if (mounted) {

View file

@ -19,6 +19,8 @@ class VerificationBadgeComp extends StatefulWidget {
this.showOnlyIfVerified = false, this.showOnlyIfVerified = false,
this.isVerifiedByTransferredTrust, this.isVerifiedByTransferredTrust,
this.clickable = true, this.clickable = true,
this.verificationStatus,
this.useProvidedStatus = false,
}); });
final Group? group; final Group? group;
final Contact? contact; final Contact? contact;
@ -26,6 +28,8 @@ class VerificationBadgeComp extends StatefulWidget {
final bool showOnlyIfVerified; final bool showOnlyIfVerified;
final bool clickable; final bool clickable;
final VerificationStatus? verificationStatus;
final bool useProvidedStatus;
final bool? isVerifiedByTransferredTrust; final bool? isVerifiedByTransferredTrust;
@override @override
@ -52,7 +56,31 @@ class _VerificationBadgeCompState extends State<VerificationBadgeComp> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
initAsync(); if (widget.useProvidedStatus) {
_applyVerificationStatus(
widget.verificationStatus ?? VerificationStatus.notTrusted,
);
} else {
initAsync();
}
}
@override
void didUpdateWidget(VerificationBadgeComp oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.useProvidedStatus &&
widget.verificationStatus != oldWidget.verificationStatus) {
_applyVerificationStatus(
widget.verificationStatus ?? VerificationStatus.notTrusted,
);
}
}
void _applyVerificationStatus(VerificationStatus status) {
_isVerified = status == VerificationStatus.trusted;
_isSharedVerified = false;
_verifiedByTransferredTrustCount =
status == VerificationStatus.partialTrusted ? 10 : 0;
} }
void _updateVerificationCounts() { void _updateVerificationCounts() {

View file

@ -1,11 +1,13 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/foundation.dart' show setEquals;
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:provider/provider.dart'; import 'package:provider/provider.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/twonly.db.dart'; import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/providers/purchases.provider.dart'; import 'package:twonly/src/providers/purchases.provider.dart';
import 'package:twonly/src/services/mediafiles/mediafile.service.dart'; import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
@ -18,6 +20,7 @@ import 'package:twonly/src/visual/components/notification_badge.comp.dart';
import 'package:twonly/src/visual/views/chats/chat_list_components/empty_chat_list.comp.dart'; import 'package:twonly/src/visual/views/chats/chat_list_components/empty_chat_list.comp.dart';
import 'package:twonly/src/visual/views/chats/chat_list_components/group_list_item.comp.dart'; import 'package:twonly/src/visual/views/chats/chat_list_components/group_list_item.comp.dart';
import 'package:twonly/src/visual/views/chats/chat_list_components/news_btn.comp.dart'; import 'package:twonly/src/visual/views/chats/chat_list_components/news_btn.comp.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/typing_indicator.dart';
import 'package:twonly/src/visual/views/onboarding/setup/components/finish_setup.comp.dart'; import 'package:twonly/src/visual/views/onboarding/setup/components/finish_setup.comp.dart';
import 'package:twonly/src/visual/views/settings/backup/components/missing_backup_setup.comp.dart'; import 'package:twonly/src/visual/views/settings/backup/components/missing_backup_setup.comp.dart';
import 'package:twonly/src/visual/views/settings/backup/passwordless_recovery/components/missing_recovery_contacts.comp.dart'; import 'package:twonly/src/visual/views/settings/backup/passwordless_recovery/components/missing_recovery_contacts.comp.dart';
@ -28,15 +31,34 @@ class ChatListView extends StatefulWidget {
State<ChatListView> createState() => _ChatListViewState(); State<ChatListView> createState() => _ChatListViewState();
} }
class _ChatListViewState extends State<ChatListView> with AutomaticKeepAliveClientMixin<ChatListView> { class _ChatListViewState extends State<ChatListView>
with AutomaticKeepAliveClientMixin<ChatListView> {
StreamSubscription<void>? _userSub; StreamSubscription<void>? _userSub;
StreamSubscription<List<Group>>? _contactsSub; StreamSubscription<List<Group>>? _contactsSub;
StreamSubscription<List<Contact>>? _contactsCountSub; StreamSubscription<List<Contact>>? _contactsCountSub;
StreamSubscription<List<MediaFile>>? _precacheSub; StreamSubscription<List<MediaFile>>? _precacheSub;
StreamSubscription<List<GroupMember>>? _typingMembersSub;
StreamSubscription<List<(Contact, GroupMember)>>? _groupMembersSub;
StreamSubscription<List<Message>>? _unopenedMessagesSub;
StreamSubscription<List<(String, Reaction)>>? _reactionsSub;
StreamSubscription<List<Message>>? _latestMessagesSub;
StreamSubscription<List<MediaFile>>? _chatListMediaSub;
StreamSubscription<Map<String, VerificationStatus>>? _verificationSub;
StreamSubscription<List<(int, Label)>>? _contactLabelsSub;
Timer? _typingUpdateTimer;
final Set<String> _precachedMediaIds = {}; final Set<String> _precachedMediaIds = {};
List<Group> _groupsNotPinned = []; List<Group> _groupsNotPinned = [];
List<Group> _groupsPinned = []; List<Group> _groupsPinned = [];
List<Group> _groupsArchived = []; List<Group> _groupsArchived = [];
Set<String> _typingGroupIds = {};
List<GroupMember> _typingMembers = [];
Map<String, List<Contact>> _contactsByGroup = {};
Map<String, List<Message>> _unopenedMessagesByGroup = {};
Map<String, Reaction> _lastReactionByGroup = {};
Map<String, Message> _lastMessageByGroup = {};
Map<String, MediaFile> _chatListMediaById = {};
Map<String, VerificationStatus> _verificationByGroup = {};
Map<int, List<Label>> _labelsByContact = {};
final ValueNotifier<bool> _hasContacts = ValueNotifier(false); final ValueNotifier<bool> _hasContacts = ValueNotifier(false);
bool _loading = true; bool _loading = true;
@ -102,7 +124,9 @@ class _ChatListViewState extends State<ChatListView> with AutomaticKeepAliveClie
_badgeCount.value = _countAnnouncedUsers + _countContactRequest; _badgeCount.value = _countAnnouncedUsers + _countContactRequest;
}); });
_precacheSub = twonlyDB.messagesDao.watchUnopenedMediaFiles().listen((mediaFiles) { _precacheSub = twonlyDB.messagesDao.watchUnopenedMediaFiles().listen((
mediaFiles,
) {
if (!mounted) return; if (!mounted) return;
for (final media in mediaFiles) { for (final media in mediaFiles) {
if (!_precachedMediaIds.contains(media.mediaId)) { if (!_precachedMediaIds.contains(media.mediaId)) {
@ -119,6 +143,80 @@ class _ChatListViewState extends State<ChatListView> with AutomaticKeepAliveClie
} }
} }
}); });
_typingMembersSub = twonlyDB.groupsDao.watchTypingGroupMembers().listen((
members,
) {
if (!mounted) return;
_typingMembers = members;
_updateTypingGroupIds();
});
_typingUpdateTimer = Timer.periodic(
const Duration(seconds: 1),
(_) => _updateTypingGroupIds(),
);
_groupMembersSub = twonlyDB.groupsDao.watchAllGroupMembers().listen((rows) {
if (!mounted) return;
final contactsByGroup = <String, List<Contact>>{};
for (final row in rows) {
contactsByGroup.putIfAbsent(row.$2.groupId, () => []).add(row.$1);
}
setState(() => _contactsByGroup = contactsByGroup);
});
_unopenedMessagesSub = twonlyDB.messagesDao
.watchAllMessagesNotOpened()
.listen((messages) {
if (!mounted) return;
final byGroup = <String, List<Message>>{};
for (final message in messages) {
byGroup.putIfAbsent(message.groupId, () => []).add(message);
}
setState(() => _unopenedMessagesByGroup = byGroup);
});
_reactionsSub = twonlyDB.reactionsDao.watchLatestReactionsByGroup().listen((
rows,
) {
if (!mounted) return;
setState(
() => _lastReactionByGroup = {for (final row in rows) row.$1: row.$2},
);
});
_latestMessagesSub = twonlyDB.messagesDao
.watchLatestMessagesByGroup()
.listen((messages) {
if (!mounted) return;
setState(
() => _lastMessageByGroup = {
for (final message in messages) message.groupId: message,
},
);
});
_chatListMediaSub = twonlyDB.mediaFilesDao.watchChatListMediaFiles().listen(
(mediaFiles) {
if (!mounted) return;
setState(
() => _chatListMediaById = {
for (final mediaFile in mediaFiles) mediaFile.mediaId: mediaFile,
},
);
},
);
_verificationSub = twonlyDB.keyVerificationDao
.watchAllGroupsVerificationStatus()
.listen((statuses) {
if (!mounted) return;
setState(() => _verificationByGroup = statuses);
});
_contactLabelsSub = twonlyDB.labelsDao.watchAllContactLabels().listen((
rows,
) {
if (!mounted) return;
final labels = <int, List<Label>>{};
for (final row in rows) {
labels.putIfAbsent(row.$1, () => []).add(row.$2);
}
setState(() => _labelsByContact = labels);
});
} }
@override @override
@ -132,9 +230,44 @@ class _ChatListViewState extends State<ChatListView> with AutomaticKeepAliveClie
_countAnnouncedStream.cancel(); _countAnnouncedStream.cancel();
_userSub?.cancel(); _userSub?.cancel();
_precacheSub?.cancel(); _precacheSub?.cancel();
_typingMembersSub?.cancel();
_typingUpdateTimer?.cancel();
_groupMembersSub?.cancel();
_unopenedMessagesSub?.cancel();
_reactionsSub?.cancel();
_latestMessagesSub?.cancel();
_chatListMediaSub?.cancel();
_verificationSub?.cancel();
_contactLabelsSub?.cancel();
super.dispose(); super.dispose();
} }
void _updateTypingGroupIds() {
if (!mounted) return;
final typingGroupIds = _typingMembers
.where(isTyping)
.map((member) => member.groupId)
.toSet();
if (setEquals(_typingGroupIds, typingGroupIds)) return;
setState(() => _typingGroupIds = typingGroupIds);
}
Map<String, MediaFile> _mediaForGroup(String groupId) {
final mediaIds = <String>{
for (final message
in _unopenedMessagesByGroup[groupId] ?? const <Message>[])
if (message.mediaId != null) message.mediaId!,
if (_lastMessageByGroup[groupId]?.mediaId != null)
_lastMessageByGroup[groupId]!.mediaId!,
};
final mediaFiles = <String, MediaFile>{};
for (final mediaId in mediaIds) {
final mediaFile = _chatListMediaById[mediaId];
if (mediaFile != null) mediaFiles[mediaId] = mediaFile;
}
return mediaFiles;
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
super.build(context); super.build(context);
@ -277,6 +410,22 @@ class _ChatListViewState extends State<ChatListView> with AutomaticKeepAliveClie
return GroupListItemComp( return GroupListItemComp(
key: ValueKey(group.groupId), key: ValueKey(group.groupId),
group: group, group: group,
isTyping: _typingGroupIds.contains(group.groupId),
contacts: _contactsByGroup[group.groupId] ?? const [],
unopenedMessages:
_unopenedMessagesByGroup[group.groupId] ?? const [],
lastReaction: _lastReactionByGroup[group.groupId],
lastMessage: _lastMessageByGroup[group.groupId],
mediaFiles: _mediaForGroup(group.groupId),
useSharedSummary: true,
verificationStatus: _verificationByGroup[group.groupId],
contactLabels:
_contactsByGroup[group.groupId]?.isNotEmpty == true
? _labelsByContact[_contactsByGroup[group.groupId]!
.first
.userId] ??
const []
: const [],
); );
} }
@ -296,6 +445,22 @@ class _ChatListViewState extends State<ChatListView> with AutomaticKeepAliveClie
return GroupListItemComp( return GroupListItemComp(
key: ValueKey(group.groupId), key: ValueKey(group.groupId),
group: group, group: group,
isTyping: _typingGroupIds.contains(group.groupId),
contacts: _contactsByGroup[group.groupId] ?? const [],
unopenedMessages:
_unopenedMessagesByGroup[group.groupId] ?? const [],
lastReaction: _lastReactionByGroup[group.groupId],
lastMessage: _lastMessageByGroup[group.groupId],
mediaFiles: _mediaForGroup(group.groupId),
useSharedSummary: true,
verificationStatus: _verificationByGroup[group.groupId],
contactLabels:
_contactsByGroup[group.groupId]?.isNotEmpty == true
? _labelsByContact[_contactsByGroup[group.groupId]!
.first
.userId] ??
const []
: const [],
); );
}, },
), ),

View file

@ -6,6 +6,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/tables/mediafiles.table.dart'; import 'package:twonly/src/database/tables/mediafiles.table.dart';
import 'package:twonly/src/database/tables/messages.table.dart'; import 'package:twonly/src/database/tables/messages.table.dart';
import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/database/twonly.db.dart';
@ -23,9 +24,27 @@ import 'package:twonly/src/visual/views/chats/chat_messages_components/message_s
class GroupListItemComp extends StatefulWidget { class GroupListItemComp extends StatefulWidget {
const GroupListItemComp({ const GroupListItemComp({
required this.group, required this.group,
this.isTyping,
this.contacts,
this.unopenedMessages,
this.lastReaction,
this.lastMessage,
this.mediaFiles,
this.useSharedSummary = false,
this.verificationStatus,
this.contactLabels = const [],
super.key, super.key,
}); });
final Group group; final Group group;
final bool? isTyping;
final List<Contact>? contacts;
final List<Message>? unopenedMessages;
final Reaction? lastReaction;
final Message? lastMessage;
final Map<String, MediaFile>? mediaFiles;
final bool useSharedSummary;
final VerificationStatus? verificationStatus;
final List<Label> contactLabels;
@override @override
State<GroupListItemComp> createState() => _UserListItem(); State<GroupListItemComp> createState() => _UserListItem();
@ -53,9 +72,47 @@ class _UserListItem extends State<GroupListItemComp> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_applyContacts();
_lastReaction = widget.lastReaction;
_lastMessage = widget.lastMessage;
_applyMediaFiles();
if (widget.useSharedSummary) {
_updateState(widget.lastMessage, widget.unopenedMessages ?? const []);
}
initStreams(); initStreams();
} }
@override
void didUpdateWidget(GroupListItemComp oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.contacts != oldWidget.contacts) _applyContacts();
if (widget.lastReaction != oldWidget.lastReaction) {
_lastReaction = widget.lastReaction;
}
if (widget.unopenedMessages != oldWidget.unopenedMessages) {
_updateState(widget.lastMessage, widget.unopenedMessages ?? const []);
} else if (widget.lastMessage != oldWidget.lastMessage) {
_updateState(widget.lastMessage, widget.unopenedMessages ?? const []);
}
if (widget.mediaFiles != oldWidget.mediaFiles) _applyMediaFiles();
}
void _applyContacts() {
if (widget.contacts == null) return;
_directContact = widget.group.isDirectChat && widget.contacts!.isNotEmpty
? widget.contacts!.first
: null;
_receiverDeletedAccount =
widget.contacts!.length == 1 && widget.contacts!.first.accountDeleted;
}
void _applyMediaFiles() {
if (widget.mediaFiles == null) return;
_previewMediaFiles
..clear()
..addAll(widget.mediaFiles!.values);
}
@override @override
void dispose() { void dispose() {
_messagesNotOpenedStream?.cancel(); _messagesNotOpenedStream?.cancel();
@ -67,45 +124,57 @@ class _UserListItem extends State<GroupListItemComp> {
} }
Future<void> initStreams() async { Future<void> initStreams() async {
final lastMsgStream = await twonlyDB.messagesDao.watchLastMessage( if (!widget.useSharedSummary) {
widget.group.groupId, final lastMsgStream = await twonlyDB.messagesDao.watchLastMessage(
); widget.group.groupId,
if (!mounted) return; );
_lastMessageStream = lastMsgStream.listen((update) { if (!mounted) return;
_updateState(update, _messagesNotOpened); _lastMessageStream = lastMsgStream.listen((update) {
}); _updateState(update, _messagesNotOpened);
});
}
_lastReactionStream = twonlyDB.reactionsDao if (!widget.useSharedSummary) {
.watchLastReactions(widget.group.groupId) _lastReactionStream = twonlyDB.reactionsDao
.listen((update) { .watchLastReactions(widget.group.groupId)
if (!mounted) return; .listen((update) {
setState(() { if (!mounted) return;
_lastReaction = update; setState(() {
_lastReaction = update;
});
}); });
}); }
_messagesNotOpenedStream = twonlyDB.messagesDao if (widget.useSharedSummary) {
.watchMessageNotOpened(widget.group.groupId) _messagesNotOpened = widget.unopenedMessages!;
.listen((update) { } else {
_updateState(_lastMessage, update); _messagesNotOpenedStream = twonlyDB.messagesDao
}); .watchMessageNotOpened(widget.group.groupId)
.listen((update) {
_updateState(_lastMessage, update);
});
}
_lastMediaFilesStream = twonlyDB.mediaFilesDao if (!widget.useSharedSummary) {
.watchMediaFilesForGroup(widget.group.groupId) _lastMediaFilesStream = twonlyDB.mediaFilesDao
.listen((mediaFiles) { .watchMediaFilesForGroup(widget.group.groupId)
if (!mounted) return; .listen((mediaFiles) {
for (final mediaFile in mediaFiles) { if (!mounted) return;
final index = _previewMediaFiles.indexWhere( for (final mediaFile in mediaFiles) {
(t) => t.mediaId == mediaFile.mediaId, final index = _previewMediaFiles.indexWhere(
); (t) => t.mediaId == mediaFile.mediaId,
if (index >= 0) { );
_previewMediaFiles[index] = mediaFile; if (index >= 0) {
_previewMediaFiles[index] = mediaFile;
}
} }
} setState(() {});
setState(() {}); });
}); }
if (widget.group.isDirectChat) { if (widget.contacts != null) {
// Contact data is shared by the parent chat list.
} else if (widget.group.isDirectChat) {
_directContactStream = twonlyDB.groupsDao _directContactStream = twonlyDB.groupsDao
.watchGroupContact(widget.group.groupId) .watchGroupContact(widget.group.groupId)
.listen((contacts) { .listen((contacts) {
@ -191,6 +260,7 @@ class _UserListItem extends State<GroupListItemComp> {
/// Fetches any media files referenced by preview messages but not yet in the /// Fetches any media files referenced by preview messages but not yet in the
/// local cache. Fire-and-forget; updates state when results arrive. /// local cache. Fire-and-forget; updates state when results arrive.
Future<void> _fetchMissingMediaFiles() async { Future<void> _fetchMissingMediaFiles() async {
if (widget.useSharedSummary) return;
final missing = <MediaFile>[]; final missing = <MediaFile>[];
for (final message in _previewMessages) { for (final message in _previewMessages) {
if (message.mediaId != null && if (message.mediaId != null &&
@ -266,6 +336,8 @@ class _UserListItem extends State<GroupListItemComp> {
const SizedBox(width: 3), const SizedBox(width: 3),
VerificationBadgeComp( VerificationBadgeComp(
group: widget.group, group: widget.group,
verificationStatus: widget.verificationStatus,
useProvidedStatus: widget.useSharedSummary,
showOnlyIfVerified: true, showOnlyIfVerified: true,
clickable: false, clickable: false,
size: 12, size: 12,
@ -278,6 +350,7 @@ class _UserListItem extends State<GroupListItemComp> {
physics: const BouncingScrollPhysics(), physics: const BouncingScrollPhysics(),
child: ContactLabels( child: ContactLabels(
contactId: _directContact!.userId, contactId: _directContact!.userId,
labels: widget.contactLabels,
), ),
), ),
), ),
@ -296,7 +369,7 @@ class _UserListItem extends State<GroupListItemComp> {
dateTime: widget.group.lastMessageExchange, dateTime: widget.group.lastMessageExchange,
), ),
FlameCounterWidget( FlameCounterWidget(
groupId: widget.group.groupId, group: widget.group,
prefix: true, prefix: true,
), ),
], ],
@ -305,6 +378,7 @@ class _UserListItem extends State<GroupListItemComp> {
children: [ children: [
TypingIndicatorSubtitleComp( TypingIndicatorSubtitleComp(
groupId: widget.group.groupId, groupId: widget.group.groupId,
isTyping: widget.isTyping,
), ),
MessageSendStateIcon( MessageSendStateIcon(
_previewMessages, _previewMessages,
@ -320,7 +394,7 @@ class _UserListItem extends State<GroupListItemComp> {
message: _currentMessage, message: _currentMessage,
), ),
FlameCounterWidget( FlameCounterWidget(
groupId: widget.group.groupId, group: widget.group,
prefix: true, prefix: true,
), ),
], ],
@ -340,7 +414,7 @@ class _UserListItem extends State<GroupListItemComp> {
await context.push(Routes.profileGroup(widget.group.groupId)); await context.push(Routes.profileGroup(widget.group.groupId));
} }
}, },
child: AvatarIcon(group: widget.group), child: AvatarIcon(group: widget.group, contacts: widget.contacts),
), ),
trailing: (widget.group.leftGroup || _receiverDeletedAccount) trailing: (widget.group.leftGroup || _receiverDeletedAccount)
? null ? null

View file

@ -7,9 +7,14 @@ import 'package:twonly/src/visual/views/chats/chat_messages.view.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/typing_indicator.dart'; import 'package:twonly/src/visual/views/chats/chat_messages_components/typing_indicator.dart';
class TypingIndicatorSubtitleComp extends StatefulWidget { class TypingIndicatorSubtitleComp extends StatefulWidget {
const TypingIndicatorSubtitleComp({required this.groupId, super.key}); const TypingIndicatorSubtitleComp({
required this.groupId,
this.isTyping,
super.key,
});
final String groupId; final String groupId;
final bool? isTyping;
@override @override
State<TypingIndicatorSubtitleComp> createState() => State<TypingIndicatorSubtitleComp> createState() =>
@ -28,6 +33,8 @@ class _TypingIndicatorSubtitleCompState
void initState() { void initState() {
super.initState(); super.initState();
if (widget.isTyping != null) return;
final membersStream = twonlyDB.groupsDao.watchGroupMembers( final membersStream = twonlyDB.groupsDao.watchGroupMembers(
widget.groupId, widget.groupId,
); );
@ -64,22 +71,24 @@ class _TypingIndicatorSubtitleCompState
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (_groupMembers.isEmpty) return Container(); if (widget.isTyping ?? _groupMembers.isNotEmpty) {
return Padding( return Padding(
padding: const EdgeInsets.only(right: 5), padding: const EdgeInsets.only(right: 5),
child: Container( child: Container(
padding: const EdgeInsets.symmetric(vertical: 3), padding: const EdgeInsets.symmetric(vertical: 3),
decoration: BoxDecoration( decoration: BoxDecoration(
color: getMessageColor(true), color: getMessageColor(true),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: Transform.scale( child: Transform.scale(
scale: 0.6, scale: 0.6,
child: const AnimatedTypingDots( child: const AnimatedTypingDots(
isTyping: true, isTyping: true,
),
), ),
), ),
), );
); }
return Container();
} }
} }

View file

@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:collection'; import 'dart:collection';
import 'package:flutter/foundation.dart' show setEquals;
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';
@ -25,11 +26,52 @@ import 'package:twonly/src/visual/views/chats/chat_messages_components/blink.com
import 'package:twonly/src/visual/views/chats/chat_messages_components/chat_group_action.dart'; import 'package:twonly/src/visual/views/chats/chat_messages_components/chat_group_action.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/chat_list_entry.dart'; import 'package:twonly/src/visual/views/chats/chat_messages_components/chat_list_entry.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/chat_date_chip.dart'; import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/chat_date_chip.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/friendly_message_time.comp.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/in_chat_group_overview.dart'; import 'package:twonly/src/visual/views/chats/chat_messages_components/in_chat_group_overview.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/message_input.dart'; import 'package:twonly/src/visual/views/chats/chat_messages_components/message_input.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/response_container.dart'; import 'package:twonly/src/visual/views/chats/chat_messages_components/response_container.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/typing_indicator.dart'; import 'package:twonly/src/visual/views/chats/chat_messages_components/typing_indicator.dart';
class _MessageAnimationState {
bool hasReceivedFirstBatch = false;
final HashSet<String> knownMessageIds = HashSet<String>();
final HashSet<String> animateMessageIds = HashSet<String>();
}
class _ChatViewData {
Map<int, Contact> contactsById = {};
Map<String, MediaFile> mediaFilesById = {};
Map<String, List<Reaction>> reactionsByMessageId = {};
Set<String> ackedMessageIds = {};
List<ChatItem> chatItems = [];
List<Message> allMessages = [];
Map<String, Message> messagesById = {};
List<GroupHistory> groupActions = [];
List<MemoryItem> galleryItems = [];
Set<String> galleryMessageIds = {};
}
class _ChatSubscriptions {
StreamSubscription<Group?>? group;
StreamSubscription<List<Message>>? messages;
StreamSubscription<List<GroupHistory>>? groupActions;
StreamSubscription<List<Contact>>? contacts;
StreamSubscription<List<MediaFile>>? media;
StreamSubscription<List<Reaction>>? reactions;
StreamSubscription<List<MessageAction>>? messageActions;
void cancelAll() {
group?.cancel();
messages?.cancel();
groupActions?.cancel();
contacts?.cancel();
media?.cancel();
reactions?.cancel();
messageActions?.cancel();
}
}
class ChatMessagesView extends StatefulWidget { class ChatMessagesView extends StatefulWidget {
const ChatMessagesView(this.groupId, {super.key}); const ChatMessagesView(this.groupId, {super.key});
@ -43,23 +85,12 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
with WidgetsBindingObserver { with WidgetsBindingObserver {
HashSet<int> alreadyReportedOpened = HashSet<int>(); HashSet<int> alreadyReportedOpened = HashSet<int>();
bool _hasReceivedFirstMessageBatch = false; final _animationState = _MessageAnimationState();
final HashSet<String> _knownMessageIds = HashSet<String>(); final _subscriptions = _ChatSubscriptions();
final HashSet<String> _animateMessageIds = HashSet<String>(); final _data = _ChatViewData();
StreamSubscription<Group?>? userSub;
StreamSubscription<List<Message>>? messageSub;
StreamSubscription<List<GroupHistory>>? groupActionsSub;
StreamSubscription<List<Contact>>? contactSub;
Group? _group; Group? _group;
List<Contact> _groupContacts = [];
Map<int, Contact> userIdToContact = {};
List<ChatItem> messages = [];
List<Message> allMessages = [];
List<GroupHistory> groupActions = [];
List<MemoryItem> galleryItems = [];
Message? quotesMessage; Message? quotesMessage;
GlobalKey verifyShieldKey = GlobalKey(); GlobalKey verifyShieldKey = GlobalKey();
FocusNode? textFieldFocus; FocusNode? textFieldFocus;
@ -79,10 +110,7 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
@override @override
void dispose() { void dispose() {
userSub?.cancel(); _subscriptions.cancelAll();
messageSub?.cancel();
contactSub?.cancel();
groupActionsSub?.cancel();
_nextTypingIndicator?.cancel(); _nextTypingIndicator?.cancel();
try { try {
textFieldFocus?.dispose(); textFieldFocus?.dispose();
@ -100,43 +128,86 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
Future<void> initStreams() async { Future<void> initStreams() async {
final groupStream = twonlyDB.groupsDao.watchGroup(widget.groupId); final groupStream = twonlyDB.groupsDao.watchGroup(widget.groupId);
userSub = groupStream.listen((newGroup) { _subscriptions.group = groupStream.listen((newGroup) {
if (newGroup == null) return; if (newGroup == null) return;
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_group = newGroup; _group = newGroup;
}); });
if (groupActionsSub == null) { if (_subscriptions.groupActions == null) {
final actionsStream = twonlyDB.groupsDao.watchGroupActions( final actionsStream = twonlyDB.groupsDao.watchGroupActions(
newGroup.groupId, newGroup.groupId,
); );
groupActionsSub = actionsStream.listen((update) async { _subscriptions.groupActions = actionsStream.listen((update) async {
groupActions = update; _data.groupActions = update;
await setMessages(allMessages, update); await setMessages(_data.allMessages, update);
}); });
final contactsStream = twonlyDB.contactsDao.watchAllContacts(); final contactsStream = twonlyDB.contactsDao.watchAllContacts();
contactSub = contactsStream.listen((contacts) { _subscriptions.contacts = contactsStream.listen((contacts) {
final contactMap = <int, Contact>{};
for (final contact in contacts) { for (final contact in contacts) {
userIdToContact[contact.userId] = contact; contactMap[contact.userId] = contact;
} }
if (mounted) setState(() => _data.contactsById = contactMap);
}); });
} }
}); });
final msgStream = await twonlyDB.messagesDao.watchByGroupId(widget.groupId); final msgStream = await twonlyDB.messagesDao.watchByGroupId(widget.groupId);
messageSub = msgStream.listen((update) async { _subscriptions.messages = msgStream.listen((update) async {
allMessages = update; _data.allMessages = update;
await setMessages(update, groupActions); _data.messagesById = {
_hasReceivedFirstMessageBatch = true; for (final message in update) message.messageId: message,
};
await setMessages(update, _data.groupActions);
_animationState.hasReceivedFirstBatch = true;
}); });
_subscriptions.media = twonlyDB.mediaFilesDao
.watchMediaFilesForGroup(widget.groupId)
.listen((mediaFiles) {
if (!mounted) return;
setState(
() => _data.mediaFilesById = {
for (final mediaFile in mediaFiles) mediaFile.mediaId: mediaFile,
},
);
});
_subscriptions.reactions = twonlyDB.reactionsDao
.watchReactionsForGroup(widget.groupId)
.listen((reactions) {
if (!mounted) return;
final byMessage = <String, List<Reaction>>{};
for (final reaction in reactions) {
byMessage.putIfAbsent(reaction.messageId, () => []).add(reaction);
}
setState(() => _data.reactionsByMessageId = byMessage);
});
_subscriptions.messageActions = twonlyDB.messagesDao
.watchMessageActionsForGroup(widget.groupId)
.listen((actions) {
if (!mounted) return;
setState(
() => _data.ackedMessageIds = actions
.where(
(action) => action.type == MessageActionType.ackByUserAt,
)
.map((action) => action.messageId)
.toSet(),
);
});
final groupContacts = await twonlyDB.groupsDao.getGroupContact( final groupContacts = await twonlyDB.groupsDao.getGroupContact(
widget.groupId, widget.groupId,
); );
if (groupContacts.length == 1) { if (mounted) {
_receiverDeletedAccount = groupContacts.first.accountDeleted; setState(() {
_groupContacts = groupContacts;
_receiverDeletedAccount =
groupContacts.length == 1 && groupContacts.first.accountDeleted;
});
} }
if (userService.currentUser.typingIndicators) { if (userService.currentUser.typingIndicators) {
@ -160,12 +231,12 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
} }
for (final msg in newMessages) { for (final msg in newMessages) {
if (_hasReceivedFirstMessageBatch && if (_animationState.hasReceivedFirstBatch &&
!_knownMessageIds.contains(msg.messageId) && !_animationState.knownMessageIds.contains(msg.messageId) &&
msg.senderId == null) { msg.senderId == null) {
_animateMessageIds.add(msg.messageId); _animationState.animateMessageIds.add(msg.messageId);
} }
_knownMessageIds.add(msg.messageId); _animationState.knownMessageIds.add(msg.messageId);
} }
final chatItems = <ChatItem>[]; final chatItems = <ChatItem>[];
@ -230,13 +301,13 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
} }
final wasSentByMe = final wasSentByMe =
_hasReceivedFirstMessageBatch && _animationState.hasReceivedFirstBatch &&
newMessages.isNotEmpty && newMessages.isNotEmpty &&
newMessages.last.senderId == null; newMessages.last.senderId == null;
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
messages = chatItems.reversed.toList(); _data.chatItems = chatItems.reversed.toList();
}); });
if (wasSentByMe) { if (wasSentByMe) {
@ -255,14 +326,20 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
}); });
} }
final galleryMessageIds = storedMediaFiles
.map((message) => message.messageId)
.toSet();
if (setEquals(_data.galleryMessageIds, galleryMessageIds)) return;
final items = await MemoryItem.convertFromMessages(storedMediaFiles); final items = await MemoryItem.convertFromMessages(storedMediaFiles);
if (!mounted) return; if (!mounted) return;
galleryItems = items.values.toList(); setState(() {
setState(() {}); _data.galleryMessageIds = galleryMessageIds;
_data.galleryItems = items.values.toList();
});
} }
Future<void> scrollToMessage(String messageId) async { Future<void> scrollToMessage(String messageId) async {
final index = messages.indexWhere( final index = _data.chatItems.indexWhere(
(x) => x.isMessage && x.message!.messageId == messageId, (x) => x.isMessage && x.message!.messageId == messageId,
); );
if (index == -1) return; if (index == -1) return;
@ -309,6 +386,7 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
children: [ children: [
AvatarIcon( AvatarIcon(
group: group, group: group,
contacts: _groupContacts,
fontSize: 19, fontSize: 19,
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
@ -330,7 +408,7 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
group: group, group: group,
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
FlameCounterWidget(groupId: group.groupId), FlameCounterWidget(group: group),
], ],
), ),
if (group.isDirectChat) if (group.isDirectChat)
@ -362,69 +440,84 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
Expanded( Expanded(
child: Align( child: Align(
alignment: Alignment.topCenter, alignment: Alignment.topCenter,
child: ScrollablePositionedList.builder( child: ChatMessageActionScope(
shrinkWrap: true, ackedMessageIds: _data.ackedMessageIds,
reverse: true, child: ScrollablePositionedList.builder(
itemCount: messages.length + 1 + 1, reverse: true,
itemScrollController: itemScrollController, itemCount: _data.chatItems.length + 1 + 1,
itemBuilder: (context, i) { itemScrollController: itemScrollController,
if (i == 0) { itemBuilder: (context, i) {
return userService.currentUser.typingIndicators if (i == 0) {
? TypingIndicator(group: group) return userService.currentUser.typingIndicators
: Container(); ? TypingIndicator(group: group)
} : Container();
i -= 1; }
if (i == messages.length) { i -= 1;
return Padding( if (i == _data.chatItems.length) {
key: Key('overview_${group.groupId}'), return Padding(
padding: const EdgeInsets.only(top: 10), key: Key('overview_${group.groupId}'),
child: InChatGroupOverview( padding: const EdgeInsets.only(top: 10),
group: group, child: InChatGroupOverview(
),
);
}
if (messages[i].isDate) {
return ChatDateChip(
item: messages[i],
);
} else if (messages[i].isGroupAction) {
return ChatGroupAction(
key: Key(messages[i].groupAction!.groupHistoryId),
action: messages[i].groupAction!,
);
} else {
final chatMessage = messages[i].message!;
return BlinkWidget(
key: Key('blink_${chatMessage.messageId}'),
enabled: focusedScrollItem == i,
child: AnimatedNewMessage(
key: Key('anim_${chatMessage.messageId}'),
messageId: chatMessage.messageId,
animateIds: _animateMessageIds,
child: ChatListEntry(
key: Key(chatMessage.messageId),
message: messages[i].message!,
nextMessage: (i > 0)
? messages[i - 1].message
: null,
prevMessage: ((i + 1) < messages.length)
? messages[i + 1].message
: null,
group: group, group: group,
galleryItems: galleryItems,
userIdToContact: userIdToContact,
scrollToMessage: scrollToMessage,
onResponseTriggered: () {
setState(() {
quotesMessage = chatMessage;
});
textFieldFocus?.requestFocus();
},
), ),
), );
); }
} if (_data.chatItems[i].isDate) {
}, return ChatDateChip(
item: _data.chatItems[i],
);
} else if (_data.chatItems[i].isGroupAction) {
return ChatGroupAction(
key: Key(
_data.chatItems[i].groupAction!.groupHistoryId,
),
action: _data.chatItems[i].groupAction!,
contactsById: _data.contactsById,
);
} else {
final chatMessage = _data.chatItems[i].message!;
return BlinkWidget(
key: Key('blink_${chatMessage.messageId}'),
enabled: focusedScrollItem == i,
child: AnimatedNewMessage(
key: Key('anim_${chatMessage.messageId}'),
messageId: chatMessage.messageId,
animateIds: _animationState.animateMessageIds,
child: ChatListEntry(
key: Key(chatMessage.messageId),
message: _data.chatItems[i].message!,
nextMessage: (i > 0)
? _data.chatItems[i - 1].message
: null,
prevMessage: ((i + 1) < _data.chatItems.length)
? _data.chatItems[i + 1].message
: null,
group: group,
galleryItems: _data.galleryItems,
userIdToContact: _data.contactsById,
mediaFile: chatMessage.mediaId == null
? null
: _data.mediaFilesById[chatMessage.mediaId],
reactions:
_data.reactionsByMessageId[chatMessage
.messageId] ??
const [],
messagesById: _data.messagesById,
mediaFilesById: _data.mediaFilesById,
useSharedData: true,
scrollToMessage: scrollToMessage,
onResponseTriggered: () {
setState(() {
quotesMessage = chatMessage;
});
textFieldFocus?.requestFocus();
},
),
),
);
}
},
),
), ),
), ),
), ),

View file

@ -9,10 +9,12 @@ import 'package:twonly/src/utils/misc.dart';
class ChatGroupAction extends StatefulWidget { class ChatGroupAction extends StatefulWidget {
const ChatGroupAction({ const ChatGroupAction({
required this.action, required this.action,
this.contactsById,
super.key, super.key,
}); });
final GroupHistory action; final GroupHistory action;
final Map<int, Contact>? contactsById;
@override @override
State<ChatGroupAction> createState() => _ChatGroupActionState(); State<ChatGroupAction> createState() => _ChatGroupActionState();
@ -25,7 +27,29 @@ class _ChatGroupActionState extends State<ChatGroupAction> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
initAsync(); if (widget.contactsById == null) {
initAsync();
} else {
_applyContacts();
}
}
@override
void didUpdateWidget(ChatGroupAction oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.contactsById != null &&
widget.contactsById != oldWidget.contactsById) {
_applyContacts();
}
}
void _applyContacts() {
contact = widget.action.contactId == null
? null
: widget.contactsById?[widget.action.contactId];
affectedContact = widget.action.affectedContactId == null
? null
: widget.contactsById?[widget.action.affectedContactId];
} }
Future<void> initAsync() async { Future<void> initAsync() async {

View file

@ -37,6 +37,11 @@ class ChatListEntry extends StatefulWidget {
this.nextMessage, this.nextMessage,
this.userIdToContact, this.userIdToContact,
this.hideReactions = false, this.hideReactions = false,
this.mediaFile,
this.reactions,
this.messagesById,
this.mediaFilesById,
this.useSharedData = false,
super.key, super.key,
}); });
final Message? prevMessage; final Message? prevMessage;
@ -45,6 +50,11 @@ class ChatListEntry extends StatefulWidget {
final Group group; final Group group;
final Map<int, Contact>? userIdToContact; final Map<int, Contact>? userIdToContact;
final bool hideReactions; final bool hideReactions;
final MediaFile? mediaFile;
final List<Reaction>? reactions;
final Map<String, Message>? messagesById;
final Map<String, MediaFile>? mediaFilesById;
final bool useSharedData;
final List<MemoryItem> galleryItems; final List<MemoryItem> galleryItems;
final void Function(String)? scrollToMessage; final void Function(String)? scrollToMessage;
final void Function()? onResponseTriggered; final void Function()? onResponseTriggered;
@ -64,9 +74,24 @@ class _ChatListEntryState extends State<ChatListEntry> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_applySharedData();
initAsync(); initAsync();
} }
@override
void didUpdateWidget(ChatListEntry oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.useSharedData) _applySharedData();
}
void _applySharedData() {
if (!widget.useSharedData) return;
reactions = widget.reactions ?? const [];
mediaService = widget.mediaFile == null
? null
: MediaFileService(widget.mediaFile!);
}
@override @override
void dispose() { void dispose() {
mediaFileSub?.cancel(); mediaFileSub?.cancel();
@ -75,6 +100,7 @@ class _ChatListEntryState extends State<ChatListEntry> {
} }
Future<void> initAsync() async { Future<void> initAsync() async {
if (widget.useSharedData) return;
if (widget.message.mediaId != null) { if (widget.message.mediaId != null) {
final mediaFileStream = twonlyDB.mediaFilesDao.watchMedia( final mediaFileStream = twonlyDB.mediaFilesDao.watchMedia(
widget.message.mediaId!, widget.message.mediaId!,
@ -131,6 +157,7 @@ class _ChatListEntryState extends State<ChatListEntry> {
group: widget.group, group: widget.group,
mediaService: mediaService!, mediaService: mediaService!,
galleryItems: widget.galleryItems, galleryItems: widget.galleryItems,
useSharedData: widget.useSharedData,
borderRadius: borderRadius, borderRadius: borderRadius,
info: info, info: info,
); );
@ -225,6 +252,15 @@ class _ChatListEntryState extends State<ChatListEntry> {
mediaService: mediaService, mediaService: mediaService,
borderRadius: borderRadius, borderRadius: borderRadius,
scrollToMessage: widget.scrollToMessage, scrollToMessage: widget.scrollToMessage,
quotedMessage: widget.msgQuote(
messagesById: widget.messagesById,
),
quotedMediaFile: widget.msgQuoteMedia(
messagesById: widget.messagesById,
mediaFilesById: widget.mediaFilesById,
),
contactsById: widget.userIdToContact,
useSharedData: widget.useSharedData,
child: _getChatEntry(borderRadius, reactionsForWidth, info), child: _getChatEntry(borderRadius, reactionsForWidth, info),
), ),
if (reactionsForWidth > 0) const SizedBox(height: 20, width: 10), if (reactionsForWidth > 0) const SizedBox(height: 20, width: 10),
@ -281,6 +317,10 @@ class _ChatListEntryState extends State<ChatListEntry> {
), ),
child: AvatarIcon( child: AvatarIcon(
contactId: widget.message.senderId, contactId: widget.message.senderId,
contacts:
widget.userIdToContact?[widget.message.senderId] == null
? null
: [widget.userIdToContact![widget.message.senderId]!],
fontSize: 12, fontSize: 12,
), ),
), ),
@ -292,6 +332,21 @@ class _ChatListEntryState extends State<ChatListEntry> {
} }
} }
extension on ChatListEntry {
Message? msgQuote({Map<String, Message>? messagesById}) {
final quotedId = message.quotesMessageId;
return quotedId == null ? null : messagesById?[quotedId];
}
MediaFile? msgQuoteMedia({
Map<String, Message>? messagesById,
Map<String, MediaFile>? mediaFilesById,
}) {
final quoted = msgQuote(messagesById: messagesById);
return quoted?.mediaId == null ? null : mediaFilesById?[quoted!.mediaId];
}
}
(EdgeInsetsGeometry, BorderRadius, bool) getMessageLayout( (EdgeInsetsGeometry, BorderRadius, bool) getMessageLayout(
Message message, Message message,
Message? prevMessage, Message? prevMessage,

View file

@ -29,6 +29,7 @@ class ChatMediaEntry extends StatefulWidget {
required this.mediaService, required this.mediaService,
required this.borderRadius, required this.borderRadius,
required this.info, required this.info,
this.useSharedData = false,
super.key, super.key,
}); });
@ -38,6 +39,7 @@ class ChatMediaEntry extends StatefulWidget {
final MediaFileService mediaService; final MediaFileService mediaService;
final BorderRadius borderRadius; final BorderRadius borderRadius;
final BubbleInfo info; final BubbleInfo info;
final bool useSharedData;
@override @override
State<ChatMediaEntry> createState() => _ChatMediaEntryState(); State<ChatMediaEntry> createState() => _ChatMediaEntryState();
@ -179,6 +181,7 @@ class _ChatMediaEntryState extends State<ChatMediaEntry> {
canBeReopened: _canBeReopened, canBeReopened: _canBeReopened,
borderRadius: imageBorderRadius, borderRadius: imageBorderRadius,
info: widget.info, info: widget.info,
useSharedData: widget.useSharedData,
), ),
), ),
), ),

View file

@ -20,7 +20,7 @@ class FriendlyMessageTime extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final statusIcon = _buildStatusIcon(Colors.grey.shade400); final statusIcon = _buildStatusIcon(context, Colors.grey.shade400);
return Padding( return Padding(
padding: const EdgeInsets.only(left: 6), padding: const EdgeInsets.only(left: 6),
@ -59,7 +59,7 @@ class FriendlyMessageTime extends StatelessWidget {
); );
} }
Widget? _buildStatusIcon(Color iconColor) { Widget? _buildStatusIcon(BuildContext context, Color iconColor) {
if (message.type != MessageType.text.name || message.senderId != null) { if (message.type != MessageType.text.name || message.senderId != null) {
return null; return null;
} }
@ -82,7 +82,14 @@ class FriendlyMessageTime extends StatelessWidget {
); );
} }
// Now check message actions for ackByUserAt final sharedAckState = ChatMessageActionScope.maybeOf(context);
if (sharedAckState != null) {
return _ackIcon(
iconColor,
sharedAckState.ackedMessageIds.contains(message.messageId),
);
}
return StreamBuilder<List<(MessageAction, Contact)>>( return StreamBuilder<List<(MessageAction, Contact)>>(
stream: twonlyDB.messagesDao.watchMessageActions(message.messageId), stream: twonlyDB.messagesDao.watchMessageActions(message.messageId),
builder: (context, snapshot) { builder: (context, snapshot) {
@ -91,28 +98,36 @@ class FriendlyMessageTime extends StatelessWidget {
(t) => t.$1.type == MessageActionType.ackByUserAt, (t) => t.$1.type == MessageActionType.ackByUserAt,
); );
if (hasAckByUser) { return _ackIcon(iconColor, hasAckByUser);
return Padding(
padding: const EdgeInsets.only(left: 4),
child: FaIcon(
FontAwesomeIcons.checkDouble,
size: 8,
color: iconColor,
),
);
}
return Padding(
padding: const EdgeInsets.only(left: 4),
child: FaIcon(
FontAwesomeIcons.check,
size: 8,
color: iconColor,
),
);
}, },
); );
} }
Widget _ackIcon(Color iconColor, bool acknowledged) => Padding(
padding: const EdgeInsets.only(left: 4),
child: FaIcon(
acknowledged ? FontAwesomeIcons.checkDouble : FontAwesomeIcons.check,
size: 8,
color: iconColor,
),
);
}
class ChatMessageActionScope extends InheritedWidget {
const ChatMessageActionScope({
required this.ackedMessageIds,
required super.child,
super.key,
});
final Set<String> ackedMessageIds;
static ChatMessageActionScope? maybeOf(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<ChatMessageActionScope>();
@override
bool updateShouldNotify(ChatMessageActionScope oldWidget) =>
ackedMessageIds != oldWidget.ackedMessageIds;
} }
String friendlyTime(BuildContext context, DateTime dt) { String friendlyTime(BuildContext context, DateTime dt) {

View file

@ -22,6 +22,7 @@ class InChatMediaViewer extends StatefulWidget {
required this.canBeReopened, required this.canBeReopened,
required this.borderRadius, required this.borderRadius,
required this.info, required this.info,
this.useSharedData = false,
super.key, super.key,
}); });
@ -33,6 +34,7 @@ class InChatMediaViewer extends StatefulWidget {
final bool canBeReopened; final bool canBeReopened;
final BorderRadius borderRadius; final BorderRadius borderRadius;
final BubbleInfo info; final BubbleInfo info;
final bool useSharedData;
@override @override
State<InChatMediaViewer> createState() => _InChatMediaViewerState(); State<InChatMediaViewer> createState() => _InChatMediaViewerState();
@ -42,7 +44,6 @@ class _InChatMediaViewerState extends State<InChatMediaViewer> {
bool mirrorVideo = false; bool mirrorVideo = false;
int? galleryItemIndex; int? galleryItemIndex;
StreamSubscription<Message?>? messageStream; StreamSubscription<Message?>? messageStream;
Timer? _timer;
late final ValueNotifier<String?> _activeMediaIdNotifier = ValueNotifier( late final ValueNotifier<String?> _activeMediaIdNotifier = ValueNotifier(
widget.message.mediaId, widget.message.mediaId,
); );
@ -50,33 +51,23 @@ class _InChatMediaViewerState extends State<InChatMediaViewer> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
unawaited(loadIndexAsync()); loadIndex();
unawaited(initStream()); if (!widget.useSharedData) unawaited(initStream());
} }
@override @override
void didUpdateWidget(InChatMediaViewer oldWidget) { void didUpdateWidget(InChatMediaViewer oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (widget.message.mediaId != oldWidget.message.mediaId) {
_activeMediaIdNotifier.value = widget.message.mediaId;
}
if (widget.message.mediaStored != oldWidget.message.mediaStored || if (widget.message.mediaStored != oldWidget.message.mediaStored ||
widget.galleryItems != oldWidget.galleryItems) { widget.galleryItems != oldWidget.galleryItems) {
if (widget.message.mediaStored) { galleryItemIndex = null;
unawaited(loadIndexAsync()); loadIndex();
}
} }
} }
Future<void> loadIndexAsync() async {
_timer?.cancel();
_timer = Timer.periodic(const Duration(milliseconds: 10), (timer) {
/// when the galleryItems are updated this widget is not reloaded
/// so using this timer as a workaround
if (loadIndex()) {
timer.cancel();
if (mounted) setState(() {});
}
});
}
bool loadIndex() { bool loadIndex() {
if (widget.message.mediaStored) { if (widget.message.mediaStored) {
final index = widget.galleryItems.indexWhere( final index = widget.galleryItems.indexWhere(
@ -93,7 +84,6 @@ class _InChatMediaViewerState extends State<InChatMediaViewer> {
@override @override
void dispose() { void dispose() {
messageStream?.cancel(); messageStream?.cancel();
_timer?.cancel();
_activeMediaIdNotifier.dispose(); _activeMediaIdNotifier.dispose();
super.dispose(); super.dispose();
} }
@ -112,7 +102,7 @@ class _InChatMediaViewerState extends State<InChatMediaViewer> {
if (updated != null) { if (updated != null) {
if (updated.mediaStored) { if (updated.mediaStored) {
await messageStream?.cancel(); await messageStream?.cancel();
await loadIndexAsync(); if (loadIndex() && mounted) setState(() {});
} }
} }
}); });

View file

@ -17,6 +17,10 @@ class ResponseContainer extends StatelessWidget {
required this.mediaService, required this.mediaService,
required this.borderRadius, required this.borderRadius,
this.scrollToMessage, this.scrollToMessage,
this.quotedMessage,
this.quotedMediaFile,
this.contactsById,
this.useSharedData = false,
super.key, super.key,
}); });
@ -26,6 +30,10 @@ class ResponseContainer extends StatelessWidget {
final MediaFileService? mediaService; final MediaFileService? mediaService;
final BorderRadius borderRadius; final BorderRadius borderRadius;
final void Function(String)? scrollToMessage; final void Function(String)? scrollToMessage;
final Message? quotedMessage;
final MediaFile? quotedMediaFile;
final Map<int, Contact>? contactsById;
final bool useSharedData;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -67,6 +75,12 @@ class ResponseContainer extends StatelessWidget {
child: ResponsePreview( child: ResponsePreview(
group: group, group: group,
messageId: msg.quotesMessageId, messageId: msg.quotesMessageId,
message: quotedMessage,
mediaFile: quotedMediaFile,
contact: quotedMessage?.senderId == null
? null
: contactsById?[quotedMessage!.senderId],
useSharedData: useSharedData,
showBorder: false, showBorder: false,
showLeftBorder: false, showLeftBorder: false,
), ),
@ -89,6 +103,9 @@ class ResponsePreview extends StatefulWidget {
this.messageId, this.messageId,
this.showLeftBorder = true, this.showLeftBorder = true,
this.colorUsername = false, this.colorUsername = false,
this.mediaFile,
this.contact,
this.useSharedData = false,
super.key, super.key,
}); });
@ -98,6 +115,9 @@ class ResponsePreview extends StatefulWidget {
final bool showBorder; final bool showBorder;
final bool showLeftBorder; final bool showLeftBorder;
final bool colorUsername; final bool colorUsername;
final MediaFile? mediaFile;
final Contact? contact;
final bool useSharedData;
@override @override
State<ResponsePreview> createState() => _ResponsePreviewState(); State<ResponsePreview> createState() => _ResponsePreviewState();
@ -112,17 +132,30 @@ class _ResponsePreviewState extends State<ResponsePreview> {
void initState() { void initState() {
super.initState(); super.initState();
_message = widget.message; _message = widget.message;
initAsync(); if (widget.mediaFile != null) {
_mediaService = MediaFileService(widget.mediaFile!);
}
if (widget.contact != null) {
_username = getContactDisplayName(widget.contact!);
}
if (!widget.useSharedData &&
(widget.message == null ||
(widget.message?.mediaId != null && widget.mediaFile == null) ||
(widget.message?.senderId != null && widget.contact == null))) {
initAsync();
}
} }
Future<void> initAsync() async { Future<void> initAsync() async {
_message ??= await twonlyDB.messagesDao if (_message == null && widget.messageId != null) {
.getMessageById(widget.messageId!) _message = await twonlyDB.messagesDao
.getSingleOrNull(); .getMessageById(widget.messageId!)
if (_message?.mediaId != null) { .getSingleOrNull();
}
if (_message?.mediaId != null && _mediaService == null) {
_mediaService = await MediaFileService.fromMediaId(_message!.mediaId!); _mediaService = await MediaFileService.fromMediaId(_message!.mediaId!);
} }
if (_message?.senderId != null) { if (_message?.senderId != null && _username.isEmpty) {
final contact = await twonlyDB.contactsDao final contact = await twonlyDB.contactsDao
.getContactByUserId(_message!.senderId!) .getContactByUserId(_message!.senderId!)
.getSingleOrNull(); .getSingleOrNull();

3
rust/.env Normal file
View file

@ -0,0 +1,3 @@
# Local schema database used by SQLx query macros and rust-analyzer.
# Rebuild it after changing a migration with: ./scripts/prepare_sqlx.sh
DATABASE_URL=sqlite://sqlx-dev.sqlite

View file

@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "\n SELECT identity_key\n FROM signal_identities\n WHERE name = ?\n ",
"describe": {
"columns": [
{
"name": "identity_key",
"ordinal": 0,
"type_info": "Blob",
"origin": {
"Table": {
"table": "signal_identities",
"name": "identity_key"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "08ec1e1198b16f3bb313b4c2c5a92bd8ca3251f0aac8d8e2748a7617c53afc65"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO user_discovery_user_relations (\n announced_user_id,\n from_contact_id,\n public_key_verified_timestamp\n ) VALUES (?, ?, ?)\n ON CONFLICT(announced_user_id, from_contact_id) DO UPDATE SET\n public_key_verified_timestamp = excluded.public_key_verified_timestamp\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "1be4155879419ae7cdcd7f8774ea5a6cce7ecb05e825095f4fffd6aaf4288d18"
}

View file

@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "\n SELECT promotion\n FROM user_discovery_own_promotions\n WHERE version_id > ?\n ",
"describe": {
"columns": [
{
"name": "promotion",
"ordinal": 0,
"type_info": "Blob",
"origin": {
"Table": {
"table": "user_discovery_own_promotions",
"name": "promotion"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "1bf6e82ae32eff099bd28dc8e577247ebef69141bd7c6b887b5b11ed724e11b3"
}

View file

@ -0,0 +1,86 @@
{
"db_name": "SQLite",
"query": "\n SELECT\n promotion_id,\n public_id,\n from_contact_id,\n threshold,\n announcement_share,\n public_key_verified_timestamp\n FROM user_discovery_other_promotions\n WHERE public_id = ?\n ",
"describe": {
"columns": [
{
"name": "promotion_id",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "user_discovery_other_promotions",
"name": "promotion_id"
}
}
},
{
"name": "public_id",
"ordinal": 1,
"type_info": "Integer",
"origin": {
"Table": {
"table": "user_discovery_other_promotions",
"name": "public_id"
}
}
},
{
"name": "from_contact_id",
"ordinal": 2,
"type_info": "Integer",
"origin": {
"Table": {
"table": "user_discovery_other_promotions",
"name": "from_contact_id"
}
}
},
{
"name": "threshold",
"ordinal": 3,
"type_info": "Integer",
"origin": {
"Table": {
"table": "user_discovery_other_promotions",
"name": "threshold"
}
}
},
{
"name": "announcement_share",
"ordinal": 4,
"type_info": "Blob",
"origin": {
"Table": {
"table": "user_discovery_other_promotions",
"name": "announcement_share"
}
}
},
{
"name": "public_key_verified_timestamp",
"ordinal": 5,
"type_info": "Integer",
"origin": {
"Table": {
"table": "user_discovery_other_promotions",
"name": "public_key_verified_timestamp"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
false,
true
]
},
"hash": "229b83c3c0777a900e71c71d83920a2b93f5df34f29921263f0c960fe0a34ceb"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n UPDATE contacts\n SET user_discovery_version = ?\n WHERE user_id = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "3462ce3b677445a641de96634d6f69a661f2b58cbfff84eac55eaec901a54284"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO user_discovery_shares (share) VALUES (?)",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "385a7075d70cf7ce1194c6ee5757d2ab4145f9bb2d1f65512f17dca5a0e2ed15"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n UPDATE user_discovery_own_promotions\n SET promotion = X''\n WHERE contact_id = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "5a7ba59111d6353f7a18d3f8eb8fa1ae3a491f080a4735361e9aaa41ccaa7d51"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n UPDATE user_discovery_shares\n SET contact_id = ?\n WHERE share_id = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "629c9b14b6e65f3209752e66b4f7709d252feae82dd009ce628274ed9b9c680a"
}

View file

@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "\n SELECT user_discovery_version\n FROM contacts\n WHERE user_id = ?\n ",
"describe": {
"columns": [
{
"name": "user_discovery_version",
"ordinal": 0,
"type_info": "Blob",
"origin": {
"Table": {
"table": "contacts",
"name": "user_discovery_version"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
true
]
},
"hash": "70f9d68d251f1c354c61584a84f509682e341595e0d98cb880c50ec80f11f02a"
}

View file

@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "\n SELECT promotion\n FROM user_discovery_own_promotions\n WHERE contact_id = ?\n ORDER BY version_id DESC\n LIMIT 1\n ",
"describe": {
"columns": [
{
"name": "promotion",
"ordinal": 0,
"type_info": "Blob",
"origin": {
"Table": {
"table": "user_discovery_own_promotions",
"name": "promotion"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "9d236335cde01fe13d6836309e7a4bcb8485282d2230d2504350642ce1236169"
}

View file

@ -0,0 +1,50 @@
{
"db_name": "SQLite",
"query": "\n SELECT\n announced_user_id,\n announced_public_key,\n public_id\n FROM user_discovery_announced_users\n WHERE public_id = ?\n ",
"describe": {
"columns": [
{
"name": "announced_user_id",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "user_discovery_announced_users",
"name": "announced_user_id"
}
}
},
{
"name": "announced_public_key",
"ordinal": 1,
"type_info": "Blob",
"origin": {
"Table": {
"table": "user_discovery_announced_users",
"name": "announced_public_key"
}
}
},
{
"name": "public_id",
"ordinal": 2,
"type_info": "Integer",
"origin": {
"Table": {
"table": "user_discovery_announced_users",
"name": "public_id"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false
]
},
"hash": "9f78cc93c1680c63c1a258ca2ce1de0ea43bf3a57382b0505828d34c562f96a6"
}

View file

@ -0,0 +1,38 @@
{
"db_name": "SQLite",
"query": "\n SELECT share_id, share\n FROM user_discovery_shares\n WHERE contact_id IS NULL\n LIMIT 1\n ",
"describe": {
"columns": [
{
"name": "share_id",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "user_discovery_shares",
"name": "share_id"
}
}
},
{
"name": "share",
"ordinal": 1,
"type_info": "Blob",
"origin": {
"Table": {
"table": "user_discovery_shares",
"name": "share"
}
}
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false
]
},
"hash": "bd4750be71cfd64ab00d5a84d28f9df5cca1e61750a186328c65d384cfcefa15"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO user_discovery_other_promotions (\n from_contact_id,\n promotion_id,\n public_id,\n threshold,\n announcement_share,\n public_key_verified_timestamp\n ) VALUES (?, ?, ?, ?, ?, ?)\n ON CONFLICT(from_contact_id, public_id) DO UPDATE SET\n promotion_id = excluded.promotion_id,\n threshold = excluded.threshold,\n announcement_share = excluded.announcement_share,\n public_key_verified_timestamp = excluded.public_key_verified_timestamp\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 6
},
"nullable": []
},
"hash": "ced6f5964217225fb749bde7641da1f7bf365a53a4d06aa2696a1d24c2b398f7"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM user_discovery_shares",
"describe": {
"columns": [],
"parameters": {
"Right": 0
},
"nullable": []
},
"hash": "d36767ac50047037ce2122bd59b84c94407918776b0ad2b8c47a1bfb041b620b"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO user_discovery_announced_users (\n announced_user_id,\n announced_public_key,\n public_id\n ) VALUES (?, ?, ?)\n ON CONFLICT DO UPDATE SET\n announced_user_id = excluded.announced_user_id,\n announced_public_key = excluded.announced_public_key,\n public_id = excluded.public_id\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "d8a516dd12b7f5d49807b1b5c6970f0e004f9c39a23577473d348ebb6e2b044a"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO user_discovery_own_promotions (\n contact_id,\n promotion\n ) VALUES (?, ?)\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "e026e8c5eb30ca2f7c98c0d4e8f9ccdf388bf7e98f4336ada2ac09224461082f"
}

View file

@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "\n SELECT share\n FROM user_discovery_shares\n WHERE contact_id = ?\n LIMIT 1\n ",
"describe": {
"columns": [
{
"name": "share",
"ordinal": 0,
"type_info": "Blob",
"origin": {
"Table": {
"table": "user_discovery_shares",
"name": "share"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "f939d4c2915e167dbcbac32fe36c5caa4719f1a75693e6e2b6bb804ea9f790ed"
}

21
rust/scripts/prepare_sqlx.sh Executable file
View file

@ -0,0 +1,21 @@
#!/bin/sh
set -eu
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
rust_dir=$(dirname -- "$script_dir")
database_path="$rust_dir/sqlx-dev.sqlite"
rm -f -- "$database_path"
for migration in "$rust_dir"/src/database/app/migrations/*.sql; do
sqlite3 "$database_path" ".read $migration"
done
for migration in "$rust_dir"/src/database/signal/migrations/*.sql; do
sqlite3 "$database_path" ".read $migration"
done
cd "$rust_dir"
cargo sqlx prepare -- --lib
echo "Prepared SQLx schema database and offline query metadata."

View file

@ -1,7 +1,11 @@
use crate::context::Context; use crate::context::Context;
use crate::database::Database; use crate::database::app::APP_DATABASE_FILE;
use crate::database::signal::Database;
use crate::error::Result; use crate::error::Result;
use crate::keys::{DatabaseKey, KeyManager}; use crate::keys::{DatabaseKey, KeyManager};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::fs::{remove_file, File}; use std::fs::{remove_file, File};
use std::io::{copy, Cursor}; use std::io::{copy, Cursor};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@ -12,6 +16,23 @@ use zip::{CompressionMethod, ZipArchive, ZipWriter};
pub(crate) struct BackupArchive {} pub(crate) struct BackupArchive {}
const BACKUP_MANIFEST_FILE: &str = "backup-manifest.json";
const BACKUP_FORMAT_VERSION: u32 = 2;
#[derive(Debug, Serialize, Deserialize)]
struct BackupManifest {
format_version: u32,
drift_schema_version: u32,
app_schema_version: i64,
files: BTreeMap<String, BackupManifestFile>,
}
#[derive(Debug, Serialize, Deserialize)]
struct BackupManifestFile {
size: u64,
sha256: String,
}
impl BackupArchive { impl BackupArchive {
#[allow(clippy::type_complexity)] #[allow(clippy::type_complexity)]
fn get_backup_files( fn get_backup_files(
@ -22,10 +43,17 @@ impl BackupArchive {
let database_dir = PathBuf::from(&config.database_dir); let database_dir = PathBuf::from(&config.database_dir);
let data_dir = PathBuf::from(&config.data_dir); let data_dir = PathBuf::from(&config.data_dir);
let rust_db_key = keys.main_key.get_database_key(DatabaseKey::RustDb); let rust_db_key = keys.main_key.get_database_key(DatabaseKey::RustDb);
let app_db_key = keys.main_key.get_database_key(DatabaseKey::AppDb);
Ok(vec![ Ok(vec![
("twonly.sqlite", database_dir.clone(), true, None), ("twonly.sqlite", database_dir.clone(), true, None),
("rust_db.sqlite", database_dir, true, Some(rust_db_key)), (
"rust_db.sqlite",
database_dir.clone(),
true,
Some(rust_db_key),
),
(APP_DATABASE_FILE, database_dir, true, Some(app_db_key)),
("user_discovery_config.json", data_dir.clone(), false, None), ("user_discovery_config.json", data_dir.clone(), false, None),
("user.json", data_dir.join("keyvalue"), false, None), ("user.json", data_dir.join("keyvalue"), false, None),
]) ])
@ -56,6 +84,30 @@ impl BackupArchive {
} }
if is_db { if is_db {
if file_name == APP_DATABASE_FILE {
let backup_database_file = backup_data_dir.join(file_name);
let app_database = ctx.get_app_database().await;
app_database
.create_backup(
&backup_database_file.display().to_string(),
encryption_key.as_deref().ok_or_else(|| {
crate::error::TwonlyError::Generic(
"Missing app database backup key".to_owned(),
)
})?,
)
.await?;
let backup_db = Database::new(
&backup_database_file.display().to_string(),
encryption_key.as_deref(),
true,
)
.await?;
backup_db.check_integrity().await?;
backup_db.pool.close().await;
encryption_key.zeroize();
continue;
}
// To avoid write-lock conflicts with Dart (which has the live database open in write mode), // To avoid write-lock conflicts with Dart (which has the live database open in write mode),
// we copy the database file first, then open the copy to perform the backup. // we copy the database file first, then open the copy to perform the backup.
let temp_copy_path = backup_data_dir.join(format!("{}.temp_copy", file_name)); let temp_copy_path = backup_data_dir.join(format!("{}.temp_copy", file_name));
@ -87,6 +139,8 @@ impl BackupArchive {
encryption_key.zeroize(); encryption_key.zeroize();
} }
Self::write_manifest(&backup_data_dir)?;
let mut zip_data = Vec::new(); let mut zip_data = Vec::new();
{ {
@ -147,6 +201,60 @@ impl BackupArchive {
} }
} }
Self::validate_manifest(&restore_temp_dir)?;
let restored_app_database = restore_temp_dir.join(APP_DATABASE_FILE);
let has_app_database = restored_app_database.exists();
let app_database_key = key_manager.main_key.get_database_key(DatabaseKey::AppDb);
if !has_app_database {
// A format-v1 archive has no app_db.sqlite. Build and verify it in
// staging before touching any active database file.
let staged_app_database = crate::database::app::AppDatabase::new(
&restored_app_database.display().to_string(),
Some(&app_database_key),
false,
)
.await?;
staged_app_database.run_migrations().await?;
staged_app_database
.import_legacy(&restore_temp_dir.join("twonly.sqlite"))
.await?;
staged_app_database.pool.close().await;
}
let staged_app_database = crate::database::app::AppDatabase::new(
&restored_app_database.display().to_string(),
Some(&app_database_key),
true,
)
.await?;
let integrity: String = sqlx::query_scalar("PRAGMA integrity_check")
.fetch_one(&staged_app_database.pool)
.await?;
staged_app_database.pool.close().await;
if integrity.to_lowercase() != "ok" {
return Err(crate::error::TwonlyError::Generic(format!(
"Staged app database integrity check failed: {integrity}"
)));
}
let rust_database_key = key_manager.main_key.get_database_key(DatabaseKey::RustDb);
let staged_rust_database_path = restore_temp_dir.join("rust_db.sqlite");
let staged_rust_database = Database::new(
&staged_rust_database_path.display().to_string(),
Some(&rust_database_key),
true,
)
.await?;
staged_rust_database.check_integrity().await?;
staged_rust_database.pool.close().await;
// app_db.sqlite is owned by a replaceable Rust handle. Close it before
// replacing the file so subsequent DAO calls cannot continue using an
// unlinked pre-restore database.
let current_app_database = ctx.get_app_database().await;
current_app_database.pool.close().await;
let current_rust_database = ctx.get_rust_database().await;
current_rust_database.pool.close().await;
for (file_name, target_dir, is_db, _) in Self::get_backup_files(ctx, &key_manager)? { for (file_name, target_dir, is_db, _) in Self::get_backup_files(ctx, &key_manager)? {
let src = restore_temp_dir.join(file_name); let src = restore_temp_dir.join(file_name);
if src.exists() { if src.exists() {
@ -163,17 +271,102 @@ impl BackupArchive {
} }
} }
let database_dir = PathBuf::from(&ctx.get_config()?.database_dir);
let app_database_path = database_dir.join(APP_DATABASE_FILE);
let app_database = crate::database::app::AppDatabase::new(
&app_database_path.display().to_string(),
Some(&app_database_key),
false,
)
.await?;
app_database.run_migrations().await?;
ctx.replace_app_database(app_database).await;
let rust_database_path = database_dir.join("rust_db.sqlite");
let rust_database = Database::new(
&rust_database_path.display().to_string(),
Some(&rust_database_key),
false,
)
.await?;
rust_database.run_migrations().await?;
ctx.replace_rust_database(rust_database, &key_manager)
.await?;
std::fs::remove_dir_all(&restore_temp_dir)?; std::fs::remove_dir_all(&restore_temp_dir)?;
Ok(()) Ok(())
} }
fn write_manifest(directory: &Path) -> Result<()> {
let mut files = BTreeMap::new();
for entry in WalkDir::new(directory).min_depth(1).max_depth(1) {
let entry = entry?;
if !entry.path().is_file() || entry.file_name() == BACKUP_MANIFEST_FILE {
continue;
}
let bytes = std::fs::read(entry.path())?;
files.insert(
entry.file_name().to_string_lossy().to_string(),
BackupManifestFile {
size: bytes.len() as u64,
sha256: hex::encode(Sha256::digest(&bytes)),
},
);
}
let manifest = BackupManifest {
format_version: BACKUP_FORMAT_VERSION,
drift_schema_version: 25,
app_schema_version: crate::database::app::APP_SCHEMA_VERSION,
files,
};
std::fs::write(
directory.join(BACKUP_MANIFEST_FILE),
serde_json::to_vec_pretty(&manifest)
.map_err(|error| crate::error::TwonlyError::Generic(error.to_string()))?,
)?;
Ok(())
}
fn validate_manifest(directory: &Path) -> Result<()> {
let path = directory.join(BACKUP_MANIFEST_FILE);
if !path.exists() {
// Format v1 archives predate the manifest and are migrated from
// twonly.sqlite below.
return Ok(());
}
let manifest: BackupManifest =
serde_json::from_slice(&std::fs::read(path)?).map_err(|error| {
crate::error::TwonlyError::Generic(format!("Invalid backup manifest: {error}"))
})?;
if manifest.format_version != BACKUP_FORMAT_VERSION {
return Err(crate::error::TwonlyError::Generic(format!(
"Unsupported backup format {}",
manifest.format_version
)));
}
for (name, expected) in manifest.files {
let file = directory.join(&name);
if !file.exists() {
return Err(crate::error::TwonlyError::Generic(format!(
"Backup entry {name} is missing"
)));
}
let bytes = std::fs::read(file)?;
let actual_hash = hex::encode(Sha256::digest(&bytes));
if bytes.len() as u64 != expected.size || actual_hash != expected.sha256 {
return Err(crate::error::TwonlyError::Generic(format!(
"Backup entry {name} failed checksum validation"
)));
}
}
Ok(())
}
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::{ use crate::secure_storage::SecureStorage;
database::tables::received_messages::ReceivedMessage, secure_storage::SecureStorage,
};
use super::*; use super::*;
use tempfile::tempdir; use tempfile::tempdir;
@ -195,27 +388,21 @@ mod tests {
let original_login_token = { let original_login_token = {
let secure_storage = SecureStorage::new("testing"); let secure_storage = SecureStorage::new("testing");
let config = ctx.get_config().unwrap(); let config = ctx.get_config().unwrap();
let rust_db_path = PathBuf::from(&config.database_dir).join("rust_db.sqlite");
let key_manager = ctx.get_key_manager().await.unwrap(); let key_manager = ctx.get_key_manager().await.unwrap();
key_manager.store_to_keychain(&secure_storage).unwrap(); key_manager.store_to_keychain(&secure_storage).unwrap();
let db = Database::new(
&rust_db_path.display().to_string(),
Some(&key_manager.main_key.get_database_key(DatabaseKey::RustDb)),
false,
)
.await
.unwrap();
ReceivedMessage::insert(&db.pool, 1, b"original message")
.await
.unwrap();
// Add a file // Add a file
let config_file = PathBuf::from(&config.data_dir).join("user_discovery_config.json"); let config_file = PathBuf::from(&config.data_dir).join("user_discovery_config.json");
std::fs::write(config_file, "original config").unwrap(); std::fs::write(config_file, "original config").unwrap();
key_manager.main_key.get_login_token() key_manager.main_key.get_login_token()
}; };
{
let app_db = ctx.get_app_database().await;
sqlx::query("INSERT INTO contacts(user_id, username) VALUES(1, 'original contact')")
.execute(&app_db.pool)
.await
.unwrap();
}
// 2. Create backup // 2. Create backup
let backup_path = BackupArchive::create_backup(&ctx).await.unwrap(); let backup_path = BackupArchive::create_backup(&ctx).await.unwrap();
@ -224,22 +411,15 @@ mod tests {
// 3. Modify data (to simulate state before restore) // 3. Modify data (to simulate state before restore)
{ {
let config = ctx.get_config().unwrap(); let config = ctx.get_config().unwrap();
let rust_db_path = PathBuf::from(&config.database_dir).join("rust_db.sqlite");
let key_manager = ctx.get_key_manager().await.unwrap();
let db = Database::new(
&rust_db_path.display().to_string(),
Some(&key_manager.main_key.get_database_key(DatabaseKey::RustDb)),
false,
)
.await
.unwrap();
ReceivedMessage::insert(&db.pool, 2, b"new message")
.await
.unwrap();
let config_file = PathBuf::from(&config.data_dir).join("user_discovery_config.json"); let config_file = PathBuf::from(&config.data_dir).join("user_discovery_config.json");
std::fs::write(config_file, "new config").unwrap(); std::fs::write(config_file, "new config").unwrap();
let app_db = ctx.get_app_database().await;
sqlx::query("UPDATE contacts SET username = 'changed contact' WHERE user_id = 1")
.execute(&app_db.pool)
.await
.unwrap();
} }
// 4. Restore backup // 4. Restore backup
@ -250,27 +430,100 @@ mod tests {
// 5. Verify restored data // 5. Verify restored data
{ {
let config = ctx.get_config().unwrap(); let config = ctx.get_config().unwrap();
let rust_db_path = PathBuf::from(&config.database_dir).join("rust_db.sqlite");
let key_manager = ctx.get_key_manager().await.unwrap(); let key_manager = ctx.get_key_manager().await.unwrap();
let db = Database::new(
&rust_db_path.display().to_string(),
Some(&key_manager.main_key.get_database_key(DatabaseKey::RustDb)),
false,
)
.await
.unwrap();
let messages = ReceivedMessage::get_all(&db.pool).await.unwrap();
// Should only have the original message because restore overwrites
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].sender_id, 1);
assert_eq!(messages[0].content, b"original message");
let config_file = PathBuf::from(&config.data_dir).join("user_discovery_config.json"); let config_file = PathBuf::from(&config.data_dir).join("user_discovery_config.json");
let config_content = std::fs::read_to_string(config_file).unwrap(); let config_content = std::fs::read_to_string(config_file).unwrap();
assert_eq!(config_content, "original config"); assert_eq!(config_content, "original config");
assert_eq!(key_manager.main_key.get_login_token(), original_login_token); assert_eq!(key_manager.main_key.get_login_token(), original_login_token);
let app_db = ctx.get_app_database().await;
let username: String =
sqlx::query_scalar("SELECT username FROM contacts WHERE user_id = 1")
.fetch_one(&app_db.pool)
.await
.unwrap();
assert_eq!(username, "original contact");
} }
} }
#[tokio::test]
async fn restores_pre_app_database_backup_by_importing_twonly_sqlite() {
let _ = pretty_env_logger::try_init();
let temp_dir = tempdir().unwrap();
let ctx = Context::init_for_testing(
temp_dir.path().join("database"),
temp_dir.path().join("data"),
)
.await
.unwrap();
let database_dir = PathBuf::from(&ctx.get_config().unwrap().database_dir);
let legacy_path = database_dir.join("twonly.sqlite");
let legacy =
crate::database::app::AppDatabase::new(&legacy_path.display().to_string(), None, false)
.await
.unwrap();
legacy.run_migrations().await.unwrap();
sqlx::query("PRAGMA user_version = 25")
.execute(&legacy.pool)
.await
.unwrap();
sqlx::query("INSERT INTO contacts(user_id, username) VALUES(99, 'from old backup')")
.execute(&legacy.pool)
.await
.unwrap();
legacy.pool.close().await;
let archive_path = BackupArchive::create_backup(&ctx).await.unwrap();
remove_file_from_encrypted_archive(&ctx, &archive_path, APP_DATABASE_FILE).await;
let app_db = ctx.get_app_database().await;
sqlx::query("INSERT INTO contacts(user_id, username) VALUES(1, 'current data')")
.execute(&app_db.pool)
.await
.unwrap();
BackupArchive::restore_from_backup(&ctx, &archive_path)
.await
.unwrap();
let restored = ctx.get_app_database().await;
let contacts: Vec<(i64, String)> =
sqlx::query_as("SELECT user_id, username FROM contacts ORDER BY user_id")
.fetch_all(&restored.pool)
.await
.unwrap();
assert_eq!(contacts, vec![(99, "from old backup".to_owned())]);
}
async fn remove_file_from_encrypted_archive(
ctx: &Context,
archive_path: &Path,
excluded_name: &str,
) {
let keys = ctx.get_key_manager().await.unwrap();
let encrypted = std::fs::read(archive_path).unwrap();
let decrypted = keys.main_key.decrypt_backup(&encrypted).unwrap();
let mut source = ZipArchive::new(Cursor::new(decrypted)).unwrap();
let mut rebuilt = Vec::new();
{
let mut writer = ZipWriter::new(Cursor::new(&mut rebuilt));
let options =
SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
for index in 0..source.len() {
let mut entry = source.by_index(index).unwrap();
if entry.name() == excluded_name
|| entry.name() == BACKUP_MANIFEST_FILE
|| !entry.is_file()
{
continue;
}
writer.start_file(entry.name(), options).unwrap();
copy(&mut entry, &mut writer).unwrap();
}
writer.finish().unwrap();
}
std::fs::write(archive_path, keys.main_key.encrypt_backup(&rebuilt)).unwrap();
}
} }

View file

@ -1,8 +1,5 @@
pub(crate) mod log; pub(crate) mod log;
mod macros; mod macros;
pub(crate) mod user_discovery;
use crate::user_discovery::traits::{AnnouncedUser, OtherPromotion};
use flutter_rust_bridge::DartFnFuture; use flutter_rust_bridge::DartFnFuture;
use crate::error::{Result, TwonlyError}; use crate::error::{Result, TwonlyError};
@ -23,25 +20,6 @@ callback_generator! {
FlutterCallbacks { FlutterCallbacks {
Logging logging { Logging logging {
get_stream_sink: () => StreamSink<String> get_stream_sink: () => StreamSink<String>
},
UserDiscoveryCallbacks user_discovery {
// UserDiscoveryUtils
sign_data: (Vec<u8>) => Option<Vec<u8>>,
verify_signature: (Vec<u8>, Vec<u8>, Vec<u8>) => bool,
verify_stored_pubkey: (i64, Vec<u8>) => bool,
// UserDiscoveryStore
set_shares: (Vec<Vec<u8>>) => bool,
get_share_for_contact: (i64) => Option<Vec<u8>>,
push_own_promotion_and_clear_old_version: (i64, i64, Vec<u8>) => bool,
get_own_promotions_after_version: (i64) => Option<Vec<Vec<u8>>>,
store_other_promotion: (OtherPromotion) => bool,
get_other_promotions_by_public_id: (i64) => Option<Vec<OtherPromotion>>,
get_announced_user_by_public_id: (i64) => Option<AnnouncedUser>,
get_contact_version: (i64) => Option<Vec<u8>>,
set_contact_version: (i64, Vec<u8>) => bool,
push_new_user_relation: (i64, AnnouncedUser, Option<i64>) => bool,
get_contact_promotion: (i64) => Option<Vec<u8>>
} }
} }
} }

View file

@ -1,174 +0,0 @@
use crate::bridge::callbacks::get_callbacks;
use crate::bridge::get_twonly_flutter;
use crate::error::TwonlyError;
use crate::user_discovery::error::{Result, UserDiscoveryError};
use crate::user_discovery::traits::UserDiscoveryUtils;
use crate::user_discovery::traits::{AnnouncedUser, OtherPromotion, UserDiscoveryStore};
#[cfg(test)]
use std::collections::HashMap;
use std::path::PathBuf;
#[derive(Clone)]
pub(crate) struct UserDiscoveryStoreFlutter {}
pub(crate) struct UserDiscoveryUtilsFlutter {}
impl UserDiscoveryUtils for UserDiscoveryUtilsFlutter {
async fn sign_data(&self, input_data: &[u8]) -> Result<Vec<u8>> {
match (get_callbacks()?.user_discovery.sign_data)(input_data.to_vec()).await {
Some(signature) => Ok(signature),
None => Err(TwonlyError::DartError)?,
}
}
async fn verify_signature(
&self,
input_data: &[u8],
pubkey: &[u8],
signature: &[u8],
) -> Result<bool> {
Ok((get_callbacks()?.user_discovery.verify_signature)(
input_data.to_vec(),
pubkey.to_vec(),
signature.to_vec(),
)
.await)
}
async fn verify_stored_pubkey(&self, from_contact_id: i64, pubkey: &[u8]) -> Result<bool> {
Ok(
(get_callbacks()?.user_discovery.verify_stored_pubkey)(
from_contact_id,
pubkey.to_vec(),
)
.await,
)
}
}
impl UserDiscoveryStore for UserDiscoveryStoreFlutter {
async fn get_config(&self) -> Result<String> {
let ws = get_twonly_flutter()?;
let config_path = PathBuf::from(&ws.config.data_dir).join("user_discovery_config.json");
if !config_path.is_file() {
return Err(UserDiscoveryError::NotInitialized);
}
Ok(std::fs::read_to_string(&config_path)?)
}
async fn update_config(&self, update: String) -> Result<()> {
tracing::debug!("Updating configuration file.");
let ws = get_twonly_flutter()?;
let config_path = PathBuf::from(&ws.config.data_dir).join("user_discovery_config.json");
std::fs::write(config_path, &update)?;
Ok(())
}
async fn set_shares(&self, shares: Vec<Vec<u8>>) -> Result<()> {
(get_callbacks()?.user_discovery.set_shares)(shares).await;
Ok(())
}
async fn get_share_for_contact(&self, contact_id: i64) -> Result<Vec<u8>> {
match (get_callbacks()?.user_discovery.get_share_for_contact)(contact_id).await {
Some(share) => Ok(share),
None => Err(UserDiscoveryError::NoSharesLeft),
}
}
async fn push_own_promotion_and_clear_old_version(
&self,
contact_id: i64,
version: u32,
promotion: Vec<u8>,
) -> Result<()> {
(get_callbacks()?
.user_discovery
.push_own_promotion_and_clear_old_version)(contact_id, version as i64, promotion)
.await
.then_some(())
.ok_or(TwonlyError::DartError.into())
}
async fn get_own_promotions_after_version(&self, version: u32) -> Result<Vec<Vec<u8>>> {
match (get_callbacks()?
.user_discovery
.get_own_promotions_after_version)(version as i64)
.await
{
Some(share) => Ok(share),
None => Err(TwonlyError::DartError)?,
}
}
async fn store_other_promotion(&self, promotion: OtherPromotion) -> Result<()> {
(get_callbacks()?.user_discovery.store_other_promotion)(promotion)
.await
.then_some(())
.ok_or(TwonlyError::DartError.into())
}
async fn get_other_promotions_by_public_id(
&self,
public_id: i64,
) -> Result<Vec<OtherPromotion>> {
match (get_callbacks()?
.user_discovery
.get_other_promotions_by_public_id)(public_id)
.await
{
Some(promotions) => Ok(promotions),
None => Err(TwonlyError::DartError)?,
}
}
async fn get_announced_user_by_public_id(
&self,
public_id: i64,
) -> Result<Option<AnnouncedUser>> {
Ok((get_callbacks()?
.user_discovery
.get_announced_user_by_public_id)(public_id)
.await)
}
async fn push_new_user_relation(
&self,
from_contact_id: i64,
announced_user: AnnouncedUser,
public_key_verified_timestamp: Option<i64>,
) -> Result<()> {
(get_callbacks()?.user_discovery.push_new_user_relation)(
from_contact_id,
announced_user,
public_key_verified_timestamp,
)
.await
.then_some(())
.ok_or(TwonlyError::DartError.into())
}
#[cfg(test)]
async fn get_all_announced_users(
&self,
) -> Result<HashMap<AnnouncedUser, Vec<(i64, Option<i64>)>>> {
// This is never called from the RUST code.
Err(TwonlyError::DartError)?
}
async fn get_contact_version(&self, contact_id: i64) -> Result<Option<Vec<u8>>> {
Ok((get_callbacks()?.user_discovery.get_contact_version)(contact_id).await)
}
async fn set_contact_version(&self, contact_id: i64, update: Vec<u8>) -> Result<()> {
(get_callbacks()?.user_discovery.set_contact_version)(contact_id, update)
.await
.then_some(())
.ok_or(TwonlyError::DartError.into())
}
async fn get_contact_promotion(&self, contact_id: i64) -> Result<Option<Vec<u8>>> {
Ok((get_callbacks()?.user_discovery.get_contact_promotion)(contact_id).await)
}
}

View file

@ -4,23 +4,22 @@ pub mod wrapper;
use std::sync::Arc; use std::sync::Arc;
use crate::bridge::callbacks::user_discovery::{
UserDiscoveryStoreFlutter, UserDiscoveryUtilsFlutter,
};
use crate::context::Context; use crate::context::Context;
use crate::database::Database; use crate::database::app::AppDatabase;
use crate::database::signal::Database;
use crate::error::Result; use crate::error::Result;
use crate::error::TwonlyError; use crate::error::TwonlyError;
use crate::keys::KeyManager; use crate::keys::KeyManager;
use crate::secure_storage::SecureStorage; use crate::secure_storage::SecureStorage;
use crate::signal::engine::RustSignalEngine; use crate::signal::engine::RustSignalEngine;
use crate::user_discovery::stores::{NativeUserDiscoveryStore, NativeUserDiscoveryUtils};
use crate::user_discovery::UserDiscovery; use crate::user_discovery::UserDiscovery;
use crate::utils::Shared; use crate::utils::Shared;
use flutter_rust_bridge::frb; use flutter_rust_bridge::frb;
pub use crate::user_discovery::traits::AnnouncedUser; pub use crate::user_discovery::traits::AnnouncedUser;
pub use crate::user_discovery::traits::OtherPromotion; pub use crate::user_discovery::traits::OtherPromotion;
use tokio::sync::Mutex; use tokio::sync::{Mutex, RwLock};
pub struct InitConfig { pub struct InitConfig {
pub database_dir: String, pub database_dir: String,
@ -48,9 +47,10 @@ pub(crate) struct TwonlyFlutter {
#[allow(dead_code)] #[allow(dead_code)]
pub(crate) config: InitConfig, pub(crate) config: InitConfig,
pub(crate) user_discovery: pub(crate) user_discovery:
Shared<UserDiscovery<UserDiscoveryStoreFlutter, UserDiscoveryUtilsFlutter>>, Shared<UserDiscovery<NativeUserDiscoveryStore, NativeUserDiscoveryUtils>>,
#[allow(dead_code)] #[allow(dead_code)]
pub(crate) rust_db: Arc<Database>, pub(crate) rust_db: Arc<RwLock<Arc<Database>>>,
pub(crate) app_db: Arc<RwLock<Arc<AppDatabase>>>,
pub(crate) secure_storage: SecureStorage, pub(crate) secure_storage: SecureStorage,
pub(crate) key_manager: Arc<Mutex<KeyManager>>, pub(crate) key_manager: Arc<Mutex<KeyManager>>,
pub(crate) signal_engine: Arc<Mutex<Option<RustSignalEngine>>>, pub(crate) signal_engine: Arc<Mutex<Option<RustSignalEngine>>>,

View file

@ -0,0 +1,73 @@
use std::path::PathBuf;
use crate::bridge::get_twonly_flutter;
pub use crate::database::app::{SqlExecutionResult, SqlRow, SqlRows, SqlValue};
use crate::error::Result;
pub struct RustAppDatabase {}
pub struct LegacyMigrationReport {
pub legacy_version: i64,
pub tables: Vec<LegacyTableMigrationCount>,
}
pub struct LegacyTableMigrationCount {
pub table: String,
pub rows: i64,
}
impl RustAppDatabase {
/// Imports the non-Signal tables from a Drift v25 database. The import is
/// transactional and idempotent. Drift must be closed while this runs.
pub async fn migrate_legacy_database() -> Result<LegacyMigrationReport> {
let context = get_twonly_flutter()?;
let legacy_path = PathBuf::from(&context.config.database_dir).join("twonly.sqlite");
let app_db = context.app_db.read().await.clone();
let report = if legacy_path.exists() {
app_db.import_legacy(&legacy_path).await?
} else {
app_db.complete_empty_legacy_import().await?
};
Ok(LegacyMigrationReport {
legacy_version: report.legacy_version,
tables: report
.tables
.into_iter()
.map(|entry| LegacyTableMigrationCount {
table: entry.table,
rows: entry.rows,
})
.collect(),
})
}
pub async fn legacy_import_complete() -> Result<bool> {
get_twonly_flutter()?
.app_db
.read()
.await
.is_legacy_import_complete()
.await
}
pub async fn select(statement: String, arguments: Vec<SqlValue>) -> Result<SqlRows> {
get_twonly_flutter()?
.app_db
.read()
.await
.raw_select(statement, arguments)
.await
}
pub async fn execute(
statement: String,
arguments: Vec<SqlValue>,
) -> Result<SqlExecutionResult> {
get_twonly_flutter()?
.app_db
.read()
.await
.raw_execute(statement, arguments)
.await
}
}

View file

@ -1,3 +1,4 @@
pub mod app_database;
pub mod backup; pub mod backup;
pub mod key_manager; pub mod key_manager;
pub mod signal; pub mod signal;

View file

@ -8,9 +8,7 @@ impl RustSignal {
pub async fn generate_bundle() -> Result<FrbPreKeyBundle> { pub async fn generate_bundle() -> Result<FrbPreKeyBundle> {
let guard = get_twonly_flutter()?.signal_engine.lock().await; let guard = get_twonly_flutter()?.signal_engine.lock().await;
let engine = guard.as_ref().ok_or(TwonlyError::Initialization)?; let engine = guard.as_ref().ok_or(TwonlyError::Initialization)?;
engine engine.generate_bundle().await
.generate_bundle()
.await
} }
pub async fn generate_pqc_prekeys() -> Result<Vec<FrbPqcPreKey>> { pub async fn generate_pqc_prekeys() -> Result<Vec<FrbPqcPreKey>> {

View file

@ -1,21 +1,31 @@
use crate::database::app::{AppDatabase, APP_DATABASE_FILE};
use crate::signal::engine::RustSignalEngine; use crate::signal::engine::RustSignalEngine;
use crate::user_discovery::stores::{NativeUserDiscoveryStore, NativeUserDiscoveryUtils};
use crate::user_discovery::UserDiscovery; use crate::user_discovery::UserDiscovery;
use crate::{ use crate::{
bridge::{ bridge::InitConfig,
callbacks::user_discovery::{UserDiscoveryStoreFlutter, UserDiscoveryUtilsFlutter}, database::signal::Database,
InitConfig,
},
database::Database,
error::{Result, TwonlyError}, error::{Result, TwonlyError},
keys::{DatabaseKey, KeyManager}, keys::{DatabaseKey, KeyManager},
log::init_tracing, log::init_tracing,
utils::Shared, utils::Shared,
}; };
use std::{path::PathBuf, sync::Arc}; use std::{path::PathBuf, sync::Arc};
use tokio::sync::{Mutex, OnceCell}; use tokio::sync::{Mutex, OnceCell, RwLock};
use zeroize::Zeroize; use zeroize::Zeroize;
use crate::{bridge::TwonlyFlutter, secure_storage::SecureStorage, standalone::TwonlyStandalone}; use crate::{bridge::TwonlyFlutter, secure_storage::SecureStorage};
pub(crate) struct TwonlyStandalone {
#[allow(dead_code)]
pub(crate) config: InitConfig,
#[allow(dead_code)]
pub(crate) rust_db: Arc<RwLock<Arc<Database>>>,
pub(crate) app_db: Arc<RwLock<Arc<AppDatabase>>>,
#[allow(dead_code)]
pub(crate) secure_storage: SecureStorage,
pub(crate) key_manager: Arc<Mutex<KeyManager>>,
}
pub(crate) enum Context { pub(crate) enum Context {
Flutter(TwonlyFlutter), Flutter(TwonlyFlutter),
@ -73,9 +83,20 @@ impl Context {
rust_db.run_migrations().await?; rust_db.run_migrations().await?;
let rust_db = Arc::new(rust_db); let rust_db = Arc::new(rust_db);
let app_db_path = database_dir.join(APP_DATABASE_FILE);
let app_db = AppDatabase::new(
&app_db_path.display().to_string(),
Some(&key_manager.main_key.get_database_key(DatabaseKey::AppDb)),
false,
)
.await?;
app_db.run_migrations().await?;
let app_db = Arc::new(RwLock::new(Arc::new(app_db)));
Ok(Context::from_standalone(TwonlyStandalone { Ok(Context::from_standalone(TwonlyStandalone {
config, config,
rust_db, rust_db: Arc::new(RwLock::new(rust_db)),
app_db,
secure_storage, secure_storage,
key_manager: Arc::new(Mutex::new(key_manager)), key_manager: Arc::new(Mutex::new(key_manager)),
})) }))
@ -100,6 +121,7 @@ impl Context {
let database_dir = PathBuf::from(&config.database_dir.clone()); let database_dir = PathBuf::from(&config.database_dir.clone());
let rust_db_path = database_dir.join("rust_db.sqlite"); let rust_db_path = database_dir.join("rust_db.sqlite");
let app_db_path = database_dir.join(APP_DATABASE_FILE);
tracing::info!("Initialized twonly workspace."); tracing::info!("Initialized twonly workspace.");
let res: Result<&'static Context> = GLOBAL_CONTEXT let res: Result<&'static Context> = GLOBAL_CONTEXT
@ -129,36 +151,59 @@ impl Context {
.await?; .await?;
rust_db.run_migrations().await?; rust_db.run_migrations().await?;
let rust_db = Arc::new(rust_db); let rust_db = Arc::new(rust_db);
let rust_db_handle = Arc::new(RwLock::new(rust_db));
let mut app_db_key = key_manager.main_key.get_database_key(DatabaseKey::AppDb);
let app_db = AppDatabase::new(
&app_db_path.display().to_string(),
Some(app_db_key.as_str()),
false,
)
.await?;
app_db.run_migrations().await?;
let app_db = Arc::new(RwLock::new(Arc::new(app_db)));
app_db_key.zeroize();
rust_db_key.zeroize(); rust_db_key.zeroize();
if is_flutter { if is_flutter {
let mut signal_engine = Arc::default(); let key_manager = Arc::new(Mutex::new(key_manager));
if let Some(user_id) = key_manager.user_id { let signal_engine = {
if let Some(signal_identity) = &key_manager.signal_identity { let key_manager_guard = key_manager.lock().await;
signal_engine = Arc::new(Mutex::new(Some(RustSignalEngine::new_with_pool( let engine = match (
rust_db.pool.clone(), key_manager_guard.user_id,
&key_manager_guard.signal_identity,
) {
(Some(user_id), Some(signal_identity)) => {
Some(RustSignalEngine::new_with_pool(
rust_db_handle.read().await.pool.clone(),
signal_identity.identity_key_pair_structure.clone(), signal_identity.identity_key_pair_structure.clone(),
signal_identity.registration_id as u32, signal_identity.registration_id as u32,
user_id.to_string(), user_id.to_string(),
)?))); )?)
} }
} _ => None,
};
Arc::new(Mutex::new(engine))
};
let user_discovery = Shared::new(UserDiscovery::new(
NativeUserDiscoveryStore::new(app_db.clone(), &config.data_dir),
NativeUserDiscoveryUtils::new(key_manager.clone(), rust_db_handle.clone()),
)?);
Ok(Context::Flutter(TwonlyFlutter { Ok(Context::Flutter(TwonlyFlutter {
config, config,
secure_storage, secure_storage,
rust_db, rust_db: rust_db_handle,
key_manager: Arc::new(Mutex::new(key_manager)), app_db,
user_discovery: Shared::new(UserDiscovery::new( key_manager,
UserDiscoveryStoreFlutter {}, user_discovery,
UserDiscoveryUtilsFlutter {},
)?),
signal_engine, signal_engine,
})) }))
} else { } else {
Ok(Context::Standalone(TwonlyStandalone { Ok(Context::Standalone(TwonlyStandalone {
config, config,
rust_db, rust_db: rust_db_handle,
app_db,
key_manager: Arc::new(Mutex::new(key_manager)), key_manager: Arc::new(Mutex::new(key_manager)),
secure_storage, secure_storage,
})) }))
@ -193,4 +238,50 @@ impl Context {
Self::Standalone(twonly) => Ok(twonly.key_manager.lock().await), Self::Standalone(twonly) => Ok(twonly.key_manager.lock().await),
} }
} }
pub(crate) async fn get_app_database(&self) -> Arc<AppDatabase> {
match self {
Self::Flutter(twonly) => twonly.app_db.read().await.clone(),
Self::Standalone(twonly) => twonly.app_db.read().await.clone(),
}
}
pub(crate) async fn get_rust_database(&self) -> Arc<Database> {
match self {
Self::Flutter(twonly) => twonly.rust_db.read().await.clone(),
Self::Standalone(twonly) => twonly.rust_db.read().await.clone(),
}
}
pub(crate) async fn replace_rust_database(
&self,
database: Database,
key_manager: &KeyManager,
) -> Result<()> {
let database = Arc::new(database);
match self {
Self::Flutter(twonly) => {
*twonly.rust_db.write().await = database.clone();
let engine = match (key_manager.user_id, &key_manager.signal_identity) {
(Some(user_id), Some(identity)) => Some(RustSignalEngine::new_with_pool(
database.pool.clone(),
identity.identity_key_pair_structure.clone(),
identity.registration_id as u32,
user_id.to_string(),
)?),
_ => None,
};
*twonly.signal_engine.lock().await = engine;
}
Self::Standalone(twonly) => *twonly.rust_db.write().await = database,
}
Ok(())
}
pub(crate) async fn replace_app_database(&self, database: AppDatabase) {
match self {
Self::Flutter(twonly) => *twonly.app_db.write().await = Arc::new(database),
Self::Standalone(twonly) => *twonly.app_db.write().await = Arc::new(database),
}
}
} }

View file

@ -0,0 +1,354 @@
use super::{AppDatabase, MigrationReport, TableMigrationCount, APPLICATION_TABLES};
use crate::error::{Result, TwonlyError};
use sqlx::{Acquire, AssertSqlSafe, Row};
use std::collections::BTreeSet;
use std::path::Path;
impl AppDatabase {
pub async fn is_legacy_import_complete(&self) -> Result<bool> {
let value = sqlx::query_scalar::<_, String>(
"SELECT value FROM app_metadata WHERE key = 'legacy_import_complete'",
)
.fetch_optional(&self.pool)
.await?;
Ok(value.as_deref() == Some("1"))
}
pub async fn complete_empty_legacy_import(&self) -> Result<MigrationReport> {
let mut transaction = self.pool.begin().await?;
sqlx::query("INSERT INTO app_metadata(key, value) VALUES('legacy_schema_version', '25') ON CONFLICT(key) DO UPDATE SET value = excluded.value").execute(&mut *transaction).await?;
sqlx::query("INSERT INTO app_metadata(key, value) VALUES('legacy_import_complete', '1') ON CONFLICT(key) DO UPDATE SET value = excluded.value").execute(&mut *transaction).await?;
transaction.commit().await?;
self.migration_report().await
}
pub async fn import_legacy(&self, legacy_path: &Path) -> Result<MigrationReport> {
if self.is_legacy_import_complete().await? {
return self.migration_report().await;
}
if !legacy_path.exists() {
return Err(TwonlyError::DatabaseNotFound);
}
let mut connection = self.pool.acquire().await?;
sqlx::query("ATTACH DATABASE ? AS legacy KEY ''")
.bind(legacy_path.to_string_lossy().as_ref())
.execute(&mut *connection)
.await?;
let import_result = import(&mut connection).await;
let _ = sqlx::query("DETACH DATABASE legacy")
.execute(&mut *connection)
.await;
let report = import_result?;
self.notify_committed(APPLICATION_TABLES.iter().copied());
Ok(report)
}
async fn migration_report(&self) -> Result<MigrationReport> {
let legacy_version = sqlx::query_scalar::<_, String>(
"SELECT value FROM app_metadata WHERE key = 'legacy_schema_version'",
)
.fetch_optional(&self.pool)
.await?
.and_then(|value| value.parse().ok())
.unwrap_or(25);
let mut tables = Vec::with_capacity(APPLICATION_TABLES.len());
for table in APPLICATION_TABLES {
let rows = sqlx::query_scalar::<_, i64>(AssertSqlSafe(format!(
"SELECT COUNT(*) FROM \"{table}\""
)))
.fetch_one(&self.pool)
.await?;
tables.push(TableMigrationCount {
table: (*table).to_owned(),
rows,
});
}
Ok(MigrationReport {
legacy_version,
tables,
})
}
}
async fn import(
connection: &mut sqlx::pool::PoolConnection<sqlx::Sqlite>,
) -> Result<MigrationReport> {
let legacy_version: i64 = sqlx::query_scalar("PRAGMA legacy.user_version")
.fetch_one(&mut **connection)
.await?;
if legacy_version != 25 {
return Err(TwonlyError::Generic(format!("Legacy database must be upgraded to Drift schema 25 before import; found {legacy_version}")));
}
let mut tx = connection.begin().await?;
sqlx::query("PRAGMA defer_foreign_keys = ON")
.execute(&mut *tx)
.await?;
let mut counts = Vec::with_capacity(APPLICATION_TABLES.len());
for table in APPLICATION_TABLES {
let columns = common_columns(&mut tx, table).await?;
if columns.is_empty() {
return Err(TwonlyError::Generic(format!(
"No importable columns found for {table}"
)));
}
let quoted = columns
.iter()
.map(|column| format!("\"{}\"", column.replace('"', "\"\"")))
.collect::<Vec<_>>()
.join(", ");
sqlx::query(AssertSqlSafe(format!("INSERT OR REPLACE INTO main.\"{table}\" ({quoted}) SELECT {quoted} FROM legacy.\"{table}\""))).execute(&mut *tx).await?;
let source_count: i64 = sqlx::query_scalar(AssertSqlSafe(format!(
"SELECT COUNT(*) FROM legacy.\"{table}\""
)))
.fetch_one(&mut *tx)
.await?;
let target_count: i64 = sqlx::query_scalar(AssertSqlSafe(format!(
"SELECT COUNT(*) FROM main.\"{table}\""
)))
.fetch_one(&mut *tx)
.await?;
if source_count != target_count {
return Err(TwonlyError::Generic(format!(
"Row count mismatch for {table}: source={source_count}, target={target_count}"
)));
}
let mismatch: Option<i64> = sqlx::query_scalar(AssertSqlSafe(format!("SELECT 1 FROM (SELECT {quoted} FROM main.\"{table}\" EXCEPT SELECT {quoted} FROM legacy.\"{table}\") LIMIT 1"))).fetch_optional(&mut *tx).await?;
let reverse_mismatch: Option<i64> = sqlx::query_scalar(AssertSqlSafe(format!("SELECT 1 FROM (SELECT {quoted} FROM legacy.\"{table}\" EXCEPT SELECT {quoted} FROM main.\"{table}\") LIMIT 1"))).fetch_optional(&mut *tx).await?;
if mismatch.is_some() || reverse_mismatch.is_some() {
return Err(TwonlyError::Generic(format!(
"Data mismatch while importing {table}"
)));
}
counts.push(TableMigrationCount {
table: (*table).to_owned(),
rows: source_count,
});
}
copy_sequences(&mut tx).await?;
let foreign_key_errors: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM pragma_foreign_key_check")
.fetch_one(&mut *tx)
.await?;
if foreign_key_errors != 0 {
return Err(TwonlyError::Generic(format!(
"Imported database has {foreign_key_errors} foreign-key violations"
)));
}
sqlx::query("INSERT INTO app_metadata(key, value) VALUES('legacy_schema_version', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").bind(legacy_version.to_string()).execute(&mut *tx).await?;
sqlx::query("INSERT INTO app_metadata(key, value) VALUES('legacy_import_complete', '1') ON CONFLICT(key) DO UPDATE SET value = '1'").execute(&mut *tx).await?;
tx.commit().await?;
Ok(MigrationReport {
legacy_version,
tables: counts,
})
}
async fn common_columns(
connection: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
table: &str,
) -> Result<Vec<String>> {
let source_rows = sqlx::query(AssertSqlSafe(format!(
"PRAGMA legacy.table_info(\"{table}\")"
)))
.fetch_all(&mut **connection)
.await?;
if source_rows.is_empty() {
return Err(TwonlyError::Generic(format!(
"Legacy database is missing table {table}"
)));
}
let source: BTreeSet<String> = source_rows
.iter()
.map(|row| row.get::<String, _>("name"))
.collect();
let target_rows = sqlx::query(AssertSqlSafe(format!(
"PRAGMA main.table_info(\"{table}\")"
)))
.fetch_all(&mut **connection)
.await?;
Ok(target_rows
.iter()
.map(|row| row.get::<String, _>("name"))
.filter(|column| source.contains(column))
.collect())
}
async fn copy_sequences(connection: &mut sqlx::Transaction<'_, sqlx::Sqlite>) -> Result<()> {
for table in [
"message_histories",
"key_verifications",
"verification_tokens",
"user_discovery_own_promotions",
"user_discovery_shares",
"shortcuts",
"labels",
] {
let source_sequence: Option<i64> =
sqlx::query_scalar("SELECT seq FROM legacy.sqlite_sequence WHERE name = ?")
.bind(table)
.fetch_optional(&mut **connection)
.await?;
if let Some(sequence) = source_sequence {
sqlx::query("DELETE FROM main.sqlite_sequence WHERE name = ?")
.bind(table)
.execute(&mut **connection)
.await?;
sqlx::query("INSERT INTO main.sqlite_sequence(name, seq) VALUES(?, ?)")
.bind(table)
.bind(sequence)
.execute(&mut **connection)
.await?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::database::app::{SqlValue, APPLICATION_TABLES};
use sqlx::{AssertSqlSafe, SqlitePool};
use tempfile::tempdir;
#[tokio::test]
async fn imports_all_application_tables_and_is_idempotent() {
let directory = tempdir().unwrap();
let legacy_path = directory.path().join("twonly.sqlite");
let target_path = directory.path().join("app_db.sqlite");
let legacy = AppDatabase::new(legacy_path.to_str().unwrap(), None, false)
.await
.unwrap();
legacy.run_migrations().await.unwrap();
sqlx::query("PRAGMA user_version = 25")
.execute(&legacy.pool)
.await
.unwrap();
populate_every_table(&legacy.pool).await;
create_and_populate_drift_signal_tables(&legacy.pool).await;
legacy.pool.close().await;
let target = AppDatabase::new(target_path.to_str().unwrap(), Some("test-key"), false)
.await
.unwrap();
target.run_migrations().await.unwrap();
let first = target.import_legacy(&legacy_path).await.unwrap();
assert_eq!(first.tables.len(), APPLICATION_TABLES.len());
assert!(first.tables.iter().all(|entry| entry.rows == 1));
let second = target.import_legacy(&legacy_path).await.unwrap();
assert_eq!(first, second);
assert!(target.is_legacy_import_complete().await.unwrap());
let selected = target
.raw_select(
"SELECT username, avatar_svg_compressed FROM contacts WHERE user_id = ?".to_owned(),
vec![SqlValue::integer(7)],
)
.await
.unwrap();
assert_eq!(selected.columns, vec!["username", "avatar_svg_compressed"]);
assert_eq!(
selected.rows[0].values[0],
SqlValue::text("alice".to_owned())
);
assert_eq!(selected.rows[0].values[1], SqlValue::blob(vec![0, 1, 255]));
target
.raw_execute("BEGIN TRANSACTION".to_owned(), vec![])
.await
.unwrap();
target
.raw_execute(
"UPDATE contacts SET username = ? WHERE user_id = ?".to_owned(),
vec![SqlValue::text("changed".to_owned()), SqlValue::integer(7)],
)
.await
.unwrap();
target
.raw_execute("ROLLBACK".to_owned(), vec![])
.await
.unwrap();
let username: String =
sqlx::query_scalar("SELECT username FROM contacts WHERE user_id = 7")
.fetch_one(&target.pool)
.await
.unwrap();
assert_eq!(username, "alice");
let legacy_check = AppDatabase::new(legacy_path.to_str().unwrap(), None, true)
.await
.unwrap();
let signal_checks = [
(
"signal_identity_key_stores",
"identity_key",
vec![0, 1, 2, 255],
),
("signal_pre_key_stores", "pre_key", vec![3, 4, 5]),
("signal_sender_key_stores", "sender_key", vec![6, 7, 8]),
("signal_session_stores", "session_record", vec![9, 10, 11]),
(
"signal_signed_pre_key_stores",
"signed_pre_key",
vec![12, 13, 14],
),
];
for (table, column, expected) in signal_checks {
let actual: Vec<u8> = sqlx::query_scalar(AssertSqlSafe(format!(
"SELECT {column} FROM {table} LIMIT 1"
)))
.fetch_one(&legacy_check.pool)
.await
.unwrap();
assert_eq!(actual, expected, "Signal data changed in {table}");
}
}
async fn populate_every_table(pool: &SqlitePool) {
let statements = [
"INSERT INTO contacts(user_id, username, avatar_svg_compressed, signal_version) VALUES(7, 'alice', x'0001FF', 'v2')",
"INSERT INTO groups(group_id, group_name) VALUES('group-1', 'Friends')",
"INSERT INTO media_files(media_id, type, encryption_key) VALUES('media-1', 'image', x'1020')",
"INSERT INTO messages(group_id, message_id, sender_id, type, content, media_id) VALUES('group-1', 'message-1', 7, 'media', '', 'media-1')",
"INSERT INTO message_histories(id, message_id, contact_id, content) VALUES(11, 'message-1', 7, NULL)",
"INSERT INTO reactions(message_id, emoji, sender_id) VALUES('message-1', '👍', 7)",
"INSERT INTO group_members(group_id, contact_id, member_state) VALUES('group-1', 7, 'admin')",
"INSERT INTO receipts(receipt_id, contact_id, message_id, message) VALUES('receipt-1', 7, 'message-1', x'00FF')",
"INSERT INTO received_receipts(receipt_id) VALUES('received-1')",
"INSERT INTO message_actions(message_id, contact_id, type) VALUES('message-1', 7, 'openedAt')",
"INSERT INTO group_histories(group_history_id, group_id, contact_id, type) VALUES('history-1', 'group-1', 7, 'createdGroup')",
"INSERT INTO key_verifications(verification_id, contact_id, type, verified_by) VALUES(13, 7, 'qrScanned', NULL)",
"INSERT INTO verification_tokens(token_id, token) VALUES(17, x'ABCDEF')",
"INSERT INTO user_discovery_announced_users(announced_user_id, announced_public_key, public_id, username) VALUES(19, x'01', 23, 'bob')",
"INSERT INTO user_discovery_user_relations(announced_user_id, from_contact_id) VALUES(19, 7)",
"INSERT INTO user_discovery_other_promotions(from_contact_id, promotion_id, public_id, threshold, announcement_share) VALUES(7, 29, 31, 3, x'02')",
"INSERT INTO user_discovery_own_promotions(version_id, contact_id, promotion) VALUES(37, 7, x'03')",
"INSERT INTO user_discovery_shares(share_id, share, contact_id) VALUES(41, x'04', 7)",
"INSERT INTO shortcuts(id, emoji, usage_counter) VALUES(43, '🔥', 5)",
"INSERT INTO shortcut_members(shortcut_id, group_id) VALUES(43, 'group-1')",
"INSERT INTO labels(id, name, text_color, background_color) VALUES(47, 'Family', 1, 2)",
"INSERT INTO contact_labels(contact_id, label_id) VALUES(7, 47)",
];
for statement in statements {
sqlx::query(statement).execute(pool).await.unwrap();
}
}
async fn create_and_populate_drift_signal_tables(pool: &SqlitePool) {
let statements = [
"CREATE TABLE signal_identity_key_stores(device_id INTEGER NOT NULL, name TEXT NOT NULL, identity_key BLOB NOT NULL, created_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s', CURRENT_TIMESTAMP) AS INTEGER)), PRIMARY KEY(device_id, name))",
"CREATE TABLE signal_pre_key_stores(pre_key_id INTEGER NOT NULL PRIMARY KEY, pre_key BLOB NOT NULL, created_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s', CURRENT_TIMESTAMP) AS INTEGER)))",
"CREATE TABLE signal_sender_key_stores(sender_key_name TEXT NOT NULL PRIMARY KEY, sender_key BLOB NOT NULL)",
"CREATE TABLE signal_session_stores(device_id INTEGER NOT NULL, name TEXT NOT NULL, session_record BLOB NOT NULL, created_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s', CURRENT_TIMESTAMP) AS INTEGER)), PRIMARY KEY(device_id, name))",
"CREATE TABLE signal_signed_pre_key_stores(signed_pre_key_id INTEGER NOT NULL PRIMARY KEY, signed_pre_key BLOB NOT NULL, created_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s', CURRENT_TIMESTAMP) AS INTEGER)))",
"INSERT INTO signal_identity_key_stores(device_id, name, identity_key) VALUES(1, 'alice', x'000102FF')",
"INSERT INTO signal_pre_key_stores(pre_key_id, pre_key) VALUES(2, x'030405')",
"INSERT INTO signal_sender_key_stores(sender_key_name, sender_key) VALUES('group', x'060708')",
"INSERT INTO signal_session_stores(device_id, name, session_record) VALUES(3, 'bob', x'090A0B')",
"INSERT INTO signal_signed_pre_key_stores(signed_pre_key_id, signed_pre_key) VALUES(4, x'0C0D0E')",
];
for statement in statements {
sqlx::query(statement).execute(pool).await.unwrap();
}
}
}

View file

@ -0,0 +1,258 @@
CREATE TABLE contacts (
user_id INTEGER NOT NULL PRIMARY KEY,
username TEXT NOT NULL,
display_name TEXT,
nick_name TEXT,
avatar_svg_compressed BLOB,
sender_profile_counter INTEGER NOT NULL DEFAULT 0,
accepted INTEGER NOT NULL DEFAULT 0 CHECK (accepted IN (0, 1)),
deleted_by_user INTEGER NOT NULL DEFAULT 0 CHECK (deleted_by_user IN (0, 1)),
requested INTEGER NOT NULL DEFAULT 0 CHECK (requested IN (0, 1)),
blocked INTEGER NOT NULL DEFAULT 0 CHECK (blocked IN (0, 1)),
verified INTEGER NOT NULL DEFAULT 0 CHECK (verified IN (0, 1)),
account_deleted INTEGER NOT NULL DEFAULT 0 CHECK (account_deleted IN (0, 1)),
created_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s', CURRENT_TIMESTAMP) AS INTEGER)),
signal_version TEXT NOT NULL DEFAULT 'v1',
user_discovery_version BLOB,
user_discovery_excluded INTEGER NOT NULL DEFAULT 0 CHECK (user_discovery_excluded IN (0, 1)),
user_discovery_manual_approved INTEGER DEFAULT 0 CHECK (user_discovery_manual_approved IN (0, 1)),
recovery_is_trusted_friend INTEGER NOT NULL DEFAULT 0 CHECK (recovery_is_trusted_friend IN (0, 1)),
recovery_last_heartbeat INTEGER,
recovery_secret_share BLOB,
recovery_contacts_secret_share BLOB,
recovery_contacts_last_heartbeat INTEGER,
recovery_contacts_threshold INTEGER,
ask_for_friend_promotions INTEGER CHECK (ask_for_friend_promotions IN (0, 1)),
media_send_counter INTEGER NOT NULL DEFAULT 0,
media_received_counter INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE groups (
group_id TEXT NOT NULL PRIMARY KEY,
is_group_admin INTEGER NOT NULL DEFAULT 0 CHECK (is_group_admin IN (0, 1)),
is_direct_chat INTEGER NOT NULL DEFAULT 0 CHECK (is_direct_chat IN (0, 1)),
pinned INTEGER NOT NULL DEFAULT 0 CHECK (pinned IN (0, 1)),
archived INTEGER NOT NULL DEFAULT 0 CHECK (archived IN (0, 1)),
joined_group INTEGER NOT NULL DEFAULT 0 CHECK (joined_group IN (0, 1)),
left_group INTEGER NOT NULL DEFAULT 0 CHECK (left_group IN (0, 1)),
deleted_content INTEGER NOT NULL DEFAULT 0 CHECK (deleted_content IN (0, 1)),
state_version_id INTEGER NOT NULL DEFAULT 0,
state_encryption_key BLOB,
my_group_private_key BLOB,
group_name TEXT NOT NULL,
draft_message TEXT,
total_media_counter INTEGER NOT NULL DEFAULT 0,
also_best_friend INTEGER NOT NULL DEFAULT 0 CHECK (also_best_friend IN (0, 1)),
delete_messages_after_milliseconds INTEGER NOT NULL DEFAULT 86400000,
created_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s', CURRENT_TIMESTAMP) AS INTEGER)),
last_message_send INTEGER,
last_message_received INTEGER,
last_flame_counter_change INTEGER,
last_flame_sync INTEGER,
flame_counter INTEGER NOT NULL DEFAULT 0,
max_flame_counter INTEGER NOT NULL DEFAULT 0,
max_flame_counter_from INTEGER,
last_message_exchange INTEGER NOT NULL DEFAULT (CAST(strftime('%s', CURRENT_TIMESTAMP) AS INTEGER))
);
CREATE TABLE media_files (
media_id TEXT NOT NULL PRIMARY KEY,
type TEXT NOT NULL,
upload_state TEXT,
cloud_state TEXT NOT NULL DEFAULT 'none',
blurhash TEXT,
download_state TEXT,
requires_authentication INTEGER NOT NULL DEFAULT 0 CHECK (requires_authentication IN (0, 1)),
stored INTEGER NOT NULL DEFAULT 0 CHECK (stored IN (0, 1)),
is_draft_media INTEGER NOT NULL DEFAULT 0 CHECK (is_draft_media IN (0, 1)),
is_favorite INTEGER NOT NULL DEFAULT 0 CHECK (is_favorite IN (0, 1)),
has_crop_analyzed INTEGER NOT NULL DEFAULT 0 CHECK (has_crop_analyzed IN (0, 1)),
pre_progressing_process INTEGER,
reupload_requested_by TEXT,
display_limit_in_milliseconds INTEGER,
remove_audio INTEGER CHECK (remove_audio IN (0, 1)),
download_token BLOB,
encryption_key BLOB,
encryption_mac BLOB,
encryption_nonce BLOB,
stored_file_hash BLOB,
has_thumbnail INTEGER NOT NULL DEFAULT 0 CHECK (has_thumbnail IN (0, 1)),
size_in_bytes INTEGER,
created_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s', CURRENT_TIMESTAMP) AS INTEGER)),
created_at_month TEXT
);
CREATE TABLE messages (
group_id TEXT NOT NULL REFERENCES groups(group_id) ON DELETE CASCADE,
message_id TEXT NOT NULL PRIMARY KEY,
sender_id INTEGER REFERENCES contacts(user_id),
type TEXT NOT NULL,
content TEXT,
media_id TEXT REFERENCES media_files(media_id) ON DELETE SET NULL,
additional_message_data BLOB,
media_stored INTEGER NOT NULL DEFAULT 0 CHECK (media_stored IN (0, 1)),
media_reopened INTEGER NOT NULL DEFAULT 0 CHECK (media_reopened IN (0, 1)),
download_token BLOB,
quotes_message_id TEXT,
is_deleted_from_sender INTEGER NOT NULL DEFAULT 0 CHECK (is_deleted_from_sender IN (0, 1)),
opened_at INTEGER,
opened_by_all INTEGER,
created_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s', CURRENT_TIMESTAMP) AS INTEGER)),
modified_at INTEGER,
ack_by_user INTEGER,
ack_by_server INTEGER
);
CREATE INDEX idx_messages_group_id_created_at ON messages(group_id, created_at);
CREATE TABLE message_histories (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
message_id TEXT NOT NULL REFERENCES messages(message_id) ON DELETE CASCADE,
contact_id INTEGER REFERENCES contacts(user_id) ON DELETE CASCADE,
content TEXT,
created_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s', CURRENT_TIMESTAMP) AS INTEGER))
);
CREATE TABLE reactions (
message_id TEXT NOT NULL REFERENCES messages(message_id) ON DELETE CASCADE,
emoji TEXT NOT NULL,
sender_id INTEGER REFERENCES contacts(user_id) ON DELETE CASCADE,
created_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s', CURRENT_TIMESTAMP) AS INTEGER)),
PRIMARY KEY (message_id, sender_id, emoji)
);
CREATE TABLE group_members (
group_id TEXT NOT NULL REFERENCES groups(group_id) ON DELETE CASCADE,
contact_id INTEGER NOT NULL REFERENCES contacts(user_id),
member_state TEXT,
group_public_key BLOB,
last_chat_opened INTEGER,
last_type_indicator INTEGER,
last_message INTEGER,
created_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s', CURRENT_TIMESTAMP) AS INTEGER)),
PRIMARY KEY (group_id, contact_id)
);
CREATE TABLE receipts (
receipt_id TEXT NOT NULL PRIMARY KEY,
contact_id INTEGER NOT NULL REFERENCES contacts(user_id) ON DELETE CASCADE,
message_id TEXT REFERENCES messages(message_id) ON DELETE CASCADE,
message BLOB NOT NULL,
contact_will_sends_receipt INTEGER NOT NULL DEFAULT 1 CHECK (contact_will_sends_receipt IN (0, 1)),
will_be_retried_by_media_upload INTEGER NOT NULL DEFAULT 0 CHECK (will_be_retried_by_media_upload IN (0, 1)),
mark_for_retry INTEGER,
mark_for_retry_after_accepted INTEGER,
ack_by_server_at INTEGER,
retry_count INTEGER NOT NULL DEFAULT 0,
last_retry INTEGER,
created_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s', CURRENT_TIMESTAMP) AS INTEGER))
);
CREATE INDEX idx_receipts_message_id ON receipts(message_id);
CREATE TABLE received_receipts (
receipt_id TEXT NOT NULL PRIMARY KEY,
created_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s', CURRENT_TIMESTAMP) AS INTEGER))
);
CREATE TABLE message_actions (
message_id TEXT NOT NULL REFERENCES messages(message_id) ON DELETE CASCADE,
contact_id INTEGER NOT NULL REFERENCES contacts(user_id) ON DELETE CASCADE,
type TEXT NOT NULL,
action_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s', CURRENT_TIMESTAMP) AS INTEGER)),
PRIMARY KEY (message_id, contact_id, type)
);
CREATE TABLE group_histories (
group_history_id TEXT NOT NULL PRIMARY KEY,
group_id TEXT NOT NULL REFERENCES groups(group_id) ON DELETE CASCADE,
contact_id INTEGER REFERENCES contacts(user_id),
affected_contact_id INTEGER,
old_group_name TEXT,
new_group_name TEXT,
new_delete_messages_after_milliseconds INTEGER,
type TEXT NOT NULL,
action_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s', CURRENT_TIMESTAMP) AS INTEGER))
);
CREATE TABLE key_verifications (
verification_id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
contact_id INTEGER NOT NULL REFERENCES contacts(user_id) ON DELETE CASCADE,
type TEXT NOT NULL,
verified_by INTEGER REFERENCES contacts(user_id) ON DELETE CASCADE,
created_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s', CURRENT_TIMESTAMP) AS INTEGER))
);
CREATE TABLE verification_tokens (
token_id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
token BLOB NOT NULL,
created_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s', CURRENT_TIMESTAMP) AS INTEGER))
);
CREATE TABLE user_discovery_announced_users (
announced_user_id INTEGER NOT NULL PRIMARY KEY,
announced_public_key BLOB NOT NULL,
public_id INTEGER NOT NULL UNIQUE,
username TEXT,
was_shown_to_the_user INTEGER NOT NULL DEFAULT 0 CHECK (was_shown_to_the_user IN (0, 1)),
is_hidden INTEGER NOT NULL DEFAULT 0 CHECK (is_hidden IN (0, 1)),
was_asked_friends INTEGER NOT NULL DEFAULT 0 CHECK (was_asked_friends IN (0, 1))
);
CREATE TABLE user_discovery_user_relations (
announced_user_id INTEGER NOT NULL REFERENCES user_discovery_announced_users(announced_user_id) ON DELETE CASCADE,
from_contact_id INTEGER NOT NULL REFERENCES contacts(user_id) ON DELETE CASCADE,
public_key_verified_timestamp INTEGER,
PRIMARY KEY (announced_user_id, from_contact_id)
);
CREATE TABLE user_discovery_other_promotions (
from_contact_id INTEGER NOT NULL REFERENCES contacts(user_id) ON DELETE CASCADE,
promotion_id INTEGER NOT NULL,
public_id INTEGER NOT NULL,
threshold INTEGER NOT NULL,
announcement_share BLOB NOT NULL,
public_key_verified_timestamp INTEGER,
PRIMARY KEY (from_contact_id, public_id)
);
CREATE TABLE user_discovery_own_promotions (
version_id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
contact_id INTEGER NOT NULL REFERENCES contacts(user_id) ON DELETE CASCADE,
promotion BLOB NOT NULL
);
CREATE TABLE user_discovery_shares (
share_id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
share BLOB NOT NULL,
contact_id INTEGER REFERENCES contacts(user_id) ON DELETE CASCADE
);
CREATE TABLE shortcuts (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
emoji TEXT NOT NULL UNIQUE,
usage_counter INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE shortcut_members (
shortcut_id INTEGER NOT NULL REFERENCES shortcuts(id) ON DELETE CASCADE,
group_id TEXT NOT NULL REFERENCES groups(group_id) ON DELETE CASCADE,
PRIMARY KEY (shortcut_id, group_id)
);
CREATE TABLE labels (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
text_color INTEGER NOT NULL,
background_color INTEGER NOT NULL,
created_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s', CURRENT_TIMESTAMP) AS INTEGER))
);
CREATE TABLE contact_labels (
contact_id INTEGER NOT NULL REFERENCES contacts(user_id) ON DELETE CASCADE,
label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
PRIMARY KEY (contact_id, label_id)
);
CREATE TABLE app_metadata (
key TEXT NOT NULL PRIMARY KEY,
value TEXT NOT NULL
);

View file

@ -0,0 +1,300 @@
use crate::error::{Result, TwonlyError};
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use sqlx::{AssertSqlSafe, Column, ConnectOptions, Row, SqlitePool, TypeInfo, ValueRef};
use std::collections::BTreeSet;
use std::str::FromStr;
use std::time::Duration;
use tokio::sync::broadcast;
mod legacy_import;
pub const APP_DATABASE_FILE: &str = "app_db.sqlite";
pub const APP_SCHEMA_VERSION: i64 = 1;
pub const APPLICATION_TABLES: &[&str] = &[
"contacts",
"groups",
"media_files",
"messages",
"message_histories",
"reactions",
"group_members",
"receipts",
"received_receipts",
"message_actions",
"group_histories",
"key_verifications",
"verification_tokens",
"user_discovery_announced_users",
"user_discovery_user_relations",
"user_discovery_other_promotions",
"user_discovery_own_promotions",
"user_discovery_shares",
"shortcuts",
"shortcut_members",
"labels",
"contact_labels",
];
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DatabaseChange {
pub tables: BTreeSet<String>,
}
pub struct AppDatabase {
pub pool: SqlitePool,
changes: broadcast::Sender<DatabaseChange>,
}
impl AppDatabase {
pub async fn new(db_path: &str, encryption_key: Option<&str>, read_only: bool) -> Result<Self> {
let db_url = format!("sqlite://{db_path}");
let mut options = SqliteConnectOptions::from_str(&format!("{db_url}?mode=rwc"))?
.create_if_missing(!read_only)
.journal_mode(sqlx::sqlite::SqliteJournalMode::Delete)
.foreign_keys(true)
.read_only(read_only)
.busy_timeout(Duration::from_secs(30))
.pragma("synchronous", "FULL")
.log_statements(tracing::log::LevelFilter::Off)
.log_slow_statements(tracing::log::LevelFilter::Warn, Duration::from_millis(500));
if let Some(key) = encryption_key {
options = options.pragma("key", format!("'{key}'"));
}
let pool = SqlitePoolOptions::new()
// The compatibility executor uses statement-based transactions.
// Keeping one connection guarantees BEGIN, all statements, and
// COMMIT are executed on that same native connection.
.max_connections(1)
.acquire_timeout(Duration::from_secs(30))
.connect_with(options)
.await?;
let (changes, _) = broadcast::channel(256);
Ok(Self { pool, changes })
}
pub async fn run_migrations(&self) -> Result<()> {
sqlx::migrate!("./src/database/app/migrations")
.run(&self.pool)
.await
.map_err(|error| {
TwonlyError::Generic(format!("App database migration failed: {error}"))
})?;
sqlx::query("INSERT INTO app_metadata(key, value) VALUES('schema_version', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")
.bind(APP_SCHEMA_VERSION.to_string())
.execute(&self.pool)
.await?;
Ok(())
}
pub fn subscribe(&self) -> broadcast::Receiver<DatabaseChange> {
self.changes.subscribe()
}
pub fn notify_committed<'a>(&self, tables: impl IntoIterator<Item = &'a str>) {
let tables = tables.into_iter().map(str::to_owned).collect();
let _ = self.changes.send(DatabaseChange { tables });
}
pub async fn raw_select(&self, statement: String, arguments: Vec<SqlValue>) -> Result<SqlRows> {
let rows = bind_arguments(sqlx::query(AssertSqlSafe(statement)), arguments)?
.fetch_all(&self.pool)
.await?;
let columns = rows
.first()
.map(|row| {
row.columns()
.iter()
.map(|column| column.name().to_owned())
.collect()
})
.unwrap_or_default();
let mut output_rows = Vec::with_capacity(rows.len());
for row in rows {
let mut values = Vec::with_capacity(row.len());
for index in 0..row.len() {
let raw = row.try_get_raw(index)?;
let value = if raw.is_null() {
SqlValue::null()
} else {
match raw.type_info().name() {
"INTEGER" | "INT" | "BOOLEAN" | "DATETIME" => {
SqlValue::integer(row.try_get(index)?)
}
"REAL" | "FLOAT" | "DOUBLE" => SqlValue::real(row.try_get(index)?),
"BLOB" => SqlValue::blob(row.try_get(index)?),
_ => SqlValue::text(row.try_get(index)?),
}
};
values.push(value);
}
output_rows.push(SqlRow { values });
}
Ok(SqlRows {
columns,
rows: output_rows,
})
}
pub async fn raw_execute(
&self,
statement: String,
arguments: Vec<SqlValue>,
) -> Result<SqlExecutionResult> {
let result = bind_arguments(sqlx::query(AssertSqlSafe(statement)), arguments)?
.execute(&self.pool)
.await?;
Ok(SqlExecutionResult {
affected_rows: result.rows_affected() as i64,
last_insert_row_id: result.last_insert_rowid(),
})
}
pub async fn create_backup(&self, output_path: &str, encryption_key: &str) -> Result<()> {
let mut connection = self.pool.acquire().await?;
sqlx::query("ATTACH DATABASE ? AS backup KEY ?")
.bind(output_path)
.bind(encryption_key)
.execute(&mut *connection)
.await
.map_err(|error| TwonlyError::Generic(format!("Attach app backup failed: {error}")))?;
let export = sqlx::query("SELECT sqlcipher_export('backup')")
.execute(&mut *connection)
.await;
let detach = sqlx::query("DETACH DATABASE backup")
.execute(&mut *connection)
.await;
export.map_err(|error| TwonlyError::Generic(format!("App export failed: {error}")))?;
detach
.map_err(|error| TwonlyError::Generic(format!("Detach app backup failed: {error}")))?;
Ok(())
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct SqlValue {
/// 0 = null, 1 = integer, 2 = real, 3 = text, 4 = blob.
pub kind: u8,
pub integer_value: Option<i64>,
pub real_value: Option<f64>,
pub text_value: Option<String>,
pub blob_value: Option<Vec<u8>>,
}
impl SqlValue {
fn null() -> Self {
Self {
kind: 0,
integer_value: None,
real_value: None,
text_value: None,
blob_value: None,
}
}
fn integer(value: i64) -> Self {
Self {
kind: 1,
integer_value: Some(value),
real_value: None,
text_value: None,
blob_value: None,
}
}
fn real(value: f64) -> Self {
Self {
kind: 2,
integer_value: None,
real_value: Some(value),
text_value: None,
blob_value: None,
}
}
fn text(value: String) -> Self {
Self {
kind: 3,
integer_value: None,
real_value: None,
text_value: Some(value),
blob_value: None,
}
}
fn blob(value: Vec<u8>) -> Self {
Self {
kind: 4,
integer_value: None,
real_value: None,
text_value: None,
blob_value: Some(value),
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct SqlRows {
pub columns: Vec<String>,
pub rows: Vec<SqlRow>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct SqlRow {
pub values: Vec<SqlValue>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SqlExecutionResult {
pub affected_rows: i64,
pub last_insert_row_id: i64,
}
fn bind_arguments<'q>(
mut query: sqlx::query::Query<'q, sqlx::Sqlite, sqlx::sqlite::SqliteArguments>,
arguments: Vec<SqlValue>,
) -> Result<sqlx::query::Query<'q, sqlx::Sqlite, sqlx::sqlite::SqliteArguments>> {
for argument in arguments {
query = match argument {
SqlValue { kind: 0, .. } => query.bind(Option::<i64>::None),
SqlValue {
kind: 1,
integer_value: Some(value),
..
} => query.bind(value),
SqlValue {
kind: 2,
real_value: Some(value),
..
} => query.bind(value),
SqlValue {
kind: 3,
text_value: Some(value),
..
} => query.bind(value),
SqlValue {
kind: 4,
blob_value: Some(value),
..
} => query.bind(value),
invalid => {
return Err(TwonlyError::Generic(format!(
"Invalid SQL value received from bridge: {invalid:?}"
)))
}
};
}
Ok(query)
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MigrationReport {
pub legacy_version: i64,
pub tables: Vec<TableMigrationCount>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TableMigrationCount {
pub table: String,
pub rows: i64,
}

View file

@ -1,203 +1,2 @@
use crate::error::{Result, TwonlyError}; pub mod app;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; pub mod signal;
use sqlx::{ConnectOptions, SqlitePool};
use std::time::Duration;
pub(crate) mod tables;
pub struct Database {
pub pool: SqlitePool,
}
impl Database {
pub async fn new(
db_path: &String,
encryption_key: Option<&str>,
read_only: bool,
) -> Result<Self> {
let db_url = format!("sqlite://{}", db_path);
let log_statements_level = if std::env::var("SQLX_LOG_STATEMENTS").is_ok() {
tracing::log::LevelFilter::Info
} else {
tracing::log::LevelFilter::Off
};
let mut connect_options = format!("{db_url}?mode=rwc")
.parse::<SqliteConnectOptions>()?
.log_statements(log_statements_level)
.journal_mode(sqlx::sqlite::SqliteJournalMode::Delete)
.foreign_keys(true)
.read_only(read_only)
.busy_timeout(Duration::from_secs(30))
.pragma("synchronous", "FULL")
.pragma("recursive_triggers", "ON")
.log_slow_statements(tracing::log::LevelFilter::Warn, Duration::from_millis(500));
if let Some(encryption_key) = encryption_key {
connect_options = connect_options.pragma("key", format!("'{}'", encryption_key));
}
let pool = SqlitePoolOptions::new()
.acquire_timeout(Duration::from_secs(30))
.max_connections(10)
.connect_with(connect_options)
.await?;
Ok(Self { pool })
}
pub async fn run_migrations(&self) -> Result<()> {
sqlx::migrate!("./src/database/migrations")
.run(&self.pool)
.await
.map_err(|e| {
tracing::error!("migration error: {:?}", e);
TwonlyError::Generic(format!("Migration error: {}", e))
})?;
Ok(())
}
pub(crate) async fn create_backup(
&self,
output_path: &str,
encryption_key: Option<&str>,
) -> Result<()> {
if let Some(key) = encryption_key {
let mut conn = self
.pool
.acquire()
.await
.map_err(|e| TwonlyError::Generic(e.to_string()))?;
sqlx::query("ATTACH DATABASE ? AS backup KEY ?")
.bind(output_path)
.bind(key)
.execute(&mut *conn)
.await
.map_err(|e| TwonlyError::Generic(format!("Attach failed: {}", e)))?;
sqlx::query("SELECT sqlcipher_export('backup')")
.execute(&mut *conn)
.await
.map_err(|e| TwonlyError::Generic(format!("Export failed: {}", e)))?;
sqlx::query("DETACH DATABASE backup")
.execute(&mut *conn)
.await
.map_err(|e| TwonlyError::Generic(format!("Detach failed: {}", e)))?;
} else {
sqlx::query("VACUUM INTO ?")
.bind(output_path)
.execute(&self.pool)
.await
.map_err(|e| TwonlyError::Generic(format!("Backup failed: {}", e)))?;
}
Ok(())
}
pub(crate) async fn check_integrity(&self) -> Result<()> {
let row: (String,) = sqlx::query_as("PRAGMA integrity_check")
.fetch_one(&self.pool)
.await
.map_err(|e| TwonlyError::Generic(format!("Integrity check query failed: {}", e)))?;
if row.0.to_lowercase() == "ok" {
Ok(())
} else {
Err(TwonlyError::Generic(format!(
"Database integrity check failed: {}",
row.0
)))
}
}
}
#[cfg(test)]
mod tests {
use crate::database::tables::received_messages::ReceivedMessage;
use super::*;
use tempfile::tempdir;
#[tokio::test]
async fn test_database_encryption_and_migrations() {
let _ = pretty_env_logger::try_init();
let dir = tempdir().unwrap();
let db_path = dir.path().join("test.sqlite").display().to_string();
let key = "secure_password";
// 1. Create and initialize database with key
let db = Database::new(&db_path, Some(key), false).await.unwrap();
db.run_migrations().await.unwrap();
ReceivedMessage::insert(&db.pool, 1, b"hello world")
.await
.unwrap();
// 2. Try to open with WRONG key
let result = Database::new(&db_path, Some("wrong_password"), false).await;
assert!(
result.is_err(),
"Opening with wrong key should fail. If this passes, the database might not be encrypted!"
);
// 3. Open with CORRECT key again
let db = Database::new(&db_path, Some(key), false).await.unwrap();
let messages = ReceivedMessage::get_all(&db.pool).await.unwrap();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].sender_id, 1);
assert_eq!(messages[0].content, b"hello world");
}
#[tokio::test]
async fn test_database_backup_encrypted() {
let _ = pretty_env_logger::try_init();
let dir = tempdir().unwrap();
let db_path = dir.path().join("test_enc.sqlite").display().to_string();
let backup_path = dir.path().join("backup_enc.sqlite").display().to_string();
let key = "secure_password";
let db = Database::new(&db_path, Some(key), false).await.unwrap();
db.run_migrations().await.unwrap();
ReceivedMessage::insert(&db.pool, 1, b"hello world")
.await
.unwrap();
db.create_backup(&backup_path, Some(key)).await.unwrap();
// 1. Verify it cannot be opened with wrong key
let result = Database::new(&backup_path, Some("wrong_password"), false).await;
assert!(
result.is_err(),
"Encrypted backup should fail with wrong key"
);
// 2. Open backup with correct key and verify data
let backup_db = Database::new(&backup_path, Some(key), false).await.unwrap();
let messages = ReceivedMessage::get_all(&backup_db.pool).await.unwrap();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].sender_id, 1);
}
#[tokio::test]
async fn test_database_backup_plaintext() {
let _ = pretty_env_logger::try_init();
let dir = tempdir().unwrap();
let db_path = dir.path().join("test_plain.sqlite").display().to_string();
let backup_path = dir.path().join("backup_plain.sqlite").display().to_string();
let db = Database::new(&db_path, None, false).await.unwrap();
db.run_migrations().await.unwrap();
ReceivedMessage::insert(&db.pool, 1, b"hello world")
.await
.unwrap();
db.create_backup(&backup_path, None).await.unwrap();
// Open backup and verify
let backup_db = Database::new(&backup_path, None, false).await.unwrap();
let messages = ReceivedMessage::get_all(&backup_db.pool).await.unwrap();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].sender_id, 1);
}
}

View file

@ -0,0 +1 @@
DROP TABLE IF EXISTS received_messages;

View file

@ -0,0 +1,180 @@
use crate::error::{Result, TwonlyError};
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use sqlx::{ConnectOptions, SqlitePool};
use std::time::Duration;
pub struct Database {
pub pool: SqlitePool,
}
impl Database {
pub async fn new(
db_path: &String,
encryption_key: Option<&str>,
read_only: bool,
) -> Result<Self> {
let db_url = format!("sqlite://{}", db_path);
let log_statements_level = if std::env::var("SQLX_LOG_STATEMENTS").is_ok() {
tracing::log::LevelFilter::Info
} else {
tracing::log::LevelFilter::Off
};
let mut connect_options = format!("{db_url}?mode=rwc")
.parse::<SqliteConnectOptions>()?
.log_statements(log_statements_level)
.journal_mode(sqlx::sqlite::SqliteJournalMode::Delete)
.foreign_keys(true)
.read_only(read_only)
.busy_timeout(Duration::from_secs(30))
.pragma("synchronous", "FULL")
.pragma("recursive_triggers", "ON")
.log_slow_statements(tracing::log::LevelFilter::Warn, Duration::from_millis(500));
if let Some(encryption_key) = encryption_key {
connect_options = connect_options.pragma("key", format!("'{}'", encryption_key));
}
let pool = SqlitePoolOptions::new()
.acquire_timeout(Duration::from_secs(30))
.max_connections(10)
.connect_with(connect_options)
.await?;
Ok(Self { pool })
}
pub async fn run_migrations(&self) -> Result<()> {
sqlx::migrate!("./src/database/signal/migrations")
.run(&self.pool)
.await
.map_err(|e| {
tracing::error!("migration error: {:?}", e);
TwonlyError::Generic(format!("Migration error: {}", e))
})?;
Ok(())
}
pub(crate) async fn create_backup(
&self,
output_path: &str,
encryption_key: Option<&str>,
) -> Result<()> {
if let Some(key) = encryption_key {
let mut conn = self
.pool
.acquire()
.await
.map_err(|e| TwonlyError::Generic(e.to_string()))?;
sqlx::query("ATTACH DATABASE ? AS backup KEY ?")
.bind(output_path)
.bind(key)
.execute(&mut *conn)
.await
.map_err(|e| TwonlyError::Generic(format!("Attach failed: {}", e)))?;
sqlx::query("SELECT sqlcipher_export('backup')")
.execute(&mut *conn)
.await
.map_err(|e| TwonlyError::Generic(format!("Export failed: {}", e)))?;
sqlx::query("DETACH DATABASE backup")
.execute(&mut *conn)
.await
.map_err(|e| TwonlyError::Generic(format!("Detach failed: {}", e)))?;
} else {
sqlx::query("VACUUM INTO ?")
.bind(output_path)
.execute(&self.pool)
.await
.map_err(|e| TwonlyError::Generic(format!("Backup failed: {}", e)))?;
}
Ok(())
}
pub(crate) async fn check_integrity(&self) -> Result<()> {
let row: (String,) = sqlx::query_as("PRAGMA integrity_check")
.fetch_one(&self.pool)
.await
.map_err(|e| TwonlyError::Generic(format!("Integrity check query failed: {}", e)))?;
if row.0.to_lowercase() == "ok" {
Ok(())
} else {
Err(TwonlyError::Generic(format!(
"Database integrity check failed: {}",
row.0
)))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[tokio::test]
async fn test_database_encryption_and_migrations() {
let _ = pretty_env_logger::try_init();
let dir = tempdir().unwrap();
let db_path = dir.path().join("test.sqlite").display().to_string();
let key = "secure_password";
// 1. Create and initialize database with key
let db = Database::new(&db_path, Some(key), false).await.unwrap();
db.run_migrations().await.unwrap();
// 2. Try to open with WRONG key
let result = Database::new(&db_path, Some("wrong_password"), false).await;
assert!(
result.is_err(),
"Opening with wrong key should fail. If this passes, the database might not be encrypted!"
);
// 3. Open with CORRECT key again
Database::new(&db_path, Some(key), false).await.unwrap();
}
#[tokio::test]
async fn test_database_backup_encrypted() {
let _ = pretty_env_logger::try_init();
let dir = tempdir().unwrap();
let db_path = dir.path().join("test_enc.sqlite").display().to_string();
let backup_path = dir.path().join("backup_enc.sqlite").display().to_string();
let key = "secure_password";
let db = Database::new(&db_path, Some(key), false).await.unwrap();
db.run_migrations().await.unwrap();
db.create_backup(&backup_path, Some(key)).await.unwrap();
// 1. Verify it cannot be opened with wrong key
let result = Database::new(&backup_path, Some("wrong_password"), false).await;
assert!(
result.is_err(),
"Encrypted backup should fail with wrong key"
);
// 2. Open backup with correct key and verify data
Database::new(&backup_path, Some(key), false).await.unwrap();
}
#[tokio::test]
async fn test_database_backup_plaintext() {
let _ = pretty_env_logger::try_init();
let dir = tempdir().unwrap();
let db_path = dir.path().join("test_plain.sqlite").display().to_string();
let backup_path = dir.path().join("backup_plain.sqlite").display().to_string();
let db = Database::new(&db_path, None, false).await.unwrap();
db.run_migrations().await.unwrap();
db.create_backup(&backup_path, None).await.unwrap();
// Open backup and verify
Database::new(&backup_path, None, false).await.unwrap();
}
}

View file

@ -1,103 +0,0 @@
pub mod received_messages;
#[macro_export]
macro_rules! generate_insert {
($table:literal, $fn_name:ident, $($field:ident : $ty:ty),+) => {
pub async fn $fn_name(
pool: &sqlx::SqlitePool,
$($field: $ty),+
) -> $crate::error::Result<i64> {
let sql = format!(
"INSERT INTO {} ({}) VALUES ({}) RETURNING id",
$table,
vec![$(stringify!($field)),+].join(", "),
vec!["?"; [$({stringify!($field); 1}),+].len()].join(", ")
);
let row: (i64,) = sqlx::query_as(sqlx::AssertSqlSafe(sql))
$(.bind($field))+
.fetch_one(pool)
.await?;
Ok(row.0)
}
};
}
#[macro_export]
macro_rules! generate_select {
($table:literal, $fn_name:ident) => {
pub async fn $fn_name(pool: &sqlx::SqlitePool) -> $crate::error::Result<Vec<Self>> {
let sql = format!("SELECT * FROM {}", $table);
let results = sqlx::query_as::<_, Self>(sqlx::AssertSqlSafe(sql))
.fetch_all(pool)
.await?;
Ok(results)
}
};
($table:literal, $fn_name:ident, $($field:ident : $ty:ty),+) => {
pub async fn $fn_name(pool: &sqlx::SqlitePool, $($field: $ty),+) -> $crate::error::Result<Vec<Self>> {
let mut sql = format!("SELECT * FROM {} WHERE ", $table);
let mut filters = Vec::new();
$(
filters.push(format!("{} = ?", stringify!($field)));
)+
sql.push_str(&filters.join(" AND "));
let results = sqlx::query_as::<_, Self>(sqlx::AssertSqlSafe(sql))
$(.bind($field))+
.fetch_all(pool)
.await?;
Ok(results)
}
};
}
#[macro_export]
macro_rules! generate_table_tests {
(
$struct:ident,
$insert_fn:ident ($($arg:expr),+),
$select_all_fn:ident
) => {
#[cfg(test)]
mod tests {
use super::*;
use $crate::database::Database;
use tempfile::tempdir;
#[tokio::test]
async fn test_generated_basic() {
let dir = tempdir().unwrap();
let db_path = dir.path().join("test.sqlite").display().to_string();
let db = Database::new(&db_path, None, false).await.unwrap();
db.run_migrations().await.unwrap();
let _id = $struct::$insert_fn(&db.pool, $($arg),+).await.unwrap();
let all = $struct::$select_all_fn(&db.pool).await.unwrap();
assert_eq!(all.len(), 1);
}
}
};
}
#[macro_export]
macro_rules! generate_test_select {
($struct:ident, $insert_fn:ident ($($arg:expr),+), $select_fn:ident ($($sel_arg:expr),+)) => {
paste::paste! {
#[cfg(test)]
#[tokio::test]
async fn [<test_ $select_fn>]() {
use tempfile::tempdir;
let dir = tempdir().unwrap();
let db_path = dir.path().join("test.sqlite").display().to_string();
let db = $crate::database::Database::new(&db_path, None, false).await.unwrap();
db.run_migrations().await.unwrap();
$struct::$insert_fn(&db.pool, $($arg),+).await.unwrap();
let results = $struct::$select_fn(&db.pool, $($sel_arg),+).await.unwrap();
assert_eq!(results.len(), 1);
}
}
};
}

View file

@ -1,26 +0,0 @@
#![allow(dead_code)]
use chrono::{DateTime, Utc};
use sqlx::FromRow;
#[derive(Debug, FromRow, PartialEq, Clone)]
pub struct ReceivedMessage {
pub id: i64,
pub sender_id: i64,
pub content: Vec<u8>,
pub timestamp: DateTime<Utc>,
}
impl ReceivedMessage {
crate::generate_insert!(
"received_messages",
insert,
sender_id: i64,
content: &[u8]
);
crate::generate_select!("received_messages", get_all);
crate::generate_select!("received_messages", get_by_sender, sender_id: i64);
}
crate::generate_table_tests!(ReceivedMessage, insert(1, b"hello world"), get_all);
crate::generate_test_select!(ReceivedMessage, insert(1, b"hello world"), get_by_sender(1));

File diff suppressed because it is too large Load diff

View file

@ -1,73 +0,0 @@
# Cryptographic Architecture
## 1. Main Key
A cryptographically secure, immutable master key. Loss of this key, in the absence of valid backups, results in permanent loss of account access.
*Key Derivation*: Utilizes HKDF to derive subordinate keys.
- Authentication Token: Uploaded to the server for session authentication.
- Backup Key: Used to encrypt a backup
- Backup Content: The encrypted backup encompasses
- Main Key
- Identity keys
- Local database (including contacts, public identities, memories (only references), and messages).
- Lifecycle: Backups are refreshed daily and deleted after one year.
- Media Main Key: Used to wrap media-specific keys.
- A new, cryptographically secure key is generated for every media file.
- The media key is wrapped using AES-GCM and the Main Media Key and stored in the online database along side to the uploaded media file database entry.
- The original media file is encrypted using AES-GCM and uploaded to the designated storage bucket.
## 3. Identity Keys
- Signal Identity
- Generates a private and public key pair for secure communication.
- Nostr Identity
- Generates a private and public key pair for Nostr network interactions.
## 1. Backup Keys
Independent, securely generated keys used to wrap the primary backup key.
### 1.1. Password-Based Backup
1. Derivation
- Utilizes scrypt with the username as the salt (cost 65536) to derive a 64-byte sequence.
2. Allocation:
- 32 bytes: Backup ID, used as the identifier to locate the backup on the server.
- 32 bytes: Backup wrapper key.
3. Content
- The payload contains the main key required to generate the auth token and the backup key.
4. Operation
- The backup wrapper key encrypts the main key. The ciphertext is uploaded anonymously to the server, indexed by the Backup ID.
5. Security Measures
- The server enforces strict rate limiting per IP address to prevent brute-force attacks.
6. Lifecycle
- These backup keys require a monthly refresh; otherwise, they are scheduled for deletion after two years.
### 1.2. Trusted Friends Keys (Passwordless Recovery)
1. Initiation
- The recovering user generates a temporary ID (TempID) and a new ephemeral asymmetric key pair.
2. Request
- A recovery request containing the TempID and the public key is transmitted to a trusted contact via a secure link.
3. Verification
- The contact manually verifies the requestor's identity within their application to mitigate phishing risks.
4. Share Transmission
- The contact encrypts a trusted friend share using the provided public key. This share includes the user IDs, the minimum threshold required for decryption, and the cryptographic share (utilizing Shamir's Secret Sharing).
5. Reconstruction
- Upon receiving the required threshold of shares, the user reconstructs the shared secret data.
6. Second Factor (Optional)
- The shared secret data may mandate an additional factor (PIN or Email). For a PIN factor, an unlock token and a PIN seed are used to securely retrieve the remaining share from the server without exposing the raw PIN.
7. Final Recovery
- The decrypted recovery data provides the User ID, private key, and the backup master key necessary to restore the account and its backups.
## 4. Web Portal Upload Protocol
1. Initialization
- The web portal generates a cryptographically secure symmetric key for end-to-end encrypted (E2EE) communication with the mobile application, alongside a newly registered session token.
2. Handshake
- The mobile application scans the QR code containing the session token and the symmetric key.
3. Authorization
- The application signals readiness via the server using the session token and securely provisions a temporary authentication token for media uploads over the established symmetric E2EE channel.
4. Key Exchange
- The web portal encrypts the media file using a newly generated media key. It transmits this media key to the application (encrypted via the E2EE symmetric key) and receives the wrapped media key in return.
5. Upload
- The web portal uploads the encrypted media file to the server, assigning it a device ID of 0.
6. Synchronization
- Finally, the application requests all memories with a device ID lower than its current one (the device ID increments after a backup restoration).

View file

@ -18,6 +18,7 @@ pub struct MainKey {
#[derive(Debug)] #[derive(Debug)]
pub(crate) enum DatabaseKey { pub(crate) enum DatabaseKey {
RustDb, RustDb,
AppDb,
} }
impl MainKey { impl MainKey {
@ -42,8 +43,9 @@ impl MainKey {
/// Derives the database encryption key. /// Derives the database encryption key.
pub(crate) fn get_database_key(&self, db: DatabaseKey) -> String { pub(crate) fn get_database_key(&self, db: DatabaseKey) -> String {
let db_name = match db { let db_name: &[u8] = match db {
DatabaseKey::RustDb => b"rust_db", DatabaseKey::RustDb => b"rust_db",
DatabaseKey::AppDb => b"app_db",
}; };
let info = [b"database_key_", db_name as &[u8]].concat(); let info = [b"database_key_", db_name as &[u8]].concat();
let key = self.derive_key(&info); let key = self.derive_key(&info);

View file

@ -8,6 +8,5 @@ mod keys;
mod log; mod log;
mod secure_storage; mod secure_storage;
pub mod signal; pub mod signal;
mod standalone;
mod user_discovery; mod user_discovery;
mod utils; mod utils;

View file

@ -44,7 +44,7 @@ pub struct FrbPqcPreKey {
impl RustSignalEngine { impl RustSignalEngine {
pub async fn new(local_name: String) -> Result<Self> { pub async fn new(local_name: String) -> Result<Self> {
let twonly = get_twonly_flutter()?; let twonly = get_twonly_flutter()?;
let pool = twonly.rust_db.pool.clone(); let pool = twonly.rust_db.read().await.pool.clone();
let km = twonly.key_manager.lock().await; let km = twonly.key_manager.lock().await;
let signal_identity = km let signal_identity = km
@ -541,10 +541,13 @@ mod tests {
// create the file manually just to be sure // create the file manually just to be sure
std::fs::File::create(&db_path).unwrap(); std::fs::File::create(&db_path).unwrap();
let db = let db = crate::database::signal::Database::new(
crate::database::Database::new(&db_path.to_str().unwrap().to_string(), None, false) &db_path.to_str().unwrap().to_string(),
.await None,
.unwrap(); false,
)
.await
.unwrap();
db.run_migrations().await.unwrap(); db.run_migrations().await.unwrap();
let pool = db.pool.clone(); let pool = db.pool.clone();
@ -567,7 +570,7 @@ mod tests {
async fn test_generate_pqc_prekeys() { async fn test_generate_pqc_prekeys() {
let (engine, _dir) = create_test_engine("alice").await; let (engine, _dir) = create_test_engine("alice").await;
let prekeys = engine.generate_pqc_prekeys().await.unwrap(); let prekeys = engine.generate_pqc_prekeys().await.unwrap();
assert_eq!(prekeys.len(), 50); assert_eq!(prekeys.len(), 30);
for prekey in prekeys { for prekey in prekeys {
assert!(prekey.ecc_pre_key.len() > 0); assert!(prekey.ecc_pre_key.len() > 0);
assert!(prekey.kyber_pre_key.len() > 0); assert!(prekey.kyber_pre_key.len() > 0);

View file

@ -1,17 +0,0 @@
use tokio::sync::Mutex;
use crate::bridge::InitConfig;
use crate::database::Database;
use crate::keys::KeyManager;
use crate::secure_storage::SecureStorage;
use std::sync::Arc;
pub(crate) struct TwonlyStandalone {
#[allow(dead_code)]
pub(crate) config: InitConfig,
#[allow(dead_code)]
pub(crate) rust_db: Arc<Database>,
#[allow(dead_code)]
pub(crate) secure_storage: SecureStorage,
pub(crate) key_manager: Arc<Mutex<KeyManager>>,
}

View file

@ -2,3 +2,6 @@
mod in_memory_store; mod in_memory_store;
#[cfg(test)] #[cfg(test)]
pub(super) use in_memory_store::InMemoryStore; pub(super) use in_memory_store::InMemoryStore;
mod native;
pub(crate) use native::{NativeUserDiscoveryStore, NativeUserDiscoveryUtils};

View file

@ -0,0 +1,552 @@
use crate::database::{app::AppDatabase, signal::Database};
use crate::keys::KeyManager;
use crate::user_discovery::error::{Result, UserDiscoveryError};
use crate::user_discovery::traits::{
AnnouncedUser, OtherPromotion, UserDiscoveryStore, UserDiscoveryUtils,
};
use crate::user_discovery::UserID;
use libsignal_protocol::{IdentityKey, IdentityKeyPair};
use rand::SeedableRng;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::{Mutex, RwLock};
fn store_error(error: impl std::fmt::Display) -> UserDiscoveryError {
UserDiscoveryError::Store(error.to_string())
}
#[derive(Clone)]
pub(crate) struct NativeUserDiscoveryStore {
app_db: Arc<RwLock<Arc<AppDatabase>>>,
config_path: PathBuf,
}
impl NativeUserDiscoveryStore {
pub(crate) fn new(app_db: Arc<RwLock<Arc<AppDatabase>>>, data_dir: &str) -> Self {
Self {
app_db,
config_path: PathBuf::from(data_dir).join("user_discovery_config.json"),
}
}
async fn database(&self) -> Arc<AppDatabase> {
self.app_db.read().await.clone()
}
}
impl UserDiscoveryStore for NativeUserDiscoveryStore {
async fn get_config(&self) -> Result<String> {
if !self.config_path.is_file() {
return Err(UserDiscoveryError::NotInitialized);
}
Ok(std::fs::read_to_string(&self.config_path)?)
}
async fn update_config(&self, update: String) -> Result<()> {
std::fs::write(&self.config_path, update)?;
Ok(())
}
async fn set_shares(&self, shares: Vec<Vec<u8>>) -> Result<()> {
let db = self.database().await;
let mut tx = db.pool.begin().await.map_err(store_error)?;
sqlx::query!("DELETE FROM user_discovery_shares")
.execute(&mut *tx)
.await
.map_err(store_error)?;
for share in shares {
sqlx::query!(
"INSERT INTO user_discovery_shares (share) VALUES (?)",
share
)
.execute(&mut *tx)
.await
.map_err(store_error)?;
}
tx.commit().await.map_err(store_error)?;
db.notify_committed(["user_discovery_shares"]);
Ok(())
}
async fn get_share_for_contact(&self, contact_id: UserID) -> Result<Vec<u8>> {
let db = self.database().await;
let mut tx = db.pool.begin().await.map_err(store_error)?;
if let Some(share) = sqlx::query_scalar!(
r#"
SELECT share
FROM user_discovery_shares
WHERE contact_id = ?
LIMIT 1
"#,
contact_id,
)
.fetch_optional(&mut *tx)
.await
.map_err(store_error)?
{
tx.commit().await.map_err(store_error)?;
return Ok(share);
}
let available = sqlx::query!(
r#"
SELECT share_id, share
FROM user_discovery_shares
WHERE contact_id IS NULL
LIMIT 1
"#,
)
.fetch_optional(&mut *tx)
.await
.map_err(store_error)?;
let Some(row) = available else {
tx.rollback().await.map_err(store_error)?;
return Err(UserDiscoveryError::NoSharesLeft);
};
let share_id = row.share_id;
let share = row.share;
sqlx::query!(
r#"
UPDATE user_discovery_shares
SET contact_id = ?
WHERE share_id = ?
"#,
contact_id,
share_id,
)
.execute(&mut *tx)
.await
.map_err(store_error)?;
tx.commit().await.map_err(store_error)?;
db.notify_committed(["user_discovery_shares"]);
Ok(share)
}
async fn push_own_promotion_and_clear_old_version(
&self,
contact_id: UserID,
_version: u32,
promotion: Vec<u8>,
) -> Result<()> {
let db = self.database().await;
let mut tx = db.pool.begin().await.map_err(store_error)?;
sqlx::query!(
r#"
UPDATE user_discovery_own_promotions
SET promotion = X''
WHERE contact_id = ?
"#,
contact_id,
)
.execute(&mut *tx)
.await
.map_err(store_error)?;
sqlx::query!(
r#"
INSERT INTO user_discovery_own_promotions (
contact_id,
promotion
) VALUES (?, ?)
"#,
contact_id,
promotion,
)
.execute(&mut *tx)
.await
.map_err(store_error)?;
tx.commit().await.map_err(store_error)?;
db.notify_committed(["user_discovery_own_promotions"]);
Ok(())
}
async fn get_own_promotions_after_version(&self, version: u32) -> Result<Vec<Vec<u8>>> {
let db = self.database().await;
sqlx::query_scalar!(
r#"
SELECT promotion
FROM user_discovery_own_promotions
WHERE version_id > ?
"#,
version,
)
.fetch_all(&db.pool)
.await
.map_err(store_error)
}
async fn store_other_promotion(&self, promotion: OtherPromotion) -> Result<()> {
let db = self.database().await;
sqlx::query!(
r#"
INSERT INTO user_discovery_other_promotions (
from_contact_id,
promotion_id,
public_id,
threshold,
announcement_share,
public_key_verified_timestamp
) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(from_contact_id, public_id) DO UPDATE SET
promotion_id = excluded.promotion_id,
threshold = excluded.threshold,
announcement_share = excluded.announcement_share,
public_key_verified_timestamp = excluded.public_key_verified_timestamp
"#,
promotion.from_contact_id,
promotion.promotion_id,
promotion.public_id,
promotion.threshold,
promotion.announcement_share,
promotion.public_key_verified_timestamp,
)
.execute(&db.pool)
.await
.map_err(store_error)?;
db.notify_committed(["user_discovery_other_promotions"]);
Ok(())
}
async fn get_other_promotions_by_public_id(
&self,
public_id: i64,
) -> Result<Vec<OtherPromotion>> {
let db = self.database().await;
let rows = sqlx::query!(
r#"
SELECT
promotion_id,
public_id,
from_contact_id,
threshold,
announcement_share,
public_key_verified_timestamp
FROM user_discovery_other_promotions
WHERE public_id = ?
"#,
public_id,
)
.fetch_all(&db.pool)
.await
.map_err(store_error)?;
rows.into_iter()
.map(|row| {
Ok(OtherPromotion {
promotion_id: u32::try_from(row.promotion_id).map_err(store_error)?,
public_id: row.public_id,
from_contact_id: row.from_contact_id,
threshold: u8::try_from(row.threshold).map_err(store_error)?,
announcement_share: row.announcement_share,
public_key_verified_timestamp: row.public_key_verified_timestamp,
})
})
.collect()
}
async fn get_announced_user_by_public_id(
&self,
public_id: i64,
) -> Result<Option<AnnouncedUser>> {
let db = self.database().await;
let row = sqlx::query!(
r#"
SELECT
announced_user_id,
announced_public_key,
public_id
FROM user_discovery_announced_users
WHERE public_id = ?
"#,
public_id,
)
.fetch_optional(&db.pool)
.await
.map_err(store_error)?;
row.map(|row| {
Ok(AnnouncedUser {
user_id: row.announced_user_id,
public_key: row.announced_public_key,
public_id: row.public_id,
})
})
.transpose()
}
async fn push_new_user_relation(
&self,
from_contact_id: UserID,
announced_user: AnnouncedUser,
public_key_verified_timestamp: Option<i64>,
) -> Result<()> {
let db = self.database().await;
let mut tx = db.pool.begin().await.map_err(store_error)?;
sqlx::query!(
r#"
INSERT INTO user_discovery_announced_users (
announced_user_id,
announced_public_key,
public_id
) VALUES (?, ?, ?)
ON CONFLICT DO UPDATE SET
announced_user_id = excluded.announced_user_id,
announced_public_key = excluded.announced_public_key,
public_id = excluded.public_id
"#,
announced_user.user_id,
announced_user.public_key,
announced_user.public_id,
)
.execute(&mut *tx)
.await
.map_err(store_error)?;
sqlx::query!(
r#"
INSERT INTO user_discovery_user_relations (
announced_user_id,
from_contact_id,
public_key_verified_timestamp
) VALUES (?, ?, ?)
ON CONFLICT(announced_user_id, from_contact_id) DO UPDATE SET
public_key_verified_timestamp = excluded.public_key_verified_timestamp
"#,
announced_user.user_id,
from_contact_id,
public_key_verified_timestamp,
)
.execute(&mut *tx)
.await
.map_err(store_error)?;
tx.commit().await.map_err(store_error)?;
db.notify_committed([
"user_discovery_announced_users",
"user_discovery_user_relations",
]);
Ok(())
}
#[cfg(test)]
async fn get_all_announced_users(
&self,
) -> Result<std::collections::HashMap<AnnouncedUser, Vec<(UserID, Option<i64>)>>> {
Err(UserDiscoveryError::Store("not used by native store".into()))
}
async fn get_contact_promotion(&self, contact_id: UserID) -> Result<Option<Vec<u8>>> {
let db = self.database().await;
sqlx::query_scalar!(
r#"
SELECT promotion
FROM user_discovery_own_promotions
WHERE contact_id = ?
ORDER BY version_id DESC
LIMIT 1
"#,
contact_id,
)
.fetch_optional(&db.pool)
.await
.map_err(store_error)
}
async fn get_contact_version(&self, contact_id: UserID) -> Result<Option<Vec<u8>>> {
let db = self.database().await;
sqlx::query_scalar!(
r#"
SELECT user_discovery_version
FROM contacts
WHERE user_id = ?
"#,
contact_id,
)
.fetch_optional(&db.pool)
.await
.map_err(store_error)
.map(Option::flatten)
}
async fn set_contact_version(&self, contact_id: UserID, update: Vec<u8>) -> Result<()> {
let db = self.database().await;
sqlx::query!(
r#"
UPDATE contacts
SET user_discovery_version = ?
WHERE user_id = ?
"#,
update,
contact_id,
)
.execute(&db.pool)
.await
.map_err(store_error)?;
db.notify_committed(["contacts"]);
Ok(())
}
}
pub(crate) struct NativeUserDiscoveryUtils {
key_manager: Arc<Mutex<KeyManager>>,
rust_db: Arc<RwLock<Arc<Database>>>,
}
impl NativeUserDiscoveryUtils {
pub(crate) fn new(
key_manager: Arc<Mutex<KeyManager>>,
rust_db: Arc<RwLock<Arc<Database>>>,
) -> Self {
Self {
key_manager,
rust_db,
}
}
}
impl UserDiscoveryUtils for NativeUserDiscoveryUtils {
async fn sign_data(&self, input_data: &[u8]) -> Result<Vec<u8>> {
let key_manager = self.key_manager.lock().await;
let identity = key_manager
.signal_identity
.as_ref()
.ok_or_else(|| UserDiscoveryError::Store("no Signal identity found".into()))?;
let key_pair = IdentityKeyPair::try_from(identity.identity_key_pair_structure.as_slice())
.map_err(store_error)?;
let mut csprng = rand::rngs::StdRng::from_os_rng();
key_pair
.private_key()
.calculate_signature_for_multipart_message(&[input_data], &mut csprng)
.map(|signature| signature.to_vec())
.map_err(store_error)
}
async fn verify_signature(
&self,
input_data: &[u8],
pubkey: &[u8],
signature: &[u8],
) -> Result<bool> {
let identity = match IdentityKey::decode(pubkey) {
Ok(identity) => identity,
Err(_) => return Ok(false),
};
Ok(identity
.public_key()
.verify_signature(input_data, signature))
}
async fn verify_stored_pubkey(&self, from_contact_id: UserID, pubkey: &[u8]) -> Result<bool> {
let db = self.rust_db.read().await.clone();
let stored = sqlx::query_scalar!(
r#"
SELECT identity_key
FROM signal_identities
WHERE name = ?
"#,
from_contact_id.to_string(),
)
.fetch_optional(&db.pool)
.await
.map_err(store_error)?;
Ok(stored.as_deref() == Some(pubkey))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::keys::SignalIdentityKey;
use std::collections::HashMap;
#[tokio::test]
async fn native_store_and_signal_utils_use_rust_owned_state() {
let temp = tempfile::tempdir().unwrap();
let app_path = temp.path().join("app.sqlite");
let app_db = AppDatabase::new(app_path.to_str().unwrap(), None, false)
.await
.unwrap();
app_db.run_migrations().await.unwrap();
sqlx::query("INSERT INTO contacts (user_id, username) VALUES (1, 'one'), (2, 'two')")
.execute(&app_db.pool)
.await
.unwrap();
let app_handle = Arc::new(RwLock::new(Arc::new(app_db)));
let store = NativeUserDiscoveryStore::new(app_handle, temp.path().to_str().unwrap());
store.update_config("config".into()).await.unwrap();
assert_eq!(store.get_config().await.unwrap(), "config");
store.set_shares(vec![vec![1], vec![2]]).await.unwrap();
let assigned = store.get_share_for_contact(1).await.unwrap();
assert_eq!(store.get_share_for_contact(1).await.unwrap(), assigned);
store.set_contact_version(1, vec![7]).await.unwrap();
assert_eq!(store.get_contact_version(1).await.unwrap(), Some(vec![7]));
store
.push_own_promotion_and_clear_old_version(1, 1, vec![8])
.await
.unwrap();
assert_eq!(store.get_contact_promotion(1).await.unwrap(), Some(vec![8]));
let promotion = OtherPromotion {
promotion_id: 3,
public_id: 4,
from_contact_id: 1,
threshold: 2,
announcement_share: vec![5],
public_key_verified_timestamp: Some(6),
};
store.store_other_promotion(promotion).await.unwrap();
assert_eq!(
store
.get_other_promotions_by_public_id(4)
.await
.unwrap()
.len(),
1
);
let announced = AnnouncedUser {
user_id: 2,
public_key: vec![9],
public_id: 10,
};
store
.push_new_user_relation(1, announced, Some(11))
.await
.unwrap();
assert_eq!(
store
.get_announced_user_by_public_id(10)
.await
.unwrap()
.unwrap()
.user_id,
2
);
let rust_path = temp.path().join("rust.sqlite");
let rust_path_string = rust_path.display().to_string();
let rust_db = Database::new(&rust_path_string, None, false).await.unwrap();
rust_db.run_migrations().await.unwrap();
let rust_handle = Arc::new(RwLock::new(Arc::new(rust_db)));
let mut rng = rand::rng();
let identity = IdentityKeyPair::generate(&mut rng);
let public_key = identity.identity_key().serialize().to_vec();
sqlx::query(
"INSERT INTO signal_identities (name, identity_key, timestamp) VALUES ('1', ?, 0)",
)
.bind(&public_key)
.execute(&rust_handle.read().await.pool)
.await
.unwrap();
let mut key_manager = KeyManager::generate().unwrap();
key_manager.signal_identity = Some(SignalIdentityKey {
identity_key_pair_structure: identity.serialize().to_vec(),
registration_id: 1,
pre_key_store: HashMap::new(),
});
let utils = NativeUserDiscoveryUtils::new(Arc::new(Mutex::new(key_manager)), rust_handle);
let message = b"native discovery";
let signature = utils.sign_data(message).await.unwrap();
assert!(utils
.verify_signature(message, &public_key, &signature)
.await
.unwrap());
assert!(utils.verify_stored_pubkey(1, &public_key).await.unwrap());
assert!(!utils.verify_stored_pubkey(2, &public_key).await.unwrap());
}
}

View file

@ -1,6 +1,6 @@
#[tokio::test] #[tokio::test]
async fn test_twonly_api_100_messages() -> Result<(), Box<dyn std::error::Error>> { async fn test_twonly_api_100_messages() -> Result<(), Box<dyn std::error::Error>> {
use rust_lib_twonly::database::Database; use rust_lib_twonly::database::signal::Database;
use rust_lib_twonly::signal::engine::RustSignalEngine; use rust_lib_twonly::signal::engine::RustSignalEngine;
let _ = pretty_env_logger::try_init(); let _ = pretty_env_logger::try_init();

View file

@ -53,19 +53,19 @@ abstract class CargoKitBuildTask extends DefaultTask {
} }
def executableName = Os.isFamily(Os.FAMILY_WINDOWS) ? "run_build_tool.cmd" : "run_build_tool.sh" def executableName = Os.isFamily(Os.FAMILY_WINDOWS) ? "run_build_tool.cmd" : "run_build_tool.sh"
def path = Paths.get(new File(pluginFile).parent, "..", executableName); def path = Paths.get(new File(pluginFile).parent, "..", executableName).toString()
def manifestDir = Paths.get(project.buildscript.sourceFile.parent, project.cargokit.manifestDir) def manifestDir = Paths.get(project.buildscript.sourceFile.parent, project.cargokit.manifestDir).toString()
def rootProjectDir = project.rootProject.projectDir def rootProjectDir = project.rootProject.projectDir.absolutePath
if (!Os.isFamily(Os.FAMILY_WINDOWS)) { if (!Os.isFamily(Os.FAMILY_WINDOWS)) {
project.exec { project.providers.exec {
commandLine 'chmod', '+x', path commandLine 'chmod', '+x', path
} }.result.get()
} }
project.exec { project.providers.exec {
executable path executable path
args "build-gradle" args "build-gradle"
environment "CARGOKIT_ROOT_PROJECT_DIR", rootProjectDir environment "CARGOKIT_ROOT_PROJECT_DIR", rootProjectDir
@ -80,7 +80,7 @@ abstract class CargoKitBuildTask extends DefaultTask {
environment "CARGOKIT_MIN_SDK_VERSION", minSdkVersion environment "CARGOKIT_MIN_SDK_VERSION", minSdkVersion
environment "CARGOKIT_TARGET_PLATFORMS", targetPlatforms.join(",") environment "CARGOKIT_TARGET_PLATFORMS", targetPlatforms.join(",")
environment "CARGOKIT_JAVA_HOME", System.properties['java.home'] environment "CARGOKIT_JAVA_HOME", System.properties['java.home']
} }.result.get()
} }
} }