mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-01-15 09:08:40 +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();
|
||||
twonlyDatabase = TwonlyDatabase();
|
||||
await twonlyDatabase.messagesDao.appRestarted();
|
||||
await twonlyDatabase.messagesDao.resetPendingDownloadState();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
Future appRestarted() {
|
||||
Future resetPendingDownloadState() {
|
||||
// All media files in the downloading state are reseteded to the pending state
|
||||
// When the app is used in mobile network, they will not be downloaded at the start
|
||||
// if they are not yet downloaded...
|
||||
|
|
|
|||
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 messageOtherId => integer().nullable()();
|
||||
|
||||
IntColumn get mediaUploadId => integer().nullable()();
|
||||
IntColumn get mediaDownloadId => integer().nullable()();
|
||||
|
||||
IntColumn get responseToMessageId => integer().nullable()();
|
||||
IntColumn get responseToOtherMessageId => integer().nullable()();
|
||||
|
||||
|
|
|
|||
|
|
@ -3,8 +3,12 @@ import 'package:drift_flutter/drift_flutter.dart'
|
|||
show driftDatabase, DriftNativeOptions;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:twonly/src/database/daos/contacts_dao.dart';
|
||||
import 'package:twonly/src/database/daos/media_downloads_dao.dart';
|
||||
import 'package:twonly/src/database/daos/media_uploads_dao.dart';
|
||||
import 'package:twonly/src/database/daos/messages_dao.dart';
|
||||
import 'package:twonly/src/database/tables/contacts_table.dart';
|
||||
import 'package:twonly/src/database/tables/media_download_table.dart';
|
||||
import 'package:twonly/src/database/tables/media_uploads_table.dart';
|
||||
import 'package:twonly/src/database/tables/messages_table.dart';
|
||||
import 'package:twonly/src/database/tables/signal_identity_key_store_table.dart';
|
||||
import 'package:twonly/src/database/tables/signal_pre_key_store_table.dart';
|
||||
|
|
@ -18,13 +22,17 @@ part 'twonly_database.g.dart';
|
|||
@DriftDatabase(tables: [
|
||||
Contacts,
|
||||
Messages,
|
||||
MediaUploads,
|
||||
MediaDownloads,
|
||||
SignalIdentityKeyStores,
|
||||
SignalPreKeyStores,
|
||||
SignalSenderKeyStores,
|
||||
SignalSessionStores
|
||||
], daos: [
|
||||
MessagesDao,
|
||||
ContactsDao
|
||||
ContactsDao,
|
||||
MediaUploadsDao,
|
||||
MediaDownloadsDao,
|
||||
])
|
||||
class TwonlyDatabase extends _$TwonlyDatabase {
|
||||
TwonlyDatabase([QueryExecutor? e])
|
||||
|
|
@ -35,7 +43,7 @@ class TwonlyDatabase extends _$TwonlyDatabase {
|
|||
TwonlyDatabase.forTesting(DatabaseConnection super.connection);
|
||||
|
||||
@override
|
||||
int get schemaVersion => 3;
|
||||
int get schemaVersion => 4;
|
||||
|
||||
static QueryExecutor _openConnection() {
|
||||
return driftDatabase(
|
||||
|
|
@ -58,6 +66,9 @@ class TwonlyDatabase extends _$TwonlyDatabase {
|
|||
m.addColumn(
|
||||
schema.contacts, schema.contacts.deleteMessagesAfterXMinutes);
|
||||
},
|
||||
from3To4: (m, schema) async {
|
||||
m.createTable(mediaUploads);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -599,9 +599,209 @@ i1.GeneratedColumn<int> _column_40(String aliasedName) =>
|
|||
'delete_messages_after_x_minutes', aliasedName, false,
|
||||
type: i1.DriftSqlType.int,
|
||||
defaultValue: const CustomExpression('1440'));
|
||||
|
||||
final class Schema4 extends i0.VersionedSchema {
|
||||
Schema4({required super.database}) : super(version: 4);
|
||||
@override
|
||||
late final List<i1.DatabaseSchemaEntity> entities = [
|
||||
contacts,
|
||||
messages,
|
||||
mediaUploads,
|
||||
signalIdentityKeyStores,
|
||||
signalPreKeyStores,
|
||||
signalSenderKeyStores,
|
||||
signalSessionStores,
|
||||
];
|
||||
late final Shape6 contacts = Shape6(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'contacts',
|
||||
withoutRowId: false,
|
||||
isStrict: false,
|
||||
tableConstraints: [
|
||||
'PRIMARY KEY(user_id)',
|
||||
],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_1,
|
||||
_column_2,
|
||||
_column_3,
|
||||
_column_4,
|
||||
_column_5,
|
||||
_column_6,
|
||||
_column_7,
|
||||
_column_8,
|
||||
_column_9,
|
||||
_column_39,
|
||||
_column_40,
|
||||
_column_10,
|
||||
_column_11,
|
||||
_column_12,
|
||||
_column_13,
|
||||
_column_14,
|
||||
_column_15,
|
||||
_column_16,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null);
|
||||
late final Shape1 messages = Shape1(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'messages',
|
||||
withoutRowId: false,
|
||||
isStrict: false,
|
||||
tableConstraints: [],
|
||||
columns: [
|
||||
_column_17,
|
||||
_column_18,
|
||||
_column_19,
|
||||
_column_20,
|
||||
_column_21,
|
||||
_column_22,
|
||||
_column_23,
|
||||
_column_24,
|
||||
_column_25,
|
||||
_column_26,
|
||||
_column_27,
|
||||
_column_28,
|
||||
_column_29,
|
||||
_column_30,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null);
|
||||
late final Shape7 mediaUploads = Shape7(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'media_uploads',
|
||||
withoutRowId: false,
|
||||
isStrict: false,
|
||||
tableConstraints: [],
|
||||
columns: [
|
||||
_column_41,
|
||||
_column_42,
|
||||
_column_43,
|
||||
_column_44,
|
||||
_column_45,
|
||||
_column_46,
|
||||
_column_47,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null);
|
||||
late final Shape2 signalIdentityKeyStores = Shape2(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'signal_identity_key_stores',
|
||||
withoutRowId: false,
|
||||
isStrict: false,
|
||||
tableConstraints: [
|
||||
'PRIMARY KEY(device_id, name)',
|
||||
],
|
||||
columns: [
|
||||
_column_31,
|
||||
_column_32,
|
||||
_column_33,
|
||||
_column_10,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null);
|
||||
late final Shape3 signalPreKeyStores = Shape3(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'signal_pre_key_stores',
|
||||
withoutRowId: false,
|
||||
isStrict: false,
|
||||
tableConstraints: [
|
||||
'PRIMARY KEY(pre_key_id)',
|
||||
],
|
||||
columns: [
|
||||
_column_34,
|
||||
_column_35,
|
||||
_column_10,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null);
|
||||
late final Shape4 signalSenderKeyStores = Shape4(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'signal_sender_key_stores',
|
||||
withoutRowId: false,
|
||||
isStrict: false,
|
||||
tableConstraints: [
|
||||
'PRIMARY KEY(sender_key_name)',
|
||||
],
|
||||
columns: [
|
||||
_column_36,
|
||||
_column_37,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null);
|
||||
late final Shape5 signalSessionStores = Shape5(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'signal_session_stores',
|
||||
withoutRowId: false,
|
||||
isStrict: false,
|
||||
tableConstraints: [
|
||||
'PRIMARY KEY(device_id, name)',
|
||||
],
|
||||
columns: [
|
||||
_column_31,
|
||||
_column_32,
|
||||
_column_38,
|
||||
_column_10,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null);
|
||||
}
|
||||
|
||||
class Shape7 extends i0.VersionedTable {
|
||||
Shape7({required super.source, required super.alias}) : super.aliased();
|
||||
i1.GeneratedColumn<int> get mediaUploadId =>
|
||||
columnsByName['media_upload_id']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get state =>
|
||||
columnsByName['state']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get metadata =>
|
||||
columnsByName['metadata']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get messageIds =>
|
||||
columnsByName['message_ids']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get encryptionData =>
|
||||
columnsByName['encryption_data']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get uploadTokens =>
|
||||
columnsByName['upload_tokens']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get alreadyNotified =>
|
||||
columnsByName['already_notified']! as i1.GeneratedColumn<String>;
|
||||
}
|
||||
|
||||
i1.GeneratedColumn<int> _column_41(String aliasedName) =>
|
||||
i1.GeneratedColumn<int>('media_upload_id', aliasedName, false,
|
||||
hasAutoIncrement: true,
|
||||
type: i1.DriftSqlType.int,
|
||||
defaultConstraints:
|
||||
i1.GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT'));
|
||||
i1.GeneratedColumn<String> _column_42(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>('state', aliasedName, false,
|
||||
type: i1.DriftSqlType.string,
|
||||
defaultValue: const CustomExpression('\'pending\''));
|
||||
i1.GeneratedColumn<String> _column_43(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>('metadata', aliasedName, false,
|
||||
type: i1.DriftSqlType.string);
|
||||
i1.GeneratedColumn<String> _column_44(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>('message_ids', aliasedName, true,
|
||||
type: i1.DriftSqlType.string);
|
||||
i1.GeneratedColumn<String> _column_45(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>('encryption_data', aliasedName, true,
|
||||
type: i1.DriftSqlType.string);
|
||||
i1.GeneratedColumn<String> _column_46(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>('upload_tokens', aliasedName, true,
|
||||
type: i1.DriftSqlType.string);
|
||||
i1.GeneratedColumn<String> _column_47(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>('already_notified', aliasedName, false,
|
||||
type: i1.DriftSqlType.string,
|
||||
defaultValue: const CustomExpression('\'[]\''));
|
||||
i0.MigrationStepWithVersion migrationSteps({
|
||||
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
|
||||
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
|
||||
required Future<void> Function(i1.Migrator m, Schema4 schema) from3To4,
|
||||
}) {
|
||||
return (currentVersion, database) async {
|
||||
switch (currentVersion) {
|
||||
|
|
@ -615,6 +815,11 @@ i0.MigrationStepWithVersion migrationSteps({
|
|||
final migrator = i1.Migrator(database, schema);
|
||||
await from2To3(migrator, schema);
|
||||
return 3;
|
||||
case 3:
|
||||
final schema = Schema4(database: database);
|
||||
final migrator = i1.Migrator(database, schema);
|
||||
await from3To4(migrator, schema);
|
||||
return 4;
|
||||
default:
|
||||
throw ArgumentError.value('Unknown migration from $currentVersion');
|
||||
}
|
||||
|
|
@ -624,9 +829,11 @@ i0.MigrationStepWithVersion migrationSteps({
|
|||
i1.OnUpgrade stepByStep({
|
||||
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
|
||||
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
|
||||
required Future<void> Function(i1.Migrator m, Schema4 schema) from3To4,
|
||||
}) =>
|
||||
i0.VersionedSchema.stepByStepHelper(
|
||||
step: migrationSteps(
|
||||
from1To2: from1To2,
|
||||
from2To3: from2To3,
|
||||
from3To4: from3To4,
|
||||
));
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@
|
|||
"imageEditorDrawOk": "Zeichnung machen",
|
||||
"settingsTitle": "Einstellungen",
|
||||
"settingsChats": "Chats",
|
||||
"settingsStorageData": "Daten und Speicher",
|
||||
"settingsPreSelectedReactions": "Vorgewählte Reaktions-Emojis",
|
||||
"settingsPreSelectedReactionsError": "Es können maximal 12 Reaktionen ausgewählt werden.",
|
||||
"settingsProfile": "Profil",
|
||||
|
|
|
|||
|
|
@ -140,6 +140,8 @@
|
|||
"@settingsPreSelectedReactionsError": {},
|
||||
"settingsProfile": "Profile",
|
||||
"@settingsProfile": {},
|
||||
"settingsStorageData": "Data and storage",
|
||||
"@settingsStorageData": {},
|
||||
"settingsProfileCustomizeAvatar": "Customize your avatar",
|
||||
"@settingsProfileCustomizeAvatar": {},
|
||||
"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:typed_data';
|
||||
import 'package:cryptography_plus/cryptography_plus.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:fixnum/fixnum.dart';
|
||||
import 'package:libsignal_protocol_dart/libsignal_protocol_dart.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:mutex/mutex.dart';
|
||||
import 'package:twonly/globals.dart';
|
||||
import 'package:twonly/app.dart';
|
||||
import 'package:twonly/src/database/twonly_database.dart';
|
||||
import 'package:twonly/src/database/tables/messages_table.dart';
|
||||
import 'package:twonly/src/model/json/message.dart';
|
||||
|
|
@ -17,11 +14,9 @@ import 'package:twonly/src/model/protobuf/api/client_to_server.pbserver.dart';
|
|||
import 'package:twonly/src/model/protobuf/api/error.pb.dart';
|
||||
import 'package:twonly/src/model/protobuf/api/server_to_client.pb.dart'
|
||||
as server;
|
||||
import 'package:twonly/src/model/protobuf/api/server_to_client.pbserver.dart';
|
||||
import 'package:twonly/src/providers/api/api.dart';
|
||||
import 'package:twonly/src/providers/api/api_utils.dart';
|
||||
import 'package:twonly/src/providers/api/media.dart';
|
||||
import 'package:twonly/src/providers/hive.dart';
|
||||
import 'package:twonly/src/providers/api/media_received.dart';
|
||||
import 'package:twonly/src/services/notification_service.dart';
|
||||
// ignore: library_prefixes
|
||||
import 'package:twonly/src/utils/signal.dart' as SignalHelper;
|
||||
|
|
@ -58,115 +53,6 @@ Future handleServerMessage(server.ServerToClient msg) async {
|
|||
});
|
||||
}
|
||||
|
||||
Future<client.Response> handleDownloadData(DownloadData data) async {
|
||||
if (globalIsAppInBackground) {
|
||||
// download should only be done when the app is open
|
||||
return client.Response()..error = ErrorCode.InternalError;
|
||||
}
|
||||
|
||||
Logger("server_messages")
|
||||
.info("downloading: ${data.downloadToken} ${data.fin}");
|
||||
|
||||
final box = await getMediaStorage();
|
||||
|
||||
String boxId = data.downloadToken.toString();
|
||||
|
||||
int? messageId = box.get("${data.downloadToken}_messageId");
|
||||
|
||||
if (messageId == null) {
|
||||
Logger("server_messages")
|
||||
.shout("download data received, but unknown messageID");
|
||||
// answers with ok, so the server will delete the message
|
||||
var ok = client.Response_Ok()..none = true;
|
||||
return client.Response()..ok = ok;
|
||||
}
|
||||
|
||||
if (data.fin && data.data.isEmpty) {
|
||||
Logger("server_messages")
|
||||
.shout("Got an image message, but was already deleted by the server!");
|
||||
// media file was deleted by the server. remove the media from device
|
||||
await twonlyDatabase.messagesDao.deleteMessageById(messageId);
|
||||
await box.delete(boxId);
|
||||
await box.delete("${data.downloadToken}_downloaded");
|
||||
var ok = client.Response_Ok()..none = true;
|
||||
return client.Response()..ok = ok;
|
||||
}
|
||||
|
||||
Uint8List? buffered = box.get(boxId);
|
||||
Uint8List downloadedBytes;
|
||||
if (buffered != null) {
|
||||
if (data.offset != buffered.length) {
|
||||
Logger("server_messages")
|
||||
.info("server send wrong offset: ${data.offset} ${buffered.length}");
|
||||
// Logger("handleDownloadData").error(object)
|
||||
return client.Response()..error = ErrorCode.InvalidOffset;
|
||||
}
|
||||
var b = BytesBuilder();
|
||||
b.add(buffered);
|
||||
b.add(data.data);
|
||||
|
||||
downloadedBytes = b.takeBytes();
|
||||
} else {
|
||||
downloadedBytes = Uint8List.fromList(data.data);
|
||||
}
|
||||
|
||||
if (!data.fin) {
|
||||
// download not finished, so waiting for more data...
|
||||
await box.put(boxId, downloadedBytes);
|
||||
var ok = client.Response_Ok()..none = true;
|
||||
return client.Response()..ok = ok;
|
||||
}
|
||||
|
||||
Message? msg = await twonlyDatabase.messagesDao
|
||||
.getMessageByMessageId(messageId)
|
||||
.getSingleOrNull();
|
||||
if (msg == null) {
|
||||
Logger("server_messages")
|
||||
.info("messageId not found in database. Ignoring download");
|
||||
// answers with ok, so the server will delete the message
|
||||
var ok = client.Response_Ok()..none = true;
|
||||
return client.Response()..ok = ok;
|
||||
}
|
||||
|
||||
MediaMessageContent content =
|
||||
MediaMessageContent.fromJson(jsonDecode(msg.contentJson!));
|
||||
|
||||
final xchacha20 = Xchacha20.poly1305Aead();
|
||||
SecretKeyData secretKeyData = SecretKeyData(content.encryptionKey!);
|
||||
|
||||
SecretBox secretBox = SecretBox(
|
||||
downloadedBytes,
|
||||
nonce: content.encryptionNonce!,
|
||||
mac: Mac(content.encryptionMac!),
|
||||
);
|
||||
|
||||
try {
|
||||
final rawBytes =
|
||||
await xchacha20.decrypt(secretBox, secretKey: secretKeyData);
|
||||
|
||||
await box.put("${data.downloadToken}_downloaded", rawBytes);
|
||||
} catch (e) {
|
||||
Logger("server_messages").info("Decryption error: $e");
|
||||
// deleting message as this is an invalid image
|
||||
await twonlyDatabase.messagesDao.deleteMessageById(messageId);
|
||||
// answers with ok, so the server will delete the message
|
||||
var ok = client.Response_Ok()..none = true;
|
||||
return client.Response()..ok = ok;
|
||||
}
|
||||
|
||||
Logger("server_messages").info("Downloaded: $messageId");
|
||||
await twonlyDatabase.messagesDao.updateMessageByOtherUser(
|
||||
msg.contactId,
|
||||
messageId,
|
||||
MessagesCompanion(downloadState: Value(DownloadState.downloaded)),
|
||||
);
|
||||
|
||||
await box.delete(boxId);
|
||||
|
||||
var ok = client.Response_Ok()..none = true;
|
||||
return client.Response()..ok = ok;
|
||||
}
|
||||
|
||||
Future<client.Response> handleNewMessage(int fromUserId, Uint8List body) async {
|
||||
MessageJson? message = await SignalHelper.getDecryptedText(fromUserId, body);
|
||||
if (message == null) {
|
||||
|
|
@ -315,15 +201,11 @@ Future<client.Response> handleNewMessage(int fromUserId, Uint8List body) async {
|
|||
message.timestamp,
|
||||
);
|
||||
|
||||
if (!globalIsAppInBackground) {
|
||||
final content = message.content;
|
||||
if (content is MediaMessageContent) {
|
||||
tryDownloadMedia(
|
||||
messageId,
|
||||
fromUserId,
|
||||
content,
|
||||
);
|
||||
}
|
||||
final msg = await twonlyDatabase.messagesDao
|
||||
.getMessageByMessageId(messageId)
|
||||
.getSingleOrNull();
|
||||
if (msg != null) {
|
||||
startDownloadMedia(msg, false);
|
||||
}
|
||||
}
|
||||
// dearchive contact when receiving a new message
|
||||
|
|
|
|||
|
|
@ -17,7 +17,8 @@ import 'package:twonly/src/model/protobuf/api/server_to_client.pb.dart'
|
|||
as server;
|
||||
import 'package:twonly/src/providers/api/api.dart';
|
||||
import 'package:twonly/src/providers/api/api_utils.dart';
|
||||
import 'package:twonly/src/providers/api/media.dart';
|
||||
import 'package:twonly/src/providers/api/media_received.dart';
|
||||
import 'package:twonly/src/providers/api/media_send.dart';
|
||||
import 'package:twonly/src/providers/api/server_messages.dart';
|
||||
import 'package:twonly/src/services/fcm_service.dart';
|
||||
import 'package:twonly/src/utils/misc.dart';
|
||||
|
|
@ -82,13 +83,20 @@ class ApiProvider {
|
|||
|
||||
if (!globalIsAppInBackground) {
|
||||
tryTransmitMessages();
|
||||
retransmitMediaFiles();
|
||||
retryMediaUpload();
|
||||
tryDownloadAllMediaFiles();
|
||||
notifyContactsAboutProfileChange();
|
||||
twonlyDatabase.markUpdated();
|
||||
}
|
||||
}
|
||||
|
||||
Future onClosed() async {
|
||||
_channel = null;
|
||||
isAuthenticated = false;
|
||||
globalCallbackConnectionState(false);
|
||||
await twonlyDatabase.messagesDao.resetPendingDownloadState();
|
||||
}
|
||||
|
||||
Future close(Function callback) async {
|
||||
log.info("Closing the websocket connection!");
|
||||
if (_channel != null) {
|
||||
|
|
@ -131,16 +139,12 @@ class ApiProvider {
|
|||
|
||||
void _onDone() {
|
||||
log.info("WebSocket Closed");
|
||||
globalCallbackConnectionState(false);
|
||||
_channel = null;
|
||||
isAuthenticated = false;
|
||||
onClosed();
|
||||
}
|
||||
|
||||
void _onError(dynamic e) {
|
||||
log.info("WebSocket Error: $e");
|
||||
globalCallbackConnectionState(false);
|
||||
_channel = null;
|
||||
isAuthenticated = false;
|
||||
onClosed();
|
||||
}
|
||||
|
||||
void _onData(dynamic msgBuffer) async {
|
||||
|
|
|
|||
|
|
@ -160,7 +160,7 @@ Future<bool> authenticateUser(String localizedReason,
|
|||
return false;
|
||||
}
|
||||
|
||||
Future<bool> isAllowedToDownload() async {
|
||||
Future<bool> isAllowedToDownload(bool isVideo) async {
|
||||
final List<ConnectivityResult> connectivityResult =
|
||||
await (Connectivity().checkConnectivity());
|
||||
if (connectivityResult.contains(ConnectivityResult.mobile)) {
|
||||
|
|
|
|||
|
|
@ -5,13 +5,13 @@ import 'package:flutter/material.dart';
|
|||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:twonly/globals.dart';
|
||||
import 'package:twonly/src/providers/api/media_send.dart';
|
||||
import 'package:twonly/src/views/camera/components/save_to_gallery.dart';
|
||||
import 'package:twonly/src/views/camera/image_editor/action_button.dart';
|
||||
import 'package:twonly/src/views/components/media_view_sizing.dart';
|
||||
import 'package:twonly/src/views/components/notification_badge.dart';
|
||||
import 'package:twonly/src/database/daos/contacts_dao.dart';
|
||||
import 'package:twonly/src/database/twonly_database.dart';
|
||||
import 'package:twonly/src/providers/api/media.dart';
|
||||
import 'package:twonly/src/utils/misc.dart';
|
||||
import 'package:twonly/src/utils/storage.dart';
|
||||
import 'package:twonly/src/views/camera/share_image_view.dart';
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import 'package:camera/camera.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||
import 'package:twonly/globals.dart';
|
||||
import 'package:twonly/src/providers/api/media_send.dart';
|
||||
import 'package:twonly/src/views/camera/components/best_friends_selector.dart';
|
||||
import 'package:twonly/src/views/components/flame.dart';
|
||||
import 'package:twonly/src/views/components/headline.dart';
|
||||
|
|
@ -12,7 +13,6 @@ import 'package:twonly/src/views/components/initialsavatar.dart';
|
|||
import 'package:twonly/src/views/components/verified_shield.dart';
|
||||
import 'package:twonly/src/database/daos/contacts_dao.dart';
|
||||
import 'package:twonly/src/database/twonly_database.dart';
|
||||
import 'package:twonly/src/providers/api/media.dart';
|
||||
import 'package:twonly/src/utils/misc.dart';
|
||||
import 'package:twonly/src/views/home_view.dart';
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import 'package:twonly/src/database/twonly_database.dart';
|
|||
import 'package:twonly/src/database/tables/messages_table.dart';
|
||||
import 'package:twonly/src/model/json/message.dart';
|
||||
import 'package:twonly/src/providers/api/api.dart';
|
||||
import 'package:twonly/src/providers/api/media.dart';
|
||||
import 'package:twonly/src/providers/api/media_received.dart';
|
||||
import 'package:twonly/src/services/notification_service.dart';
|
||||
import 'package:twonly/src/views/camera/camera_send_to_view.dart';
|
||||
import 'package:twonly/src/views/chats/media_viewer_view.dart';
|
||||
|
|
@ -229,9 +229,8 @@ class ChatListEntry extends StatelessWidget {
|
|||
return MediaViewerView(contact);
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
tryDownloadMedia(message.messageId, message.contactId, content,
|
||||
force: true);
|
||||
} else if (message.downloadState == DownloadState.pending) {
|
||||
startDownloadMedia(message, true);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:twonly/globals.dart';
|
||||
import 'package:twonly/src/providers/api/media_received.dart';
|
||||
import 'package:twonly/src/views/components/connection_state.dart';
|
||||
import 'package:twonly/src/views/components/flame.dart';
|
||||
import 'package:twonly/src/views/components/initialsavatar.dart';
|
||||
|
|
@ -12,8 +12,6 @@ import 'package:twonly/src/views/components/user_context_menu.dart';
|
|||
import 'package:twonly/src/database/daos/contacts_dao.dart';
|
||||
import 'package:twonly/src/database/twonly_database.dart';
|
||||
import 'package:twonly/src/database/tables/messages_table.dart';
|
||||
import 'package:twonly/src/model/json/message.dart';
|
||||
import 'package:twonly/src/providers/api/media.dart';
|
||||
import 'package:twonly/src/providers/connection_provider.dart';
|
||||
import 'package:twonly/src/utils/misc.dart';
|
||||
import 'package:twonly/src/views/camera/camera_send_to_view.dart';
|
||||
|
|
@ -325,11 +323,7 @@ class _UserListItem extends State<UserListItem> {
|
|||
msgs.first.openedAt == null) {
|
||||
switch (msgs.first.downloadState) {
|
||||
case DownloadState.pending:
|
||||
MediaMessageContent content = MediaMessageContent.fromJson(
|
||||
jsonDecode(msgs.first.contentJson!));
|
||||
tryDownloadMedia(
|
||||
msgs.first.messageId, msgs.first.contactId, content,
|
||||
force: true);
|
||||
startDownloadMedia(msgs.first, true);
|
||||
case DownloadState.downloaded:
|
||||
Navigator.push(
|
||||
context,
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import 'package:twonly/src/database/twonly_database.dart';
|
|||
import 'package:twonly/src/database/tables/messages_table.dart';
|
||||
import 'package:twonly/src/model/json/message.dart';
|
||||
import 'package:twonly/src/providers/api/api.dart';
|
||||
import 'package:twonly/src/providers/api/media.dart';
|
||||
import 'package:twonly/src/providers/api/media_received.dart';
|
||||
import 'package:twonly/src/services/notification_service.dart';
|
||||
import 'package:twonly/src/utils/misc.dart';
|
||||
import 'package:twonly/src/utils/storage.dart';
|
||||
|
|
@ -145,17 +145,29 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
|||
setState(() {
|
||||
isDownloading = true;
|
||||
});
|
||||
await tryDownloadMedia(current.messageId, current.contactId, content,
|
||||
force: true);
|
||||
await startDownloadMedia(current, true);
|
||||
}
|
||||
do {
|
||||
if (isDownloading) {
|
||||
await Future.delayed(Duration(milliseconds: 10));
|
||||
if (!apiProvider.isConnected) break;
|
||||
}
|
||||
if (content.downloadToken == null) break;
|
||||
imageBytes = await getDownloadedMedia(current, content.downloadToken!);
|
||||
} while (isDownloading && imageBytes == null);
|
||||
|
||||
|
||||
load downloaded status from database
|
||||
|
||||
notifyContactAboutOpeningMessage(
|
||||
message.contactId, [message.messageOtherId!]);
|
||||
twonlyDatabase.messagesDao.updateMessageByMessageId(
|
||||
message.messageId, MessagesCompanion(openedAt: Value(DateTime.now())));
|
||||
// do {
|
||||
// if (isDownloading) {
|
||||
// await Future.delayed(Duration(milliseconds: 10));
|
||||
// if (!apiProvider.isConnected) break;
|
||||
// }
|
||||
// if (content.downloadToken == null) break;
|
||||
// imageBytes = await getDownloadedMedia(current, content.downloadToken!);
|
||||
// } while (isDownloading && imageBytes == null);
|
||||
|
||||
if twonly deleteMediaFile()
|
||||
|
||||
|
||||
|
||||
|
||||
isDownloading = false;
|
||||
if (imageBytes == null) {
|
||||
|
|
|
|||
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(),
|
||||
BetterListTile(
|
||||
icon: FontAwesomeIcons.circleQuestion,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import 'package:drift/internal/migrations.dart';
|
|||
import 'schema_v1.dart' as v1;
|
||||
import 'schema_v2.dart' as v2;
|
||||
import 'schema_v3.dart' as v3;
|
||||
import 'schema_v4.dart' as v4;
|
||||
|
||||
class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
@override
|
||||
|
|
@ -17,10 +18,12 @@ class GeneratedHelper implements SchemaInstantiationHelper {
|
|||
return v2.DatabaseAtV2(db);
|
||||
case 3:
|
||||
return v3.DatabaseAtV3(db);
|
||||
case 4:
|
||||
return v4.DatabaseAtV4(db);
|
||||
default:
|
||||
throw MissingSchemaException(version, versions);
|
||||
}
|
||||
}
|
||||
|
||||
static const versions = const [1, 2, 3];
|
||||
static const versions = const [1, 2, 3, 4];
|
||||
}
|
||||
|
|
|
|||
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