working on #25, #65, fixes #128, #127, #122, #119, #77

This commit is contained in:
otsmr 2025-04-24 00:28:07 +02:00
parent a845065faf
commit 5a6afaa6d4
30 changed files with 5113 additions and 663 deletions

File diff suppressed because one or more lines are too long

View file

@ -31,7 +31,7 @@ void main() async {
apiProvider = ApiProvider();
twonlyDatabase = TwonlyDatabase();
await twonlyDatabase.messagesDao.appRestarted();
await twonlyDatabase.messagesDao.resetPendingDownloadState();
await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);

View file

@ -0,0 +1,44 @@
import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:logging/logging.dart';
import 'package:twonly/src/database/tables/media_download_table.dart';
import 'package:twonly/src/database/twonly_database.dart';
part 'media_downloads_dao.g.dart';
@DriftAccessor(tables: [MediaDownloads])
class MediaDownloadsDao extends DatabaseAccessor<TwonlyDatabase>
with _$MediaDownloadsDaoMixin {
MediaDownloadsDao(super.db);
Future updateMediaDownload(
int messageId, MediaDownloadsCompanion updatedValues) {
return (update(mediaDownloads)..where((c) => c.messageId.equals(messageId)))
.write(updatedValues);
}
Future<int?> insertMediaDownload(MediaDownloadsCompanion values) async {
try {
return await into(mediaDownloads).insert(values);
} catch (e) {
Logger("media_downloads_dao.dart")
.shout("Error while inserting media upload: $e");
return null;
}
}
Future deleteMediaDownload(int messageId) {
return (delete(mediaDownloads)..where((t) => t.messageId.equals(messageId)))
.go();
}
SingleOrNullSelectable<MediaDownload> getMediaDownloadById(int messageId) {
return select(mediaDownloads)..where((t) => t.messageId.equals(messageId));
}
SingleOrNullSelectable<MediaDownload> getMediaDownloadByDownloadToken(
List<int> downloadToken) {
return select(mediaDownloads)
..where((t) => t.downloadToken.equals(json.encode(downloadToken)));
}
}

View file

@ -0,0 +1,8 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'media_downloads_dao.dart';
// ignore_for_file: type=lint
mixin _$MediaDownloadsDaoMixin on DatabaseAccessor<TwonlyDatabase> {
$MediaDownloadsTable get mediaDownloads => attachedDatabase.mediaDownloads;
}

View file

@ -0,0 +1,47 @@
import 'package:drift/drift.dart';
import 'package:logging/logging.dart';
import 'package:twonly/src/database/tables/media_uploads_table.dart';
import 'package:twonly/src/database/twonly_database.dart';
part 'media_uploads_dao.g.dart';
@DriftAccessor(tables: [MediaUploads])
class MediaUploadsDao extends DatabaseAccessor<TwonlyDatabase>
with _$MediaUploadsDaoMixin {
MediaUploadsDao(super.db);
Future<List<MediaUpload>> getMediaUploadsForRetry() {
return (select(mediaUploads)
..where(
(t) => t.state.equals(UploadState.receiverNotified.name).not()))
.get();
}
Future updateMediaUpload(
int mediaUploadId, MediaUploadsCompanion updatedValues) {
return (update(mediaUploads)
..where((c) => c.mediaUploadId.equals(mediaUploadId)))
.write(updatedValues);
}
Future<int?> insertMediaUpload(MediaUploadsCompanion values) async {
try {
return await into(mediaUploads).insert(values);
} catch (e) {
Logger("media_uploads_dao.dart")
.shout("Error while inserting media upload: $e");
return null;
}
}
Future deleteMediaUpload(int mediaUploadId) {
return (delete(mediaUploads)
..where((t) => t.mediaUploadId.equals(mediaUploadId)))
.go();
}
SingleOrNullSelectable<MediaUpload> getMediaUploadById(int mediaUploadId) {
return select(mediaUploads)
..where((t) => t.mediaUploadId.equals(mediaUploadId));
}
}

View file

@ -0,0 +1,8 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'media_uploads_dao.dart';
// ignore_for_file: type=lint
mixin _$MediaUploadsDaoMixin on DatabaseAccessor<TwonlyDatabase> {
$MediaUploadsTable get mediaUploads => attachedDatabase.mediaUploads;
}

View file

@ -104,7 +104,7 @@ class MessagesDao extends DatabaseAccessor<TwonlyDatabase>
.write(updates);
}
Future appRestarted() {
Future resetPendingDownloadState() {
// All media files in the downloading state are reseteded to the pending state
// When the app is used in mobile network, they will not be downloaded at the start
// if they are not yet downloaded...

View file

@ -0,0 +1,8 @@
import 'package:drift/drift.dart';
import 'package:twonly/src/database/tables/media_uploads_table.dart';
@DataClassName('MediaDownload')
class MediaDownloads extends Table {
IntColumn get messageId => integer()();
TextColumn get downloadToken => text().map(IntListTypeConverter())();
}

View file

@ -0,0 +1,216 @@
import 'dart:convert';
import 'package:drift/drift.dart';
enum UploadState {
pending,
addedToMessagesDb,
// added .compressed to filename
isCompressed,
// added .encypted to filename
isEncrypted,
hasUploadToken,
isUploaded,
receiverNotified,
// after all users notified all media files that are not storeable by the other person will be deleted
}
@DataClassName('MediaUpload')
class MediaUploads extends Table {
IntColumn get mediaUploadId => integer().autoIncrement()();
TextColumn get state =>
textEnum<UploadState>().withDefault(Constant(UploadState.pending.name))();
TextColumn get metadata => text().map(MediaUploadMetadataConverter())();
/// exists in UploadState.addedToMessagesDb
TextColumn get messageIds => text().map(IntListTypeConverter()).nullable()();
/// exsists in UploadState.isEncrypted
TextColumn get encryptionData =>
text().map(MediaEncryptionDataConverter()).nullable()();
/// exsists in UploadState.hasUploadToken
TextColumn get uploadTokens =>
text().map(MediaUploadTokensConverter()).nullable()();
/// exists in UploadState.addedToMessagesDb
TextColumn get alreadyNotified =>
text().map(IntListTypeConverter()).withDefault(Constant("[]"))();
}
// --- state ----
class MediaUploadMetadata {
late List<int> contactIds;
late bool isRealTwonly;
late int maxShowTime;
late DateTime messageSendAt;
late bool isVideo;
late bool videoWithAudio;
MediaUploadMetadata();
Map<String, dynamic> toJson() {
return {
'contactIds': contactIds,
'isRealTwonly': isRealTwonly,
'maxShowTime': maxShowTime,
'isVideo': isVideo,
'videoWithAudio': videoWithAudio,
'messageSendAt': messageSendAt.toIso8601String(),
};
}
factory MediaUploadMetadata.fromJson(Map<String, dynamic> json) {
MediaUploadMetadata state = MediaUploadMetadata();
state.contactIds = List<int>.from(json['contactIds']);
state.isRealTwonly = json['isRealTwonly'];
state.isVideo = json['isVideo'];
state.maxShowTime = json['maxShowTime'];
state.maxShowTime = json['maxShowTime'];
state.messageSendAt = DateTime.parse(json['messageSendAt']);
return state;
}
}
class MediaEncryptionData {
late List<int> sha2Hash;
late List<int> encryptionKey;
late List<int> encryptionMac;
late List<int> encryptionNonce;
MediaEncryptionData();
Map<String, dynamic> toJson() {
return {
'sha2Hash': sha2Hash,
'encryptionKey': encryptionKey,
'encryptionMac': encryptionMac,
'encryptionNonce': encryptionNonce,
};
}
factory MediaEncryptionData.fromJson(Map<String, dynamic> json) {
MediaEncryptionData state = MediaEncryptionData();
state.sha2Hash = List<int>.from(json['sha2Hash']);
state.encryptionKey = List<int>.from(json['encryptionKey']);
state.encryptionMac = List<int>.from(json['encryptionMac']);
state.encryptionNonce = List<int>.from(json['encryptionNonce']);
return state;
}
}
class MediaUploadTokens {
late List<int> uploadToken;
late List<List<int>> downloadTokens;
MediaUploadTokens();
Map<String, dynamic> toJson() {
return {
'uploadToken': uploadToken,
'downloadTokens': downloadTokens,
};
}
factory MediaUploadTokens.fromJson(Map<String, dynamic> json) {
MediaUploadTokens state = MediaUploadTokens();
state.uploadToken = List<int>.from(json['uploadToken']);
state.downloadTokens = List<List<int>>.from(
json['downloadTokens'].map((token) => List<int>.from(token)),
);
return state;
}
}
// --- converters ----
class IntListTypeConverter extends TypeConverter<List<int>, String> {
@override
List<int> fromSql(String fromDb) {
return List<int>.from(jsonDecode(fromDb));
}
@override
String toSql(List<int> value) {
return json.encode(value);
}
}
class MediaUploadMetadataConverter
extends TypeConverter<MediaUploadMetadata, String>
with JsonTypeConverter2<MediaUploadMetadata, String, Map<String, Object?>> {
const MediaUploadMetadataConverter();
@override
MediaUploadMetadata fromSql(String fromDb) {
return fromJson(json.decode(fromDb) as Map<String, dynamic>);
}
@override
String toSql(MediaUploadMetadata value) {
return json.encode(toJson(value));
}
@override
MediaUploadMetadata fromJson(Map<String, Object?> json) {
return MediaUploadMetadata.fromJson(json);
}
@override
Map<String, Object?> toJson(MediaUploadMetadata value) {
return value.toJson();
}
}
class MediaEncryptionDataConverter
extends TypeConverter<MediaEncryptionData, String>
with JsonTypeConverter2<MediaEncryptionData, String, Map<String, Object?>> {
const MediaEncryptionDataConverter();
@override
MediaEncryptionData fromSql(String fromDb) {
return fromJson(json.decode(fromDb) as Map<String, dynamic>);
}
@override
String toSql(MediaEncryptionData value) {
return json.encode(toJson(value));
}
@override
MediaEncryptionData fromJson(Map<String, Object?> json) {
return MediaEncryptionData.fromJson(json);
}
@override
Map<String, Object?> toJson(MediaEncryptionData value) {
return value.toJson();
}
}
class MediaUploadTokensConverter
extends TypeConverter<MediaUploadTokens, String>
with JsonTypeConverter2<MediaUploadTokens, String, Map<String, Object?>> {
const MediaUploadTokensConverter();
@override
MediaUploadTokens fromSql(String fromDb) {
return fromJson(json.decode(fromDb) as Map<String, dynamic>);
}
@override
String toSql(MediaUploadTokens value) {
return json.encode(toJson(value));
}
@override
MediaUploadTokens fromJson(Map<String, Object?> json) {
return MediaUploadTokens.fromJson(json);
}
@override
Map<String, Object?> toJson(MediaUploadTokens value) {
return value.toJson();
}
}

View file

@ -27,6 +27,9 @@ class Messages extends Table {
IntColumn get messageId => integer().autoIncrement()();
IntColumn get messageOtherId => integer().nullable()();
IntColumn get mediaUploadId => integer().nullable()();
IntColumn get mediaDownloadId => integer().nullable()();
IntColumn get responseToMessageId => integer().nullable()();
IntColumn get responseToOtherMessageId => integer().nullable()();

View file

@ -3,8 +3,12 @@ import 'package:drift_flutter/drift_flutter.dart'
show driftDatabase, DriftNativeOptions;
import 'package:path_provider/path_provider.dart';
import 'package:twonly/src/database/daos/contacts_dao.dart';
import 'package:twonly/src/database/daos/media_downloads_dao.dart';
import 'package:twonly/src/database/daos/media_uploads_dao.dart';
import 'package:twonly/src/database/daos/messages_dao.dart';
import 'package:twonly/src/database/tables/contacts_table.dart';
import 'package:twonly/src/database/tables/media_download_table.dart';
import 'package:twonly/src/database/tables/media_uploads_table.dart';
import 'package:twonly/src/database/tables/messages_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';
@ -18,13 +22,17 @@ part 'twonly_database.g.dart';
@DriftDatabase(tables: [
Contacts,
Messages,
MediaUploads,
MediaDownloads,
SignalIdentityKeyStores,
SignalPreKeyStores,
SignalSenderKeyStores,
SignalSessionStores
], daos: [
MessagesDao,
ContactsDao
ContactsDao,
MediaUploadsDao,
MediaDownloadsDao,
])
class TwonlyDatabase extends _$TwonlyDatabase {
TwonlyDatabase([QueryExecutor? e])
@ -35,7 +43,7 @@ class TwonlyDatabase extends _$TwonlyDatabase {
TwonlyDatabase.forTesting(DatabaseConnection super.connection);
@override
int get schemaVersion => 3;
int get schemaVersion => 4;
static QueryExecutor _openConnection() {
return driftDatabase(
@ -58,6 +66,9 @@ class TwonlyDatabase extends _$TwonlyDatabase {
m.addColumn(
schema.contacts, schema.contacts.deleteMessagesAfterXMinutes);
},
from3To4: (m, schema) async {
m.createTable(mediaUploads);
},
),
);
}

File diff suppressed because it is too large Load diff

View file

@ -599,9 +599,209 @@ i1.GeneratedColumn<int> _column_40(String aliasedName) =>
'delete_messages_after_x_minutes', aliasedName, false,
type: i1.DriftSqlType.int,
defaultValue: const CustomExpression('1440'));
final class Schema4 extends i0.VersionedSchema {
Schema4({required super.database}) : super(version: 4);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
contacts,
messages,
mediaUploads,
signalIdentityKeyStores,
signalPreKeyStores,
signalSenderKeyStores,
signalSessionStores,
];
late final Shape6 contacts = Shape6(
source: i0.VersionedTable(
entityName: 'contacts',
withoutRowId: false,
isStrict: false,
tableConstraints: [
'PRIMARY KEY(user_id)',
],
columns: [
_column_0,
_column_1,
_column_2,
_column_3,
_column_4,
_column_5,
_column_6,
_column_7,
_column_8,
_column_9,
_column_39,
_column_40,
_column_10,
_column_11,
_column_12,
_column_13,
_column_14,
_column_15,
_column_16,
],
attachedDatabase: database,
),
alias: null);
late final Shape1 messages = Shape1(
source: i0.VersionedTable(
entityName: 'messages',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_17,
_column_18,
_column_19,
_column_20,
_column_21,
_column_22,
_column_23,
_column_24,
_column_25,
_column_26,
_column_27,
_column_28,
_column_29,
_column_30,
],
attachedDatabase: database,
),
alias: null);
late final Shape7 mediaUploads = Shape7(
source: i0.VersionedTable(
entityName: 'media_uploads',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_41,
_column_42,
_column_43,
_column_44,
_column_45,
_column_46,
_column_47,
],
attachedDatabase: database,
),
alias: null);
late final Shape2 signalIdentityKeyStores = Shape2(
source: i0.VersionedTable(
entityName: 'signal_identity_key_stores',
withoutRowId: false,
isStrict: false,
tableConstraints: [
'PRIMARY KEY(device_id, name)',
],
columns: [
_column_31,
_column_32,
_column_33,
_column_10,
],
attachedDatabase: database,
),
alias: null);
late final Shape3 signalPreKeyStores = Shape3(
source: i0.VersionedTable(
entityName: 'signal_pre_key_stores',
withoutRowId: false,
isStrict: false,
tableConstraints: [
'PRIMARY KEY(pre_key_id)',
],
columns: [
_column_34,
_column_35,
_column_10,
],
attachedDatabase: database,
),
alias: null);
late final Shape4 signalSenderKeyStores = Shape4(
source: i0.VersionedTable(
entityName: 'signal_sender_key_stores',
withoutRowId: false,
isStrict: false,
tableConstraints: [
'PRIMARY KEY(sender_key_name)',
],
columns: [
_column_36,
_column_37,
],
attachedDatabase: database,
),
alias: null);
late final Shape5 signalSessionStores = Shape5(
source: i0.VersionedTable(
entityName: 'signal_session_stores',
withoutRowId: false,
isStrict: false,
tableConstraints: [
'PRIMARY KEY(device_id, name)',
],
columns: [
_column_31,
_column_32,
_column_38,
_column_10,
],
attachedDatabase: database,
),
alias: null);
}
class Shape7 extends i0.VersionedTable {
Shape7({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<int> get mediaUploadId =>
columnsByName['media_upload_id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get state =>
columnsByName['state']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get metadata =>
columnsByName['metadata']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get messageIds =>
columnsByName['message_ids']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get encryptionData =>
columnsByName['encryption_data']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get uploadTokens =>
columnsByName['upload_tokens']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get alreadyNotified =>
columnsByName['already_notified']! as i1.GeneratedColumn<String>;
}
i1.GeneratedColumn<int> _column_41(String aliasedName) =>
i1.GeneratedColumn<int>('media_upload_id', aliasedName, false,
hasAutoIncrement: true,
type: i1.DriftSqlType.int,
defaultConstraints:
i1.GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT'));
i1.GeneratedColumn<String> _column_42(String aliasedName) =>
i1.GeneratedColumn<String>('state', aliasedName, false,
type: i1.DriftSqlType.string,
defaultValue: const CustomExpression('\'pending\''));
i1.GeneratedColumn<String> _column_43(String aliasedName) =>
i1.GeneratedColumn<String>('metadata', aliasedName, false,
type: i1.DriftSqlType.string);
i1.GeneratedColumn<String> _column_44(String aliasedName) =>
i1.GeneratedColumn<String>('message_ids', aliasedName, true,
type: i1.DriftSqlType.string);
i1.GeneratedColumn<String> _column_45(String aliasedName) =>
i1.GeneratedColumn<String>('encryption_data', aliasedName, true,
type: i1.DriftSqlType.string);
i1.GeneratedColumn<String> _column_46(String aliasedName) =>
i1.GeneratedColumn<String>('upload_tokens', aliasedName, true,
type: i1.DriftSqlType.string);
i1.GeneratedColumn<String> _column_47(String aliasedName) =>
i1.GeneratedColumn<String>('already_notified', aliasedName, false,
type: i1.DriftSqlType.string,
defaultValue: const CustomExpression('\'[]\''));
i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
required Future<void> Function(i1.Migrator m, Schema4 schema) from3To4,
}) {
return (currentVersion, database) async {
switch (currentVersion) {
@ -615,6 +815,11 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema);
await from2To3(migrator, schema);
return 3;
case 3:
final schema = Schema4(database: database);
final migrator = i1.Migrator(database, schema);
await from3To4(migrator, schema);
return 4;
default:
throw ArgumentError.value('Unknown migration from $currentVersion');
}
@ -624,9 +829,11 @@ i0.MigrationStepWithVersion migrationSteps({
i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
required Future<void> Function(i1.Migrator m, Schema4 schema) from3To4,
}) =>
i0.VersionedSchema.stepByStepHelper(
step: migrationSteps(
from1To2: from1To2,
from2To3: from2To3,
from3To4: from3To4,
));

View file

@ -80,6 +80,7 @@
"imageEditorDrawOk": "Zeichnung machen",
"settingsTitle": "Einstellungen",
"settingsChats": "Chats",
"settingsStorageData": "Daten und Speicher",
"settingsPreSelectedReactions": "Vorgewählte Reaktions-Emojis",
"settingsPreSelectedReactionsError": "Es können maximal 12 Reaktionen ausgewählt werden.",
"settingsProfile": "Profil",

View file

@ -140,6 +140,8 @@
"@settingsPreSelectedReactionsError": {},
"settingsProfile": "Profile",
"@settingsProfile": {},
"settingsStorageData": "Data and storage",
"@settingsStorageData": {},
"settingsProfileCustomizeAvatar": "Customize your avatar",
"@settingsProfileCustomizeAvatar": {},
"settingsProfileEditDisplayName": "Displayname",

View file

@ -1,500 +0,0 @@
import 'dart:convert';
import 'package:camera/camera.dart';
import 'package:cryptography_plus/cryptography_plus.dart';
import 'package:drift/drift.dart';
import 'package:hive/hive.dart';
import 'package:logging/logging.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/app.dart';
import 'package:twonly/src/database/twonly_database.dart';
import 'package:twonly/src/database/tables/messages_table.dart';
import 'package:twonly/src/model/json/message.dart';
import 'package:twonly/src/model/protobuf/api/server_to_client.pb.dart';
import 'package:twonly/src/providers/api/api.dart';
import 'package:twonly/src/providers/api/api_utils.dart';
import 'package:twonly/src/providers/hive.dart';
import 'package:twonly/src/services/notification_service.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:video_compress/video_compress.dart';
Future tryDownloadAllMediaFiles() async {
if (!await isAllowedToDownload()) {
return;
}
List<Message> messages =
await twonlyDatabase.messagesDao.getAllMessagesPendingDownloading();
for (Message message in messages) {
MessageContent? content =
MessageContent.fromJson(message.kind, jsonDecode(message.contentJson!));
if (content is MediaMessageContent) {
await tryDownloadMedia(
message.messageId,
message.contactId,
content,
);
}
}
}
class Metadata {
late List<int> userIds;
late Map<int, int> messageIds;
late bool isRealTwonly;
late int maxShowTime;
late DateTime messageSendAt;
Metadata();
Map<String, dynamic> toJson() {
// Convert Map<int, int> to Map<String, int> for JSON encoding
Map<String, int> stringKeyMessageIds =
messageIds.map((key, value) => MapEntry(key.toString(), value));
return {
'userIds': userIds,
'messageIds': stringKeyMessageIds,
'isRealTwonly': isRealTwonly,
'maxShowTime': maxShowTime,
'messageSendAt': messageSendAt.toIso8601String(),
};
}
factory Metadata.fromJson(Map<String, dynamic> json) {
Metadata state = Metadata();
state.userIds = List<int>.from(json['userIds']);
// Convert Map<String, dynamic> to Map<int, int>
state.messageIds = (json['messageIds'] as Map<String, dynamic>)
.map((key, value) => MapEntry(int.parse(key), value as int));
state.isRealTwonly = json['isRealTwonly'];
state.maxShowTime = json['maxShowTime'];
state.messageSendAt = DateTime.parse(json['messageSendAt']);
return state;
}
}
class PrepareState {
late List<int> sha2Hash;
late List<int> encryptionKey;
late List<int> encryptionMac;
late List<int> encryptedBytes;
late List<int> encryptionNonce;
PrepareState();
Map<String, dynamic> toJson() {
return {
'sha2Hash': sha2Hash,
'encryptionKey': encryptionKey,
'encryptionMac': encryptionMac,
'encryptedBytes': encryptedBytes,
'encryptionNonce': encryptionNonce,
};
}
factory PrepareState.fromJson(Map<String, dynamic> json) {
PrepareState state = PrepareState();
state.sha2Hash = List<int>.from(json['sha2Hash']);
state.encryptionKey = List<int>.from(json['encryptionKey']);
state.encryptionMac = List<int>.from(json['encryptionMac']);
state.encryptedBytes = List<int>.from(json['encryptedBytes']);
state.encryptionNonce = List<int>.from(json['encryptionNonce']);
return state;
}
}
class UploadState {
late List<int> uploadToken;
late List<List<int>> downloadTokens;
UploadState();
Map<String, dynamic> toJson() {
return {
'uploadToken': uploadToken,
'downloadTokens': downloadTokens,
};
}
factory UploadState.fromJson(Map<String, dynamic> json) {
UploadState state = UploadState();
state.uploadToken = List<int>.from(json['uploadToken']);
state.downloadTokens = List<List<int>>.from(
json['downloadTokens'].map((token) => List<int>.from(token)),
);
return state;
}
}
class States {
late Metadata metadata;
late PrepareState prepareState;
States({
required this.metadata,
required this.prepareState,
});
Map<String, dynamic> toJson() {
return {
'metadata': metadata.toJson(),
'prepareState': prepareState.toJson(),
};
}
factory States.fromJson(Map<String, dynamic> json) {
return States(
metadata: Metadata.fromJson(json['metadata']),
prepareState: PrepareState.fromJson(json['prepareState']),
);
}
}
class Uploader {
static Future<PrepareState?> prepareState(Uint8List rawBytes) async {
var state = PrepareState();
try {
final xchacha20 = Xchacha20.poly1305Aead();
SecretKeyData secretKey =
await (await xchacha20.newSecretKey()).extract();
state.encryptionKey = secretKey.bytes;
state.encryptionNonce = xchacha20.newNonce();
final secretBox = await xchacha20.encrypt(
rawBytes,
secretKey: secretKey,
nonce: state.encryptionNonce,
);
state.encryptionMac = secretBox.mac.bytes;
state.encryptedBytes = secretBox.cipherText;
final algorithm = Sha256();
state.sha2Hash = (await algorithm.hash(state.encryptedBytes)).bytes;
return state;
} catch (e) {
Logger("media.dart").shout("Error encrypting image: $e");
// non recoverable state
return null;
}
}
static Future<UploadState?> uploadState(
PrepareState prepareState, int recipientsCount) async {
int fragmentedTransportSize = 1000000; // per upload transfer
final res = await apiProvider.getUploadToken(recipientsCount);
if (res.isError || !res.value.hasUploadtoken()) {
Logger("media.dart").shout("Error getting upload token!");
return null; // will be retried on next app start
}
Response_UploadToken tokens = res.value.uploadtoken;
var state = UploadState();
state.uploadToken = tokens.uploadToken;
state.downloadTokens = tokens.downloadTokens;
// box.get("retransmit-$messageId-offset", offset)
int offset = 0;
while (offset < prepareState.encryptedBytes.length) {
Logger("api.dart").info(
"Uploading image ${prepareState.encryptionMac} with offset: $offset");
int end;
List<int>? checksum;
if (offset + fragmentedTransportSize <
prepareState.encryptedBytes.length) {
end = offset + fragmentedTransportSize;
} else {
end = prepareState.encryptedBytes.length;
checksum = prepareState.sha2Hash;
}
Result wasSend = await apiProvider.uploadData(
state.uploadToken,
Uint8List.fromList(prepareState.encryptedBytes.sublist(offset, end)),
offset,
checksum,
);
if (wasSend.isError) {
Logger("api.dart").shout("error while uploading media");
return null;
}
offset = end;
}
return state;
}
static Future notifyState(PrepareState prepareState, UploadState uploadState,
Metadata metadata) async {
for (int targetUserId in metadata.userIds) {
// should never happen
if (uploadState.downloadTokens.isEmpty) return;
if (!metadata.messageIds.containsKey(targetUserId)) continue;
final downloadToken = uploadState.downloadTokens.removeLast();
twonlyDatabase.contactsDao.incFlameCounter(
targetUserId,
false,
metadata.messageSendAt,
);
// Ensures the retransmit of the message
encryptAndSendMessage(
metadata.messageIds[targetUserId],
targetUserId,
MessageJson(
kind: MessageKind.media,
messageId: metadata.messageIds[targetUserId],
content: MediaMessageContent(
downloadToken: downloadToken,
maxShowTime: metadata.maxShowTime,
isRealTwonly: metadata.isRealTwonly,
isVideo: false,
encryptionKey: prepareState.encryptionKey,
encryptionMac: prepareState.encryptionMac,
encryptionNonce: prepareState.encryptionNonce,
),
timestamp: metadata.messageSendAt,
),
pushKind: PushKind.image,
);
}
}
}
Future sendMediaFile(
List<int> userIds,
Uint8List imageBytes,
bool isRealTwonly,
int maxShowTime,
XFile? videoFilePath,
bool? enableVideoAudio,
) async {
// First: Compress the image.
Uint8List? imageBytesCompressed = await getCompressedImage(imageBytes);
if (imageBytesCompressed == null) {
// non recoverable state
Logger("media.dart").shout("Error compressing image!");
return null;
}
if (imageBytesCompressed.length >= 2000000) {
// non recoverable state
Logger("media.dart").shout("Image to big aborting!");
return null;
}
if (videoFilePath != null) {
print(videoFilePath.path);
// Second: If existand compress video
MediaInfo? mediaInfo = await VideoCompress.compressVideo(
videoFilePath.path,
quality: VideoQuality.MediumQuality,
includeAudio: enableVideoAudio,
deleteOrigin: false,
);
if (mediaInfo == null) {
Logger("send.media.file").shout("Error while compressing the video!");
return;
}
print(mediaInfo.file);
}
final prepareState = await Uploader.prepareState(imageBytes);
if (prepareState == null) {
// non recoverable state
return;
}
var metadata = Metadata();
metadata.userIds = userIds;
metadata.isRealTwonly = isRealTwonly;
metadata.maxShowTime = maxShowTime;
metadata.messageIds = {};
metadata.messageSendAt = DateTime.now();
// at this point it is safe inform the user about the process of sending the image..
for (final userId in metadata.userIds) {
int? messageId = await twonlyDatabase.messagesDao.insertMessage(
MessagesCompanion(
contactId: Value(userId),
kind: Value(MessageKind.media),
sendAt: Value(metadata.messageSendAt),
downloadState: Value(DownloadState.pending),
contentJson: Value(
jsonEncode(
MediaMessageContent(
maxShowTime: metadata.maxShowTime,
isRealTwonly: metadata.isRealTwonly,
isVideo: false,
).toJson(),
),
),
),
); // dearchive contact when sending a new message
twonlyDatabase.contactsDao.updateContact(
userId,
ContactsCompanion(
archived: Value(false),
),
);
if (messageId != null) {
metadata.messageIds[userId] = messageId;
} else {
Logger("media.dart")
.shout("Error inserting message in messages database...");
}
}
String stateId = prepareState.sha2Hash.toString();
{
Map<String, dynamic> allMediaFiles = await getStoredMediaUploads();
allMediaFiles[stateId] = jsonEncode(
States(metadata: metadata, prepareState: prepareState).toJson(),
);
(await getMediaStorage()).put("mediaUploads", jsonEncode(allMediaFiles));
}
uploadMediaState(stateId, prepareState, metadata);
}
Future<Map<String, dynamic>> getStoredMediaUploads() async {
Box storage = await getMediaStorage();
String? mediaFilesJson = storage.get("mediaUploads");
Map<String, dynamic> allMediaFiles = {};
if (mediaFilesJson != null) {
allMediaFiles = jsonDecode(mediaFilesJson);
}
return allMediaFiles;
}
Future retransmitMediaFiles() async {
Map<String, dynamic> allMediaFiles = await getStoredMediaUploads();
if (allMediaFiles.isEmpty) return;
bool allSuccess = true;
for (final entry in allMediaFiles.entries) {
try {
String stateId = entry.key;
States states = States.fromJson(jsonDecode(entry.value));
// upload one by one
allSuccess = allSuccess &
await uploadMediaState(stateId, states.prepareState, states.metadata);
} catch (e) {
Logger("media.dart").shout(e);
}
}
if (allSuccess) {
// when all retransmittions where sucessfull tag the errors
final pendings = await twonlyDatabase.messagesDao
.getAllMessagesPendingUploadOlderThanAMinute();
for (final pending in pendings) {
twonlyDatabase.messagesDao.updateMessageByMessageId(
pending.messageId,
MessagesCompanion(errorWhileSending: Value(true)),
);
}
}
// return allSuccess;
}
// if the upload failes this function is called again from the retransmitMediaFiles function which is
// called when the WebSocket is reconnected again.
Future<bool> uploadMediaState(
String stateId, PrepareState prepareState, Metadata metadata) async {
final uploadState =
await Uploader.uploadState(prepareState, metadata.userIds.length);
if (uploadState == null) {
return false;
}
{
Map<String, dynamic> allMediaFiles = await getStoredMediaUploads();
if (allMediaFiles.isNotEmpty) {
allMediaFiles.remove(stateId);
(await getMediaStorage()).put("mediaUploads", jsonEncode(allMediaFiles));
}
}
await Uploader.notifyState(prepareState, uploadState, metadata);
return true;
}
Future tryDownloadMedia(
int messageId, int fromUserId, MediaMessageContent content,
{bool force = false}) async {
if (globalIsAppInBackground) return;
if (content.downloadToken == null) return;
if (!force) {
if (!await isAllowedToDownload()) {
Logger("tryDownloadMedia").info("abort download over mobile connection");
return;
}
}
final box = await getMediaStorage();
if (box.containsKey("${messageId}_downloaded")) {
Logger("tryDownloadMedia").shout("mediaToken already downloaded");
return;
}
Logger("tryDownloadMedia").info("Downloading: $messageId");
int offset = 0;
Uint8List? media = box.get("${content.downloadToken!}");
if (media != null && media.isNotEmpty) {
offset = media.length;
}
box.put("${content.downloadToken!}_messageId", messageId);
await twonlyDatabase.messagesDao.updateMessageByOtherUser(
fromUserId,
messageId,
MessagesCompanion(
downloadState: Value(DownloadState.downloading),
),
);
apiProvider.triggerDownload(content.downloadToken!, offset);
}
Future<Uint8List?> getDownloadedMedia(
Message message, List<int> downloadToken) async {
final box = await getMediaStorage();
Uint8List? media;
try {
media = box.get("${downloadToken}_downloaded");
} catch (e) {
return null;
}
if (media == null) return null;
// await userOpenedOtherMessage(otherUserId, messageOtherId);
notifyContactAboutOpeningMessage(
message.contactId, [message.messageOtherId!]);
twonlyDatabase.messagesDao.updateMessageByMessageId(
message.messageId, MessagesCompanion(openedAt: Value(DateTime.now())));
box.delete(downloadToken.toString());
box.put("${downloadToken}_downloaded", "deleted");
box.delete("${downloadToken}_messageId");
return media;
}

View file

@ -0,0 +1,230 @@
import 'dart:convert';
import 'dart:io';
import 'package:drift/drift.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/src/database/twonly_database.dart';
import 'package:twonly/src/database/tables/messages_table.dart';
import 'package:twonly/src/model/json/message.dart';
import 'package:twonly/src/providers/api/media_send.dart';
import 'package:twonly/src/providers/hive.dart';
import 'package:twonly/src/utils/misc.dart';
import 'dart:typed_data';
import 'package:cryptography_plus/cryptography_plus.dart';
import 'package:logging/logging.dart';
import 'package:twonly/app.dart';
import 'package:twonly/src/model/protobuf/api/client_to_server.pb.dart'
as client;
import 'package:twonly/src/model/protobuf/api/error.pb.dart';
import 'package:twonly/src/model/protobuf/api/server_to_client.pbserver.dart';
Future tryDownloadAllMediaFiles() async {
List<Message> messages =
await twonlyDatabase.messagesDao.getAllMessagesPendingDownloading();
for (Message message in messages) {
await startDownloadMedia(message, false);
}
}
Future startDownloadMedia(Message message, bool force) async {
if (message.contentJson == null) return;
final content =
MessageContent.fromJson(message.kind, jsonDecode(message.contentJson!));
if (content is! MediaMessageContent) return;
if (content.downloadToken == null) return;
var media = await twonlyDatabase.mediaDownloadsDao
.getMediaDownloadById(message.messageId)
.getSingleOrNull();
if (media == null) {
await twonlyDatabase.mediaDownloadsDao.insertMediaDownload(
MediaDownloadsCompanion(
messageId: Value(message.messageId),
downloadToken: Value(content.downloadToken!),
),
);
media = await twonlyDatabase.mediaDownloadsDao
.getMediaDownloadById(message.messageId)
.getSingleOrNull();
}
if (media == null) return;
if (!force && !await isAllowedToDownload(content.isVideo)) {
return;
}
if (message.downloadState != DownloadState.downloaded) {
await twonlyDatabase.messagesDao.updateMessageByMessageId(
message.messageId,
MessagesCompanion(
downloadState: Value(DownloadState.downloading),
),
);
int offset = 0;
Uint8List? bytes = await readMediaFile(media.messageId, "encrypted");
if (bytes != null && bytes.isNotEmpty) {
offset = bytes.length;
}
apiProvider.triggerDownload(content.downloadToken!, offset);
}
}
Future<client.Response> handleDownloadData(DownloadData data) async {
if (globalIsAppInBackground) {
// download should only be done when the app is open
return client.Response()..error = ErrorCode.InternalError;
}
Logger("server_messages")
.info("downloading: ${data.downloadToken} ${data.fin}");
final box = await getMediaStorage();
String boxId = data.downloadToken.toString();
final media = await twonlyDatabase.mediaDownloadsDao
.getMediaDownloadByDownloadToken(data.downloadToken)
.getSingleOrNull();
if (media == null) {
Logger("server_messages")
.shout("download data received, but unknown messageID");
// answers with ok, so the server will delete the message
var ok = client.Response_Ok()..none = true;
return client.Response()..ok = ok;
}
if (data.fin && data.offset == 3_980_938_213 && data.data.isEmpty) {
Logger("media_received.dart").shout("Image already deleted by the server!");
// media file was deleted by the server. remove the media from device
await twonlyDatabase.messagesDao.updateMessageByMessageId(
media.messageId,
MessagesCompanion(
errorWhileSending: Value(true),
),
);
await deleteMediaFile(media.messageId, "encrypted");
var ok = client.Response_Ok()..none = true;
return client.Response()..ok = ok;
}
Uint8List? buffered = await readMediaFile(media.messageId, "encrypted");
Uint8List downloadedBytes;
if (buffered != null) {
if (data.offset != buffered.length) {
Logger("media_received.dart")
.shout("server send wrong offset: ${data.offset} ${buffered.length}");
return client.Response()..error = ErrorCode.InvalidOffset;
}
var b = BytesBuilder();
b.add(buffered);
b.add(data.data);
downloadedBytes = b.takeBytes();
} else {
downloadedBytes = Uint8List.fromList(data.data);
}
if (!data.fin) {
// download not finished, so waiting for more data...
await box.put(boxId, downloadedBytes);
var ok = client.Response_Ok()..none = true;
return client.Response()..ok = ok;
}
Message? msg = await twonlyDatabase.messagesDao
.getMessageByMessageId(media.messageId)
.getSingleOrNull();
if (msg == null) {
Logger("media_received.dart")
.info("messageId not found in database. Ignoring download");
// answers with ok, so the server will delete the message
var ok = client.Response_Ok()..none = true;
return client.Response()..ok = ok;
}
MediaMessageContent content =
MediaMessageContent.fromJson(jsonDecode(msg.contentJson!));
final xchacha20 = Xchacha20.poly1305Aead();
SecretKeyData secretKeyData = SecretKeyData(content.encryptionKey!);
SecretBox secretBox = SecretBox(
downloadedBytes,
nonce: content.encryptionNonce!,
mac: Mac(content.encryptionMac!),
);
try {
final plaintextBytes =
await xchacha20.decrypt(secretBox, secretKey: secretKeyData);
var imageBytes = Uint8List.fromList(plaintextBytes);
if (content.isVideo) {
final splited = extractUint8Lists(imageBytes);
imageBytes = splited[0];
await writeMediaFile(media.messageId, "video", splited[1]);
}
await writeMediaFile(media.messageId, "image", imageBytes);
} catch (e) {
Logger("media_received.dart").info("Decryption error: $e");
await twonlyDatabase.messagesDao.updateMessageByMessageId(
media.messageId,
MessagesCompanion(
errorWhileSending: Value(true),
),
);
// answers with ok, so the server will delete the message
var ok = client.Response_Ok()..none = true;
return client.Response()..ok = ok;
}
await twonlyDatabase.messagesDao.updateMessageByMessageId(
media.messageId,
MessagesCompanion(downloadState: Value(DownloadState.downloaded)),
);
await deleteMediaFile(media.messageId, "encrypted");
var ok = client.Response_Ok()..none = true;
return client.Response()..ok = ok;
}
Future<Uint8List?> getImageBytes(int mediaId) async {
return await readMediaFile(mediaId, "image");
}
Future<File?> getVideoPath(int mediaId) async {
String basePath = await getMediaFilePath(mediaId, "received");
return File("$basePath.video");
}
/// --- helper functions ---
Future<Uint8List?> readMediaFile(int mediaId, String type) async {
String basePath = await getMediaFilePath(mediaId, "received");
File file = File("$basePath.$type");
if (!await file.exists()) {
return null;
}
return await file.readAsBytes();
}
Future<void> writeMediaFile(int mediaId, String type, Uint8List data) async {
String basePath = await getMediaFilePath(mediaId, "received");
File file = File("$basePath.$type");
await file.writeAsBytes(data);
}
Future<void> deleteMediaFile(int mediaId, String type) async {
String basePath = await getMediaFilePath(mediaId, "received");
File file = File("$basePath.$type");
if (await file.exists()) {
await file.delete();
}
}

View file

@ -0,0 +1,472 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:cryptography_plus/cryptography_plus.dart';
import 'package:drift/drift.dart';
import 'package:flutter_image_compress/flutter_image_compress.dart';
import 'package:logging/logging.dart';
import 'package:mutex/mutex.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/src/database/tables/media_uploads_table.dart';
import 'package:twonly/src/database/tables/messages_table.dart';
import 'package:twonly/src/database/twonly_database.dart';
import 'package:twonly/src/model/json/message.dart';
import 'package:twonly/src/model/protobuf/api/server_to_client.pb.dart';
import 'package:twonly/src/providers/api/api.dart';
import 'package:twonly/src/providers/api/api_utils.dart';
import 'package:twonly/src/services/notification_service.dart';
import 'package:video_compress/video_compress.dart';
Future sendMediaFile(
List<int> userIds,
Uint8List imageBytes,
bool isRealTwonly,
int maxShowTime,
XFile? videoFilePath,
bool? enableVideoAudio,
) async {
MediaUploadMetadata metadata = MediaUploadMetadata();
metadata.contactIds = userIds;
metadata.isRealTwonly = isRealTwonly;
metadata.messageSendAt = DateTime.now();
metadata.isVideo = videoFilePath != null;
metadata.videoWithAudio = enableVideoAudio != null && enableVideoAudio;
int? mediaUploadId = await twonlyDatabase.mediaUploadsDao.insertMediaUpload(
MediaUploadsCompanion(
metadata: Value(metadata),
),
);
if (mediaUploadId != null) {
await handleSingleMediaFile(mediaUploadId);
}
}
Future retryMediaUpload() async {
final mediaFiles =
await twonlyDatabase.mediaUploadsDao.getMediaUploadsForRetry();
for (final mediaFile in mediaFiles) {
await handleSingleMediaFile(mediaFile.mediaUploadId);
}
}
final lockingHandleMediaFile = Mutex();
Future handleSingleMediaFile(int mediaUploadId) async {
await lockingHandleMediaFile.protect(() async {
MediaUpload? media = await twonlyDatabase.mediaUploadsDao
.getMediaUploadById(mediaUploadId)
.getSingleOrNull();
if (media == null) return;
try {
switch (media.state) {
case UploadState.pending:
await handleAddToMessageDb(media);
break;
case UploadState.addedToMessagesDb:
await handleCompressionState(media);
break;
case UploadState.isCompressed:
await handleEncryptionState(media);
break;
case UploadState.isEncrypted:
if (!await handleGetUploadToken(media)) {
return; // recoverable error. try again when connected again to the server...
}
break;
case UploadState.hasUploadToken:
if (!await handleUpload(media)) {
return; // recoverable error. try again when connected again to the server...
}
break;
case UploadState.isUploaded:
if (!await handleNotifyReceiver(media)) {
return; // recoverable error. try again when connected again to the server...
}
break;
case UploadState.receiverNotified:
return;
}
// this will be called until there is an recoverable error OR
// the upload is ready
await handleSingleMediaFile(mediaUploadId);
} catch (e) {
// if the messageIds are already there notify the user about this error...
if (media.messageIds != null) {
for (int messageId in media.messageIds!) {
await twonlyDatabase.messagesDao.updateMessageByMessageId(
messageId,
MessagesCompanion(
errorWhileSending: Value(true),
),
);
}
}
await twonlyDatabase.mediaUploadsDao.deleteMediaUpload(mediaUploadId);
Logger("media_send.dart")
.shout("Non recoverable error while sending media file: $e");
}
});
}
Future handleAddToMessageDb(MediaUpload media) async {
List<int> messageIds = [];
for (final contactId in media.metadata.contactIds) {
int? messageId = await twonlyDatabase.messagesDao.insertMessage(
MessagesCompanion(
contactId: Value(contactId),
kind: Value(MessageKind.media),
sendAt: Value(media.metadata.messageSendAt),
downloadState: Value(DownloadState.pending),
mediaUploadId: Value(media.mediaUploadId),
contentJson: Value(
jsonEncode(
MediaMessageContent(
maxShowTime: media.metadata.maxShowTime,
isRealTwonly: media.metadata.isRealTwonly,
isVideo: media.metadata.isVideo,
).toJson(),
),
),
),
); // dearchive contact when sending a new message
await twonlyDatabase.contactsDao.updateContact(
contactId,
ContactsCompanion(
archived: Value(false),
),
);
if (messageId != null) {
messageIds.add(messageId);
} else {
Logger("media_send.dart")
.shout("Error inserting media upload message in database.");
}
}
await twonlyDatabase.mediaUploadsDao.updateMediaUpload(
media.mediaUploadId,
MediaUploadsCompanion(
state: Value(UploadState.addedToMessagesDb),
messageIds: Value(messageIds),
),
);
}
Future handleCompressionState(MediaUpload media) async {
Uint8List imageBytes = await readMediaFile(media, "image");
try {
Uint8List imageBytesCompressed =
await FlutterImageCompress.compressWithList(
imageBytes,
quality: 90,
);
if (imageBytesCompressed.length >= 1 * 1000 * 1000) {
// if the media file is over 1MB compress it with 60%
imageBytesCompressed = await FlutterImageCompress.compressWithList(
imageBytes,
quality: 60,
);
}
await writeMediaFile(media, "image.compressed", imageBytesCompressed);
} catch (e) {
Logger("media_send.dart").shout("$e");
// as a fall back use the original image
await writeMediaFile(media, "image.compressed", imageBytes);
}
if (media.metadata.isVideo) {
String basePath = await getMediaFilePath(media.mediaUploadId, "send");
File videoOriginalFile = File("$basePath.video");
File videoCompressedFile = File("$basePath.video.compressed");
MediaInfo? mediaInfo;
try {
mediaInfo = await VideoCompress.compressVideo(
videoOriginalFile.path,
quality: VideoQuality.Res1280x720Quality,
deleteOrigin: false,
includeAudio: media.metadata.videoWithAudio,
);
if (mediaInfo!.filesize! >= 20 * 1000 * 1000) {
// if the media file is over 20MB compress it with low quality
mediaInfo = await VideoCompress.compressVideo(
videoOriginalFile.path,
quality: VideoQuality.Res960x540Quality,
deleteOrigin: false,
includeAudio: media.metadata.videoWithAudio,
);
}
} catch (e) {
Logger("media_send.dart").shout("$e");
}
if (mediaInfo == null) {
Logger("media_send.dart").shout("Error compressing video.");
mediaInfo!.file!.rename(videoCompressedFile.path);
} else {
// as a fall back use the non compressed version
videoOriginalFile.rename(videoCompressedFile.path);
}
}
// delete non compressed media files
await deleteMediaFile(media, "image");
await deleteMediaFile(media, "video");
await twonlyDatabase.mediaUploadsDao.updateMediaUpload(
media.mediaUploadId,
MediaUploadsCompanion(
state: Value(UploadState.isCompressed),
),
);
return true;
}
Future handleEncryptionState(MediaUpload media) async {
var state = MediaEncryptionData();
Uint8List dataToEncrypt = await readMediaFile(media, "image.compressed");
if (media.metadata.isVideo) {
Uint8List compressedVideo = await readMediaFile(media, "video.compressed");
dataToEncrypt = combineUint8Lists(dataToEncrypt, compressedVideo);
}
final xchacha20 = Xchacha20.poly1305Aead();
SecretKeyData secretKey = await (await xchacha20.newSecretKey()).extract();
state.encryptionKey = secretKey.bytes;
state.encryptionNonce = xchacha20.newNonce();
final secretBox = await xchacha20.encrypt(
dataToEncrypt,
secretKey: secretKey,
nonce: state.encryptionNonce,
);
state.encryptionMac = secretBox.mac.bytes;
final algorithm = Sha256();
state.sha2Hash = (await algorithm.hash(secretBox.cipherText)).bytes;
await writeMediaFile(
media,
"encrypted",
Uint8List.fromList(secretBox.cipherText),
);
await twonlyDatabase.mediaUploadsDao.updateMediaUpload(
media.mediaUploadId,
MediaUploadsCompanion(
state: Value(UploadState.isEncrypted),
encryptionData: Value(state),
),
);
}
Future<bool> handleGetUploadToken(MediaUpload media) async {
final res =
await apiProvider.getUploadToken(media.metadata.contactIds.length);
if (res.isError || !res.value.hasUploadtoken()) {
Logger("media_send.dart")
.shout("Will be tried again when reconnected to server!");
return false;
}
Response_UploadToken tokens = res.value.uploadtoken;
var token = MediaUploadTokens();
token.uploadToken = tokens.uploadToken;
token.downloadTokens = tokens.downloadTokens;
await twonlyDatabase.mediaUploadsDao.updateMediaUpload(
media.mediaUploadId,
MediaUploadsCompanion(
state: Value(UploadState.hasUploadToken),
uploadTokens: Value(token),
),
);
return true;
}
Future<bool> handleUpload(MediaUpload media) async {
Uint8List bytesToUpload = await readMediaFile(media, "encrypted");
int fragmentedTransportSize = 1000000;
int offset = 0;
while (offset < bytesToUpload.length) {
Logger("media_send.dart").fine(
"Uploading media file ${media.mediaUploadId} with offset: $offset");
int end;
List<int>? checksum;
if (offset + fragmentedTransportSize < bytesToUpload.length) {
end = offset + fragmentedTransportSize;
} else {
end = bytesToUpload.length;
checksum = media.encryptionData!.sha2Hash;
}
Result wasSend = await apiProvider.uploadData(
media.uploadTokens!.uploadToken,
Uint8List.fromList(bytesToUpload.sublist(offset, end)),
offset,
checksum,
);
if (wasSend.isError) {
Logger("media_send.dart")
.shout("error while uploading media: ${wasSend.error}");
return false;
}
offset = end;
}
await twonlyDatabase.mediaUploadsDao.updateMediaUpload(
media.mediaUploadId,
MediaUploadsCompanion(
state: Value(UploadState.isUploaded),
),
);
await deleteMediaFile(media, "encrypted");
return true;
}
Future<bool> handleNotifyReceiver(MediaUpload media) async {
List<int> alreadyNotified = media.alreadyNotified;
for (var i = 0; i < media.messageIds!.length; i++) {
int messageId = media.messageIds![i];
if (alreadyNotified.contains(messageId)) {
continue;
}
Message? message = await twonlyDatabase.messagesDao
.getMessageByMessageId(messageId)
.getSingleOrNull();
if (message == null) continue;
await twonlyDatabase.contactsDao.incFlameCounter(
message.contactId,
false,
message.sendAt,
);
// Ensures the retransmit of the message
await encryptAndSendMessage(
messageId,
message.contactId,
MessageJson(
kind: MessageKind.media,
messageId: messageId,
content: MediaMessageContent(
downloadToken: media.uploadTokens!.downloadTokens[i],
maxShowTime: media.metadata.maxShowTime,
isRealTwonly: media.metadata.isRealTwonly,
isVideo: media.metadata.isVideo,
encryptionKey: media.encryptionData!.encryptionKey,
encryptionMac: media.encryptionData!.encryptionMac,
encryptionNonce: media.encryptionData!.encryptionNonce,
),
timestamp: media.metadata.messageSendAt,
),
pushKind: (media.metadata.isRealTwonly)
? PushKind.twonly
: (media.metadata.isVideo)
? PushKind.video
: PushKind.image,
);
alreadyNotified.add(messageId);
await twonlyDatabase.mediaUploadsDao.updateMediaUpload(
media.mediaUploadId,
MediaUploadsCompanion(
alreadyNotified: Value(alreadyNotified),
),
);
}
await twonlyDatabase.mediaUploadsDao.updateMediaUpload(
media.mediaUploadId,
MediaUploadsCompanion(
state: Value(UploadState.receiverNotified),
),
);
return true;
}
/// --- helper functions ---
Future<Uint8List> readMediaFile(MediaUpload media, String type) async {
String basePath = await getMediaFilePath(media.mediaUploadId, "send");
File file = File("$basePath.$type");
if (!await file.exists()) {
throw Exception("$file not found");
}
return await file.readAsBytes();
}
Future<void> writeMediaFile(
MediaUpload media, String type, Uint8List data) async {
String basePath = await getMediaFilePath(media.mediaUploadId, "send");
File file = File("$basePath.$type");
await file.writeAsBytes(data);
}
Future<void> deleteMediaFile(MediaUpload media, String type) async {
String basePath = await getMediaFilePath(media.mediaUploadId, "send");
File file = File("$basePath.$type");
if (await file.exists()) {
await file.delete();
}
}
Future<String> getMediaFilePath(int mediaId, String type) async {
final basedir = await getApplicationSupportDirectory();
final mediaSendDir = Directory(join(basedir.path, 'media', type));
if (!await mediaSendDir.exists()) {
await mediaSendDir.create(recursive: true);
}
return join(mediaSendDir.path, '$mediaId');
}
/// combines two utf8 list
Uint8List combineUint8Lists(Uint8List list1, Uint8List list2) {
final combinedLength = 4 + list1.length + list2.length;
final combinedList = Uint8List(combinedLength);
final byteData = ByteData.sublistView(combinedList);
byteData.setInt32(
0, list1.length, Endian.big); // Store size in big-endian format
combinedList.setRange(4, 4 + list1.length, list1);
combinedList.setRange(4 + list1.length, combinedLength, list2);
return combinedList;
}
List<Uint8List> extractUint8Lists(Uint8List combinedList) {
final byteData = ByteData.sublistView(combinedList);
final sizeOfList1 = byteData.getInt32(0, Endian.big);
final list1 = Uint8List.view(combinedList.buffer, 4, sizeOfList1);
final list2 = Uint8List.view(combinedList.buffer, 4 + sizeOfList1,
combinedList.lengthInBytes - 4 - sizeOfList1);
return [list1, list2];
}

View file

@ -1,13 +1,10 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:cryptography_plus/cryptography_plus.dart';
import 'package:drift/drift.dart';
import 'package:fixnum/fixnum.dart';
import 'package:libsignal_protocol_dart/libsignal_protocol_dart.dart';
import 'package:logging/logging.dart';
import 'package:mutex/mutex.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/app.dart';
import 'package:twonly/src/database/twonly_database.dart';
import 'package:twonly/src/database/tables/messages_table.dart';
import 'package:twonly/src/model/json/message.dart';
@ -17,11 +14,9 @@ import 'package:twonly/src/model/protobuf/api/client_to_server.pbserver.dart';
import 'package:twonly/src/model/protobuf/api/error.pb.dart';
import 'package:twonly/src/model/protobuf/api/server_to_client.pb.dart'
as server;
import 'package:twonly/src/model/protobuf/api/server_to_client.pbserver.dart';
import 'package:twonly/src/providers/api/api.dart';
import 'package:twonly/src/providers/api/api_utils.dart';
import 'package:twonly/src/providers/api/media.dart';
import 'package:twonly/src/providers/hive.dart';
import 'package:twonly/src/providers/api/media_received.dart';
import 'package:twonly/src/services/notification_service.dart';
// ignore: library_prefixes
import 'package:twonly/src/utils/signal.dart' as SignalHelper;
@ -58,115 +53,6 @@ Future handleServerMessage(server.ServerToClient msg) async {
});
}
Future<client.Response> handleDownloadData(DownloadData data) async {
if (globalIsAppInBackground) {
// download should only be done when the app is open
return client.Response()..error = ErrorCode.InternalError;
}
Logger("server_messages")
.info("downloading: ${data.downloadToken} ${data.fin}");
final box = await getMediaStorage();
String boxId = data.downloadToken.toString();
int? messageId = box.get("${data.downloadToken}_messageId");
if (messageId == null) {
Logger("server_messages")
.shout("download data received, but unknown messageID");
// answers with ok, so the server will delete the message
var ok = client.Response_Ok()..none = true;
return client.Response()..ok = ok;
}
if (data.fin && data.data.isEmpty) {
Logger("server_messages")
.shout("Got an image message, but was already deleted by the server!");
// media file was deleted by the server. remove the media from device
await twonlyDatabase.messagesDao.deleteMessageById(messageId);
await box.delete(boxId);
await box.delete("${data.downloadToken}_downloaded");
var ok = client.Response_Ok()..none = true;
return client.Response()..ok = ok;
}
Uint8List? buffered = box.get(boxId);
Uint8List downloadedBytes;
if (buffered != null) {
if (data.offset != buffered.length) {
Logger("server_messages")
.info("server send wrong offset: ${data.offset} ${buffered.length}");
// Logger("handleDownloadData").error(object)
return client.Response()..error = ErrorCode.InvalidOffset;
}
var b = BytesBuilder();
b.add(buffered);
b.add(data.data);
downloadedBytes = b.takeBytes();
} else {
downloadedBytes = Uint8List.fromList(data.data);
}
if (!data.fin) {
// download not finished, so waiting for more data...
await box.put(boxId, downloadedBytes);
var ok = client.Response_Ok()..none = true;
return client.Response()..ok = ok;
}
Message? msg = await twonlyDatabase.messagesDao
.getMessageByMessageId(messageId)
.getSingleOrNull();
if (msg == null) {
Logger("server_messages")
.info("messageId not found in database. Ignoring download");
// answers with ok, so the server will delete the message
var ok = client.Response_Ok()..none = true;
return client.Response()..ok = ok;
}
MediaMessageContent content =
MediaMessageContent.fromJson(jsonDecode(msg.contentJson!));
final xchacha20 = Xchacha20.poly1305Aead();
SecretKeyData secretKeyData = SecretKeyData(content.encryptionKey!);
SecretBox secretBox = SecretBox(
downloadedBytes,
nonce: content.encryptionNonce!,
mac: Mac(content.encryptionMac!),
);
try {
final rawBytes =
await xchacha20.decrypt(secretBox, secretKey: secretKeyData);
await box.put("${data.downloadToken}_downloaded", rawBytes);
} catch (e) {
Logger("server_messages").info("Decryption error: $e");
// deleting message as this is an invalid image
await twonlyDatabase.messagesDao.deleteMessageById(messageId);
// answers with ok, so the server will delete the message
var ok = client.Response_Ok()..none = true;
return client.Response()..ok = ok;
}
Logger("server_messages").info("Downloaded: $messageId");
await twonlyDatabase.messagesDao.updateMessageByOtherUser(
msg.contactId,
messageId,
MessagesCompanion(downloadState: Value(DownloadState.downloaded)),
);
await box.delete(boxId);
var ok = client.Response_Ok()..none = true;
return client.Response()..ok = ok;
}
Future<client.Response> handleNewMessage(int fromUserId, Uint8List body) async {
MessageJson? message = await SignalHelper.getDecryptedText(fromUserId, body);
if (message == null) {
@ -315,15 +201,11 @@ Future<client.Response> handleNewMessage(int fromUserId, Uint8List body) async {
message.timestamp,
);
if (!globalIsAppInBackground) {
final content = message.content;
if (content is MediaMessageContent) {
tryDownloadMedia(
messageId,
fromUserId,
content,
);
}
final msg = await twonlyDatabase.messagesDao
.getMessageByMessageId(messageId)
.getSingleOrNull();
if (msg != null) {
startDownloadMedia(msg, false);
}
}
// dearchive contact when receiving a new message

View file

@ -17,7 +17,8 @@ import 'package:twonly/src/model/protobuf/api/server_to_client.pb.dart'
as server;
import 'package:twonly/src/providers/api/api.dart';
import 'package:twonly/src/providers/api/api_utils.dart';
import 'package:twonly/src/providers/api/media.dart';
import 'package:twonly/src/providers/api/media_received.dart';
import 'package:twonly/src/providers/api/media_send.dart';
import 'package:twonly/src/providers/api/server_messages.dart';
import 'package:twonly/src/services/fcm_service.dart';
import 'package:twonly/src/utils/misc.dart';
@ -82,13 +83,20 @@ class ApiProvider {
if (!globalIsAppInBackground) {
tryTransmitMessages();
retransmitMediaFiles();
retryMediaUpload();
tryDownloadAllMediaFiles();
notifyContactsAboutProfileChange();
twonlyDatabase.markUpdated();
}
}
Future onClosed() async {
_channel = null;
isAuthenticated = false;
globalCallbackConnectionState(false);
await twonlyDatabase.messagesDao.resetPendingDownloadState();
}
Future close(Function callback) async {
log.info("Closing the websocket connection!");
if (_channel != null) {
@ -131,16 +139,12 @@ class ApiProvider {
void _onDone() {
log.info("WebSocket Closed");
globalCallbackConnectionState(false);
_channel = null;
isAuthenticated = false;
onClosed();
}
void _onError(dynamic e) {
log.info("WebSocket Error: $e");
globalCallbackConnectionState(false);
_channel = null;
isAuthenticated = false;
onClosed();
}
void _onData(dynamic msgBuffer) async {

View file

@ -160,7 +160,7 @@ Future<bool> authenticateUser(String localizedReason,
return false;
}
Future<bool> isAllowedToDownload() async {
Future<bool> isAllowedToDownload(bool isVideo) async {
final List<ConnectivityResult> connectivityResult =
await (Connectivity().checkConnectivity());
if (connectivityResult.contains(ConnectivityResult.mobile)) {

View file

@ -5,13 +5,13 @@ import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:logging/logging.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/src/providers/api/media_send.dart';
import 'package:twonly/src/views/camera/components/save_to_gallery.dart';
import 'package:twonly/src/views/camera/image_editor/action_button.dart';
import 'package:twonly/src/views/components/media_view_sizing.dart';
import 'package:twonly/src/views/components/notification_badge.dart';
import 'package:twonly/src/database/daos/contacts_dao.dart';
import 'package:twonly/src/database/twonly_database.dart';
import 'package:twonly/src/providers/api/media.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/utils/storage.dart';
import 'package:twonly/src/views/camera/share_image_view.dart';

View file

@ -5,6 +5,7 @@ import 'package:camera/camera.dart';
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/src/providers/api/media_send.dart';
import 'package:twonly/src/views/camera/components/best_friends_selector.dart';
import 'package:twonly/src/views/components/flame.dart';
import 'package:twonly/src/views/components/headline.dart';
@ -12,7 +13,6 @@ import 'package:twonly/src/views/components/initialsavatar.dart';
import 'package:twonly/src/views/components/verified_shield.dart';
import 'package:twonly/src/database/daos/contacts_dao.dart';
import 'package:twonly/src/database/twonly_database.dart';
import 'package:twonly/src/providers/api/media.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/views/home_view.dart';

View file

@ -15,7 +15,7 @@ import 'package:twonly/src/database/twonly_database.dart';
import 'package:twonly/src/database/tables/messages_table.dart';
import 'package:twonly/src/model/json/message.dart';
import 'package:twonly/src/providers/api/api.dart';
import 'package:twonly/src/providers/api/media.dart';
import 'package:twonly/src/providers/api/media_received.dart';
import 'package:twonly/src/services/notification_service.dart';
import 'package:twonly/src/views/camera/camera_send_to_view.dart';
import 'package:twonly/src/views/chats/media_viewer_view.dart';
@ -229,9 +229,8 @@ class ChatListEntry extends StatelessWidget {
return MediaViewerView(contact);
}),
);
} else {
tryDownloadMedia(message.messageId, message.contactId, content,
force: true);
} else if (message.downloadState == DownloadState.pending) {
startDownloadMedia(message, true);
}
}
},

View file

@ -1,8 +1,8 @@
import 'dart:async';
import 'dart:convert';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:provider/provider.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/src/providers/api/media_received.dart';
import 'package:twonly/src/views/components/connection_state.dart';
import 'package:twonly/src/views/components/flame.dart';
import 'package:twonly/src/views/components/initialsavatar.dart';
@ -12,8 +12,6 @@ import 'package:twonly/src/views/components/user_context_menu.dart';
import 'package:twonly/src/database/daos/contacts_dao.dart';
import 'package:twonly/src/database/twonly_database.dart';
import 'package:twonly/src/database/tables/messages_table.dart';
import 'package:twonly/src/model/json/message.dart';
import 'package:twonly/src/providers/api/media.dart';
import 'package:twonly/src/providers/connection_provider.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/views/camera/camera_send_to_view.dart';
@ -325,11 +323,7 @@ class _UserListItem extends State<UserListItem> {
msgs.first.openedAt == null) {
switch (msgs.first.downloadState) {
case DownloadState.pending:
MediaMessageContent content = MediaMessageContent.fromJson(
jsonDecode(msgs.first.contentJson!));
tryDownloadMedia(
msgs.first.messageId, msgs.first.contactId, content,
force: true);
startDownloadMedia(msgs.first, true);
case DownloadState.downloaded:
Navigator.push(
context,

View file

@ -12,7 +12,7 @@ import 'package:twonly/src/database/twonly_database.dart';
import 'package:twonly/src/database/tables/messages_table.dart';
import 'package:twonly/src/model/json/message.dart';
import 'package:twonly/src/providers/api/api.dart';
import 'package:twonly/src/providers/api/media.dart';
import 'package:twonly/src/providers/api/media_received.dart';
import 'package:twonly/src/services/notification_service.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/utils/storage.dart';
@ -145,17 +145,29 @@ class _MediaViewerViewState extends State<MediaViewerView> {
setState(() {
isDownloading = true;
});
await tryDownloadMedia(current.messageId, current.contactId, content,
force: true);
await startDownloadMedia(current, true);
}
do {
if (isDownloading) {
await Future.delayed(Duration(milliseconds: 10));
if (!apiProvider.isConnected) break;
}
if (content.downloadToken == null) break;
imageBytes = await getDownloadedMedia(current, content.downloadToken!);
} while (isDownloading && imageBytes == null);
load downloaded status from database
notifyContactAboutOpeningMessage(
message.contactId, [message.messageOtherId!]);
twonlyDatabase.messagesDao.updateMessageByMessageId(
message.messageId, MessagesCompanion(openedAt: Value(DateTime.now())));
// do {
// if (isDownloading) {
// await Future.delayed(Duration(milliseconds: 10));
// if (!apiProvider.isConnected) break;
// }
// if (content.downloadToken == null) break;
// imageBytes = await getDownloadedMedia(current, content.downloadToken!);
// } while (isDownloading && imageBytes == null);
if twonly deleteMediaFile()
isDownloading = false;
if (imageBytes == null) {

View file

@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:twonly/src/views/components/better_list_title.dart';
import 'package:twonly/src/views/components/better_text.dart';
import 'package:twonly/src/views/components/radio_button.dart';
import 'package:twonly/src/providers/settings_change_provider.dart';
import 'package:twonly/src/utils/misc.dart';
class DataAndStorageView extends StatelessWidget {
const DataAndStorageView({super.key});
void _showSelectThemeMode(BuildContext context) async {
ThemeMode? selectedValue = context.read<SettingsChangeProvider>().themeMode;
await showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text(context.lang.settingsAppearanceTheme),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
RadioButton<ThemeMode>(
value: ThemeMode.system,
groupValue: selectedValue,
label: 'System default',
onChanged: (ThemeMode? value) {
selectedValue = value;
Navigator.of(context).pop();
},
),
RadioButton<ThemeMode>(
value: ThemeMode.light,
groupValue: selectedValue,
label: 'Light',
onChanged: (ThemeMode? value) {
selectedValue = value;
Navigator.of(context).pop();
},
),
RadioButton<ThemeMode>(
value: ThemeMode.dark,
groupValue: selectedValue,
label: 'Dark',
onChanged: (ThemeMode? value) {
selectedValue = value;
Navigator.of(context).pop();
},
),
],
),
);
},
);
if (selectedValue != null && context.mounted) {
context.read<SettingsChangeProvider>().updateThemeMode(selectedValue);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(context.lang.settingsStorageData),
),
body: ListView(
children: [
BetterText(text: "Media auto-download"),
ListTile(
title: Text("When using mobile data"),
subtitle: Text("Images", style: TextStyle(color: Colors.grey)),
onTap: () {
_showSelectThemeMode(context);
},
),
ListTile(
title: Text("When using Wi-Fi"),
subtitle: Text("Images", style: TextStyle(color: Colors.grey)),
onTap: () {
_showSelectThemeMode(context);
},
),
ListTile(
title: Text("When using roaming"),
subtitle: Text("Images", style: TextStyle(color: Colors.grey)),
onTap: () {
_showSelectThemeMode(context);
},
),
],
),
);
}
}

View file

@ -151,6 +151,16 @@ class _SettingsMainViewState extends State<SettingsMainView> {
}));
},
),
BetterListTile(
icon: FontAwesomeIcons.chartPie,
text: context.lang.settingsStorageData,
onTap: () async {
Navigator.push(context,
MaterialPageRoute(builder: (context) {
return NotificationView();
}));
},
),
const Divider(),
BetterListTile(
icon: FontAwesomeIcons.circleQuestion,

View file

@ -6,6 +6,7 @@ import 'package:drift/internal/migrations.dart';
import 'schema_v1.dart' as v1;
import 'schema_v2.dart' as v2;
import 'schema_v3.dart' as v3;
import 'schema_v4.dart' as v4;
class GeneratedHelper implements SchemaInstantiationHelper {
@override
@ -17,10 +18,12 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v2.DatabaseAtV2(db);
case 3:
return v3.DatabaseAtV3(db);
case 4:
return v4.DatabaseAtV4(db);
default:
throw MissingSchemaException(version, versions);
}
}
static const versions = const [1, 2, 3];
static const versions = const [1, 2, 3, 4];
}

File diff suppressed because it is too large Load diff