mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-01-15 09:28:41 +00:00
video sending and receiving works #25
This commit is contained in:
parent
5a6afaa6d4
commit
53ccb325b5
17 changed files with 3382 additions and 215 deletions
1
drift_schemas/twonly_database/drift_schema_v5.json
Normal file
1
drift_schemas/twonly_database/drift_schema_v5.json
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -65,6 +65,7 @@ class MediaUploadMetadata {
|
|||
MediaUploadMetadata state = MediaUploadMetadata();
|
||||
state.contactIds = List<int>.from(json['contactIds']);
|
||||
state.isRealTwonly = json['isRealTwonly'];
|
||||
state.videoWithAudio = json['videoWithAudio'];
|
||||
state.isVideo = json['isVideo'];
|
||||
state.maxShowTime = json['maxShowTime'];
|
||||
state.maxShowTime = json['maxShowTime'];
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ class TwonlyDatabase extends _$TwonlyDatabase {
|
|||
TwonlyDatabase.forTesting(DatabaseConnection super.connection);
|
||||
|
||||
@override
|
||||
int get schemaVersion => 4;
|
||||
int get schemaVersion => 5;
|
||||
|
||||
static QueryExecutor _openConnection() {
|
||||
return driftDatabase(
|
||||
|
|
@ -57,19 +57,19 @@ class TwonlyDatabase extends _$TwonlyDatabase {
|
|||
@override
|
||||
MigrationStrategy get migration {
|
||||
return MigrationStrategy(
|
||||
onUpgrade: stepByStep(
|
||||
from1To2: (m, schema) async {
|
||||
onUpgrade: stepByStep(from1To2: (m, schema) async {
|
||||
m.addColumn(schema.messages, schema.messages.errorWhileSending);
|
||||
},
|
||||
from2To3: (m, schema) async {
|
||||
}, from2To3: (m, schema) async {
|
||||
m.addColumn(schema.contacts, schema.contacts.archived);
|
||||
m.addColumn(
|
||||
schema.contacts, schema.contacts.deleteMessagesAfterXMinutes);
|
||||
},
|
||||
from3To4: (m, schema) async {
|
||||
}, from3To4: (m, schema) async {
|
||||
m.createTable(mediaUploads);
|
||||
},
|
||||
),
|
||||
}, from4To5: (m, schema) async {
|
||||
m.createTable(mediaDownloads);
|
||||
m.addColumn(schema.messages, schema.messages.mediaDownloadId);
|
||||
m.addColumn(schema.messages, schema.messages.mediaUploadId);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -798,10 +798,239 @@ i1.GeneratedColumn<String> _column_47(String aliasedName) =>
|
|||
i1.GeneratedColumn<String>('already_notified', aliasedName, false,
|
||||
type: i1.DriftSqlType.string,
|
||||
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({
|
||||
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,
|
||||
required Future<void> Function(i1.Migrator m, Schema5 schema) from4To5,
|
||||
}) {
|
||||
return (currentVersion, database) async {
|
||||
switch (currentVersion) {
|
||||
|
|
@ -820,6 +1049,11 @@ i0.MigrationStepWithVersion migrationSteps({
|
|||
final migrator = i1.Migrator(database, schema);
|
||||
await from3To4(migrator, schema);
|
||||
return 4;
|
||||
case 4:
|
||||
final schema = Schema5(database: database);
|
||||
final migrator = i1.Migrator(database, schema);
|
||||
await from4To5(migrator, schema);
|
||||
return 5;
|
||||
default:
|
||||
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, Schema3 schema) from2To3,
|
||||
required Future<void> Function(i1.Migrator m, Schema4 schema) from3To4,
|
||||
required Future<void> Function(i1.Migrator m, Schema5 schema) from4To5,
|
||||
}) =>
|
||||
i0.VersionedSchema.stepByStepHelper(
|
||||
step: migrationSteps(
|
||||
from1To2: from1To2,
|
||||
from2To3: from2To3,
|
||||
from3To4: from3To4,
|
||||
from4To5: from4To5,
|
||||
));
|
||||
|
|
|
|||
|
|
@ -503,6 +503,12 @@ abstract class AppLocalizations {
|
|||
/// **'Profile'**
|
||||
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.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -214,6 +214,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
String get settingsProfile => 'Profil';
|
||||
|
||||
@override
|
||||
String get settingsStorageData => 'Daten und Speicher';
|
||||
|
||||
@override
|
||||
String get settingsProfileCustomizeAvatar => 'Avatar anpassen';
|
||||
|
||||
|
|
|
|||
|
|
@ -214,6 +214,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get settingsProfile => 'Profile';
|
||||
|
||||
@override
|
||||
String get settingsStorageData => 'Data and storage';
|
||||
|
||||
@override
|
||||
String get settingsProfileCustomizeAvatar => 'Customize your avatar';
|
||||
|
||||
|
|
|
|||
|
|
@ -148,6 +148,7 @@ class MediaMessageContent extends MessageContent {
|
|||
'encryptionNonce': encryptionNonce,
|
||||
'isRealTwonly': isRealTwonly,
|
||||
'maxShowTime': maxShowTime,
|
||||
'isVideo': isVideo,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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/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';
|
||||
|
|
@ -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/server_to_client.pbserver.dart';
|
||||
|
||||
Map<int, DateTime> downloadStartedForMediaReceived = {};
|
||||
|
||||
Future tryDownloadAllMediaFiles() async {
|
||||
// this is called when websocket is newly connected, so allow all downloads to be restarted.
|
||||
downloadStartedForMediaReceived = {};
|
||||
List<Message> messages =
|
||||
await twonlyDatabase.messagesDao.getAllMessagesPendingDownloading();
|
||||
|
||||
|
|
@ -28,6 +31,14 @@ Future tryDownloadAllMediaFiles() async {
|
|||
|
||||
Future startDownloadMedia(Message message, bool force) async {
|
||||
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 =
|
||||
MessageContent.fromJson(message.kind, jsonDecode(message.contentJson!));
|
||||
|
|
@ -69,6 +80,8 @@ Future startDownloadMedia(Message message, bool force) async {
|
|||
if (bytes != null && bytes.isNotEmpty) {
|
||||
offset = bytes.length;
|
||||
}
|
||||
|
||||
downloadStartedForMediaReceived[message.messageId] = DateTime.now();
|
||||
apiProvider.triggerDownload(content.downloadToken!, offset);
|
||||
}
|
||||
}
|
||||
|
|
@ -82,10 +95,6 @@ Future<client.Response> handleDownloadData(DownloadData data) async {
|
|||
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();
|
||||
|
|
@ -128,9 +137,10 @@ Future<client.Response> handleDownloadData(DownloadData data) async {
|
|||
downloadedBytes = Uint8List.fromList(data.data);
|
||||
}
|
||||
|
||||
await writeMediaFile(media.messageId, "encrypted", downloadedBytes);
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
@ -140,6 +150,7 @@ Future<client.Response> handleDownloadData(DownloadData data) async {
|
|||
.getSingleOrNull();
|
||||
|
||||
if (msg == null) {
|
||||
await deleteMediaFile(media.messageId, "encrypted");
|
||||
Logger("media_received.dart")
|
||||
.info("messageId not found in database. Ignoring download");
|
||||
// answers with ok, so the server will delete the message
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
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';
|
||||
|
|
@ -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_utils.dart';
|
||||
import 'package:twonly/src/services/notification_service.dart';
|
||||
import 'package:video_compress/video_compress.dart';
|
||||
|
||||
Future sendMediaFile(
|
||||
List<int> userIds,
|
||||
|
|
@ -34,6 +32,7 @@ Future sendMediaFile(
|
|||
metadata.messageSendAt = DateTime.now();
|
||||
metadata.isVideo = videoFilePath != null;
|
||||
metadata.videoWithAudio = enableVideoAudio != null && enableVideoAudio;
|
||||
metadata.maxShowTime = maxShowTime;
|
||||
|
||||
int? mediaUploadId = await twonlyDatabase.mediaUploadsDao.insertMediaUpload(
|
||||
MediaUploadsCompanion(
|
||||
|
|
@ -42,6 +41,11 @@ Future sendMediaFile(
|
|||
);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -57,7 +61,7 @@ Future retryMediaUpload() async {
|
|||
final lockingHandleMediaFile = Mutex();
|
||||
|
||||
Future handleSingleMediaFile(int mediaUploadId) async {
|
||||
await lockingHandleMediaFile.protect(() async {
|
||||
// await lockingHandleMediaFile.protect(() async {
|
||||
MediaUpload? media = await twonlyDatabase.mediaUploadsDao
|
||||
.getMediaUploadById(mediaUploadId)
|
||||
.getSingleOrNull();
|
||||
|
|
@ -112,7 +116,7 @@ Future handleSingleMediaFile(int mediaUploadId) async {
|
|||
Logger("media_send.dart")
|
||||
.shout("Non recoverable error while sending media file: $e");
|
||||
}
|
||||
});
|
||||
// });
|
||||
}
|
||||
|
||||
Future handleAddToMessageDb(MediaUpload media) async {
|
||||
|
|
@ -177,11 +181,12 @@ Future handleCompressionState(MediaUpload media) async {
|
|||
quality: 60,
|
||||
);
|
||||
}
|
||||
await writeMediaFile(media, "image.compressed", imageBytesCompressed);
|
||||
await writeMediaFile(
|
||||
media.mediaUploadId, "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);
|
||||
await writeMediaFile(media.mediaUploadId, "image.compressed", imageBytes);
|
||||
}
|
||||
|
||||
if (media.metadata.isVideo) {
|
||||
|
|
@ -189,36 +194,37 @@ Future handleCompressionState(MediaUpload media) async {
|
|||
File videoOriginalFile = File("$basePath.video");
|
||||
File videoCompressedFile = File("$basePath.video.compressed");
|
||||
|
||||
MediaInfo? mediaInfo;
|
||||
// MediaInfo? mediaInfo;
|
||||
|
||||
try {
|
||||
mediaInfo = await VideoCompress.compressVideo(
|
||||
videoOriginalFile.path,
|
||||
quality: VideoQuality.Res1280x720Quality,
|
||||
deleteOrigin: false,
|
||||
includeAudio: media.metadata.videoWithAudio,
|
||||
);
|
||||
// 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,
|
||||
);
|
||||
}
|
||||
// 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");
|
||||
Logger("media_send.dart").shout("Video compression: $e");
|
||||
}
|
||||
|
||||
if (mediaInfo == null) {
|
||||
Logger("media_send.dart").shout("Error compressing video.");
|
||||
mediaInfo!.file!.rename(videoCompressedFile.path);
|
||||
} else {
|
||||
// if (mediaInfo == null) {
|
||||
// as a fall back use the non compressed version
|
||||
videoOriginalFile.rename(videoCompressedFile.path);
|
||||
}
|
||||
await videoOriginalFile.copy(videoCompressedFile.path);
|
||||
await videoOriginalFile.delete();
|
||||
// } else {
|
||||
// Logger("media_send.dart").shout("Error compressing video.");
|
||||
// mediaInfo!.file!.rename(videoCompressedFile.path);
|
||||
// }
|
||||
}
|
||||
|
||||
// delete non compressed media files
|
||||
|
|
@ -263,7 +269,7 @@ Future handleEncryptionState(MediaUpload media) async {
|
|||
state.sha2Hash = (await algorithm.hash(secretBox.cipherText)).bytes;
|
||||
|
||||
await writeMediaFile(
|
||||
media,
|
||||
media.mediaUploadId,
|
||||
"encrypted",
|
||||
Uint8List.fromList(secretBox.cipherText),
|
||||
);
|
||||
|
|
@ -427,8 +433,8 @@ Future<Uint8List> readMediaFile(MediaUpload media, String type) async {
|
|||
}
|
||||
|
||||
Future<void> writeMediaFile(
|
||||
MediaUpload media, String type, Uint8List data) async {
|
||||
String basePath = await getMediaFilePath(media.mediaUploadId, "send");
|
||||
int mediaUploadId, String type, Uint8List data) async {
|
||||
String basePath = await getMediaFilePath(mediaUploadId, "send");
|
||||
File file = File("$basePath.$type");
|
||||
await file.writeAsBytes(data);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -240,7 +240,8 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
|
|||
),
|
||||
);
|
||||
if (!context.mounted) return true;
|
||||
if (shoudReturn != null && shoudReturn) {
|
||||
if (shoudReturn == null) return true;
|
||||
if (shoudReturn) {
|
||||
if (!context.mounted) return true;
|
||||
// ignore: use_build_context_synchronously
|
||||
Navigator.pop(context);
|
||||
|
|
@ -438,7 +439,9 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
|
|||
// Positioned.fill(
|
||||
// child: GestureDetector(),
|
||||
// ),
|
||||
if (!sharePreviewIsShown && widget.sendTo != null)
|
||||
if (!sharePreviewIsShown &&
|
||||
widget.sendTo != null &&
|
||||
!isVideoRecording)
|
||||
SendToWidget(sendTo: getContactDisplayName(widget.sendTo!)),
|
||||
if (!sharePreviewIsShown && !isVideoRecording)
|
||||
Positioned(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'dart:async';
|
||||
import 'package:camera/camera.dart';
|
||||
import 'package:flutter/material.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/storage.dart';
|
||||
import 'package:twonly/src/views/camera/share_image_view.dart';
|
||||
import 'dart:async';
|
||||
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/layer.dart';
|
||||
|
|
@ -44,7 +43,8 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
|
|||
int maxShowTime = 999999;
|
||||
String? sendNextMediaToUserName;
|
||||
double tabDownPostion = 0;
|
||||
bool sendingImage = false;
|
||||
bool sendingOrLoadingImage = true;
|
||||
bool isDisposed = false;
|
||||
double widthRatio = 1, heightRatio = 1, pixelRatio = 1;
|
||||
VideoPlayerController? videoController;
|
||||
|
||||
|
|
@ -58,6 +58,9 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
|
|||
if (widget.imageBytes != null) {
|
||||
loadImage(widget.imageBytes!);
|
||||
} else if (widget.videoFilePath != null) {
|
||||
setState(() {
|
||||
sendingOrLoadingImage = false;
|
||||
});
|
||||
videoController =
|
||||
VideoPlayerController.file(File(widget.videoFilePath!.path));
|
||||
videoController?.setLooping(true);
|
||||
|
|
@ -67,8 +70,6 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
|
|||
}).catchError((Object error) {
|
||||
Logger("ui.share_image_editor").shout(error);
|
||||
});
|
||||
videoController?.play();
|
||||
print(widget.videoFilePath!.path);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -84,6 +85,7 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
|
|||
|
||||
@override
|
||||
void dispose() {
|
||||
isDisposed = true;
|
||||
layers.clear();
|
||||
videoController?.dispose();
|
||||
super.dispose();
|
||||
|
|
@ -152,11 +154,25 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
|
|||
),
|
||||
const SizedBox(height: 8),
|
||||
NotificationBadge(
|
||||
count: maxShowTime == 999999 ? "∞" : maxShowTime.toString(),
|
||||
count: (widget.videoFilePath != null)
|
||||
? "0"
|
||||
: maxShowTime == 999999
|
||||
? "∞"
|
||||
: maxShowTime.toString(),
|
||||
child: ActionButton(
|
||||
Icons.timer_outlined,
|
||||
(widget.videoFilePath != null)
|
||||
? maxShowTime == 1
|
||||
? Icons.repeat_rounded
|
||||
: Icons.repeat_one_rounded
|
||||
: Icons.timer_outlined,
|
||||
tooltipText: context.lang.protectAsARealTwonly,
|
||||
onPressed: () async {
|
||||
if (widget.videoFilePath != null) {
|
||||
setState(() {
|
||||
maxShowTime = (maxShowTime + 1) % 2;
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (maxShowTime == 999999) {
|
||||
maxShowTime = 4;
|
||||
} else if (maxShowTime >= 22) {
|
||||
|
|
@ -276,7 +292,7 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
|
|||
Future<Uint8List?> getMergedImage() async {
|
||||
Uint8List? image;
|
||||
|
||||
if (layers.length > 1) {
|
||||
if (layers.length > 1 || widget.videoFilePath != null) {
|
||||
for (var x in layers) {
|
||||
x.showCustomButtons = false;
|
||||
}
|
||||
|
|
@ -297,6 +313,7 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
|
|||
Future<void> loadImage(Future<Uint8List?> imageFile) async {
|
||||
Uint8List? imageBytes = await imageFile;
|
||||
await currentImage.load(imageBytes);
|
||||
if (isDisposed) return;
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
|
|
@ -307,21 +324,31 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
|
|||
));
|
||||
|
||||
layers.add(FilterLayerData());
|
||||
setState(() {
|
||||
sendingOrLoadingImage = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future sendImageToSinglePerson() async {
|
||||
if (sendingOrLoadingImage) return;
|
||||
setState(() {
|
||||
sendingImage = true;
|
||||
sendingOrLoadingImage = true;
|
||||
});
|
||||
Uint8List? imageBytes = await getMergedImage();
|
||||
if (!context.mounted) return;
|
||||
if (imageBytes == null) {
|
||||
// ignore: use_build_context_synchronously
|
||||
Navigator.pop(context, false);
|
||||
Navigator.pop(context, true);
|
||||
return;
|
||||
}
|
||||
sendMediaFile([widget.sendTo!.userId], imageBytes, _isRealTwonly,
|
||||
maxShowTime, widget.videoFilePath, videoWithAudio);
|
||||
sendMediaFile(
|
||||
[widget.sendTo!.userId],
|
||||
imageBytes,
|
||||
_isRealTwonly,
|
||||
maxShowTime,
|
||||
widget.videoFilePath,
|
||||
videoWithAudio,
|
||||
);
|
||||
if (context.mounted) {
|
||||
// ignore: use_build_context_synchronously
|
||||
Navigator.pop(context, true);
|
||||
|
|
@ -434,7 +461,7 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
|
|||
),
|
||||
SizedBox(width: sendNextMediaToUserName == null ? 20 : 10),
|
||||
FilledButton.icon(
|
||||
icon: sendingImage
|
||||
icon: sendingOrLoadingImage
|
||||
? SizedBox(
|
||||
height: 12,
|
||||
width: 12,
|
||||
|
|
@ -445,7 +472,7 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
|
|||
)
|
||||
: FaIcon(FontAwesomeIcons.solidPaperPlane),
|
||||
onPressed: () async {
|
||||
if (sendingImage) return;
|
||||
if (sendingOrLoadingImage) return;
|
||||
if (widget.sendTo == null) return pushShareImageView();
|
||||
sendImageToSinglePerson();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -212,7 +212,7 @@ class ChatListEntry extends StatelessWidget {
|
|||
child: BetterText(text: content.text),
|
||||
);
|
||||
}
|
||||
} else if (content is MediaMessageContent && !content.isVideo) {
|
||||
} else if (content is MediaMessageContent) {
|
||||
Color color = getMessageColorFromType(
|
||||
content,
|
||||
Theme.of(context).colorScheme.primary,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:drift/drift.dart' hide Column;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:lottie/lottie.dart';
|
||||
import 'package:no_screenshot/no_screenshot.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/views/camera/camera_send_to_view.dart';
|
||||
import 'package:twonly/src/views/chats/chat_item_details_view.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
final _noScreenshot = NoScreenshot.instance;
|
||||
|
||||
|
|
@ -37,6 +40,8 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
|||
|
||||
// current image related
|
||||
Uint8List? imageBytes;
|
||||
VideoPlayerController? videoController;
|
||||
|
||||
DateTime? canBeSeenUntil;
|
||||
int maxShowTime = 999999;
|
||||
double progress = 0;
|
||||
|
|
@ -47,6 +52,8 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
|||
bool imageSaved = false;
|
||||
bool imageSaving = false;
|
||||
|
||||
StreamSubscription<Message?>? downloadStateListener;
|
||||
|
||||
List<Message> allMediaFiles = [];
|
||||
late StreamSubscription<List<Message>> _subscription;
|
||||
TextEditingController textMessageController = TextEditingController();
|
||||
|
|
@ -97,7 +104,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
|||
}
|
||||
} else {
|
||||
allMediaFiles.removeAt(0);
|
||||
loadCurrentMediaFile();
|
||||
await loadCurrentMediaFile();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -105,11 +112,8 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
|||
await _noScreenshot.screenshotOff();
|
||||
if (!context.mounted || allMediaFiles.isEmpty) return nextMediaOrExit();
|
||||
|
||||
final current = allMediaFiles.first;
|
||||
final MediaMessageContent content =
|
||||
MediaMessageContent.fromJson(jsonDecode(current.contentJson!));
|
||||
|
||||
setState(() {
|
||||
videoController = null;
|
||||
imageBytes = null;
|
||||
canBeSeenUntil = null;
|
||||
maxShowTime = 999999;
|
||||
|
|
@ -121,57 +125,85 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
|||
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) {
|
||||
setState(() {
|
||||
isRealTwonly = true;
|
||||
});
|
||||
if (!showTwonly) {
|
||||
return;
|
||||
}
|
||||
if (!showTwonly) return;
|
||||
|
||||
if (isRealTwonly) {
|
||||
if (!context.mounted) return;
|
||||
// ignore: use_build_context_synchronously
|
||||
bool isAuth = await authenticateUser(context.lang.mediaViewerAuthReason,
|
||||
force: false);
|
||||
bool isAuth = await authenticateUser(
|
||||
context.lang.mediaViewerAuthReason,
|
||||
force: false,
|
||||
);
|
||||
if (!isAuth) {
|
||||
nextMediaOrExit();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
flutterLocalNotificationsPlugin.cancel(current.contactId);
|
||||
if (current.downloadState == DownloadState.pending) {
|
||||
setState(() {
|
||||
isDownloading = true;
|
||||
});
|
||||
await startDownloadMedia(current, true);
|
||||
}
|
||||
|
||||
|
||||
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);
|
||||
current.contactId,
|
||||
[current.messageOtherId!],
|
||||
);
|
||||
|
||||
if twonly deleteMediaFile()
|
||||
await twonlyDatabase.messagesDao.updateMessageByMessageId(
|
||||
current.messageId,
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
imageBytes = await getImageBytes(current.messageId);
|
||||
|
||||
|
||||
|
||||
isDownloading = false;
|
||||
if (imageBytes == null) {
|
||||
if (current.downloadState == DownloadState.downloaded) {
|
||||
if ((imageBytes == null && !content.isVideo) ||
|
||||
(content.isVideo && videoController == null)) {
|
||||
// When the message should be downloaded but imageBytes are null then a error happened
|
||||
await twonlyDatabase.messagesDao.updateMessageByMessageId(
|
||||
current.messageId,
|
||||
|
|
@ -179,12 +211,10 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
|||
errorWhileSending: Value(true),
|
||||
),
|
||||
);
|
||||
return nextMediaOrExit();
|
||||
}
|
||||
|
||||
nextMediaOrExit();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!content.isVideo) {
|
||||
if (content.maxShowTime != 999999) {
|
||||
canBeSeenUntil = DateTime.now().add(
|
||||
Duration(seconds: content.maxShowTime),
|
||||
|
|
@ -192,7 +222,10 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
|||
maxShowTime = content.maxShowTime;
|
||||
startTimer();
|
||||
}
|
||||
setState(() {});
|
||||
}
|
||||
setState(() {
|
||||
isDownloading = false;
|
||||
});
|
||||
}
|
||||
|
||||
startTimer() {
|
||||
|
|
@ -215,11 +248,13 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
|||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
nextMediaTimer?.cancel();
|
||||
progressTimer?.cancel();
|
||||
_noScreenshot.screenshotOn();
|
||||
_subscription.cancel();
|
||||
downloadStateListener?.cancel();
|
||||
videoController?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future onPressedSaveToGallery() async {
|
||||
|
|
@ -357,7 +392,8 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
|||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (imageBytes != null && (canBeSeenUntil == null || progress >= 0))
|
||||
if ((imageBytes != null || videoController != null) &&
|
||||
(canBeSeenUntil == null || progress >= 0))
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
if (showSendTextMessageInput) {
|
||||
|
|
@ -372,11 +408,17 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
|||
child: MediaViewSizing(
|
||||
bottomNavigation: bottomNavigation(),
|
||||
requiredHeight: 80,
|
||||
child: Stack(
|
||||
children: [
|
||||
if (videoController != null)
|
||||
Positioned.fill(child: VideoPlayer(videoController!)),
|
||||
if (imageBytes != null)
|
||||
Positioned.fill(
|
||||
child: Image.memory(
|
||||
imageBytes!,
|
||||
fit: BoxFit.contain,
|
||||
frameBuilder:
|
||||
((context, child, frame, wasSynchronouslyLoaded) {
|
||||
frameBuilder: ((context, child, frame,
|
||||
wasSynchronouslyLoaded) {
|
||||
if (wasSynchronouslyLoaded) return child;
|
||||
return AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
|
|
@ -385,13 +427,17 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
|||
: SizedBox(
|
||||
height: 60,
|
||||
width: 60,
|
||||
child:
|
||||
CircularProgressIndicator(strokeWidth: 2),
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isRealTwonly && imageBytes == null)
|
||||
Positioned.fill(
|
||||
|
|
@ -439,12 +485,12 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
|||
),
|
||||
),
|
||||
),
|
||||
if (canBeSeenUntil != null)
|
||||
Positioned(
|
||||
right: 20,
|
||||
top: 27,
|
||||
child: Row(
|
||||
children: [
|
||||
if (canBeSeenUntil != null)
|
||||
SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
|
|
|
|||
|
|
@ -149,7 +149,7 @@ class _MessageSendStateIconState extends State<MessageSendStateIcon> {
|
|||
if (message.errorWhileSending) {
|
||||
icon =
|
||||
FaIcon(FontAwesomeIcons.circleExclamation, size: 12, color: color);
|
||||
text = "Unknown error.";
|
||||
text = "Error";
|
||||
}
|
||||
|
||||
if (message.kind == MessageKind.media) {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import 'schema_v1.dart' as v1;
|
|||
import 'schema_v2.dart' as v2;
|
||||
import 'schema_v3.dart' as v3;
|
||||
import 'schema_v4.dart' as v4;
|
||||
import 'schema_v5.dart' as v5;
|
||||
|
||||
class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
@override
|
||||
|
|
@ -20,10 +21,12 @@ class GeneratedHelper implements SchemaInstantiationHelper {
|
|||
return v3.DatabaseAtV3(db);
|
||||
case 4:
|
||||
return v4.DatabaseAtV4(db);
|
||||
case 5:
|
||||
return v5.DatabaseAtV5(db);
|
||||
default:
|
||||
throw MissingSchemaException(version, versions);
|
||||
}
|
||||
}
|
||||
|
||||
static const versions = const [1, 2, 3, 4];
|
||||
static const versions = const [1, 2, 3, 4, 5];
|
||||
}
|
||||
|
|
|
|||
2820
test/drift/twonly_database/generated/schema_v5.dart
Normal file
2820
test/drift/twonly_database/generated/schema_v5.dart
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue