video sending and receiving works #25

This commit is contained in:
otsmr 2025-04-25 00:32:19 +02:00
parent 5a6afaa6d4
commit 53ccb325b5
17 changed files with 3382 additions and 215 deletions

File diff suppressed because one or more lines are too long

View file

@ -65,6 +65,7 @@ class MediaUploadMetadata {
MediaUploadMetadata state = MediaUploadMetadata(); MediaUploadMetadata state = MediaUploadMetadata();
state.contactIds = List<int>.from(json['contactIds']); state.contactIds = List<int>.from(json['contactIds']);
state.isRealTwonly = json['isRealTwonly']; state.isRealTwonly = json['isRealTwonly'];
state.videoWithAudio = json['videoWithAudio'];
state.isVideo = json['isVideo']; state.isVideo = json['isVideo'];
state.maxShowTime = json['maxShowTime']; state.maxShowTime = json['maxShowTime'];
state.maxShowTime = json['maxShowTime']; state.maxShowTime = json['maxShowTime'];

View file

@ -43,7 +43,7 @@ class TwonlyDatabase extends _$TwonlyDatabase {
TwonlyDatabase.forTesting(DatabaseConnection super.connection); TwonlyDatabase.forTesting(DatabaseConnection super.connection);
@override @override
int get schemaVersion => 4; int get schemaVersion => 5;
static QueryExecutor _openConnection() { static QueryExecutor _openConnection() {
return driftDatabase( return driftDatabase(
@ -57,19 +57,19 @@ class TwonlyDatabase extends _$TwonlyDatabase {
@override @override
MigrationStrategy get migration { MigrationStrategy get migration {
return MigrationStrategy( return MigrationStrategy(
onUpgrade: stepByStep( onUpgrade: stepByStep(from1To2: (m, schema) async {
from1To2: (m, schema) async { m.addColumn(schema.messages, schema.messages.errorWhileSending);
m.addColumn(schema.messages, schema.messages.errorWhileSending); }, from2To3: (m, schema) async {
}, m.addColumn(schema.contacts, schema.contacts.archived);
from2To3: (m, schema) async { m.addColumn(
m.addColumn(schema.contacts, schema.contacts.archived); schema.contacts, schema.contacts.deleteMessagesAfterXMinutes);
m.addColumn( }, from3To4: (m, schema) async {
schema.contacts, schema.contacts.deleteMessagesAfterXMinutes); m.createTable(mediaUploads);
}, }, from4To5: (m, schema) async {
from3To4: (m, schema) async { m.createTable(mediaDownloads);
m.createTable(mediaUploads); m.addColumn(schema.messages, schema.messages.mediaDownloadId);
}, m.addColumn(schema.messages, schema.messages.mediaUploadId);
), }),
); );
} }

View file

@ -798,10 +798,239 @@ i1.GeneratedColumn<String> _column_47(String aliasedName) =>
i1.GeneratedColumn<String>('already_notified', aliasedName, false, i1.GeneratedColumn<String>('already_notified', aliasedName, false,
type: i1.DriftSqlType.string, type: i1.DriftSqlType.string,
defaultValue: const CustomExpression('\'[]\'')); defaultValue: const CustomExpression('\'[]\''));
final class Schema5 extends i0.VersionedSchema {
Schema5({required super.database}) : super(version: 5);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
contacts,
messages,
mediaUploads,
mediaDownloads,
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 Shape8 messages = Shape8(
source: i0.VersionedTable(
entityName: 'messages',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_17,
_column_18,
_column_19,
_column_48,
_column_49,
_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 Shape9 mediaDownloads = Shape9(
source: i0.VersionedTable(
entityName: 'media_downloads',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_50,
_column_51,
],
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 Shape8 extends i0.VersionedTable {
Shape8({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<int> get contactId =>
columnsByName['contact_id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get messageId =>
columnsByName['message_id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get messageOtherId =>
columnsByName['message_other_id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get mediaUploadId =>
columnsByName['media_upload_id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get mediaDownloadId =>
columnsByName['media_download_id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get responseToMessageId =>
columnsByName['response_to_message_id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get responseToOtherMessageId =>
columnsByName['response_to_other_message_id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<bool> get acknowledgeByUser =>
columnsByName['acknowledge_by_user']! as i1.GeneratedColumn<bool>;
i1.GeneratedColumn<int> get downloadState =>
columnsByName['download_state']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<bool> get acknowledgeByServer =>
columnsByName['acknowledge_by_server']! as i1.GeneratedColumn<bool>;
i1.GeneratedColumn<bool> get errorWhileSending =>
columnsByName['error_while_sending']! as i1.GeneratedColumn<bool>;
i1.GeneratedColumn<String> get kind =>
columnsByName['kind']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get contentJson =>
columnsByName['content_json']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<DateTime> get openedAt =>
columnsByName['opened_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<DateTime> get sendAt =>
columnsByName['send_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<DateTime> get updatedAt =>
columnsByName['updated_at']! as i1.GeneratedColumn<DateTime>;
}
i1.GeneratedColumn<int> _column_48(String aliasedName) =>
i1.GeneratedColumn<int>('media_upload_id', aliasedName, true,
type: i1.DriftSqlType.int);
i1.GeneratedColumn<int> _column_49(String aliasedName) =>
i1.GeneratedColumn<int>('media_download_id', aliasedName, true,
type: i1.DriftSqlType.int);
class Shape9 extends i0.VersionedTable {
Shape9({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<int> get messageId =>
columnsByName['message_id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get downloadToken =>
columnsByName['download_token']! as i1.GeneratedColumn<String>;
}
i1.GeneratedColumn<int> _column_50(String aliasedName) =>
i1.GeneratedColumn<int>('message_id', aliasedName, false,
type: i1.DriftSqlType.int);
i1.GeneratedColumn<String> _column_51(String aliasedName) =>
i1.GeneratedColumn<String>('download_token', aliasedName, false,
type: i1.DriftSqlType.string);
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, required Future<void> Function(i1.Migrator m, Schema4 schema) from3To4,
required Future<void> Function(i1.Migrator m, Schema5 schema) from4To5,
}) { }) {
return (currentVersion, database) async { return (currentVersion, database) async {
switch (currentVersion) { switch (currentVersion) {
@ -820,6 +1049,11 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema); final migrator = i1.Migrator(database, schema);
await from3To4(migrator, schema); await from3To4(migrator, schema);
return 4; return 4;
case 4:
final schema = Schema5(database: database);
final migrator = i1.Migrator(database, schema);
await from4To5(migrator, schema);
return 5;
default: default:
throw ArgumentError.value('Unknown migration from $currentVersion'); throw ArgumentError.value('Unknown migration from $currentVersion');
} }
@ -830,10 +1064,12 @@ 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, required Future<void> Function(i1.Migrator m, Schema4 schema) from3To4,
required Future<void> Function(i1.Migrator m, Schema5 schema) from4To5,
}) => }) =>
i0.VersionedSchema.stepByStepHelper( i0.VersionedSchema.stepByStepHelper(
step: migrationSteps( step: migrationSteps(
from1To2: from1To2, from1To2: from1To2,
from2To3: from2To3, from2To3: from2To3,
from3To4: from3To4, from3To4: from3To4,
from4To5: from4To5,
)); ));

View file

@ -503,6 +503,12 @@ abstract class AppLocalizations {
/// **'Profile'** /// **'Profile'**
String get settingsProfile; String get settingsProfile;
/// No description provided for @settingsStorageData.
///
/// In en, this message translates to:
/// **'Data and storage'**
String get settingsStorageData;
/// No description provided for @settingsProfileCustomizeAvatar. /// No description provided for @settingsProfileCustomizeAvatar.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -214,6 +214,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get settingsProfile => 'Profil'; String get settingsProfile => 'Profil';
@override
String get settingsStorageData => 'Daten und Speicher';
@override @override
String get settingsProfileCustomizeAvatar => 'Avatar anpassen'; String get settingsProfileCustomizeAvatar => 'Avatar anpassen';

View file

@ -214,6 +214,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get settingsProfile => 'Profile'; String get settingsProfile => 'Profile';
@override
String get settingsStorageData => 'Data and storage';
@override @override
String get settingsProfileCustomizeAvatar => 'Customize your avatar'; String get settingsProfileCustomizeAvatar => 'Customize your avatar';

View file

@ -148,6 +148,7 @@ class MediaMessageContent extends MessageContent {
'encryptionNonce': encryptionNonce, 'encryptionNonce': encryptionNonce,
'isRealTwonly': isRealTwonly, 'isRealTwonly': isRealTwonly,
'maxShowTime': maxShowTime, 'maxShowTime': maxShowTime,
'isVideo': isVideo,
}; };
} }
} }

View file

@ -6,7 +6,6 @@ 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/media_send.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 'package:twonly/src/utils/misc.dart';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:cryptography_plus/cryptography_plus.dart'; import 'package:cryptography_plus/cryptography_plus.dart';
@ -17,7 +16,11 @@ import 'package:twonly/src/model/protobuf/api/client_to_server.pb.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.pbserver.dart'; import 'package:twonly/src/model/protobuf/api/server_to_client.pbserver.dart';
Map<int, DateTime> downloadStartedForMediaReceived = {};
Future tryDownloadAllMediaFiles() async { Future tryDownloadAllMediaFiles() async {
// this is called when websocket is newly connected, so allow all downloads to be restarted.
downloadStartedForMediaReceived = {};
List<Message> messages = List<Message> messages =
await twonlyDatabase.messagesDao.getAllMessagesPendingDownloading(); await twonlyDatabase.messagesDao.getAllMessagesPendingDownloading();
@ -28,6 +31,14 @@ Future tryDownloadAllMediaFiles() async {
Future startDownloadMedia(Message message, bool force) async { Future startDownloadMedia(Message message, bool force) async {
if (message.contentJson == null) return; if (message.contentJson == null) return;
if (downloadStartedForMediaReceived[message.messageId] != null) {
DateTime started = downloadStartedForMediaReceived[message.messageId]!;
Duration elapsed = DateTime.now().difference(started);
if (elapsed <= Duration(seconds: 60)) {
Logger("media_received.dart").shout("Download already started...");
return;
}
}
final content = final content =
MessageContent.fromJson(message.kind, jsonDecode(message.contentJson!)); MessageContent.fromJson(message.kind, jsonDecode(message.contentJson!));
@ -69,6 +80,8 @@ Future startDownloadMedia(Message message, bool force) async {
if (bytes != null && bytes.isNotEmpty) { if (bytes != null && bytes.isNotEmpty) {
offset = bytes.length; offset = bytes.length;
} }
downloadStartedForMediaReceived[message.messageId] = DateTime.now();
apiProvider.triggerDownload(content.downloadToken!, offset); apiProvider.triggerDownload(content.downloadToken!, offset);
} }
} }
@ -82,10 +95,6 @@ Future<client.Response> handleDownloadData(DownloadData data) async {
Logger("server_messages") Logger("server_messages")
.info("downloading: ${data.downloadToken} ${data.fin}"); .info("downloading: ${data.downloadToken} ${data.fin}");
final box = await getMediaStorage();
String boxId = data.downloadToken.toString();
final media = await twonlyDatabase.mediaDownloadsDao final media = await twonlyDatabase.mediaDownloadsDao
.getMediaDownloadByDownloadToken(data.downloadToken) .getMediaDownloadByDownloadToken(data.downloadToken)
.getSingleOrNull(); .getSingleOrNull();
@ -128,9 +137,10 @@ Future<client.Response> handleDownloadData(DownloadData data) async {
downloadedBytes = Uint8List.fromList(data.data); downloadedBytes = Uint8List.fromList(data.data);
} }
await writeMediaFile(media.messageId, "encrypted", downloadedBytes);
if (!data.fin) { if (!data.fin) {
// download not finished, so waiting for more data... // download not finished, so waiting for more data...
await box.put(boxId, downloadedBytes);
var ok = client.Response_Ok()..none = true; var ok = client.Response_Ok()..none = true;
return client.Response()..ok = ok; return client.Response()..ok = ok;
} }
@ -140,6 +150,7 @@ Future<client.Response> handleDownloadData(DownloadData data) async {
.getSingleOrNull(); .getSingleOrNull();
if (msg == null) { if (msg == null) {
await deleteMediaFile(media.messageId, "encrypted");
Logger("media_received.dart") Logger("media_received.dart")
.info("messageId not found in database. Ignoring download"); .info("messageId not found in database. Ignoring download");
// answers with ok, so the server will delete the message // answers with ok, so the server will delete the message

View file

@ -1,7 +1,6 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:cryptography_plus/cryptography_plus.dart'; import 'package:cryptography_plus/cryptography_plus.dart';
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:flutter_image_compress/flutter_image_compress.dart'; import 'package:flutter_image_compress/flutter_image_compress.dart';
@ -18,7 +17,6 @@ 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.dart';
import 'package:twonly/src/providers/api/api_utils.dart'; import 'package:twonly/src/providers/api/api_utils.dart';
import 'package:twonly/src/services/notification_service.dart'; import 'package:twonly/src/services/notification_service.dart';
import 'package:video_compress/video_compress.dart';
Future sendMediaFile( Future sendMediaFile(
List<int> userIds, List<int> userIds,
@ -34,6 +32,7 @@ Future sendMediaFile(
metadata.messageSendAt = DateTime.now(); metadata.messageSendAt = DateTime.now();
metadata.isVideo = videoFilePath != null; metadata.isVideo = videoFilePath != null;
metadata.videoWithAudio = enableVideoAudio != null && enableVideoAudio; metadata.videoWithAudio = enableVideoAudio != null && enableVideoAudio;
metadata.maxShowTime = maxShowTime;
int? mediaUploadId = await twonlyDatabase.mediaUploadsDao.insertMediaUpload( int? mediaUploadId = await twonlyDatabase.mediaUploadsDao.insertMediaUpload(
MediaUploadsCompanion( MediaUploadsCompanion(
@ -42,6 +41,11 @@ Future sendMediaFile(
); );
if (mediaUploadId != null) { if (mediaUploadId != null) {
if (videoFilePath != null) {
String basePath = await getMediaFilePath(mediaUploadId, "send");
await File(videoFilePath.path).rename("$basePath.video");
}
await writeMediaFile(mediaUploadId, "image", imageBytes);
await handleSingleMediaFile(mediaUploadId); await handleSingleMediaFile(mediaUploadId);
} }
} }
@ -57,62 +61,62 @@ Future retryMediaUpload() async {
final lockingHandleMediaFile = Mutex(); final lockingHandleMediaFile = Mutex();
Future handleSingleMediaFile(int mediaUploadId) async { Future handleSingleMediaFile(int mediaUploadId) async {
await lockingHandleMediaFile.protect(() async { // await lockingHandleMediaFile.protect(() async {
MediaUpload? media = await twonlyDatabase.mediaUploadsDao MediaUpload? media = await twonlyDatabase.mediaUploadsDao
.getMediaUploadById(mediaUploadId) .getMediaUploadById(mediaUploadId)
.getSingleOrNull(); .getSingleOrNull();
if (media == null) return; if (media == null) return;
try { try {
switch (media.state) { switch (media.state) {
case UploadState.pending: case UploadState.pending:
await handleAddToMessageDb(media); await handleAddToMessageDb(media);
break; break;
case UploadState.addedToMessagesDb: case UploadState.addedToMessagesDb:
await handleCompressionState(media); await handleCompressionState(media);
break; break;
case UploadState.isCompressed: case UploadState.isCompressed:
await handleEncryptionState(media); await handleEncryptionState(media);
break; break;
case UploadState.isEncrypted: case UploadState.isEncrypted:
if (!await handleGetUploadToken(media)) { if (!await handleGetUploadToken(media)) {
return; // recoverable error. try again when connected again to the server... 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),
),
);
} }
} break;
await twonlyDatabase.mediaUploadsDao.deleteMediaUpload(mediaUploadId); case UploadState.hasUploadToken:
Logger("media_send.dart") if (!await handleUpload(media)) {
.shout("Non recoverable error while sending media file: $e"); 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 { Future handleAddToMessageDb(MediaUpload media) async {
@ -177,11 +181,12 @@ Future handleCompressionState(MediaUpload media) async {
quality: 60, quality: 60,
); );
} }
await writeMediaFile(media, "image.compressed", imageBytesCompressed); await writeMediaFile(
media.mediaUploadId, "image.compressed", imageBytesCompressed);
} catch (e) { } catch (e) {
Logger("media_send.dart").shout("$e"); Logger("media_send.dart").shout("$e");
// as a fall back use the original image // as a fall back use the original image
await writeMediaFile(media, "image.compressed", imageBytes); await writeMediaFile(media.mediaUploadId, "image.compressed", imageBytes);
} }
if (media.metadata.isVideo) { if (media.metadata.isVideo) {
@ -189,36 +194,37 @@ Future handleCompressionState(MediaUpload media) async {
File videoOriginalFile = File("$basePath.video"); File videoOriginalFile = File("$basePath.video");
File videoCompressedFile = File("$basePath.video.compressed"); File videoCompressedFile = File("$basePath.video.compressed");
MediaInfo? mediaInfo; // MediaInfo? mediaInfo;
try { try {
mediaInfo = await VideoCompress.compressVideo( // mediaInfo = await VideoCompress.compressVideo(
videoOriginalFile.path, // videoOriginalFile.path,
quality: VideoQuality.Res1280x720Quality, // quality: VideoQuality.Res1280x720Quality,
deleteOrigin: false, // deleteOrigin: false,
includeAudio: media.metadata.videoWithAudio, // includeAudio: media.metadata.videoWithAudio,
); // );
if (mediaInfo!.filesize! >= 20 * 1000 * 1000) { // if (mediaInfo!.filesize! >= 20 * 1000 * 1000) {
// if the media file is over 20MB compress it with low quality // // if the media file is over 20MB compress it with low quality
mediaInfo = await VideoCompress.compressVideo( // mediaInfo = await VideoCompress.compressVideo(
videoOriginalFile.path, // videoOriginalFile.path,
quality: VideoQuality.Res960x540Quality, // quality: VideoQuality.Res960x540Quality,
deleteOrigin: false, // deleteOrigin: false,
includeAudio: media.metadata.videoWithAudio, // includeAudio: media.metadata.videoWithAudio,
); // );
} // }
} catch (e) { } catch (e) {
Logger("media_send.dart").shout("$e"); Logger("media_send.dart").shout("Video compression: $e");
} }
if (mediaInfo == null) { // if (mediaInfo == null) {
Logger("media_send.dart").shout("Error compressing video."); // as a fall back use the non compressed version
mediaInfo!.file!.rename(videoCompressedFile.path); await videoOriginalFile.copy(videoCompressedFile.path);
} else { await videoOriginalFile.delete();
// as a fall back use the non compressed version // } else {
videoOriginalFile.rename(videoCompressedFile.path); // Logger("media_send.dart").shout("Error compressing video.");
} // mediaInfo!.file!.rename(videoCompressedFile.path);
// }
} }
// delete non compressed media files // delete non compressed media files
@ -263,7 +269,7 @@ Future handleEncryptionState(MediaUpload media) async {
state.sha2Hash = (await algorithm.hash(secretBox.cipherText)).bytes; state.sha2Hash = (await algorithm.hash(secretBox.cipherText)).bytes;
await writeMediaFile( await writeMediaFile(
media, media.mediaUploadId,
"encrypted", "encrypted",
Uint8List.fromList(secretBox.cipherText), Uint8List.fromList(secretBox.cipherText),
); );
@ -427,8 +433,8 @@ Future<Uint8List> readMediaFile(MediaUpload media, String type) async {
} }
Future<void> writeMediaFile( Future<void> writeMediaFile(
MediaUpload media, String type, Uint8List data) async { int mediaUploadId, String type, Uint8List data) async {
String basePath = await getMediaFilePath(media.mediaUploadId, "send"); String basePath = await getMediaFilePath(mediaUploadId, "send");
File file = File("$basePath.$type"); File file = File("$basePath.$type");
await file.writeAsBytes(data); await file.writeAsBytes(data);
} }

View file

@ -240,7 +240,8 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
), ),
); );
if (!context.mounted) return true; if (!context.mounted) return true;
if (shoudReturn != null && shoudReturn) { if (shoudReturn == null) return true;
if (shoudReturn) {
if (!context.mounted) return true; if (!context.mounted) return true;
// ignore: use_build_context_synchronously // ignore: use_build_context_synchronously
Navigator.pop(context); Navigator.pop(context);
@ -438,7 +439,9 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
// Positioned.fill( // Positioned.fill(
// child: GestureDetector(), // child: GestureDetector(),
// ), // ),
if (!sharePreviewIsShown && widget.sendTo != null) if (!sharePreviewIsShown &&
widget.sendTo != null &&
!isVideoRecording)
SendToWidget(sendTo: getContactDisplayName(widget.sendTo!)), SendToWidget(sendTo: getContactDisplayName(widget.sendTo!)),
if (!sharePreviewIsShown && !isVideoRecording) if (!sharePreviewIsShown && !isVideoRecording)
Positioned( Positioned(

View file

@ -1,5 +1,5 @@
import 'dart:io'; import 'dart:io';
import 'dart:async';
import 'package:camera/camera.dart'; 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';
@ -15,7 +15,6 @@ import 'package:twonly/src/database/twonly_database.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';
import 'dart:async';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:twonly/src/views/camera/image_editor/data/image_item.dart'; import 'package:twonly/src/views/camera/image_editor/data/image_item.dart';
import 'package:twonly/src/views/camera/image_editor/data/layer.dart'; import 'package:twonly/src/views/camera/image_editor/data/layer.dart';
@ -44,7 +43,8 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
int maxShowTime = 999999; int maxShowTime = 999999;
String? sendNextMediaToUserName; String? sendNextMediaToUserName;
double tabDownPostion = 0; double tabDownPostion = 0;
bool sendingImage = false; bool sendingOrLoadingImage = true;
bool isDisposed = false;
double widthRatio = 1, heightRatio = 1, pixelRatio = 1; double widthRatio = 1, heightRatio = 1, pixelRatio = 1;
VideoPlayerController? videoController; VideoPlayerController? videoController;
@ -58,6 +58,9 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
if (widget.imageBytes != null) { if (widget.imageBytes != null) {
loadImage(widget.imageBytes!); loadImage(widget.imageBytes!);
} else if (widget.videoFilePath != null) { } else if (widget.videoFilePath != null) {
setState(() {
sendingOrLoadingImage = false;
});
videoController = videoController =
VideoPlayerController.file(File(widget.videoFilePath!.path)); VideoPlayerController.file(File(widget.videoFilePath!.path));
videoController?.setLooping(true); videoController?.setLooping(true);
@ -67,8 +70,6 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
}).catchError((Object error) { }).catchError((Object error) {
Logger("ui.share_image_editor").shout(error); Logger("ui.share_image_editor").shout(error);
}); });
videoController?.play();
print(widget.videoFilePath!.path);
} }
} }
@ -84,6 +85,7 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
@override @override
void dispose() { void dispose() {
isDisposed = true;
layers.clear(); layers.clear();
videoController?.dispose(); videoController?.dispose();
super.dispose(); super.dispose();
@ -152,11 +154,25 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
NotificationBadge( NotificationBadge(
count: maxShowTime == 999999 ? "" : maxShowTime.toString(), count: (widget.videoFilePath != null)
? "0"
: maxShowTime == 999999
? ""
: maxShowTime.toString(),
child: ActionButton( child: ActionButton(
Icons.timer_outlined, (widget.videoFilePath != null)
? maxShowTime == 1
? Icons.repeat_rounded
: Icons.repeat_one_rounded
: Icons.timer_outlined,
tooltipText: context.lang.protectAsARealTwonly, tooltipText: context.lang.protectAsARealTwonly,
onPressed: () async { onPressed: () async {
if (widget.videoFilePath != null) {
setState(() {
maxShowTime = (maxShowTime + 1) % 2;
});
return;
}
if (maxShowTime == 999999) { if (maxShowTime == 999999) {
maxShowTime = 4; maxShowTime = 4;
} else if (maxShowTime >= 22) { } else if (maxShowTime >= 22) {
@ -276,7 +292,7 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
Future<Uint8List?> getMergedImage() async { Future<Uint8List?> getMergedImage() async {
Uint8List? image; Uint8List? image;
if (layers.length > 1) { if (layers.length > 1 || widget.videoFilePath != null) {
for (var x in layers) { for (var x in layers) {
x.showCustomButtons = false; x.showCustomButtons = false;
} }
@ -297,6 +313,7 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
Future<void> loadImage(Future<Uint8List?> imageFile) async { Future<void> loadImage(Future<Uint8List?> imageFile) async {
Uint8List? imageBytes = await imageFile; Uint8List? imageBytes = await imageFile;
await currentImage.load(imageBytes); await currentImage.load(imageBytes);
if (isDisposed) return;
if (!context.mounted) return; if (!context.mounted) return;
@ -307,21 +324,31 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
)); ));
layers.add(FilterLayerData()); layers.add(FilterLayerData());
setState(() {
sendingOrLoadingImage = false;
});
} }
Future sendImageToSinglePerson() async { Future sendImageToSinglePerson() async {
if (sendingOrLoadingImage) return;
setState(() { setState(() {
sendingImage = true; sendingOrLoadingImage = true;
}); });
Uint8List? imageBytes = await getMergedImage(); Uint8List? imageBytes = await getMergedImage();
if (!context.mounted) return; if (!context.mounted) return;
if (imageBytes == null) { if (imageBytes == null) {
// ignore: use_build_context_synchronously // ignore: use_build_context_synchronously
Navigator.pop(context, false); Navigator.pop(context, true);
return; return;
} }
sendMediaFile([widget.sendTo!.userId], imageBytes, _isRealTwonly, sendMediaFile(
maxShowTime, widget.videoFilePath, videoWithAudio); [widget.sendTo!.userId],
imageBytes,
_isRealTwonly,
maxShowTime,
widget.videoFilePath,
videoWithAudio,
);
if (context.mounted) { if (context.mounted) {
// ignore: use_build_context_synchronously // ignore: use_build_context_synchronously
Navigator.pop(context, true); Navigator.pop(context, true);
@ -434,7 +461,7 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
), ),
SizedBox(width: sendNextMediaToUserName == null ? 20 : 10), SizedBox(width: sendNextMediaToUserName == null ? 20 : 10),
FilledButton.icon( FilledButton.icon(
icon: sendingImage icon: sendingOrLoadingImage
? SizedBox( ? SizedBox(
height: 12, height: 12,
width: 12, width: 12,
@ -445,7 +472,7 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
) )
: FaIcon(FontAwesomeIcons.solidPaperPlane), : FaIcon(FontAwesomeIcons.solidPaperPlane),
onPressed: () async { onPressed: () async {
if (sendingImage) return; if (sendingOrLoadingImage) return;
if (widget.sendTo == null) return pushShareImageView(); if (widget.sendTo == null) return pushShareImageView();
sendImageToSinglePerson(); sendImageToSinglePerson();
}, },

View file

@ -212,7 +212,7 @@ class ChatListEntry extends StatelessWidget {
child: BetterText(text: content.text), child: BetterText(text: content.text),
); );
} }
} else if (content is MediaMessageContent && !content.isVideo) { } else if (content is MediaMessageContent) {
Color color = getMessageColorFromType( Color color = getMessageColorFromType(
content, content,
Theme.of(context).colorScheme.primary, Theme.of(context).colorScheme.primary,

View file

@ -1,8 +1,10 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'package:drift/drift.dart' hide Column; import 'package:drift/drift.dart' hide Column;
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:logging/logging.dart';
import 'package:lottie/lottie.dart'; import 'package:lottie/lottie.dart';
import 'package:no_screenshot/no_screenshot.dart'; import 'package:no_screenshot/no_screenshot.dart';
import 'package:twonly/globals.dart'; import 'package:twonly/globals.dart';
@ -18,6 +20,7 @@ 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/camera_send_to_view.dart'; import 'package:twonly/src/views/camera/camera_send_to_view.dart';
import 'package:twonly/src/views/chats/chat_item_details_view.dart'; import 'package:twonly/src/views/chats/chat_item_details_view.dart';
import 'package:video_player/video_player.dart';
final _noScreenshot = NoScreenshot.instance; final _noScreenshot = NoScreenshot.instance;
@ -37,6 +40,8 @@ class _MediaViewerViewState extends State<MediaViewerView> {
// current image related // current image related
Uint8List? imageBytes; Uint8List? imageBytes;
VideoPlayerController? videoController;
DateTime? canBeSeenUntil; DateTime? canBeSeenUntil;
int maxShowTime = 999999; int maxShowTime = 999999;
double progress = 0; double progress = 0;
@ -47,6 +52,8 @@ class _MediaViewerViewState extends State<MediaViewerView> {
bool imageSaved = false; bool imageSaved = false;
bool imageSaving = false; bool imageSaving = false;
StreamSubscription<Message?>? downloadStateListener;
List<Message> allMediaFiles = []; List<Message> allMediaFiles = [];
late StreamSubscription<List<Message>> _subscription; late StreamSubscription<List<Message>> _subscription;
TextEditingController textMessageController = TextEditingController(); TextEditingController textMessageController = TextEditingController();
@ -97,7 +104,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
} }
} else { } else {
allMediaFiles.removeAt(0); allMediaFiles.removeAt(0);
loadCurrentMediaFile(); await loadCurrentMediaFile();
} }
} }
@ -105,11 +112,8 @@ class _MediaViewerViewState extends State<MediaViewerView> {
await _noScreenshot.screenshotOff(); await _noScreenshot.screenshotOff();
if (!context.mounted || allMediaFiles.isEmpty) return nextMediaOrExit(); if (!context.mounted || allMediaFiles.isEmpty) return nextMediaOrExit();
final current = allMediaFiles.first;
final MediaMessageContent content =
MediaMessageContent.fromJson(jsonDecode(current.contentJson!));
setState(() { setState(() {
videoController = null;
imageBytes = null; imageBytes = null;
canBeSeenUntil = null; canBeSeenUntil = null;
maxShowTime = 999999; maxShowTime = 999999;
@ -121,78 +125,107 @@ class _MediaViewerViewState extends State<MediaViewerView> {
showSendTextMessageInput = false; showSendTextMessageInput = false;
}); });
flutterLocalNotificationsPlugin.cancel(allMediaFiles.first.contactId);
if (allMediaFiles.first.downloadState != DownloadState.downloaded) {
setState(() {
isDownloading = true;
});
await startDownloadMedia(allMediaFiles.first, true);
final stream = twonlyDatabase.messagesDao
.getMessageByMessageId(allMediaFiles.first.messageId)
.watchSingleOrNull();
downloadStateListener?.cancel();
downloadStateListener = stream.listen((updated) async {
if (updated != null) {
if (updated.downloadState == DownloadState.downloaded) {
downloadStateListener?.cancel();
await handleNextDownloadedMedia(updated, showTwonly);
}
}
});
} else {
await handleNextDownloadedMedia(allMediaFiles.first, showTwonly);
}
}
Future handleNextDownloadedMedia(Message current, bool showTwonly) async {
final MediaMessageContent content =
MediaMessageContent.fromJson(jsonDecode(current.contentJson!));
if (content.isRealTwonly) { if (content.isRealTwonly) {
setState(() { setState(() {
isRealTwonly = true; isRealTwonly = true;
}); });
if (!showTwonly) { if (!showTwonly) return;
bool isAuth = await authenticateUser(
context.lang.mediaViewerAuthReason,
force: false,
);
if (!isAuth) {
nextMediaOrExit();
return; return;
} }
}
if (isRealTwonly) { notifyContactAboutOpeningMessage(
if (!context.mounted) return; current.contactId,
// ignore: use_build_context_synchronously [current.messageOtherId!],
bool isAuth = await authenticateUser(context.lang.mediaViewerAuthReason, );
force: false);
if (!isAuth) { await twonlyDatabase.messagesDao.updateMessageByMessageId(
nextMediaOrExit(); current.messageId,
return; MessagesCompanion(openedAt: Value(DateTime.now())),
);
if (content.isVideo) {
final vidoePath = await getVideoPath(current.messageId);
if (vidoePath != null) {
videoController = VideoPlayerController.file(File(vidoePath.path));
videoController?.setLooping(content.maxShowTime == 1);
if (content.maxShowTime == 0) {
videoController?.addListener(() {
if (videoController?.value.position ==
videoController?.value.duration) {
nextMediaOrExit();
}
});
} }
videoController?.initialize().then((_) {
videoController!.play();
setState(() {});
}).catchError((Object error) {
Logger("media_viewer_view.dart").shout(error);
});
} }
} }
flutterLocalNotificationsPlugin.cancel(current.contactId); imageBytes = await getImageBytes(current.messageId);
if (current.downloadState == DownloadState.pending) {
setState(() {
isDownloading = true;
});
await startDownloadMedia(current, true);
}
if ((imageBytes == null && !content.isVideo) ||
load downloaded status from database (content.isVideo && videoController == null)) {
// When the message should be downloaded but imageBytes are null then a error happened
notifyContactAboutOpeningMessage( await twonlyDatabase.messagesDao.updateMessageByMessageId(
message.contactId, [message.messageOtherId!]); current.messageId,
twonlyDatabase.messagesDao.updateMessageByMessageId( MessagesCompanion(
message.messageId, MessagesCompanion(openedAt: Value(DateTime.now()))); errorWhileSending: Value(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);
if twonly deleteMediaFile()
isDownloading = false;
if (imageBytes == null) {
if (current.downloadState == DownloadState.downloaded) {
// When the message should be downloaded but imageBytes are null then a error happened
await twonlyDatabase.messagesDao.updateMessageByMessageId(
current.messageId,
MessagesCompanion(
errorWhileSending: Value(true),
),
);
}
nextMediaOrExit();
return;
}
if (content.maxShowTime != 999999) {
canBeSeenUntil = DateTime.now().add(
Duration(seconds: content.maxShowTime),
); );
maxShowTime = content.maxShowTime; return nextMediaOrExit();
startTimer();
} }
setState(() {});
if (!content.isVideo) {
if (content.maxShowTime != 999999) {
canBeSeenUntil = DateTime.now().add(
Duration(seconds: content.maxShowTime),
);
maxShowTime = content.maxShowTime;
startTimer();
}
}
setState(() {
isDownloading = false;
});
} }
startTimer() { startTimer() {
@ -215,11 +248,13 @@ class _MediaViewerViewState extends State<MediaViewerView> {
@override @override
void dispose() { void dispose() {
super.dispose();
nextMediaTimer?.cancel(); nextMediaTimer?.cancel();
progressTimer?.cancel(); progressTimer?.cancel();
_noScreenshot.screenshotOn(); _noScreenshot.screenshotOn();
_subscription.cancel(); _subscription.cancel();
downloadStateListener?.cancel();
videoController?.dispose();
super.dispose();
} }
Future onPressedSaveToGallery() async { Future onPressedSaveToGallery() async {
@ -357,7 +392,8 @@ class _MediaViewerViewState extends State<MediaViewerView> {
child: Stack( child: Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
if (imageBytes != null && (canBeSeenUntil == null || progress >= 0)) if ((imageBytes != null || videoController != null) &&
(canBeSeenUntil == null || progress >= 0))
GestureDetector( GestureDetector(
onTap: () { onTap: () {
if (showSendTextMessageInput) { if (showSendTextMessageInput) {
@ -372,24 +408,34 @@ class _MediaViewerViewState extends State<MediaViewerView> {
child: MediaViewSizing( child: MediaViewSizing(
bottomNavigation: bottomNavigation(), bottomNavigation: bottomNavigation(),
requiredHeight: 80, requiredHeight: 80,
child: Image.memory( child: Stack(
imageBytes!, children: [
fit: BoxFit.contain, if (videoController != null)
frameBuilder: Positioned.fill(child: VideoPlayer(videoController!)),
((context, child, frame, wasSynchronouslyLoaded) { if (imageBytes != null)
if (wasSynchronouslyLoaded) return child; Positioned.fill(
return AnimatedSwitcher( child: Image.memory(
duration: const Duration(milliseconds: 200), imageBytes!,
child: frame != null fit: BoxFit.contain,
? child frameBuilder: ((context, child, frame,
: SizedBox( wasSynchronouslyLoaded) {
height: 60, if (wasSynchronouslyLoaded) return child;
width: 60, return AnimatedSwitcher(
child: duration: const Duration(milliseconds: 200),
CircularProgressIndicator(strokeWidth: 2), child: frame != null
), ? child
); : SizedBox(
}), height: 60,
width: 60,
child: CircularProgressIndicator(
strokeWidth: 2,
),
),
);
}),
),
),
],
), ),
), ),
), ),
@ -439,12 +485,12 @@ class _MediaViewerViewState extends State<MediaViewerView> {
), ),
), ),
), ),
Positioned( if (canBeSeenUntil != null)
right: 20, Positioned(
top: 27, right: 20,
child: Row( top: 27,
children: [ child: Row(
if (canBeSeenUntil != null) children: [
SizedBox( SizedBox(
width: 20, width: 20,
height: 20, height: 20,
@ -453,9 +499,9 @@ class _MediaViewerViewState extends State<MediaViewerView> {
strokeWidth: 2.0, strokeWidth: 2.0,
), ),
), ),
], ],
),
), ),
),
if (showSendTextMessageInput) if (showSendTextMessageInput)
Positioned( Positioned(
bottom: 0, bottom: 0,

View file

@ -149,7 +149,7 @@ class _MessageSendStateIconState extends State<MessageSendStateIcon> {
if (message.errorWhileSending) { if (message.errorWhileSending) {
icon = icon =
FaIcon(FontAwesomeIcons.circleExclamation, size: 12, color: color); FaIcon(FontAwesomeIcons.circleExclamation, size: 12, color: color);
text = "Unknown error."; text = "Error";
} }
if (message.kind == MessageKind.media) { if (message.kind == MessageKind.media) {

View file

@ -7,6 +7,7 @@ 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; import 'schema_v4.dart' as v4;
import 'schema_v5.dart' as v5;
class GeneratedHelper implements SchemaInstantiationHelper { class GeneratedHelper implements SchemaInstantiationHelper {
@override @override
@ -20,10 +21,12 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v3.DatabaseAtV3(db); return v3.DatabaseAtV3(db);
case 4: case 4:
return v4.DatabaseAtV4(db); return v4.DatabaseAtV4(db);
case 5:
return v5.DatabaseAtV5(db);
default: default:
throw MissingSchemaException(version, versions); throw MissingSchemaException(version, versions);
} }
} }
static const versions = const [1, 2, 3, 4]; static const versions = const [1, 2, 3, 4, 5];
} }

File diff suppressed because it is too large Load diff