mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-01-15 12:48:41 +00:00
parent
a845065faf
commit
5a6afaa6d4
30 changed files with 5113 additions and 663 deletions
1
drift_schemas/twonly_database/drift_schema_v4.json
Normal file
1
drift_schemas/twonly_database/drift_schema_v4.json
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -31,7 +31,7 @@ void main() async {
|
||||||
|
|
||||||
apiProvider = ApiProvider();
|
apiProvider = ApiProvider();
|
||||||
twonlyDatabase = TwonlyDatabase();
|
twonlyDatabase = TwonlyDatabase();
|
||||||
await twonlyDatabase.messagesDao.appRestarted();
|
await twonlyDatabase.messagesDao.resetPendingDownloadState();
|
||||||
|
|
||||||
await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
|
await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
|
||||||
|
|
||||||
|
|
|
||||||
44
lib/src/database/daos/media_downloads_dao.dart
Normal file
44
lib/src/database/daos/media_downloads_dao.dart
Normal 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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
8
lib/src/database/daos/media_downloads_dao.g.dart
Normal file
8
lib/src/database/daos/media_downloads_dao.g.dart
Normal 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;
|
||||||
|
}
|
||||||
47
lib/src/database/daos/media_uploads_dao.dart
Normal file
47
lib/src/database/daos/media_uploads_dao.dart
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
8
lib/src/database/daos/media_uploads_dao.g.dart
Normal file
8
lib/src/database/daos/media_uploads_dao.g.dart
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -104,7 +104,7 @@ class MessagesDao extends DatabaseAccessor<TwonlyDatabase>
|
||||||
.write(updates);
|
.write(updates);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future appRestarted() {
|
Future resetPendingDownloadState() {
|
||||||
// All media files in the downloading state are reseteded to the pending state
|
// 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
|
// When the app is used in mobile network, they will not be downloaded at the start
|
||||||
// if they are not yet downloaded...
|
// if they are not yet downloaded...
|
||||||
|
|
|
||||||
8
lib/src/database/tables/media_download_table.dart
Normal file
8
lib/src/database/tables/media_download_table.dart
Normal 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())();
|
||||||
|
}
|
||||||
216
lib/src/database/tables/media_uploads_table.dart
Normal file
216
lib/src/database/tables/media_uploads_table.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -27,6 +27,9 @@ class Messages extends Table {
|
||||||
IntColumn get messageId => integer().autoIncrement()();
|
IntColumn get messageId => integer().autoIncrement()();
|
||||||
IntColumn get messageOtherId => integer().nullable()();
|
IntColumn get messageOtherId => integer().nullable()();
|
||||||
|
|
||||||
|
IntColumn get mediaUploadId => integer().nullable()();
|
||||||
|
IntColumn get mediaDownloadId => integer().nullable()();
|
||||||
|
|
||||||
IntColumn get responseToMessageId => integer().nullable()();
|
IntColumn get responseToMessageId => integer().nullable()();
|
||||||
IntColumn get responseToOtherMessageId => integer().nullable()();
|
IntColumn get responseToOtherMessageId => integer().nullable()();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,12 @@ import 'package:drift_flutter/drift_flutter.dart'
|
||||||
show driftDatabase, DriftNativeOptions;
|
show driftDatabase, DriftNativeOptions;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:twonly/src/database/daos/contacts_dao.dart';
|
import 'package:twonly/src/database/daos/contacts_dao.dart';
|
||||||
|
import 'package:twonly/src/database/daos/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/daos/messages_dao.dart';
|
||||||
import 'package:twonly/src/database/tables/contacts_table.dart';
|
import 'package:twonly/src/database/tables/contacts_table.dart';
|
||||||
|
import 'package:twonly/src/database/tables/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/messages_table.dart';
|
||||||
import 'package:twonly/src/database/tables/signal_identity_key_store_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_pre_key_store_table.dart';
|
||||||
|
|
@ -18,13 +22,17 @@ part 'twonly_database.g.dart';
|
||||||
@DriftDatabase(tables: [
|
@DriftDatabase(tables: [
|
||||||
Contacts,
|
Contacts,
|
||||||
Messages,
|
Messages,
|
||||||
|
MediaUploads,
|
||||||
|
MediaDownloads,
|
||||||
SignalIdentityKeyStores,
|
SignalIdentityKeyStores,
|
||||||
SignalPreKeyStores,
|
SignalPreKeyStores,
|
||||||
SignalSenderKeyStores,
|
SignalSenderKeyStores,
|
||||||
SignalSessionStores
|
SignalSessionStores
|
||||||
], daos: [
|
], daos: [
|
||||||
MessagesDao,
|
MessagesDao,
|
||||||
ContactsDao
|
ContactsDao,
|
||||||
|
MediaUploadsDao,
|
||||||
|
MediaDownloadsDao,
|
||||||
])
|
])
|
||||||
class TwonlyDatabase extends _$TwonlyDatabase {
|
class TwonlyDatabase extends _$TwonlyDatabase {
|
||||||
TwonlyDatabase([QueryExecutor? e])
|
TwonlyDatabase([QueryExecutor? e])
|
||||||
|
|
@ -35,7 +43,7 @@ class TwonlyDatabase extends _$TwonlyDatabase {
|
||||||
TwonlyDatabase.forTesting(DatabaseConnection super.connection);
|
TwonlyDatabase.forTesting(DatabaseConnection super.connection);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => 3;
|
int get schemaVersion => 4;
|
||||||
|
|
||||||
static QueryExecutor _openConnection() {
|
static QueryExecutor _openConnection() {
|
||||||
return driftDatabase(
|
return driftDatabase(
|
||||||
|
|
@ -58,6 +66,9 @@ class TwonlyDatabase extends _$TwonlyDatabase {
|
||||||
m.addColumn(
|
m.addColumn(
|
||||||
schema.contacts, schema.contacts.deleteMessagesAfterXMinutes);
|
schema.contacts, schema.contacts.deleteMessagesAfterXMinutes);
|
||||||
},
|
},
|
||||||
|
from3To4: (m, schema) async {
|
||||||
|
m.createTable(mediaUploads);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -599,9 +599,209 @@ i1.GeneratedColumn<int> _column_40(String aliasedName) =>
|
||||||
'delete_messages_after_x_minutes', aliasedName, false,
|
'delete_messages_after_x_minutes', aliasedName, false,
|
||||||
type: i1.DriftSqlType.int,
|
type: i1.DriftSqlType.int,
|
||||||
defaultValue: const CustomExpression('1440'));
|
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({
|
i0.MigrationStepWithVersion migrationSteps({
|
||||||
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
|
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
|
||||||
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
|
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
|
||||||
|
required Future<void> Function(i1.Migrator m, Schema4 schema) from3To4,
|
||||||
}) {
|
}) {
|
||||||
return (currentVersion, database) async {
|
return (currentVersion, database) async {
|
||||||
switch (currentVersion) {
|
switch (currentVersion) {
|
||||||
|
|
@ -615,6 +815,11 @@ i0.MigrationStepWithVersion migrationSteps({
|
||||||
final migrator = i1.Migrator(database, schema);
|
final migrator = i1.Migrator(database, schema);
|
||||||
await from2To3(migrator, schema);
|
await from2To3(migrator, schema);
|
||||||
return 3;
|
return 3;
|
||||||
|
case 3:
|
||||||
|
final schema = Schema4(database: database);
|
||||||
|
final migrator = i1.Migrator(database, schema);
|
||||||
|
await from3To4(migrator, schema);
|
||||||
|
return 4;
|
||||||
default:
|
default:
|
||||||
throw ArgumentError.value('Unknown migration from $currentVersion');
|
throw ArgumentError.value('Unknown migration from $currentVersion');
|
||||||
}
|
}
|
||||||
|
|
@ -624,9 +829,11 @@ i0.MigrationStepWithVersion migrationSteps({
|
||||||
i1.OnUpgrade stepByStep({
|
i1.OnUpgrade stepByStep({
|
||||||
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
|
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
|
||||||
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
|
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
|
||||||
|
required Future<void> Function(i1.Migrator m, Schema4 schema) from3To4,
|
||||||
}) =>
|
}) =>
|
||||||
i0.VersionedSchema.stepByStepHelper(
|
i0.VersionedSchema.stepByStepHelper(
|
||||||
step: migrationSteps(
|
step: migrationSteps(
|
||||||
from1To2: from1To2,
|
from1To2: from1To2,
|
||||||
from2To3: from2To3,
|
from2To3: from2To3,
|
||||||
|
from3To4: from3To4,
|
||||||
));
|
));
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,7 @@
|
||||||
"imageEditorDrawOk": "Zeichnung machen",
|
"imageEditorDrawOk": "Zeichnung machen",
|
||||||
"settingsTitle": "Einstellungen",
|
"settingsTitle": "Einstellungen",
|
||||||
"settingsChats": "Chats",
|
"settingsChats": "Chats",
|
||||||
|
"settingsStorageData": "Daten und Speicher",
|
||||||
"settingsPreSelectedReactions": "Vorgewählte Reaktions-Emojis",
|
"settingsPreSelectedReactions": "Vorgewählte Reaktions-Emojis",
|
||||||
"settingsPreSelectedReactionsError": "Es können maximal 12 Reaktionen ausgewählt werden.",
|
"settingsPreSelectedReactionsError": "Es können maximal 12 Reaktionen ausgewählt werden.",
|
||||||
"settingsProfile": "Profil",
|
"settingsProfile": "Profil",
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,8 @@
|
||||||
"@settingsPreSelectedReactionsError": {},
|
"@settingsPreSelectedReactionsError": {},
|
||||||
"settingsProfile": "Profile",
|
"settingsProfile": "Profile",
|
||||||
"@settingsProfile": {},
|
"@settingsProfile": {},
|
||||||
|
"settingsStorageData": "Data and storage",
|
||||||
|
"@settingsStorageData": {},
|
||||||
"settingsProfileCustomizeAvatar": "Customize your avatar",
|
"settingsProfileCustomizeAvatar": "Customize your avatar",
|
||||||
"@settingsProfileCustomizeAvatar": {},
|
"@settingsProfileCustomizeAvatar": {},
|
||||||
"settingsProfileEditDisplayName": "Displayname",
|
"settingsProfileEditDisplayName": "Displayname",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
230
lib/src/providers/api/media_received.dart
Normal file
230
lib/src/providers/api/media_received.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
472
lib/src/providers/api/media_send.dart
Normal file
472
lib/src/providers/api/media_send.dart
Normal 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];
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,10 @@
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:typed_data';
|
|
||||||
import 'package:cryptography_plus/cryptography_plus.dart';
|
|
||||||
import 'package:drift/drift.dart';
|
import 'package:drift/drift.dart';
|
||||||
import 'package:fixnum/fixnum.dart';
|
import 'package:fixnum/fixnum.dart';
|
||||||
import 'package:libsignal_protocol_dart/libsignal_protocol_dart.dart';
|
import 'package:libsignal_protocol_dart/libsignal_protocol_dart.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:mutex/mutex.dart';
|
import 'package:mutex/mutex.dart';
|
||||||
import 'package:twonly/globals.dart';
|
import 'package:twonly/globals.dart';
|
||||||
import 'package:twonly/app.dart';
|
|
||||||
import 'package:twonly/src/database/twonly_database.dart';
|
import 'package:twonly/src/database/twonly_database.dart';
|
||||||
import 'package:twonly/src/database/tables/messages_table.dart';
|
import 'package:twonly/src/database/tables/messages_table.dart';
|
||||||
import 'package:twonly/src/model/json/message.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/error.pb.dart';
|
||||||
import 'package:twonly/src/model/protobuf/api/server_to_client.pb.dart'
|
import 'package:twonly/src/model/protobuf/api/server_to_client.pb.dart'
|
||||||
as server;
|
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.dart';
|
||||||
import 'package:twonly/src/providers/api/api_utils.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/hive.dart';
|
|
||||||
import 'package:twonly/src/services/notification_service.dart';
|
import 'package:twonly/src/services/notification_service.dart';
|
||||||
// ignore: library_prefixes
|
// ignore: library_prefixes
|
||||||
import 'package:twonly/src/utils/signal.dart' as SignalHelper;
|
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 {
|
Future<client.Response> handleNewMessage(int fromUserId, Uint8List body) async {
|
||||||
MessageJson? message = await SignalHelper.getDecryptedText(fromUserId, body);
|
MessageJson? message = await SignalHelper.getDecryptedText(fromUserId, body);
|
||||||
if (message == null) {
|
if (message == null) {
|
||||||
|
|
@ -315,15 +201,11 @@ Future<client.Response> handleNewMessage(int fromUserId, Uint8List body) async {
|
||||||
message.timestamp,
|
message.timestamp,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!globalIsAppInBackground) {
|
final msg = await twonlyDatabase.messagesDao
|
||||||
final content = message.content;
|
.getMessageByMessageId(messageId)
|
||||||
if (content is MediaMessageContent) {
|
.getSingleOrNull();
|
||||||
tryDownloadMedia(
|
if (msg != null) {
|
||||||
messageId,
|
startDownloadMedia(msg, false);
|
||||||
fromUserId,
|
|
||||||
content,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// dearchive contact when receiving a new message
|
// dearchive contact when receiving a new message
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,8 @@ import 'package:twonly/src/model/protobuf/api/server_to_client.pb.dart'
|
||||||
as server;
|
as server;
|
||||||
import 'package:twonly/src/providers/api/api.dart';
|
import 'package:twonly/src/providers/api/api.dart';
|
||||||
import 'package:twonly/src/providers/api/api_utils.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/providers/api/server_messages.dart';
|
||||||
import 'package:twonly/src/services/fcm_service.dart';
|
import 'package:twonly/src/services/fcm_service.dart';
|
||||||
import 'package:twonly/src/utils/misc.dart';
|
import 'package:twonly/src/utils/misc.dart';
|
||||||
|
|
@ -82,13 +83,20 @@ class ApiProvider {
|
||||||
|
|
||||||
if (!globalIsAppInBackground) {
|
if (!globalIsAppInBackground) {
|
||||||
tryTransmitMessages();
|
tryTransmitMessages();
|
||||||
retransmitMediaFiles();
|
retryMediaUpload();
|
||||||
tryDownloadAllMediaFiles();
|
tryDownloadAllMediaFiles();
|
||||||
notifyContactsAboutProfileChange();
|
notifyContactsAboutProfileChange();
|
||||||
twonlyDatabase.markUpdated();
|
twonlyDatabase.markUpdated();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future onClosed() async {
|
||||||
|
_channel = null;
|
||||||
|
isAuthenticated = false;
|
||||||
|
globalCallbackConnectionState(false);
|
||||||
|
await twonlyDatabase.messagesDao.resetPendingDownloadState();
|
||||||
|
}
|
||||||
|
|
||||||
Future close(Function callback) async {
|
Future close(Function callback) async {
|
||||||
log.info("Closing the websocket connection!");
|
log.info("Closing the websocket connection!");
|
||||||
if (_channel != null) {
|
if (_channel != null) {
|
||||||
|
|
@ -131,16 +139,12 @@ class ApiProvider {
|
||||||
|
|
||||||
void _onDone() {
|
void _onDone() {
|
||||||
log.info("WebSocket Closed");
|
log.info("WebSocket Closed");
|
||||||
globalCallbackConnectionState(false);
|
onClosed();
|
||||||
_channel = null;
|
|
||||||
isAuthenticated = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onError(dynamic e) {
|
void _onError(dynamic e) {
|
||||||
log.info("WebSocket Error: $e");
|
log.info("WebSocket Error: $e");
|
||||||
globalCallbackConnectionState(false);
|
onClosed();
|
||||||
_channel = null;
|
|
||||||
isAuthenticated = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onData(dynamic msgBuffer) async {
|
void _onData(dynamic msgBuffer) async {
|
||||||
|
|
|
||||||
|
|
@ -160,7 +160,7 @@ Future<bool> authenticateUser(String localizedReason,
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> isAllowedToDownload() async {
|
Future<bool> isAllowedToDownload(bool isVideo) async {
|
||||||
final List<ConnectivityResult> connectivityResult =
|
final List<ConnectivityResult> connectivityResult =
|
||||||
await (Connectivity().checkConnectivity());
|
await (Connectivity().checkConnectivity());
|
||||||
if (connectivityResult.contains(ConnectivityResult.mobile)) {
|
if (connectivityResult.contains(ConnectivityResult.mobile)) {
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,13 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:twonly/globals.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/components/save_to_gallery.dart';
|
||||||
import 'package:twonly/src/views/camera/image_editor/action_button.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/media_view_sizing.dart';
|
||||||
import 'package:twonly/src/views/components/notification_badge.dart';
|
import 'package:twonly/src/views/components/notification_badge.dart';
|
||||||
import 'package:twonly/src/database/daos/contacts_dao.dart';
|
import 'package:twonly/src/database/daos/contacts_dao.dart';
|
||||||
import 'package:twonly/src/database/twonly_database.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/misc.dart';
|
||||||
import 'package:twonly/src/utils/storage.dart';
|
import 'package:twonly/src/utils/storage.dart';
|
||||||
import 'package:twonly/src/views/camera/share_image_view.dart';
|
import 'package:twonly/src/views/camera/share_image_view.dart';
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import 'package:camera/camera.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||||
import 'package:twonly/globals.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/camera/components/best_friends_selector.dart';
|
||||||
import 'package:twonly/src/views/components/flame.dart';
|
import 'package:twonly/src/views/components/flame.dart';
|
||||||
import 'package:twonly/src/views/components/headline.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/views/components/verified_shield.dart';
|
||||||
import 'package:twonly/src/database/daos/contacts_dao.dart';
|
import 'package:twonly/src/database/daos/contacts_dao.dart';
|
||||||
import 'package:twonly/src/database/twonly_database.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/misc.dart';
|
||||||
import 'package:twonly/src/views/home_view.dart';
|
import 'package:twonly/src/views/home_view.dart';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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/database/tables/messages_table.dart';
|
||||||
import 'package:twonly/src/model/json/message.dart';
|
import 'package:twonly/src/model/json/message.dart';
|
||||||
import 'package:twonly/src/providers/api/api.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/services/notification_service.dart';
|
||||||
import 'package:twonly/src/views/camera/camera_send_to_view.dart';
|
import 'package:twonly/src/views/camera/camera_send_to_view.dart';
|
||||||
import 'package:twonly/src/views/chats/media_viewer_view.dart';
|
import 'package:twonly/src/views/chats/media_viewer_view.dart';
|
||||||
|
|
@ -229,9 +229,8 @@ class ChatListEntry extends StatelessWidget {
|
||||||
return MediaViewerView(contact);
|
return MediaViewerView(contact);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
} else {
|
} else if (message.downloadState == DownloadState.pending) {
|
||||||
tryDownloadMedia(message.messageId, message.contactId, content,
|
startDownloadMedia(message, true);
|
||||||
force: true);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
|
||||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:twonly/globals.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/connection_state.dart';
|
||||||
import 'package:twonly/src/views/components/flame.dart';
|
import 'package:twonly/src/views/components/flame.dart';
|
||||||
import 'package:twonly/src/views/components/initialsavatar.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/daos/contacts_dao.dart';
|
||||||
import 'package:twonly/src/database/twonly_database.dart';
|
import 'package:twonly/src/database/twonly_database.dart';
|
||||||
import 'package:twonly/src/database/tables/messages_table.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/providers/connection_provider.dart';
|
||||||
import 'package:twonly/src/utils/misc.dart';
|
import 'package:twonly/src/utils/misc.dart';
|
||||||
import 'package:twonly/src/views/camera/camera_send_to_view.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) {
|
msgs.first.openedAt == null) {
|
||||||
switch (msgs.first.downloadState) {
|
switch (msgs.first.downloadState) {
|
||||||
case DownloadState.pending:
|
case DownloadState.pending:
|
||||||
MediaMessageContent content = MediaMessageContent.fromJson(
|
startDownloadMedia(msgs.first, true);
|
||||||
jsonDecode(msgs.first.contentJson!));
|
|
||||||
tryDownloadMedia(
|
|
||||||
msgs.first.messageId, msgs.first.contactId, content,
|
|
||||||
force: true);
|
|
||||||
case DownloadState.downloaded:
|
case DownloadState.downloaded:
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
|
|
|
||||||
|
|
@ -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/database/tables/messages_table.dart';
|
||||||
import 'package:twonly/src/model/json/message.dart';
|
import 'package:twonly/src/model/json/message.dart';
|
||||||
import 'package:twonly/src/providers/api/api.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/services/notification_service.dart';
|
||||||
import 'package:twonly/src/utils/misc.dart';
|
import 'package:twonly/src/utils/misc.dart';
|
||||||
import 'package:twonly/src/utils/storage.dart';
|
import 'package:twonly/src/utils/storage.dart';
|
||||||
|
|
@ -145,17 +145,29 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
setState(() {
|
setState(() {
|
||||||
isDownloading = true;
|
isDownloading = true;
|
||||||
});
|
});
|
||||||
await tryDownloadMedia(current.messageId, current.contactId, content,
|
await startDownloadMedia(current, true);
|
||||||
force: true);
|
|
||||||
}
|
}
|
||||||
do {
|
|
||||||
if (isDownloading) {
|
|
||||||
await Future.delayed(Duration(milliseconds: 10));
|
load downloaded status from database
|
||||||
if (!apiProvider.isConnected) break;
|
|
||||||
}
|
notifyContactAboutOpeningMessage(
|
||||||
if (content.downloadToken == null) break;
|
message.contactId, [message.messageOtherId!]);
|
||||||
imageBytes = await getDownloadedMedia(current, content.downloadToken!);
|
twonlyDatabase.messagesDao.updateMessageByMessageId(
|
||||||
} while (isDownloading && imageBytes == null);
|
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;
|
isDownloading = false;
|
||||||
if (imageBytes == null) {
|
if (imageBytes == null) {
|
||||||
|
|
|
||||||
94
lib/src/views/settings/data_and_storage_view.dart
Normal file
94
lib/src/views/settings/data_and_storage_view.dart
Normal 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);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
const Divider(),
|
||||||
BetterListTile(
|
BetterListTile(
|
||||||
icon: FontAwesomeIcons.circleQuestion,
|
icon: FontAwesomeIcons.circleQuestion,
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import 'package:drift/internal/migrations.dart';
|
||||||
import 'schema_v1.dart' as v1;
|
import 'schema_v1.dart' as v1;
|
||||||
import 'schema_v2.dart' as v2;
|
import 'schema_v2.dart' as v2;
|
||||||
import 'schema_v3.dart' as v3;
|
import 'schema_v3.dart' as v3;
|
||||||
|
import 'schema_v4.dart' as v4;
|
||||||
|
|
||||||
class GeneratedHelper implements SchemaInstantiationHelper {
|
class GeneratedHelper implements SchemaInstantiationHelper {
|
||||||
@override
|
@override
|
||||||
|
|
@ -17,10 +18,12 @@ class GeneratedHelper implements SchemaInstantiationHelper {
|
||||||
return v2.DatabaseAtV2(db);
|
return v2.DatabaseAtV2(db);
|
||||||
case 3:
|
case 3:
|
||||||
return v3.DatabaseAtV3(db);
|
return v3.DatabaseAtV3(db);
|
||||||
|
case 4:
|
||||||
|
return v4.DatabaseAtV4(db);
|
||||||
default:
|
default:
|
||||||
throw MissingSchemaException(version, versions);
|
throw MissingSchemaException(version, versions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static const versions = const [1, 2, 3];
|
static const versions = const [1, 2, 3, 4];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
2572
test/drift/twonly_database/generated/schema_v4.dart
Normal file
2572
test/drift/twonly_database/generated/schema_v4.dart
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue