mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-09-01 08:54:08 +00:00
move ownership of the database to rust
This commit is contained in:
parent
1fb6b3c94a
commit
74c20389c9
94 changed files with 7866 additions and 7329 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -57,3 +57,5 @@ rust/target
|
|||
rust_dependencies/target
|
||||
fastlane/repo/status/running.json
|
||||
.cache/
|
||||
# Widget Preview related
|
||||
.widget_preview/
|
||||
|
|
|
|||
|
|
@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
|||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
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
|
||||
|
|
|
|||
|
|
@ -18,11 +18,11 @@ pluginManagement {
|
|||
|
||||
plugins {
|
||||
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
|
||||
id "com.google.gms.google-services" version "4.3.15" apply false
|
||||
// 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"
|
||||
|
|
|
|||
103
lib/core/app_database.dart
Normal file
103
lib/core/app_database.dart
Normal 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;
|
||||
}
|
||||
|
|
@ -7,35 +7,11 @@ import 'frb_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 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}) =>
|
||||
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 {
|
||||
final String databaseDir;
|
||||
final String dataDir;
|
||||
|
|
@ -56,42 +32,3 @@ class InitConfig {
|
|||
databaseDir == other.databaseDir &&
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,62 +3,17 @@
|
|||
|
||||
// ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import
|
||||
|
||||
import '../bridge.dart';
|
||||
import '../frb_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 types are ignored because they are neither used by any `pub` functions nor (for structs and enums) marked `#[frb(unignore)]`: `FlutterCallbacks`, `Logging`, `UserDiscoveryCallbacks`
|
||||
// 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 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`
|
||||
|
||||
Future<void> initFlutterCallbacks({
|
||||
required int callbackId,
|
||||
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(
|
||||
callbackId: callbackId,
|
||||
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,
|
||||
);
|
||||
|
|
|
|||
89
lib/core/bridge/wrapper/app_database.dart
Normal file
89
lib/core/bridge/wrapper/app_database.dart
Normal 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
103
lib/core/database/app.dart
Normal 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
|
|
@ -5,8 +5,8 @@
|
|||
|
||||
import 'bridge.dart';
|
||||
import 'bridge/callbacks.dart';
|
||||
import 'bridge/callbacks/user_discovery.dart';
|
||||
import 'bridge/wrapper.dart';
|
||||
import 'bridge/wrapper/app_database.dart';
|
||||
import 'bridge/wrapper/backup.dart';
|
||||
import 'bridge/wrapper/key_manager.dart';
|
||||
import 'bridge/wrapper/signal.dart';
|
||||
|
|
@ -14,6 +14,7 @@ import 'bridge/wrapper/user_discovery.dart';
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:ffi' as ffi;
|
||||
import 'database/app.dart';
|
||||
import 'frb_generated.dart';
|
||||
import 'keys/backup_password_keys.dart';
|
||||
import 'lib.dart';
|
||||
|
|
@ -37,72 +38,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
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
|
||||
Object dco_decode_DartOpaque(dynamic raw);
|
||||
|
||||
|
|
@ -117,9 +52,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
@protected
|
||||
String dco_decode_String(dynamic raw);
|
||||
|
||||
@protected
|
||||
AnnouncedUser dco_decode_announced_user(dynamic raw);
|
||||
|
||||
@protected
|
||||
BackupPasswordKeys dco_decode_backup_password_keys(dynamic raw);
|
||||
|
||||
|
|
@ -127,10 +59,10 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
bool dco_decode_bool(dynamic raw);
|
||||
|
||||
@protected
|
||||
AnnouncedUser dco_decode_box_autoadd_announced_user(dynamic raw);
|
||||
BackupPasswordKeys dco_decode_box_autoadd_backup_password_keys(dynamic raw);
|
||||
|
||||
@protected
|
||||
BackupPasswordKeys dco_decode_box_autoadd_backup_password_keys(dynamic raw);
|
||||
double dco_decode_box_autoadd_f_64(dynamic raw);
|
||||
|
||||
@protected
|
||||
FrbPreKeyBundle dco_decode_box_autoadd_frb_pre_key_bundle(dynamic raw);
|
||||
|
|
@ -141,21 +73,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
@protected
|
||||
InitConfig dco_decode_box_autoadd_init_config(dynamic raw);
|
||||
|
||||
@protected
|
||||
OtherPromotion dco_decode_box_autoadd_other_promotion(dynamic raw);
|
||||
|
||||
@protected
|
||||
int dco_decode_box_autoadd_u_32(dynamic raw);
|
||||
|
||||
@protected
|
||||
UserDiscoveryStoreFlutter dco_decode_box_autoadd_user_discovery_store_flutter(
|
||||
dynamic raw,
|
||||
);
|
||||
|
||||
@protected
|
||||
UserDiscoveryUtilsFlutter dco_decode_box_autoadd_user_discovery_utils_flutter(
|
||||
dynamic raw,
|
||||
);
|
||||
double dco_decode_f_64(dynamic raw);
|
||||
|
||||
@protected
|
||||
FlutterUserDiscovery dco_decode_flutter_user_discovery(dynamic raw);
|
||||
|
|
@ -175,14 +97,27 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
@protected
|
||||
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
|
||||
List<FrbPqcPreKey> dco_decode_list_frb_pqc_pre_key(dynamic raw);
|
||||
|
||||
@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
|
||||
List<OtherPromotion> dco_decode_list_other_promotion(dynamic raw);
|
||||
List<Uint8List> dco_decode_list_list_prim_u_8_strict(dynamic raw);
|
||||
|
||||
@protected
|
||||
List<int> dco_decode_list_prim_u_8_loose(dynamic raw);
|
||||
|
|
@ -194,11 +129,17 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
List<(PlatformInt64, Uint8List)>
|
||||
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
|
||||
String? dco_decode_opt_String(dynamic raw);
|
||||
|
||||
@protected
|
||||
AnnouncedUser? dco_decode_opt_box_autoadd_announced_user(dynamic raw);
|
||||
double? dco_decode_opt_box_autoadd_f_64(dynamic raw);
|
||||
|
||||
@protected
|
||||
PlatformInt64? dco_decode_opt_box_autoadd_i_64(dynamic raw);
|
||||
|
|
@ -206,18 +147,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
@protected
|
||||
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
|
||||
Uint8List? dco_decode_opt_list_prim_u_8_strict(dynamic raw);
|
||||
|
||||
@protected
|
||||
OtherPromotion dco_decode_other_promotion(dynamic raw);
|
||||
|
||||
@protected
|
||||
(PlatformInt64, Uint8List) dco_decode_record_i_64_list_prim_u_8_strict(
|
||||
dynamic raw,
|
||||
|
|
@ -231,6 +163,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
@protected
|
||||
(String, String) dco_decode_record_string_string(dynamic raw);
|
||||
|
||||
@protected
|
||||
RustAppDatabase dco_decode_rust_app_database(dynamic raw);
|
||||
|
||||
@protected
|
||||
RustBackupArchive dco_decode_rust_backup_archive(dynamic raw);
|
||||
|
||||
|
|
@ -246,6 +181,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
@protected
|
||||
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
|
||||
int dco_decode_u_32(dynamic raw);
|
||||
|
||||
|
|
@ -258,16 +205,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
@protected
|
||||
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
|
||||
BigInt dco_decode_usize(dynamic raw);
|
||||
|
||||
|
|
@ -290,9 +227,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
@protected
|
||||
String sse_decode_String(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
AnnouncedUser sse_decode_announced_user(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
BackupPasswordKeys sse_decode_backup_password_keys(
|
||||
SseDeserializer deserializer,
|
||||
|
|
@ -302,14 +236,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
bool sse_decode_bool(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
AnnouncedUser sse_decode_box_autoadd_announced_user(
|
||||
BackupPasswordKeys sse_decode_box_autoadd_backup_password_keys(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
BackupPasswordKeys sse_decode_box_autoadd_backup_password_keys(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
double sse_decode_box_autoadd_f_64(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
FrbPreKeyBundle sse_decode_box_autoadd_frb_pre_key_bundle(
|
||||
|
|
@ -322,23 +254,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
@protected
|
||||
InitConfig sse_decode_box_autoadd_init_config(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
OtherPromotion sse_decode_box_autoadd_other_promotion(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
int sse_decode_box_autoadd_u_32(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
UserDiscoveryStoreFlutter sse_decode_box_autoadd_user_discovery_store_flutter(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
UserDiscoveryUtilsFlutter sse_decode_box_autoadd_user_discovery_utils_flutter(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
double sse_decode_f_64(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
FlutterUserDiscovery sse_decode_flutter_user_discovery(
|
||||
|
|
@ -360,18 +280,31 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
@protected
|
||||
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
|
||||
List<FrbPqcPreKey> sse_decode_list_frb_pqc_pre_key(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
List<Uint8List> sse_decode_list_list_prim_u_8_strict(
|
||||
List<LegacyTableMigrationCount> sse_decode_list_legacy_table_migration_count(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
List<OtherPromotion> sse_decode_list_other_promotion(
|
||||
List<Uint8List> sse_decode_list_list_prim_u_8_strict(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
|
|
@ -387,13 +320,17 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
List<SqlRow> sse_decode_list_sql_row(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
List<SqlValue> sse_decode_list_sql_value(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
String? sse_decode_opt_String(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
AnnouncedUser? sse_decode_opt_box_autoadd_announced_user(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
double? sse_decode_opt_box_autoadd_f_64(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
PlatformInt64? sse_decode_opt_box_autoadd_i_64(SseDeserializer deserializer);
|
||||
|
|
@ -401,22 +338,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
@protected
|
||||
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
|
||||
Uint8List? sse_decode_opt_list_prim_u_8_strict(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
OtherPromotion sse_decode_other_promotion(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
(PlatformInt64, Uint8List) sse_decode_record_i_64_list_prim_u_8_strict(
|
||||
SseDeserializer deserializer,
|
||||
|
|
@ -432,6 +356,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
RustAppDatabase sse_decode_rust_app_database(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
RustBackupArchive sse_decode_rust_backup_archive(
|
||||
SseDeserializer deserializer,
|
||||
|
|
@ -451,6 +378,20 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
@protected
|
||||
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
|
||||
int sse_decode_u_32(SseDeserializer deserializer);
|
||||
|
||||
|
|
@ -463,16 +404,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
@protected
|
||||
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
|
||||
BigInt sse_decode_usize(SseDeserializer deserializer);
|
||||
|
||||
|
|
@ -491,82 +422,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
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
|
||||
void sse_encode_DartOpaque(Object self, SseSerializer serializer);
|
||||
|
||||
|
|
@ -585,9 +440,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
@protected
|
||||
void sse_encode_String(String self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_announced_user(AnnouncedUser self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_backup_password_keys(
|
||||
BackupPasswordKeys self,
|
||||
|
|
@ -597,18 +449,15 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
@protected
|
||||
void sse_encode_bool(bool self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_box_autoadd_announced_user(
|
||||
AnnouncedUser self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_box_autoadd_backup_password_keys(
|
||||
BackupPasswordKeys self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_box_autoadd_f_64(double self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_box_autoadd_frb_pre_key_bundle(
|
||||
FrbPreKeyBundle self,
|
||||
|
|
@ -627,26 +476,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_box_autoadd_other_promotion(
|
||||
OtherPromotion self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_box_autoadd_u_32(int self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_box_autoadd_user_discovery_store_flutter(
|
||||
UserDiscoveryStoreFlutter self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_box_autoadd_user_discovery_utils_flutter(
|
||||
UserDiscoveryUtilsFlutter self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
void sse_encode_f_64(double self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_flutter_user_discovery(
|
||||
|
|
@ -672,6 +506,21 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
@protected
|
||||
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
|
||||
void sse_encode_list_frb_pqc_pre_key(
|
||||
List<FrbPqcPreKey> self,
|
||||
|
|
@ -679,14 +528,14 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_list_list_prim_u_8_strict(
|
||||
List<Uint8List> self,
|
||||
void sse_encode_list_legacy_table_migration_count(
|
||||
List<LegacyTableMigrationCount> self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_list_other_promotion(
|
||||
List<OtherPromotion> self,
|
||||
void sse_encode_list_list_prim_u_8_strict(
|
||||
List<Uint8List> self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
|
|
@ -705,14 +554,17 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
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
|
||||
void sse_encode_opt_String(String? self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_opt_box_autoadd_announced_user(
|
||||
AnnouncedUser? self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
void sse_encode_opt_box_autoadd_f_64(double? self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_opt_box_autoadd_i_64(
|
||||
|
|
@ -723,30 +575,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
@protected
|
||||
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
|
||||
void sse_encode_opt_list_prim_u_8_strict(
|
||||
Uint8List? self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_other_promotion(
|
||||
OtherPromotion self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_record_i_64_list_prim_u_8_strict(
|
||||
(PlatformInt64, Uint8List) self,
|
||||
|
|
@ -765,6 +599,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_rust_app_database(
|
||||
RustAppDatabase self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_rust_backup_archive(
|
||||
RustBackupArchive self,
|
||||
|
|
@ -789,6 +629,21 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
@protected
|
||||
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
|
||||
void sse_encode_u_32(int self, SseSerializer serializer);
|
||||
|
||||
|
|
@ -801,18 +656,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
@protected
|
||||
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
|
||||
void sse_encode_usize(BigInt self, SseSerializer serializer);
|
||||
|
||||
|
|
|
|||
|
|
@ -8,14 +8,15 @@
|
|||
|
||||
import 'bridge.dart';
|
||||
import 'bridge/callbacks.dart';
|
||||
import 'bridge/callbacks/user_discovery.dart';
|
||||
import 'bridge/wrapper.dart';
|
||||
import 'bridge/wrapper/app_database.dart';
|
||||
import 'bridge/wrapper/backup.dart';
|
||||
import 'bridge/wrapper/key_manager.dart';
|
||||
import 'bridge/wrapper/signal.dart';
|
||||
import 'bridge/wrapper/user_discovery.dart';
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'database/app.dart';
|
||||
import 'frb_generated.dart';
|
||||
import 'keys/backup_password_keys.dart';
|
||||
import 'lib.dart';
|
||||
|
|
@ -39,72 +40,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
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
|
||||
Object dco_decode_DartOpaque(dynamic raw);
|
||||
|
||||
|
|
@ -119,9 +54,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
@protected
|
||||
String dco_decode_String(dynamic raw);
|
||||
|
||||
@protected
|
||||
AnnouncedUser dco_decode_announced_user(dynamic raw);
|
||||
|
||||
@protected
|
||||
BackupPasswordKeys dco_decode_backup_password_keys(dynamic raw);
|
||||
|
||||
|
|
@ -129,10 +61,10 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
bool dco_decode_bool(dynamic raw);
|
||||
|
||||
@protected
|
||||
AnnouncedUser dco_decode_box_autoadd_announced_user(dynamic raw);
|
||||
BackupPasswordKeys dco_decode_box_autoadd_backup_password_keys(dynamic raw);
|
||||
|
||||
@protected
|
||||
BackupPasswordKeys dco_decode_box_autoadd_backup_password_keys(dynamic raw);
|
||||
double dco_decode_box_autoadd_f_64(dynamic raw);
|
||||
|
||||
@protected
|
||||
FrbPreKeyBundle dco_decode_box_autoadd_frb_pre_key_bundle(dynamic raw);
|
||||
|
|
@ -143,21 +75,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
@protected
|
||||
InitConfig dco_decode_box_autoadd_init_config(dynamic raw);
|
||||
|
||||
@protected
|
||||
OtherPromotion dco_decode_box_autoadd_other_promotion(dynamic raw);
|
||||
|
||||
@protected
|
||||
int dco_decode_box_autoadd_u_32(dynamic raw);
|
||||
|
||||
@protected
|
||||
UserDiscoveryStoreFlutter dco_decode_box_autoadd_user_discovery_store_flutter(
|
||||
dynamic raw,
|
||||
);
|
||||
|
||||
@protected
|
||||
UserDiscoveryUtilsFlutter dco_decode_box_autoadd_user_discovery_utils_flutter(
|
||||
dynamic raw,
|
||||
);
|
||||
double dco_decode_f_64(dynamic raw);
|
||||
|
||||
@protected
|
||||
FlutterUserDiscovery dco_decode_flutter_user_discovery(dynamic raw);
|
||||
|
|
@ -177,14 +99,27 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
@protected
|
||||
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
|
||||
List<FrbPqcPreKey> dco_decode_list_frb_pqc_pre_key(dynamic raw);
|
||||
|
||||
@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
|
||||
List<OtherPromotion> dco_decode_list_other_promotion(dynamic raw);
|
||||
List<Uint8List> dco_decode_list_list_prim_u_8_strict(dynamic raw);
|
||||
|
||||
@protected
|
||||
List<int> dco_decode_list_prim_u_8_loose(dynamic raw);
|
||||
|
|
@ -196,11 +131,17 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
List<(PlatformInt64, Uint8List)>
|
||||
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
|
||||
String? dco_decode_opt_String(dynamic raw);
|
||||
|
||||
@protected
|
||||
AnnouncedUser? dco_decode_opt_box_autoadd_announced_user(dynamic raw);
|
||||
double? dco_decode_opt_box_autoadd_f_64(dynamic raw);
|
||||
|
||||
@protected
|
||||
PlatformInt64? dco_decode_opt_box_autoadd_i_64(dynamic raw);
|
||||
|
|
@ -208,18 +149,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
@protected
|
||||
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
|
||||
Uint8List? dco_decode_opt_list_prim_u_8_strict(dynamic raw);
|
||||
|
||||
@protected
|
||||
OtherPromotion dco_decode_other_promotion(dynamic raw);
|
||||
|
||||
@protected
|
||||
(PlatformInt64, Uint8List) dco_decode_record_i_64_list_prim_u_8_strict(
|
||||
dynamic raw,
|
||||
|
|
@ -233,6 +165,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
@protected
|
||||
(String, String) dco_decode_record_string_string(dynamic raw);
|
||||
|
||||
@protected
|
||||
RustAppDatabase dco_decode_rust_app_database(dynamic raw);
|
||||
|
||||
@protected
|
||||
RustBackupArchive dco_decode_rust_backup_archive(dynamic raw);
|
||||
|
||||
|
|
@ -248,6 +183,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
@protected
|
||||
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
|
||||
int dco_decode_u_32(dynamic raw);
|
||||
|
||||
|
|
@ -260,16 +207,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
@protected
|
||||
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
|
||||
BigInt dco_decode_usize(dynamic raw);
|
||||
|
||||
|
|
@ -292,9 +229,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
@protected
|
||||
String sse_decode_String(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
AnnouncedUser sse_decode_announced_user(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
BackupPasswordKeys sse_decode_backup_password_keys(
|
||||
SseDeserializer deserializer,
|
||||
|
|
@ -304,14 +238,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
bool sse_decode_bool(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
AnnouncedUser sse_decode_box_autoadd_announced_user(
|
||||
BackupPasswordKeys sse_decode_box_autoadd_backup_password_keys(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
BackupPasswordKeys sse_decode_box_autoadd_backup_password_keys(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
double sse_decode_box_autoadd_f_64(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
FrbPreKeyBundle sse_decode_box_autoadd_frb_pre_key_bundle(
|
||||
|
|
@ -324,23 +256,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
@protected
|
||||
InitConfig sse_decode_box_autoadd_init_config(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
OtherPromotion sse_decode_box_autoadd_other_promotion(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
int sse_decode_box_autoadd_u_32(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
UserDiscoveryStoreFlutter sse_decode_box_autoadd_user_discovery_store_flutter(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
UserDiscoveryUtilsFlutter sse_decode_box_autoadd_user_discovery_utils_flutter(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
double sse_decode_f_64(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
FlutterUserDiscovery sse_decode_flutter_user_discovery(
|
||||
|
|
@ -362,18 +282,31 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
@protected
|
||||
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
|
||||
List<FrbPqcPreKey> sse_decode_list_frb_pqc_pre_key(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
List<Uint8List> sse_decode_list_list_prim_u_8_strict(
|
||||
List<LegacyTableMigrationCount> sse_decode_list_legacy_table_migration_count(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
List<OtherPromotion> sse_decode_list_other_promotion(
|
||||
List<Uint8List> sse_decode_list_list_prim_u_8_strict(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
|
|
@ -389,13 +322,17 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
List<SqlRow> sse_decode_list_sql_row(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
List<SqlValue> sse_decode_list_sql_value(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
String? sse_decode_opt_String(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
AnnouncedUser? sse_decode_opt_box_autoadd_announced_user(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
double? sse_decode_opt_box_autoadd_f_64(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
PlatformInt64? sse_decode_opt_box_autoadd_i_64(SseDeserializer deserializer);
|
||||
|
|
@ -403,22 +340,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
@protected
|
||||
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
|
||||
Uint8List? sse_decode_opt_list_prim_u_8_strict(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
OtherPromotion sse_decode_other_promotion(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
(PlatformInt64, Uint8List) sse_decode_record_i_64_list_prim_u_8_strict(
|
||||
SseDeserializer deserializer,
|
||||
|
|
@ -434,6 +358,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
RustAppDatabase sse_decode_rust_app_database(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
RustBackupArchive sse_decode_rust_backup_archive(
|
||||
SseDeserializer deserializer,
|
||||
|
|
@ -453,6 +380,20 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
@protected
|
||||
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
|
||||
int sse_decode_u_32(SseDeserializer deserializer);
|
||||
|
||||
|
|
@ -465,16 +406,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
@protected
|
||||
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
|
||||
BigInt sse_decode_usize(SseDeserializer deserializer);
|
||||
|
||||
|
|
@ -493,82 +424,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
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
|
||||
void sse_encode_DartOpaque(Object self, SseSerializer serializer);
|
||||
|
||||
|
|
@ -587,9 +442,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
@protected
|
||||
void sse_encode_String(String self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_announced_user(AnnouncedUser self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_backup_password_keys(
|
||||
BackupPasswordKeys self,
|
||||
|
|
@ -599,18 +451,15 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
@protected
|
||||
void sse_encode_bool(bool self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_box_autoadd_announced_user(
|
||||
AnnouncedUser self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_box_autoadd_backup_password_keys(
|
||||
BackupPasswordKeys self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_box_autoadd_f_64(double self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_box_autoadd_frb_pre_key_bundle(
|
||||
FrbPreKeyBundle self,
|
||||
|
|
@ -629,26 +478,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_box_autoadd_other_promotion(
|
||||
OtherPromotion self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_box_autoadd_u_32(int self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_box_autoadd_user_discovery_store_flutter(
|
||||
UserDiscoveryStoreFlutter self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_box_autoadd_user_discovery_utils_flutter(
|
||||
UserDiscoveryUtilsFlutter self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
void sse_encode_f_64(double self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_flutter_user_discovery(
|
||||
|
|
@ -674,6 +508,21 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
@protected
|
||||
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
|
||||
void sse_encode_list_frb_pqc_pre_key(
|
||||
List<FrbPqcPreKey> self,
|
||||
|
|
@ -681,14 +530,14 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_list_list_prim_u_8_strict(
|
||||
List<Uint8List> self,
|
||||
void sse_encode_list_legacy_table_migration_count(
|
||||
List<LegacyTableMigrationCount> self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_list_other_promotion(
|
||||
List<OtherPromotion> self,
|
||||
void sse_encode_list_list_prim_u_8_strict(
|
||||
List<Uint8List> self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
|
|
@ -707,14 +556,17 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
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
|
||||
void sse_encode_opt_String(String? self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_opt_box_autoadd_announced_user(
|
||||
AnnouncedUser? self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
void sse_encode_opt_box_autoadd_f_64(double? self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_opt_box_autoadd_i_64(
|
||||
|
|
@ -725,30 +577,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
@protected
|
||||
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
|
||||
void sse_encode_opt_list_prim_u_8_strict(
|
||||
Uint8List? self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_other_promotion(
|
||||
OtherPromotion self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_record_i_64_list_prim_u_8_strict(
|
||||
(PlatformInt64, Uint8List) self,
|
||||
|
|
@ -767,6 +601,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_rust_app_database(
|
||||
RustAppDatabase self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_rust_backup_archive(
|
||||
RustBackupArchive self,
|
||||
|
|
@ -791,6 +631,21 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
@protected
|
||||
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
|
||||
void sse_encode_u_32(int self, SseSerializer serializer);
|
||||
|
||||
|
|
@ -803,18 +658,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||
@protected
|
||||
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
|
||||
void sse_encode_usize(BigInt self, SseSerializer serializer);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
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/services/api/api.service.dart';
|
||||
import 'package:twonly/src/services/news.service.dart';
|
||||
|
|
@ -11,10 +12,12 @@ void setupLocator() {
|
|||
..registerLazySingleton<UserService>(UserService.new)
|
||||
..registerLazySingleton<ApiService>(ApiService.new)
|
||||
..registerLazySingleton<TwonlyDB>(TwonlyDB.new)
|
||||
..registerLazySingleton<SignalDB>(SignalDB.new)
|
||||
..registerLazySingleton<NewsService>(NewsService.new);
|
||||
}
|
||||
|
||||
UserService get userService => locator<UserService>();
|
||||
ApiService get apiService => locator<ApiService>();
|
||||
TwonlyDB get twonlyDB => locator<TwonlyDB>();
|
||||
SignalDB get signalDB => locator<SignalDB>();
|
||||
NewsService get newsService => locator<NewsService>();
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:camera/camera.dart';
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:mutex/mutex.dart';
|
||||
|
|
@ -7,11 +9,13 @@ import 'package:provider/provider.dart';
|
|||
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
import 'package:twonly/app.dart';
|
||||
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/frb_generated.dart';
|
||||
import 'package:twonly/globals.dart';
|
||||
import 'package:twonly/locator.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/image_editor.provider.dart';
|
||||
import 'package:twonly/src/providers/purchases.provider.dart';
|
||||
|
|
@ -55,6 +59,19 @@ Future<bool> twonlyMinimumInitialization() async {
|
|||
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) {
|
||||
Log.error(e);
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -1,33 +1,10 @@
|
|||
import 'package:twonly/core/bridge/callbacks.dart';
|
||||
import 'package:twonly/globals.dart';
|
||||
import 'package:twonly/src/callbacks/logging.callbacks.dart';
|
||||
import 'package:twonly/src/callbacks/user_discovery.callbacks.dart';
|
||||
|
||||
Future<void> initFlutterCallbacksForRust() async {
|
||||
await initFlutterCallbacks(
|
||||
callbackId: isolateCallbackId,
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -188,6 +188,15 @@ class GroupsDao extends DatabaseAccessor<TwonlyDB> with _$GroupsDaoMixin {
|
|||
.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() {
|
||||
return (select(groups)..where(
|
||||
(g) => g.leftGroup.equals(false) & g.deletedContent.equals(false),
|
||||
|
|
@ -202,6 +211,12 @@ class GroupsDao extends DatabaseAccessor<TwonlyDB> with _$GroupsDaoMixin {
|
|||
.watch();
|
||||
}
|
||||
|
||||
Stream<List<GroupMember>> watchTypingGroupMembers() {
|
||||
return (select(
|
||||
groupMembers,
|
||||
)..where((member) => member.lastTypeIndicator.isNotNull())).watch();
|
||||
}
|
||||
|
||||
Stream<Group?> watchGroup(String groupId) {
|
||||
return (select(
|
||||
groups,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
final gm = groupMembers;
|
||||
final directKv = alias(keyVerifications, 'directKv');
|
||||
|
|
|
|||
|
|
@ -14,11 +14,15 @@ class LabelsDao extends DatabaseAccessor<TwonlyDB> with _$LabelsDaoMixin {
|
|||
LabelsDao(super.db);
|
||||
|
||||
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() {
|
||||
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) {
|
||||
|
|
@ -31,6 +35,22 @@ class LabelsDao extends DatabaseAccessor<TwonlyDB> with _$LabelsDaoMixin {
|
|||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
final query = select(contactLabels).join([
|
||||
innerJoin(labels, labels.id.equalsExp(contactLabels.labelId)),
|
||||
|
|
@ -44,7 +64,9 @@ class LabelsDao extends DatabaseAccessor<TwonlyDB> with _$LabelsDaoMixin {
|
|||
Future<void> setContactLabels(int contactId, List<int> labelIds) async {
|
||||
final sanitizedLabelIds = labelIds.take(3).toList();
|
||||
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) {
|
||||
await batch((b) {
|
||||
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;
|
||||
return (update(labels)..where((t) => t.id.equals(id)))
|
||||
.write(
|
||||
|
|
|
|||
|
|
@ -177,6 +177,32 @@ class MediaFilesDao extends DatabaseAccessor<TwonlyDB>
|
|||
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 {
|
||||
await (update(mediaFiles)..where(
|
||||
(t) =>
|
||||
|
|
|
|||
|
|
@ -53,6 +53,27 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
|
|||
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) {
|
||||
final query =
|
||||
select(messages).join([
|
||||
|
|
@ -127,6 +148,38 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
|
|||
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 {
|
||||
final group = await twonlyDB.groupsDao.getGroup(groupId);
|
||||
final deletionTime = clock.now().subtract(
|
||||
|
|
@ -575,6 +628,19 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
|
|||
.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) {
|
||||
return (select(messageHistories)
|
||||
..where((t) => t.messageId.equals(messageId))
|
||||
|
|
|
|||
|
|
@ -108,6 +108,20 @@ class ReactionsDao extends DatabaseAccessor<TwonlyDB> with _$ReactionsDaoMixin {
|
|||
.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) {
|
||||
final query =
|
||||
(select(reactions)).join(
|
||||
|
|
@ -125,6 +139,35 @@ class ReactionsDao extends DatabaseAccessor<TwonlyDB> with _$ReactionsDaoMixin {
|
|||
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(
|
||||
String messageId,
|
||||
) {
|
||||
|
|
|
|||
96
lib/src/database/rust_query_executor.dart
Normal file
96
lib/src/database/rust_query_executor.dart
Normal 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}'),
|
||||
};
|
||||
}
|
||||
47
lib/src/database/signal.db.dart
Normal file
47
lib/src/database/signal.db.dart
Normal 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;');
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
2383
lib/src/database/signal.db.g.dart
Normal file
2383
lib/src/database/signal.db.g.dart
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -2,7 +2,7 @@ import 'package:collection/collection.dart';
|
|||
import 'package:drift/drift.dart';
|
||||
import 'package:libsignal_protocol_dart/libsignal_protocol_dart.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 {
|
||||
SignalIdentityKeyStore(this.identityKeyPair, this.localRegistrationId);
|
||||
|
|
@ -13,7 +13,7 @@ class SignalIdentityKeyStore extends IdentityKeyStore {
|
|||
@override
|
||||
Future<IdentityKey?> getIdentity(SignalProtocolAddress address) async {
|
||||
final identity =
|
||||
await (twonlyDB.select(twonlyDB.signalIdentityKeyStores)..where(
|
||||
await (signalDB.select(signalDB.signalIdentityKeyStores)..where(
|
||||
(t) =>
|
||||
t.deviceId.equals(address.getDeviceId()) &
|
||||
t.name.equals(address.getName()),
|
||||
|
|
@ -55,8 +55,8 @@ class SignalIdentityKeyStore extends IdentityKeyStore {
|
|||
return false;
|
||||
}
|
||||
if (await getIdentity(address) == null) {
|
||||
await twonlyDB
|
||||
.into(twonlyDB.signalIdentityKeyStores)
|
||||
await signalDB
|
||||
.into(signalDB.signalIdentityKeyStores)
|
||||
.insert(
|
||||
SignalIdentityKeyStoresCompanion(
|
||||
deviceId: Value(address.getDeviceId()),
|
||||
|
|
@ -65,7 +65,7 @@ class SignalIdentityKeyStore extends IdentityKeyStore {
|
|||
),
|
||||
);
|
||||
} else {
|
||||
await (twonlyDB.update(twonlyDB.signalIdentityKeyStores)..where(
|
||||
await (signalDB.update(signalDB.signalIdentityKeyStores)..where(
|
||||
(t) =>
|
||||
t.deviceId.equals(address.getDeviceId()) &
|
||||
t.name.equals(address.getName()),
|
||||
|
|
|
|||
|
|
@ -1,22 +1,22 @@
|
|||
import 'package:drift/drift.dart';
|
||||
import 'package:libsignal_protocol_dart/libsignal_protocol_dart.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';
|
||||
|
||||
class SignalPreKeyStore extends PreKeyStore {
|
||||
@override
|
||||
Future<bool> containsPreKey(int preKeyId) async {
|
||||
final preKeyRecord = await (twonlyDB.select(
|
||||
twonlyDB.signalPreKeyStores,
|
||||
final preKeyRecord = await (signalDB.select(
|
||||
signalDB.signalPreKeyStores,
|
||||
)..where((tbl) => tbl.preKeyId.equals(preKeyId))).get();
|
||||
return preKeyRecord.isNotEmpty;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<PreKeyRecord> loadPreKey(int preKeyId) async {
|
||||
final preKeyRecord = await (twonlyDB.select(
|
||||
twonlyDB.signalPreKeyStores,
|
||||
final preKeyRecord = await (signalDB.select(
|
||||
signalDB.signalPreKeyStores,
|
||||
)..where((tbl) => tbl.preKeyId.equals(preKeyId))).get();
|
||||
if (preKeyRecord.isEmpty) {
|
||||
throw InvalidKeyIdException(
|
||||
|
|
@ -29,8 +29,8 @@ class SignalPreKeyStore extends PreKeyStore {
|
|||
|
||||
@override
|
||||
Future<void> removePreKey(int preKeyId) async {
|
||||
await (twonlyDB.delete(
|
||||
twonlyDB.signalPreKeyStores,
|
||||
await (signalDB.delete(
|
||||
signalDB.signalPreKeyStores,
|
||||
)..where((tbl) => tbl.preKeyId.equals(preKeyId))).go();
|
||||
}
|
||||
|
||||
|
|
@ -42,8 +42,8 @@ class SignalPreKeyStore extends PreKeyStore {
|
|||
);
|
||||
|
||||
try {
|
||||
await twonlyDB
|
||||
.into(twonlyDB.signalPreKeyStores)
|
||||
await signalDB
|
||||
.into(signalDB.signalPreKeyStores)
|
||||
.insert(preKeyCompanion, mode: InsertMode.insertOrReplace);
|
||||
} catch (e) {
|
||||
Log.error('$e');
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import 'package:drift/drift.dart';
|
||||
import 'package:libsignal_protocol_dart/libsignal_protocol_dart.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 {
|
||||
@override
|
||||
Future<SenderKeyRecord> loadSenderKey(SenderKeyName senderKeyName) async {
|
||||
final identity =
|
||||
await (twonlyDB.select(twonlyDB.signalSenderKeyStores)
|
||||
await (signalDB.select(signalDB.signalSenderKeyStores)
|
||||
..where((t) => t.senderKeyName.equals(senderKeyName.serialize())))
|
||||
.getSingleOrNull();
|
||||
if (identity == null) {
|
||||
|
|
@ -23,8 +23,8 @@ class SignalSenderKeyStore extends SenderKeyStore {
|
|||
SenderKeyName senderKeyName,
|
||||
SenderKeyRecord record,
|
||||
) async {
|
||||
await twonlyDB
|
||||
.into(twonlyDB.signalSenderKeyStores)
|
||||
await signalDB
|
||||
.into(signalDB.signalSenderKeyStores)
|
||||
.insert(
|
||||
SignalSenderKeyStoresCompanion(
|
||||
senderKey: Value(record.serialize()),
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import 'package:drift/drift.dart';
|
||||
import 'package:libsignal_protocol_dart/libsignal_protocol_dart.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 {
|
||||
@override
|
||||
Future<bool> containsSession(SignalProtocolAddress address) async {
|
||||
final sessions =
|
||||
await (twonlyDB.select(twonlyDB.signalSessionStores)..where(
|
||||
await (signalDB.select(signalDB.signalSessionStores)..where(
|
||||
(tbl) =>
|
||||
tbl.deviceId.equals(address.getDeviceId()) &
|
||||
tbl.name.equals(address.getName()),
|
||||
|
|
@ -18,14 +18,14 @@ class SignalSessionStore extends SessionStore {
|
|||
|
||||
@override
|
||||
Future<void> deleteAllSessions(String name) async {
|
||||
await (twonlyDB.delete(
|
||||
twonlyDB.signalSessionStores,
|
||||
await (signalDB.delete(
|
||||
signalDB.signalSessionStores,
|
||||
)..where((tbl) => tbl.name.equals(name))).go();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteSession(SignalProtocolAddress address) async {
|
||||
await (twonlyDB.delete(twonlyDB.signalSessionStores)..where(
|
||||
await (signalDB.delete(signalDB.signalSessionStores)..where(
|
||||
(tbl) =>
|
||||
tbl.deviceId.equals(address.getDeviceId()) &
|
||||
tbl.name.equals(address.getName()),
|
||||
|
|
@ -36,7 +36,7 @@ class SignalSessionStore extends SessionStore {
|
|||
@override
|
||||
Future<List<int>> getSubDeviceSessions(String name) async {
|
||||
final deviceIds =
|
||||
await (twonlyDB.select(twonlyDB.signalSessionStores)..where(
|
||||
await (signalDB.select(signalDB.signalSessionStores)..where(
|
||||
(tbl) => tbl.deviceId.equals(1).not() & tbl.name.equals(name),
|
||||
))
|
||||
.get();
|
||||
|
|
@ -46,7 +46,7 @@ class SignalSessionStore extends SessionStore {
|
|||
@override
|
||||
Future<SessionRecord> loadSession(SignalProtocolAddress address) async {
|
||||
final dbSession =
|
||||
await (twonlyDB.select(twonlyDB.signalSessionStores)..where(
|
||||
await (signalDB.select(signalDB.signalSessionStores)..where(
|
||||
(tbl) =>
|
||||
tbl.deviceId.equals(address.getDeviceId()) &
|
||||
tbl.name.equals(address.getName()),
|
||||
|
|
@ -72,11 +72,11 @@ class SignalSessionStore extends SessionStore {
|
|||
);
|
||||
|
||||
if (!await containsSession(address)) {
|
||||
await twonlyDB
|
||||
.into(twonlyDB.signalSessionStores)
|
||||
await signalDB
|
||||
.into(signalDB.signalSessionStores)
|
||||
.insert(sessionCompanion);
|
||||
} else {
|
||||
await (twonlyDB.update(twonlyDB.signalSessionStores)..where(
|
||||
await (signalDB.update(signalDB.signalSessionStores)..where(
|
||||
(tbl) =>
|
||||
tbl.deviceId.equals(address.getDeviceId()) &
|
||||
tbl.name.equals(address.getName()),
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import 'dart:convert';
|
|||
import 'package:drift/drift.dart';
|
||||
import 'package:libsignal_protocol_dart/libsignal_protocol_dart.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/secure_storage.dart';
|
||||
|
||||
|
|
@ -26,8 +26,8 @@ Future<HashMap<int, Uint8List>> getSignalSignedPreKeyStoreOld() async {
|
|||
class SignalSignedPreKeyStore extends SignedPreKeyStore {
|
||||
@override
|
||||
Future<SignedPreKeyRecord> loadSignedPreKey(int signedPreKeyId) async {
|
||||
final record = await (twonlyDB.select(
|
||||
twonlyDB.signalSignedPreKeyStores,
|
||||
final record = await (signalDB.select(
|
||||
signalDB.signalSignedPreKeyStores,
|
||||
)..where((tbl) => tbl.signedPreKeyId.equals(signedPreKeyId))).get();
|
||||
if (record.isEmpty) {
|
||||
throw InvalidKeyIdException(
|
||||
|
|
@ -39,8 +39,8 @@ class SignalSignedPreKeyStore extends SignedPreKeyStore {
|
|||
|
||||
@override
|
||||
Future<List<SignedPreKeyRecord>> loadSignedPreKeys() async {
|
||||
final records = await twonlyDB
|
||||
.select(twonlyDB.signalSignedPreKeyStores)
|
||||
final records = await signalDB
|
||||
.select(signalDB.signalSignedPreKeyStores)
|
||||
.get();
|
||||
return records
|
||||
.map((r) => SignedPreKeyRecord.fromSerialized(r.signedPreKey))
|
||||
|
|
@ -58,8 +58,8 @@ class SignalSignedPreKeyStore extends SignedPreKeyStore {
|
|||
);
|
||||
|
||||
try {
|
||||
await twonlyDB
|
||||
.into(twonlyDB.signalSignedPreKeyStores)
|
||||
await signalDB
|
||||
.into(signalDB.signalSignedPreKeyStores)
|
||||
.insert(companion, mode: InsertMode.insertOrReplace);
|
||||
} catch (e) {
|
||||
Log.error('$e');
|
||||
|
|
@ -69,16 +69,16 @@ class SignalSignedPreKeyStore extends SignedPreKeyStore {
|
|||
|
||||
@override
|
||||
Future<bool> containsSignedPreKey(int signedPreKeyId) async {
|
||||
final record = await (twonlyDB.select(
|
||||
twonlyDB.signalSignedPreKeyStores,
|
||||
final record = await (signalDB.select(
|
||||
signalDB.signalSignedPreKeyStores,
|
||||
)..where((tbl) => tbl.signedPreKeyId.equals(signedPreKeyId))).get();
|
||||
return record.isNotEmpty;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> removeSignedPreKey(int signedPreKeyId) async {
|
||||
await (twonlyDB.delete(
|
||||
twonlyDB.signalSignedPreKeyStores,
|
||||
await (signalDB.delete(
|
||||
signalDB.signalSignedPreKeyStores,
|
||||
)..where((tbl) => tbl.signedPreKeyId.equals(signedPreKeyId))).go();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,4 @@
|
|||
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/groups.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/shortcuts.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/groups.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/receipts.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/twonly.db.steps.dart';
|
||||
import 'package:twonly/src/utils/log.dart';
|
||||
|
|
@ -45,11 +36,6 @@ part 'twonly.db.g.dart';
|
|||
GroupMembers,
|
||||
Receipts,
|
||||
ReceivedReceipts,
|
||||
SignalIdentityKeyStores,
|
||||
SignalPreKeyStores,
|
||||
SignalSenderKeyStores,
|
||||
SignalSessionStores,
|
||||
SignalSignedPreKeyStores,
|
||||
MessageActions,
|
||||
GroupHistories,
|
||||
KeyVerifications,
|
||||
|
|
@ -80,7 +66,7 @@ part 'twonly.db.g.dart';
|
|||
class TwonlyDB extends _$TwonlyDB {
|
||||
TwonlyDB([QueryExecutor? e])
|
||||
: super(
|
||||
e ?? _openConnection(),
|
||||
e ?? openRustAppDatabase(),
|
||||
);
|
||||
|
||||
// ignore: matching_super_parameters
|
||||
|
|
@ -89,29 +75,6 @@ class TwonlyDB extends _$TwonlyDB {
|
|||
@override
|
||||
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
|
||||
MigrationStrategy get migration {
|
||||
return MigrationStrategy(
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -111,15 +111,6 @@ UserData _$UserDataFromJson(Map<String, dynamic> json) =>
|
|||
json['passwordLessRecovery'] as Map<String, dynamic>,
|
||||
)
|
||||
..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
|
||||
..hasZoomed = json['hasZoomed'] as bool? ?? false;
|
||||
|
||||
|
|
@ -184,12 +175,6 @@ Map<String, dynamic> _$UserDataToJson(UserData instance) => <String, dynamic>{
|
|||
'isBackupEnabled': instance.isBackupEnabled,
|
||||
'passwordLessRecovery': instance.passwordLessRecovery,
|
||||
'fcmToken': instance.fcmToken,
|
||||
'askedForUserStudyPermission': instance.askedForUserStudyPermission,
|
||||
'userStudyParticipantsToken': instance.userStudyParticipantsToken,
|
||||
'userStudyCountNewFriendsViaSuggestion':
|
||||
instance.userStudyCountNewFriendsViaSuggestion,
|
||||
'lastUserStudyDataUpload': instance.lastUserStudyDataUpload
|
||||
?.toIso8601String(),
|
||||
'currentSetupPage': instance.currentSetupPage,
|
||||
'skipSetupPages': instance.skipSetupPages,
|
||||
'hasZoomed': instance.hasZoomed,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import 'package:twonly/core/bridge/wrapper/key_manager.dart';
|
|||
import 'package:twonly/globals.dart';
|
||||
import 'package:twonly/locator.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'
|
||||
show getSignalSignedPreKeyStoreOld;
|
||||
import 'package:twonly/src/database/tables/contacts.table.dart';
|
||||
|
|
@ -129,8 +130,8 @@ Future<void> runMigrations() async {
|
|||
signedPreKeyId: Value(entry.key),
|
||||
signedPreKey: Value(entry.value),
|
||||
);
|
||||
await twonlyDB
|
||||
.into(twonlyDB.signalSignedPreKeyStores)
|
||||
await signalDB
|
||||
.into(signalDB.signalSignedPreKeyStores)
|
||||
.insert(
|
||||
companion,
|
||||
mode: InsertMode.insertOrReplace,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ class AvatarIcon extends StatefulWidget {
|
|||
const AvatarIcon({
|
||||
super.key,
|
||||
this.group,
|
||||
this.contacts,
|
||||
this.contactId,
|
||||
this.myAvatar = false,
|
||||
this.fontSize = 20,
|
||||
|
|
@ -20,6 +21,7 @@ class AvatarIcon extends StatefulWidget {
|
|||
this.color,
|
||||
});
|
||||
final Group? group;
|
||||
final List<Contact>? contacts;
|
||||
final int? contactId;
|
||||
final bool myAvatar;
|
||||
final double? fontSize;
|
||||
|
|
@ -45,6 +47,21 @@ class _AvatarIconState extends State<AvatarIcon> {
|
|||
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
|
||||
void dispose() {
|
||||
groupStream?.cancel();
|
||||
|
|
@ -84,7 +101,9 @@ class _AvatarIconState extends State<AvatarIcon> {
|
|||
}
|
||||
|
||||
Future<void> initAsync() async {
|
||||
if (widget.group != null) {
|
||||
if (widget.contacts != null) {
|
||||
_setAvatarContacts(widget.contacts!);
|
||||
} else if (widget.group != null) {
|
||||
groupStream = twonlyDB.groupsDao
|
||||
.watchGroupContact(widget.group!.groupId)
|
||||
.listen((contacts) {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ class ContactLabels extends StatefulWidget {
|
|||
this.padding = const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
this.emptyText,
|
||||
this.showEmptyText = false,
|
||||
this.labels,
|
||||
super.key,
|
||||
});
|
||||
|
||||
|
|
@ -19,6 +20,7 @@ class ContactLabels extends StatefulWidget {
|
|||
final EdgeInsetsGeometry padding;
|
||||
final String? emptyText;
|
||||
final bool showEmptyText;
|
||||
final List<Label>? labels;
|
||||
|
||||
@override
|
||||
State<ContactLabels> createState() => _ContactLabelsState();
|
||||
|
|
@ -31,6 +33,9 @@ class _ContactLabelsState extends State<ContactLabels> {
|
|||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.labels != null) {
|
||||
_labels = widget.labels!;
|
||||
} else {
|
||||
_sub = twonlyDB.labelsDao.watchContactLabels(widget.contactId).listen((
|
||||
labels,
|
||||
) {
|
||||
|
|
@ -41,10 +46,19 @@ class _ContactLabelsState extends State<ContactLabels> {
|
|||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(ContactLabels oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.labels != null && widget.labels != oldWidget.labels) {
|
||||
_labels = widget.labels!;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_sub.cancel();
|
||||
if (widget.labels == null) _sub.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
|
@ -101,8 +115,7 @@ Widget? buildContactLabelsSubtitle({
|
|||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
additionalSubtitle,
|
||||
if (labels.isNotEmpty)
|
||||
ContactLabels(contactId: contactId),
|
||||
if (labels.isNotEmpty) ContactLabels(contactId: contactId),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,17 +3,20 @@ import 'dart:async';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:twonly/locator.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';
|
||||
|
||||
class FlameCounterWidget extends StatefulWidget {
|
||||
const FlameCounterWidget({
|
||||
this.groupId,
|
||||
this.contactId,
|
||||
this.group,
|
||||
this.prefix = false,
|
||||
super.key,
|
||||
});
|
||||
final String? groupId;
|
||||
final int? contactId;
|
||||
final Group? group;
|
||||
final bool prefix;
|
||||
|
||||
@override
|
||||
|
|
@ -33,6 +36,14 @@ class _FlameCounterWidgetState extends State<FlameCounterWidget> {
|
|||
initAsync();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(FlameCounterWidget oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.group != null && widget.group != oldWidget.group) {
|
||||
initAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
flameCounterSub?.cancel();
|
||||
|
|
@ -41,8 +52,10 @@ class _FlameCounterWidgetState extends State<FlameCounterWidget> {
|
|||
|
||||
Future<void> initAsync() async {
|
||||
var groupId = widget.groupId;
|
||||
late Group? group;
|
||||
if (widget.groupId == null && widget.contactId != null) {
|
||||
var group = widget.group;
|
||||
if (group != null) {
|
||||
groupId = group.groupId;
|
||||
} else if (widget.groupId == null && widget.contactId != null) {
|
||||
group = await twonlyDB.groupsDao.getDirectChat(widget.contactId!);
|
||||
groupId = group?.groupId;
|
||||
} else if (groupId != null) {
|
||||
|
|
@ -52,6 +65,16 @@ class _FlameCounterWidgetState extends State<FlameCounterWidget> {
|
|||
isBestFriend =
|
||||
userService.currentUser.myBestFriendGroupId == groupId &&
|
||||
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);
|
||||
flameCounterSub = stream.listen((result) {
|
||||
if (mounted) {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ class VerificationBadgeComp extends StatefulWidget {
|
|||
this.showOnlyIfVerified = false,
|
||||
this.isVerifiedByTransferredTrust,
|
||||
this.clickable = true,
|
||||
this.verificationStatus,
|
||||
this.useProvidedStatus = false,
|
||||
});
|
||||
final Group? group;
|
||||
final Contact? contact;
|
||||
|
|
@ -26,6 +28,8 @@ class VerificationBadgeComp extends StatefulWidget {
|
|||
|
||||
final bool showOnlyIfVerified;
|
||||
final bool clickable;
|
||||
final VerificationStatus? verificationStatus;
|
||||
final bool useProvidedStatus;
|
||||
final bool? isVerifiedByTransferredTrust;
|
||||
|
||||
@override
|
||||
|
|
@ -52,8 +56,32 @@ class _VerificationBadgeCompState extends State<VerificationBadgeComp> {
|
|||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
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() {
|
||||
setState(() {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart' show setEquals;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:twonly/locator.dart';
|
||||
import 'package:twonly/src/constants/routes.keys.dart';
|
||||
import 'package:twonly/src/database/daos/key_verification.dao.dart';
|
||||
import 'package:twonly/src/database/twonly.db.dart';
|
||||
import 'package:twonly/src/providers/purchases.provider.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/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_messages_components/typing_indicator.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/passwordless_recovery/components/missing_recovery_contacts.comp.dart';
|
||||
|
|
@ -28,15 +31,34 @@ class ChatListView extends StatefulWidget {
|
|||
State<ChatListView> createState() => _ChatListViewState();
|
||||
}
|
||||
|
||||
class _ChatListViewState extends State<ChatListView> with AutomaticKeepAliveClientMixin<ChatListView> {
|
||||
class _ChatListViewState extends State<ChatListView>
|
||||
with AutomaticKeepAliveClientMixin<ChatListView> {
|
||||
StreamSubscription<void>? _userSub;
|
||||
StreamSubscription<List<Group>>? _contactsSub;
|
||||
StreamSubscription<List<Contact>>? _contactsCountSub;
|
||||
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 = {};
|
||||
List<Group> _groupsNotPinned = [];
|
||||
List<Group> _groupsPinned = [];
|
||||
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);
|
||||
bool _loading = true;
|
||||
|
|
@ -102,7 +124,9 @@ class _ChatListViewState extends State<ChatListView> with AutomaticKeepAliveClie
|
|||
_badgeCount.value = _countAnnouncedUsers + _countContactRequest;
|
||||
});
|
||||
|
||||
_precacheSub = twonlyDB.messagesDao.watchUnopenedMediaFiles().listen((mediaFiles) {
|
||||
_precacheSub = twonlyDB.messagesDao.watchUnopenedMediaFiles().listen((
|
||||
mediaFiles,
|
||||
) {
|
||||
if (!mounted) return;
|
||||
for (final media in mediaFiles) {
|
||||
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
|
||||
|
|
@ -132,9 +230,44 @@ class _ChatListViewState extends State<ChatListView> with AutomaticKeepAliveClie
|
|||
_countAnnouncedStream.cancel();
|
||||
_userSub?.cancel();
|
||||
_precacheSub?.cancel();
|
||||
_typingMembersSub?.cancel();
|
||||
_typingUpdateTimer?.cancel();
|
||||
_groupMembersSub?.cancel();
|
||||
_unopenedMessagesSub?.cancel();
|
||||
_reactionsSub?.cancel();
|
||||
_latestMessagesSub?.cancel();
|
||||
_chatListMediaSub?.cancel();
|
||||
_verificationSub?.cancel();
|
||||
_contactLabelsSub?.cancel();
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
|
|
@ -277,6 +410,22 @@ class _ChatListViewState extends State<ChatListView> with AutomaticKeepAliveClie
|
|||
return GroupListItemComp(
|
||||
key: ValueKey(group.groupId),
|
||||
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(
|
||||
key: ValueKey(group.groupId),
|
||||
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 [],
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import 'package:go_router/go_router.dart';
|
|||
|
||||
import 'package:twonly/locator.dart';
|
||||
import 'package:twonly/src/constants/routes.keys.dart';
|
||||
import 'package:twonly/src/database/daos/key_verification.dao.dart';
|
||||
import 'package:twonly/src/database/tables/mediafiles.table.dart';
|
||||
import 'package:twonly/src/database/tables/messages.table.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 {
|
||||
const GroupListItemComp({
|
||||
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,
|
||||
});
|
||||
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
|
||||
State<GroupListItemComp> createState() => _UserListItem();
|
||||
|
|
@ -53,9 +72,47 @@ class _UserListItem extends State<GroupListItemComp> {
|
|||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_applyContacts();
|
||||
_lastReaction = widget.lastReaction;
|
||||
_lastMessage = widget.lastMessage;
|
||||
_applyMediaFiles();
|
||||
if (widget.useSharedSummary) {
|
||||
_updateState(widget.lastMessage, widget.unopenedMessages ?? const []);
|
||||
}
|
||||
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
|
||||
void dispose() {
|
||||
_messagesNotOpenedStream?.cancel();
|
||||
|
|
@ -67,6 +124,7 @@ class _UserListItem extends State<GroupListItemComp> {
|
|||
}
|
||||
|
||||
Future<void> initStreams() async {
|
||||
if (!widget.useSharedSummary) {
|
||||
final lastMsgStream = await twonlyDB.messagesDao.watchLastMessage(
|
||||
widget.group.groupId,
|
||||
);
|
||||
|
|
@ -74,7 +132,9 @@ class _UserListItem extends State<GroupListItemComp> {
|
|||
_lastMessageStream = lastMsgStream.listen((update) {
|
||||
_updateState(update, _messagesNotOpened);
|
||||
});
|
||||
}
|
||||
|
||||
if (!widget.useSharedSummary) {
|
||||
_lastReactionStream = twonlyDB.reactionsDao
|
||||
.watchLastReactions(widget.group.groupId)
|
||||
.listen((update) {
|
||||
|
|
@ -83,13 +143,19 @@ class _UserListItem extends State<GroupListItemComp> {
|
|||
_lastReaction = update;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (widget.useSharedSummary) {
|
||||
_messagesNotOpened = widget.unopenedMessages!;
|
||||
} else {
|
||||
_messagesNotOpenedStream = twonlyDB.messagesDao
|
||||
.watchMessageNotOpened(widget.group.groupId)
|
||||
.listen((update) {
|
||||
_updateState(_lastMessage, update);
|
||||
});
|
||||
}
|
||||
|
||||
if (!widget.useSharedSummary) {
|
||||
_lastMediaFilesStream = twonlyDB.mediaFilesDao
|
||||
.watchMediaFilesForGroup(widget.group.groupId)
|
||||
.listen((mediaFiles) {
|
||||
|
|
@ -104,8 +170,11 @@ class _UserListItem extends State<GroupListItemComp> {
|
|||
}
|
||||
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
|
||||
.watchGroupContact(widget.group.groupId)
|
||||
.listen((contacts) {
|
||||
|
|
@ -191,6 +260,7 @@ class _UserListItem extends State<GroupListItemComp> {
|
|||
/// Fetches any media files referenced by preview messages but not yet in the
|
||||
/// local cache. Fire-and-forget; updates state when results arrive.
|
||||
Future<void> _fetchMissingMediaFiles() async {
|
||||
if (widget.useSharedSummary) return;
|
||||
final missing = <MediaFile>[];
|
||||
for (final message in _previewMessages) {
|
||||
if (message.mediaId != null &&
|
||||
|
|
@ -266,6 +336,8 @@ class _UserListItem extends State<GroupListItemComp> {
|
|||
const SizedBox(width: 3),
|
||||
VerificationBadgeComp(
|
||||
group: widget.group,
|
||||
verificationStatus: widget.verificationStatus,
|
||||
useProvidedStatus: widget.useSharedSummary,
|
||||
showOnlyIfVerified: true,
|
||||
clickable: false,
|
||||
size: 12,
|
||||
|
|
@ -278,6 +350,7 @@ class _UserListItem extends State<GroupListItemComp> {
|
|||
physics: const BouncingScrollPhysics(),
|
||||
child: ContactLabels(
|
||||
contactId: _directContact!.userId,
|
||||
labels: widget.contactLabels,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -296,7 +369,7 @@ class _UserListItem extends State<GroupListItemComp> {
|
|||
dateTime: widget.group.lastMessageExchange,
|
||||
),
|
||||
FlameCounterWidget(
|
||||
groupId: widget.group.groupId,
|
||||
group: widget.group,
|
||||
prefix: true,
|
||||
),
|
||||
],
|
||||
|
|
@ -305,6 +378,7 @@ class _UserListItem extends State<GroupListItemComp> {
|
|||
children: [
|
||||
TypingIndicatorSubtitleComp(
|
||||
groupId: widget.group.groupId,
|
||||
isTyping: widget.isTyping,
|
||||
),
|
||||
MessageSendStateIcon(
|
||||
_previewMessages,
|
||||
|
|
@ -320,7 +394,7 @@ class _UserListItem extends State<GroupListItemComp> {
|
|||
message: _currentMessage,
|
||||
),
|
||||
FlameCounterWidget(
|
||||
groupId: widget.group.groupId,
|
||||
group: widget.group,
|
||||
prefix: true,
|
||||
),
|
||||
],
|
||||
|
|
@ -340,7 +414,7 @@ class _UserListItem extends State<GroupListItemComp> {
|
|||
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)
|
||||
? null
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
class TypingIndicatorSubtitleComp extends StatefulWidget {
|
||||
const TypingIndicatorSubtitleComp({required this.groupId, super.key});
|
||||
const TypingIndicatorSubtitleComp({
|
||||
required this.groupId,
|
||||
this.isTyping,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String groupId;
|
||||
final bool? isTyping;
|
||||
|
||||
@override
|
||||
State<TypingIndicatorSubtitleComp> createState() =>
|
||||
|
|
@ -28,6 +33,8 @@ class _TypingIndicatorSubtitleCompState
|
|||
void initState() {
|
||||
super.initState();
|
||||
|
||||
if (widget.isTyping != null) return;
|
||||
|
||||
final membersStream = twonlyDB.groupsDao.watchGroupMembers(
|
||||
widget.groupId,
|
||||
);
|
||||
|
|
@ -64,7 +71,7 @@ class _TypingIndicatorSubtitleCompState
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_groupMembers.isEmpty) return Container();
|
||||
if (widget.isTyping ?? _groupMembers.isNotEmpty) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 5),
|
||||
child: Container(
|
||||
|
|
@ -82,4 +89,6 @@ class _TypingIndicatorSubtitleCompState
|
|||
),
|
||||
);
|
||||
}
|
||||
return Container();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:flutter/foundation.dart' show setEquals;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:font_awesome_flutter/font_awesome_flutter.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_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/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/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/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 {
|
||||
const ChatMessagesView(this.groupId, {super.key});
|
||||
|
||||
|
|
@ -43,23 +85,12 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
|
|||
with WidgetsBindingObserver {
|
||||
HashSet<int> alreadyReportedOpened = HashSet<int>();
|
||||
|
||||
bool _hasReceivedFirstMessageBatch = false;
|
||||
final HashSet<String> _knownMessageIds = HashSet<String>();
|
||||
final HashSet<String> _animateMessageIds = HashSet<String>();
|
||||
|
||||
StreamSubscription<Group?>? userSub;
|
||||
StreamSubscription<List<Message>>? messageSub;
|
||||
StreamSubscription<List<GroupHistory>>? groupActionsSub;
|
||||
StreamSubscription<List<Contact>>? contactSub;
|
||||
final _animationState = _MessageAnimationState();
|
||||
final _subscriptions = _ChatSubscriptions();
|
||||
final _data = _ChatViewData();
|
||||
|
||||
Group? _group;
|
||||
|
||||
Map<int, Contact> userIdToContact = {};
|
||||
|
||||
List<ChatItem> messages = [];
|
||||
List<Message> allMessages = [];
|
||||
List<GroupHistory> groupActions = [];
|
||||
List<MemoryItem> galleryItems = [];
|
||||
List<Contact> _groupContacts = [];
|
||||
Message? quotesMessage;
|
||||
GlobalKey verifyShieldKey = GlobalKey();
|
||||
FocusNode? textFieldFocus;
|
||||
|
|
@ -79,10 +110,7 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
|
|||
|
||||
@override
|
||||
void dispose() {
|
||||
userSub?.cancel();
|
||||
messageSub?.cancel();
|
||||
contactSub?.cancel();
|
||||
groupActionsSub?.cancel();
|
||||
_subscriptions.cancelAll();
|
||||
_nextTypingIndicator?.cancel();
|
||||
try {
|
||||
textFieldFocus?.dispose();
|
||||
|
|
@ -100,43 +128,86 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
|
|||
|
||||
Future<void> initStreams() async {
|
||||
final groupStream = twonlyDB.groupsDao.watchGroup(widget.groupId);
|
||||
userSub = groupStream.listen((newGroup) {
|
||||
_subscriptions.group = groupStream.listen((newGroup) {
|
||||
if (newGroup == null) return;
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_group = newGroup;
|
||||
});
|
||||
|
||||
if (groupActionsSub == null) {
|
||||
if (_subscriptions.groupActions == null) {
|
||||
final actionsStream = twonlyDB.groupsDao.watchGroupActions(
|
||||
newGroup.groupId,
|
||||
);
|
||||
groupActionsSub = actionsStream.listen((update) async {
|
||||
groupActions = update;
|
||||
await setMessages(allMessages, update);
|
||||
_subscriptions.groupActions = actionsStream.listen((update) async {
|
||||
_data.groupActions = update;
|
||||
await setMessages(_data.allMessages, update);
|
||||
});
|
||||
|
||||
final contactsStream = twonlyDB.contactsDao.watchAllContacts();
|
||||
contactSub = contactsStream.listen((contacts) {
|
||||
_subscriptions.contacts = contactsStream.listen((contacts) {
|
||||
final contactMap = <int, Contact>{};
|
||||
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);
|
||||
messageSub = msgStream.listen((update) async {
|
||||
allMessages = update;
|
||||
await setMessages(update, groupActions);
|
||||
_hasReceivedFirstMessageBatch = true;
|
||||
_subscriptions.messages = msgStream.listen((update) async {
|
||||
_data.allMessages = update;
|
||||
_data.messagesById = {
|
||||
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(
|
||||
widget.groupId,
|
||||
);
|
||||
if (groupContacts.length == 1) {
|
||||
_receiverDeletedAccount = groupContacts.first.accountDeleted;
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_groupContacts = groupContacts;
|
||||
_receiverDeletedAccount =
|
||||
groupContacts.length == 1 && groupContacts.first.accountDeleted;
|
||||
});
|
||||
}
|
||||
|
||||
if (userService.currentUser.typingIndicators) {
|
||||
|
|
@ -160,12 +231,12 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
|
|||
}
|
||||
|
||||
for (final msg in newMessages) {
|
||||
if (_hasReceivedFirstMessageBatch &&
|
||||
!_knownMessageIds.contains(msg.messageId) &&
|
||||
if (_animationState.hasReceivedFirstBatch &&
|
||||
!_animationState.knownMessageIds.contains(msg.messageId) &&
|
||||
msg.senderId == null) {
|
||||
_animateMessageIds.add(msg.messageId);
|
||||
_animationState.animateMessageIds.add(msg.messageId);
|
||||
}
|
||||
_knownMessageIds.add(msg.messageId);
|
||||
_animationState.knownMessageIds.add(msg.messageId);
|
||||
}
|
||||
|
||||
final chatItems = <ChatItem>[];
|
||||
|
|
@ -230,13 +301,13 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
|
|||
}
|
||||
|
||||
final wasSentByMe =
|
||||
_hasReceivedFirstMessageBatch &&
|
||||
_animationState.hasReceivedFirstBatch &&
|
||||
newMessages.isNotEmpty &&
|
||||
newMessages.last.senderId == null;
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
messages = chatItems.reversed.toList();
|
||||
_data.chatItems = chatItems.reversed.toList();
|
||||
});
|
||||
|
||||
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);
|
||||
if (!mounted) return;
|
||||
galleryItems = items.values.toList();
|
||||
setState(() {});
|
||||
setState(() {
|
||||
_data.galleryMessageIds = galleryMessageIds;
|
||||
_data.galleryItems = items.values.toList();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> scrollToMessage(String messageId) async {
|
||||
final index = messages.indexWhere(
|
||||
final index = _data.chatItems.indexWhere(
|
||||
(x) => x.isMessage && x.message!.messageId == messageId,
|
||||
);
|
||||
if (index == -1) return;
|
||||
|
|
@ -309,6 +386,7 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
|
|||
children: [
|
||||
AvatarIcon(
|
||||
group: group,
|
||||
contacts: _groupContacts,
|
||||
fontSize: 19,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
|
|
@ -330,7 +408,7 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
|
|||
group: group,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
FlameCounterWidget(groupId: group.groupId),
|
||||
FlameCounterWidget(group: group),
|
||||
],
|
||||
),
|
||||
if (group.isDirectChat)
|
||||
|
|
@ -362,10 +440,11 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
|
|||
Expanded(
|
||||
child: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: ChatMessageActionScope(
|
||||
ackedMessageIds: _data.ackedMessageIds,
|
||||
child: ScrollablePositionedList.builder(
|
||||
shrinkWrap: true,
|
||||
reverse: true,
|
||||
itemCount: messages.length + 1 + 1,
|
||||
itemCount: _data.chatItems.length + 1 + 1,
|
||||
itemScrollController: itemScrollController,
|
||||
itemBuilder: (context, i) {
|
||||
if (i == 0) {
|
||||
|
|
@ -374,7 +453,7 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
|
|||
: Container();
|
||||
}
|
||||
i -= 1;
|
||||
if (i == messages.length) {
|
||||
if (i == _data.chatItems.length) {
|
||||
return Padding(
|
||||
key: Key('overview_${group.groupId}'),
|
||||
padding: const EdgeInsets.only(top: 10),
|
||||
|
|
@ -383,36 +462,49 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
|
|||
),
|
||||
);
|
||||
}
|
||||
if (messages[i].isDate) {
|
||||
if (_data.chatItems[i].isDate) {
|
||||
return ChatDateChip(
|
||||
item: messages[i],
|
||||
item: _data.chatItems[i],
|
||||
);
|
||||
} else if (messages[i].isGroupAction) {
|
||||
} else if (_data.chatItems[i].isGroupAction) {
|
||||
return ChatGroupAction(
|
||||
key: Key(messages[i].groupAction!.groupHistoryId),
|
||||
action: messages[i].groupAction!,
|
||||
key: Key(
|
||||
_data.chatItems[i].groupAction!.groupHistoryId,
|
||||
),
|
||||
action: _data.chatItems[i].groupAction!,
|
||||
contactsById: _data.contactsById,
|
||||
);
|
||||
} else {
|
||||
final chatMessage = messages[i].message!;
|
||||
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: _animateMessageIds,
|
||||
animateIds: _animationState.animateMessageIds,
|
||||
child: ChatListEntry(
|
||||
key: Key(chatMessage.messageId),
|
||||
message: messages[i].message!,
|
||||
message: _data.chatItems[i].message!,
|
||||
nextMessage: (i > 0)
|
||||
? messages[i - 1].message
|
||||
? _data.chatItems[i - 1].message
|
||||
: null,
|
||||
prevMessage: ((i + 1) < messages.length)
|
||||
? messages[i + 1].message
|
||||
prevMessage: ((i + 1) < _data.chatItems.length)
|
||||
? _data.chatItems[i + 1].message
|
||||
: null,
|
||||
group: group,
|
||||
galleryItems: galleryItems,
|
||||
userIdToContact: userIdToContact,
|
||||
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(() {
|
||||
|
|
@ -428,6 +520,7 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
|
|||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (quotesMessage != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.only(
|
||||
|
|
|
|||
|
|
@ -9,10 +9,12 @@ import 'package:twonly/src/utils/misc.dart';
|
|||
class ChatGroupAction extends StatefulWidget {
|
||||
const ChatGroupAction({
|
||||
required this.action,
|
||||
this.contactsById,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final GroupHistory action;
|
||||
final Map<int, Contact>? contactsById;
|
||||
|
||||
@override
|
||||
State<ChatGroupAction> createState() => _ChatGroupActionState();
|
||||
|
|
@ -25,7 +27,29 @@ class _ChatGroupActionState extends State<ChatGroupAction> {
|
|||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -37,6 +37,11 @@ class ChatListEntry extends StatefulWidget {
|
|||
this.nextMessage,
|
||||
this.userIdToContact,
|
||||
this.hideReactions = false,
|
||||
this.mediaFile,
|
||||
this.reactions,
|
||||
this.messagesById,
|
||||
this.mediaFilesById,
|
||||
this.useSharedData = false,
|
||||
super.key,
|
||||
});
|
||||
final Message? prevMessage;
|
||||
|
|
@ -45,6 +50,11 @@ class ChatListEntry extends StatefulWidget {
|
|||
final Group group;
|
||||
final Map<int, Contact>? userIdToContact;
|
||||
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 void Function(String)? scrollToMessage;
|
||||
final void Function()? onResponseTriggered;
|
||||
|
|
@ -64,9 +74,24 @@ class _ChatListEntryState extends State<ChatListEntry> {
|
|||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_applySharedData();
|
||||
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
|
||||
void dispose() {
|
||||
mediaFileSub?.cancel();
|
||||
|
|
@ -75,6 +100,7 @@ class _ChatListEntryState extends State<ChatListEntry> {
|
|||
}
|
||||
|
||||
Future<void> initAsync() async {
|
||||
if (widget.useSharedData) return;
|
||||
if (widget.message.mediaId != null) {
|
||||
final mediaFileStream = twonlyDB.mediaFilesDao.watchMedia(
|
||||
widget.message.mediaId!,
|
||||
|
|
@ -131,6 +157,7 @@ class _ChatListEntryState extends State<ChatListEntry> {
|
|||
group: widget.group,
|
||||
mediaService: mediaService!,
|
||||
galleryItems: widget.galleryItems,
|
||||
useSharedData: widget.useSharedData,
|
||||
borderRadius: borderRadius,
|
||||
info: info,
|
||||
);
|
||||
|
|
@ -225,6 +252,15 @@ class _ChatListEntryState extends State<ChatListEntry> {
|
|||
mediaService: mediaService,
|
||||
borderRadius: borderRadius,
|
||||
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),
|
||||
),
|
||||
if (reactionsForWidth > 0) const SizedBox(height: 20, width: 10),
|
||||
|
|
@ -281,6 +317,10 @@ class _ChatListEntryState extends State<ChatListEntry> {
|
|||
),
|
||||
child: AvatarIcon(
|
||||
contactId: widget.message.senderId,
|
||||
contacts:
|
||||
widget.userIdToContact?[widget.message.senderId] == null
|
||||
? null
|
||||
: [widget.userIdToContact![widget.message.senderId]!],
|
||||
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(
|
||||
Message message,
|
||||
Message? prevMessage,
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ class ChatMediaEntry extends StatefulWidget {
|
|||
required this.mediaService,
|
||||
required this.borderRadius,
|
||||
required this.info,
|
||||
this.useSharedData = false,
|
||||
super.key,
|
||||
});
|
||||
|
||||
|
|
@ -38,6 +39,7 @@ class ChatMediaEntry extends StatefulWidget {
|
|||
final MediaFileService mediaService;
|
||||
final BorderRadius borderRadius;
|
||||
final BubbleInfo info;
|
||||
final bool useSharedData;
|
||||
|
||||
@override
|
||||
State<ChatMediaEntry> createState() => _ChatMediaEntryState();
|
||||
|
|
@ -179,6 +181,7 @@ class _ChatMediaEntryState extends State<ChatMediaEntry> {
|
|||
canBeReopened: _canBeReopened,
|
||||
borderRadius: imageBorderRadius,
|
||||
info: widget.info,
|
||||
useSharedData: widget.useSharedData,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ class FriendlyMessageTime extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final statusIcon = _buildStatusIcon(Colors.grey.shade400);
|
||||
final statusIcon = _buildStatusIcon(context, Colors.grey.shade400);
|
||||
|
||||
return Padding(
|
||||
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) {
|
||||
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)>>(
|
||||
stream: twonlyDB.messagesDao.watchMessageActions(message.messageId),
|
||||
builder: (context, snapshot) {
|
||||
|
|
@ -91,28 +98,36 @@ class FriendlyMessageTime extends StatelessWidget {
|
|||
(t) => t.$1.type == MessageActionType.ackByUserAt,
|
||||
);
|
||||
|
||||
if (hasAckByUser) {
|
||||
return Padding(
|
||||
return _ackIcon(iconColor, hasAckByUser);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _ackIcon(Color iconColor, bool acknowledged) => Padding(
|
||||
padding: const EdgeInsets.only(left: 4),
|
||||
child: FaIcon(
|
||||
FontAwesomeIcons.checkDouble,
|
||||
acknowledged ? FontAwesomeIcons.checkDouble : FontAwesomeIcons.check,
|
||||
size: 8,
|
||||
color: iconColor,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 4),
|
||||
child: FaIcon(
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ class InChatMediaViewer extends StatefulWidget {
|
|||
required this.canBeReopened,
|
||||
required this.borderRadius,
|
||||
required this.info,
|
||||
this.useSharedData = false,
|
||||
super.key,
|
||||
});
|
||||
|
||||
|
|
@ -33,6 +34,7 @@ class InChatMediaViewer extends StatefulWidget {
|
|||
final bool canBeReopened;
|
||||
final BorderRadius borderRadius;
|
||||
final BubbleInfo info;
|
||||
final bool useSharedData;
|
||||
|
||||
@override
|
||||
State<InChatMediaViewer> createState() => _InChatMediaViewerState();
|
||||
|
|
@ -42,7 +44,6 @@ class _InChatMediaViewerState extends State<InChatMediaViewer> {
|
|||
bool mirrorVideo = false;
|
||||
int? galleryItemIndex;
|
||||
StreamSubscription<Message?>? messageStream;
|
||||
Timer? _timer;
|
||||
late final ValueNotifier<String?> _activeMediaIdNotifier = ValueNotifier(
|
||||
widget.message.mediaId,
|
||||
);
|
||||
|
|
@ -50,32 +51,22 @@ class _InChatMediaViewerState extends State<InChatMediaViewer> {
|
|||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
unawaited(loadIndexAsync());
|
||||
unawaited(initStream());
|
||||
loadIndex();
|
||||
if (!widget.useSharedData) unawaited(initStream());
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(InChatMediaViewer oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.message.mediaId != oldWidget.message.mediaId) {
|
||||
_activeMediaIdNotifier.value = widget.message.mediaId;
|
||||
}
|
||||
if (widget.message.mediaStored != oldWidget.message.mediaStored ||
|
||||
widget.galleryItems != oldWidget.galleryItems) {
|
||||
if (widget.message.mediaStored) {
|
||||
unawaited(loadIndexAsync());
|
||||
galleryItemIndex = null;
|
||||
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() {
|
||||
if (widget.message.mediaStored) {
|
||||
|
|
@ -93,7 +84,6 @@ class _InChatMediaViewerState extends State<InChatMediaViewer> {
|
|||
@override
|
||||
void dispose() {
|
||||
messageStream?.cancel();
|
||||
_timer?.cancel();
|
||||
_activeMediaIdNotifier.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
|
@ -112,7 +102,7 @@ class _InChatMediaViewerState extends State<InChatMediaViewer> {
|
|||
if (updated != null) {
|
||||
if (updated.mediaStored) {
|
||||
await messageStream?.cancel();
|
||||
await loadIndexAsync();
|
||||
if (loadIndex() && mounted) setState(() {});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -17,6 +17,10 @@ class ResponseContainer extends StatelessWidget {
|
|||
required this.mediaService,
|
||||
required this.borderRadius,
|
||||
this.scrollToMessage,
|
||||
this.quotedMessage,
|
||||
this.quotedMediaFile,
|
||||
this.contactsById,
|
||||
this.useSharedData = false,
|
||||
super.key,
|
||||
});
|
||||
|
||||
|
|
@ -26,6 +30,10 @@ class ResponseContainer extends StatelessWidget {
|
|||
final MediaFileService? mediaService;
|
||||
final BorderRadius borderRadius;
|
||||
final void Function(String)? scrollToMessage;
|
||||
final Message? quotedMessage;
|
||||
final MediaFile? quotedMediaFile;
|
||||
final Map<int, Contact>? contactsById;
|
||||
final bool useSharedData;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -67,6 +75,12 @@ class ResponseContainer extends StatelessWidget {
|
|||
child: ResponsePreview(
|
||||
group: group,
|
||||
messageId: msg.quotesMessageId,
|
||||
message: quotedMessage,
|
||||
mediaFile: quotedMediaFile,
|
||||
contact: quotedMessage?.senderId == null
|
||||
? null
|
||||
: contactsById?[quotedMessage!.senderId],
|
||||
useSharedData: useSharedData,
|
||||
showBorder: false,
|
||||
showLeftBorder: false,
|
||||
),
|
||||
|
|
@ -89,6 +103,9 @@ class ResponsePreview extends StatefulWidget {
|
|||
this.messageId,
|
||||
this.showLeftBorder = true,
|
||||
this.colorUsername = false,
|
||||
this.mediaFile,
|
||||
this.contact,
|
||||
this.useSharedData = false,
|
||||
super.key,
|
||||
});
|
||||
|
||||
|
|
@ -98,6 +115,9 @@ class ResponsePreview extends StatefulWidget {
|
|||
final bool showBorder;
|
||||
final bool showLeftBorder;
|
||||
final bool colorUsername;
|
||||
final MediaFile? mediaFile;
|
||||
final Contact? contact;
|
||||
final bool useSharedData;
|
||||
|
||||
@override
|
||||
State<ResponsePreview> createState() => _ResponsePreviewState();
|
||||
|
|
@ -112,17 +132,30 @@ class _ResponsePreviewState extends State<ResponsePreview> {
|
|||
void initState() {
|
||||
super.initState();
|
||||
_message = widget.message;
|
||||
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 {
|
||||
_message ??= await twonlyDB.messagesDao
|
||||
if (_message == null && widget.messageId != null) {
|
||||
_message = await twonlyDB.messagesDao
|
||||
.getMessageById(widget.messageId!)
|
||||
.getSingleOrNull();
|
||||
if (_message?.mediaId != null) {
|
||||
}
|
||||
if (_message?.mediaId != null && _mediaService == null) {
|
||||
_mediaService = await MediaFileService.fromMediaId(_message!.mediaId!);
|
||||
}
|
||||
if (_message?.senderId != null) {
|
||||
if (_message?.senderId != null && _username.isEmpty) {
|
||||
final contact = await twonlyDB.contactsDao
|
||||
.getContactByUserId(_message!.senderId!)
|
||||
.getSingleOrNull();
|
||||
|
|
|
|||
3
rust/.env
Normal file
3
rust/.env
Normal 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
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT INTO user_discovery_shares (share) VALUES (?)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "385a7075d70cf7ce1194c6ee5757d2ab4145f9bb2d1f65512f17dca5a0e2ed15"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM user_discovery_shares",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "d36767ac50047037ce2122bd59b84c94407918776b0ad2b8c47a1bfb041b620b"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
21
rust/scripts/prepare_sqlx.sh
Executable 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."
|
||||
|
|
@ -1,7 +1,11 @@
|
|||
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::keys::{DatabaseKey, KeyManager};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs::{remove_file, File};
|
||||
use std::io::{copy, Cursor};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
|
@ -12,6 +16,23 @@ use zip::{CompressionMethod, ZipArchive, ZipWriter};
|
|||
|
||||
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 {
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn get_backup_files(
|
||||
|
|
@ -22,10 +43,17 @@ impl BackupArchive {
|
|||
let database_dir = PathBuf::from(&config.database_dir);
|
||||
let data_dir = PathBuf::from(&config.data_dir);
|
||||
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![
|
||||
("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.json", data_dir.join("keyvalue"), false, None),
|
||||
])
|
||||
|
|
@ -56,6 +84,30 @@ impl BackupArchive {
|
|||
}
|
||||
|
||||
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),
|
||||
// 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));
|
||||
|
|
@ -87,6 +139,8 @@ impl BackupArchive {
|
|||
encryption_key.zeroize();
|
||||
}
|
||||
|
||||
Self::write_manifest(&backup_data_dir)?;
|
||||
|
||||
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)? {
|
||||
let src = restore_temp_dir.join(file_name);
|
||||
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)?;
|
||||
|
||||
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)]
|
||||
mod tests {
|
||||
use crate::{
|
||||
database::tables::received_messages::ReceivedMessage, secure_storage::SecureStorage,
|
||||
};
|
||||
use crate::secure_storage::SecureStorage;
|
||||
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
|
@ -195,27 +388,21 @@ mod tests {
|
|||
let original_login_token = {
|
||||
let secure_storage = SecureStorage::new("testing");
|
||||
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();
|
||||
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
|
||||
let config_file = PathBuf::from(&config.data_dir).join("user_discovery_config.json");
|
||||
std::fs::write(config_file, "original config").unwrap();
|
||||
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
|
||||
let backup_path = BackupArchive::create_backup(&ctx).await.unwrap();
|
||||
|
|
@ -224,22 +411,15 @@ mod tests {
|
|||
// 3. Modify data (to simulate state before restore)
|
||||
{
|
||||
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");
|
||||
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
|
||||
|
|
@ -250,27 +430,100 @@ mod tests {
|
|||
// 5. Verify restored data
|
||||
{
|
||||
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();
|
||||
|
||||
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_content = std::fs::read_to_string(config_file).unwrap();
|
||||
assert_eq!(config_content, "original config");
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
pub(crate) mod log;
|
||||
mod macros;
|
||||
pub(crate) mod user_discovery;
|
||||
|
||||
use crate::user_discovery::traits::{AnnouncedUser, OtherPromotion};
|
||||
use flutter_rust_bridge::DartFnFuture;
|
||||
|
||||
use crate::error::{Result, TwonlyError};
|
||||
|
|
@ -23,25 +20,6 @@ callback_generator! {
|
|||
FlutterCallbacks {
|
||||
Logging logging {
|
||||
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>>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -4,23 +4,22 @@ pub mod wrapper;
|
|||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::bridge::callbacks::user_discovery::{
|
||||
UserDiscoveryStoreFlutter, UserDiscoveryUtilsFlutter,
|
||||
};
|
||||
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::TwonlyError;
|
||||
use crate::keys::KeyManager;
|
||||
use crate::secure_storage::SecureStorage;
|
||||
use crate::signal::engine::RustSignalEngine;
|
||||
use crate::user_discovery::stores::{NativeUserDiscoveryStore, NativeUserDiscoveryUtils};
|
||||
use crate::user_discovery::UserDiscovery;
|
||||
use crate::utils::Shared;
|
||||
use flutter_rust_bridge::frb;
|
||||
|
||||
pub use crate::user_discovery::traits::AnnouncedUser;
|
||||
pub use crate::user_discovery::traits::OtherPromotion;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
|
||||
pub struct InitConfig {
|
||||
pub database_dir: String,
|
||||
|
|
@ -48,9 +47,10 @@ pub(crate) struct TwonlyFlutter {
|
|||
#[allow(dead_code)]
|
||||
pub(crate) config: InitConfig,
|
||||
pub(crate) user_discovery:
|
||||
Shared<UserDiscovery<UserDiscoveryStoreFlutter, UserDiscoveryUtilsFlutter>>,
|
||||
Shared<UserDiscovery<NativeUserDiscoveryStore, NativeUserDiscoveryUtils>>,
|
||||
#[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) key_manager: Arc<Mutex<KeyManager>>,
|
||||
pub(crate) signal_engine: Arc<Mutex<Option<RustSignalEngine>>>,
|
||||
|
|
|
|||
73
rust/src/bridge/wrapper/app_database.rs
Normal file
73
rust/src/bridge/wrapper/app_database.rs
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
pub mod app_database;
|
||||
pub mod backup;
|
||||
pub mod key_manager;
|
||||
pub mod signal;
|
||||
|
|
|
|||
|
|
@ -8,9 +8,7 @@ impl RustSignal {
|
|||
pub async fn generate_bundle() -> Result<FrbPreKeyBundle> {
|
||||
let guard = get_twonly_flutter()?.signal_engine.lock().await;
|
||||
let engine = guard.as_ref().ok_or(TwonlyError::Initialization)?;
|
||||
engine
|
||||
.generate_bundle()
|
||||
.await
|
||||
engine.generate_bundle().await
|
||||
}
|
||||
|
||||
pub async fn generate_pqc_prekeys() -> Result<Vec<FrbPqcPreKey>> {
|
||||
|
|
|
|||
|
|
@ -1,21 +1,31 @@
|
|||
use crate::database::app::{AppDatabase, APP_DATABASE_FILE};
|
||||
use crate::signal::engine::RustSignalEngine;
|
||||
use crate::user_discovery::stores::{NativeUserDiscoveryStore, NativeUserDiscoveryUtils};
|
||||
use crate::user_discovery::UserDiscovery;
|
||||
use crate::{
|
||||
bridge::{
|
||||
callbacks::user_discovery::{UserDiscoveryStoreFlutter, UserDiscoveryUtilsFlutter},
|
||||
InitConfig,
|
||||
},
|
||||
database::Database,
|
||||
bridge::InitConfig,
|
||||
database::signal::Database,
|
||||
error::{Result, TwonlyError},
|
||||
keys::{DatabaseKey, KeyManager},
|
||||
log::init_tracing,
|
||||
utils::Shared,
|
||||
};
|
||||
use std::{path::PathBuf, sync::Arc};
|
||||
use tokio::sync::{Mutex, OnceCell};
|
||||
use tokio::sync::{Mutex, OnceCell, RwLock};
|
||||
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 {
|
||||
Flutter(TwonlyFlutter),
|
||||
|
|
@ -73,9 +83,20 @@ impl Context {
|
|||
rust_db.run_migrations().await?;
|
||||
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 {
|
||||
config,
|
||||
rust_db,
|
||||
rust_db: Arc::new(RwLock::new(rust_db)),
|
||||
app_db,
|
||||
secure_storage,
|
||||
key_manager: Arc::new(Mutex::new(key_manager)),
|
||||
}))
|
||||
|
|
@ -100,6 +121,7 @@ impl Context {
|
|||
|
||||
let database_dir = PathBuf::from(&config.database_dir.clone());
|
||||
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.");
|
||||
let res: Result<&'static Context> = GLOBAL_CONTEXT
|
||||
|
|
@ -129,36 +151,59 @@ impl Context {
|
|||
.await?;
|
||||
rust_db.run_migrations().await?;
|
||||
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();
|
||||
|
||||
if is_flutter {
|
||||
let mut signal_engine = Arc::default();
|
||||
if let Some(user_id) = key_manager.user_id {
|
||||
if let Some(signal_identity) = &key_manager.signal_identity {
|
||||
signal_engine = Arc::new(Mutex::new(Some(RustSignalEngine::new_with_pool(
|
||||
rust_db.pool.clone(),
|
||||
let key_manager = Arc::new(Mutex::new(key_manager));
|
||||
let signal_engine = {
|
||||
let key_manager_guard = key_manager.lock().await;
|
||||
let engine = match (
|
||||
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.registration_id as u32,
|
||||
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 {
|
||||
config,
|
||||
secure_storage,
|
||||
rust_db,
|
||||
key_manager: Arc::new(Mutex::new(key_manager)),
|
||||
user_discovery: Shared::new(UserDiscovery::new(
|
||||
UserDiscoveryStoreFlutter {},
|
||||
UserDiscoveryUtilsFlutter {},
|
||||
)?),
|
||||
rust_db: rust_db_handle,
|
||||
app_db,
|
||||
key_manager,
|
||||
user_discovery,
|
||||
signal_engine,
|
||||
}))
|
||||
} else {
|
||||
Ok(Context::Standalone(TwonlyStandalone {
|
||||
config,
|
||||
rust_db,
|
||||
rust_db: rust_db_handle,
|
||||
app_db,
|
||||
key_manager: Arc::new(Mutex::new(key_manager)),
|
||||
secure_storage,
|
||||
}))
|
||||
|
|
@ -193,4 +238,50 @@ impl Context {
|
|||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
354
rust/src/database/app/legacy_import.rs
Normal file
354
rust/src/database/app/legacy_import.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
258
rust/src/database/app/migrations/0001_initial.sql
Normal file
258
rust/src/database/app/migrations/0001_initial.sql
Normal 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
|
||||
);
|
||||
300
rust/src/database/app/mod.rs
Normal file
300
rust/src/database/app/mod.rs
Normal 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,
|
||||
}
|
||||
|
|
@ -1,203 +1,2 @@
|
|||
use crate::error::{Result, TwonlyError};
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
|
||||
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);
|
||||
}
|
||||
}
|
||||
pub mod app;
|
||||
pub mod signal;
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
DROP TABLE IF EXISTS received_messages;
|
||||
180
rust/src/database/signal/mod.rs
Normal file
180
rust/src/database/signal/mod.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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).
|
||||
|
|
@ -18,6 +18,7 @@ pub struct MainKey {
|
|||
#[derive(Debug)]
|
||||
pub(crate) enum DatabaseKey {
|
||||
RustDb,
|
||||
AppDb,
|
||||
}
|
||||
|
||||
impl MainKey {
|
||||
|
|
@ -42,8 +43,9 @@ impl MainKey {
|
|||
|
||||
/// Derives the database encryption key.
|
||||
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::AppDb => b"app_db",
|
||||
};
|
||||
let info = [b"database_key_", db_name as &[u8]].concat();
|
||||
let key = self.derive_key(&info);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,5 @@ mod keys;
|
|||
mod log;
|
||||
mod secure_storage;
|
||||
pub mod signal;
|
||||
mod standalone;
|
||||
mod user_discovery;
|
||||
mod utils;
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ pub struct FrbPqcPreKey {
|
|||
impl RustSignalEngine {
|
||||
pub async fn new(local_name: String) -> Result<Self> {
|
||||
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 signal_identity = km
|
||||
|
|
@ -541,8 +541,11 @@ mod tests {
|
|||
// create the file manually just to be sure
|
||||
std::fs::File::create(&db_path).unwrap();
|
||||
|
||||
let db =
|
||||
crate::database::Database::new(&db_path.to_str().unwrap().to_string(), None, false)
|
||||
let db = crate::database::signal::Database::new(
|
||||
&db_path.to_str().unwrap().to_string(),
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
db.run_migrations().await.unwrap();
|
||||
|
|
@ -567,7 +570,7 @@ mod tests {
|
|||
async fn test_generate_pqc_prekeys() {
|
||||
let (engine, _dir) = create_test_engine("alice").await;
|
||||
let prekeys = engine.generate_pqc_prekeys().await.unwrap();
|
||||
assert_eq!(prekeys.len(), 50);
|
||||
assert_eq!(prekeys.len(), 30);
|
||||
for prekey in prekeys {
|
||||
assert!(prekey.ecc_pre_key.len() > 0);
|
||||
assert!(prekey.kyber_pre_key.len() > 0);
|
||||
|
|
|
|||
|
|
@ -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>>,
|
||||
}
|
||||
|
|
@ -2,3 +2,6 @@
|
|||
mod in_memory_store;
|
||||
#[cfg(test)]
|
||||
pub(super) use in_memory_store::InMemoryStore;
|
||||
|
||||
mod native;
|
||||
pub(crate) use native::{NativeUserDiscoveryStore, NativeUserDiscoveryUtils};
|
||||
|
|
|
|||
552
rust/src/user_discovery/stores/native.rs
Normal file
552
rust/src/user_discovery/stores/native.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
#[tokio::test]
|
||||
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;
|
||||
|
||||
let _ = pretty_env_logger::try_init();
|
||||
|
|
|
|||
|
|
@ -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 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)) {
|
||||
project.exec {
|
||||
project.providers.exec {
|
||||
commandLine 'chmod', '+x', path
|
||||
}
|
||||
}.result.get()
|
||||
}
|
||||
|
||||
project.exec {
|
||||
project.providers.exec {
|
||||
executable path
|
||||
args "build-gradle"
|
||||
environment "CARGOKIT_ROOT_PROJECT_DIR", rootProjectDir
|
||||
|
|
@ -80,7 +80,7 @@ abstract class CargoKitBuildTask extends DefaultTask {
|
|||
environment "CARGOKIT_MIN_SDK_VERSION", minSdkVersion
|
||||
environment "CARGOKIT_TARGET_PLATFORMS", targetPlatforms.join(",")
|
||||
environment "CARGOKIT_JAVA_HOME", System.properties['java.home']
|
||||
}
|
||||
}.result.get()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue