mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-01-15 18:08:40 +00:00
started with #203
This commit is contained in:
parent
ab125042e2
commit
6c630c78b5
17 changed files with 1287 additions and 335 deletions
|
|
@ -8,7 +8,6 @@ import 'package:twonly/src/services/api/media_send.dart';
|
||||||
import 'package:twonly/src/services/api.service.dart';
|
import 'package:twonly/src/services/api.service.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:twonly/src/providers/connection.provider.dart';
|
import 'package:twonly/src/providers/connection.provider.dart';
|
||||||
import 'package:twonly/src/utils/hive.dart';
|
|
||||||
import 'package:twonly/src/providers/settings.provider.dart';
|
import 'package:twonly/src/providers/settings.provider.dart';
|
||||||
import 'package:twonly/src/services/fcm.service.dart';
|
import 'package:twonly/src/services/fcm.service.dart';
|
||||||
import 'package:twonly/src/services/notification.service.dart';
|
import 'package:twonly/src/services/notification.service.dart';
|
||||||
|
|
@ -33,9 +32,9 @@ void main() async {
|
||||||
// Load the user's preferred theme while the splash screen is displayed.
|
// Load the user's preferred theme while the splash screen is displayed.
|
||||||
// This prevents a sudden theme change when the app is first displayed.
|
// This prevents a sudden theme change when the app is first displayed.
|
||||||
await settingsController.loadSettings();
|
await settingsController.loadSettings();
|
||||||
|
await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
|
||||||
|
|
||||||
await setupPushNotification();
|
setupPushNotification();
|
||||||
await initMediaStorage();
|
|
||||||
|
|
||||||
gCameras = await availableCameras();
|
gCameras = await availableCameras();
|
||||||
|
|
||||||
|
|
@ -48,8 +47,6 @@ void main() async {
|
||||||
purgeReceivedMediaFiles();
|
purgeReceivedMediaFiles();
|
||||||
purgeSendMediaFiles();
|
purgeSendMediaFiles();
|
||||||
|
|
||||||
await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
|
|
||||||
|
|
||||||
runApp(
|
runApp(
|
||||||
MultiProvider(
|
MultiProvider(
|
||||||
providers: [
|
providers: [
|
||||||
|
|
|
||||||
44
lib/src/database/daos/message_retransmissions.dao.dart
Normal file
44
lib/src/database/daos/message_retransmissions.dao.dart
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:twonly/src/database/tables/message_retransmissions.dart';
|
||||||
|
import 'package:twonly/src/database/twonly_database.dart';
|
||||||
|
import 'package:twonly/src/utils/log.dart';
|
||||||
|
|
||||||
|
part 'message_retransmissions.dao.g.dart';
|
||||||
|
|
||||||
|
@DriftAccessor(tables: [MessageRetransmissions])
|
||||||
|
class MessageRetransmissionDao extends DatabaseAccessor<TwonlyDatabase>
|
||||||
|
with _$MessageRetransmissionDaoMixin {
|
||||||
|
// this constructor is required so that the main database can create an instance
|
||||||
|
// of this object.
|
||||||
|
MessageRetransmissionDao(super.db);
|
||||||
|
|
||||||
|
Future<int?> insertRetransmission(
|
||||||
|
MessageRetransmissionsCompanion message) async {
|
||||||
|
try {
|
||||||
|
return await into(messageRetransmissions).insert(message);
|
||||||
|
} catch (e) {
|
||||||
|
Log.error("Error while inserting message for retransmission: $e");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<int>> getRetransmitAbleMessages() async {
|
||||||
|
return (await (select(messageRetransmissions)
|
||||||
|
..where((t) => t.acknowledgeByServerAt.isNull()))
|
||||||
|
.get())
|
||||||
|
.map((msg) => msg.retransmissionId)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
SingleOrNullSelectable<MessageRetransmission> getRetransmissionById(
|
||||||
|
int retransmissionId) {
|
||||||
|
return select(messageRetransmissions)
|
||||||
|
..where((t) => t.retransmissionId.equals(retransmissionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future deleteRetransmissionById(int retransmissionId) {
|
||||||
|
return (delete(messageRetransmissions)
|
||||||
|
..where((t) => t.retransmissionId.equals(retransmissionId)))
|
||||||
|
.go();
|
||||||
|
}
|
||||||
|
}
|
||||||
11
lib/src/database/daos/message_retransmissions.dao.g.dart
Normal file
11
lib/src/database/daos/message_retransmissions.dao.g.dart
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'message_retransmissions.dao.dart';
|
||||||
|
|
||||||
|
// ignore_for_file: type=lint
|
||||||
|
mixin _$MessageRetransmissionDaoMixin on DatabaseAccessor<TwonlyDatabase> {
|
||||||
|
$ContactsTable get contacts => attachedDatabase.contacts;
|
||||||
|
$MessagesTable get messages => attachedDatabase.messages;
|
||||||
|
$MessageRetransmissionsTable get messageRetransmissions =>
|
||||||
|
attachedDatabase.messageRetransmissions;
|
||||||
|
}
|
||||||
19
lib/src/database/tables/message_retransmissions.dart
Normal file
19
lib/src/database/tables/message_retransmissions.dart
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:twonly/src/database/tables/contacts_table.dart';
|
||||||
|
import 'package:twonly/src/database/tables/messages_table.dart';
|
||||||
|
|
||||||
|
@DataClassName('MessageRetransmission')
|
||||||
|
class MessageRetransmissions extends Table {
|
||||||
|
IntColumn get retransmissionId => integer().autoIncrement()();
|
||||||
|
IntColumn get contactId =>
|
||||||
|
integer().references(Contacts, #userId, onDelete: KeyAction.cascade)();
|
||||||
|
|
||||||
|
IntColumn get messageId => integer()
|
||||||
|
.nullable()
|
||||||
|
.references(Messages, #messageId, onDelete: KeyAction.cascade)();
|
||||||
|
|
||||||
|
BlobColumn get plaintextContent => blob()();
|
||||||
|
BlobColumn get pushData => blob().nullable()();
|
||||||
|
|
||||||
|
DateTimeColumn get acknowledgeByServerAt => dateTime().nullable()();
|
||||||
|
}
|
||||||
|
|
@ -5,11 +5,13 @@ import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:twonly/src/database/daos/contacts_dao.dart';
|
import 'package:twonly/src/database/daos/contacts_dao.dart';
|
||||||
import 'package:twonly/src/database/daos/media_downloads_dao.dart';
|
import 'package:twonly/src/database/daos/media_downloads_dao.dart';
|
||||||
import 'package:twonly/src/database/daos/media_uploads_dao.dart';
|
import 'package:twonly/src/database/daos/media_uploads_dao.dart';
|
||||||
|
import 'package:twonly/src/database/daos/message_retransmissions.dao.dart';
|
||||||
import 'package:twonly/src/database/daos/messages_dao.dart';
|
import 'package:twonly/src/database/daos/messages_dao.dart';
|
||||||
import 'package:twonly/src/database/daos/signal_dao.dart';
|
import 'package:twonly/src/database/daos/signal_dao.dart';
|
||||||
import 'package:twonly/src/database/tables/contacts_table.dart';
|
import 'package:twonly/src/database/tables/contacts_table.dart';
|
||||||
import 'package:twonly/src/database/tables/media_download_table.dart';
|
import 'package:twonly/src/database/tables/media_download_table.dart';
|
||||||
import 'package:twonly/src/database/tables/media_uploads_table.dart';
|
import 'package:twonly/src/database/tables/media_uploads_table.dart';
|
||||||
|
import 'package:twonly/src/database/tables/message_retransmissions.dart';
|
||||||
import 'package:twonly/src/database/tables/messages_table.dart';
|
import 'package:twonly/src/database/tables/messages_table.dart';
|
||||||
import 'package:twonly/src/database/tables/signal_contact_prekey_table.dart';
|
import 'package:twonly/src/database/tables/signal_contact_prekey_table.dart';
|
||||||
import 'package:twonly/src/database/tables/signal_contact_signed_prekey_table.dart';
|
import 'package:twonly/src/database/tables/signal_contact_signed_prekey_table.dart';
|
||||||
|
|
@ -32,13 +34,15 @@ part 'twonly_database.g.dart';
|
||||||
SignalSenderKeyStores,
|
SignalSenderKeyStores,
|
||||||
SignalSessionStores,
|
SignalSessionStores,
|
||||||
SignalContactPreKeys,
|
SignalContactPreKeys,
|
||||||
SignalContactSignedPreKeys
|
SignalContactSignedPreKeys,
|
||||||
|
MessageRetransmissions
|
||||||
], daos: [
|
], daos: [
|
||||||
MessagesDao,
|
MessagesDao,
|
||||||
ContactsDao,
|
ContactsDao,
|
||||||
MediaUploadsDao,
|
MediaUploadsDao,
|
||||||
MediaDownloadsDao,
|
MediaDownloadsDao,
|
||||||
SignalDao
|
SignalDao,
|
||||||
|
MessageRetransmissionDao
|
||||||
])
|
])
|
||||||
class TwonlyDatabase extends _$TwonlyDatabase {
|
class TwonlyDatabase extends _$TwonlyDatabase {
|
||||||
TwonlyDatabase([QueryExecutor? e])
|
TwonlyDatabase([QueryExecutor? e])
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -26,9 +26,9 @@ import 'package:twonly/src/services/api/server_messages.dart';
|
||||||
import 'package:twonly/src/services/signal/identity.signal.dart';
|
import 'package:twonly/src/services/signal/identity.signal.dart';
|
||||||
import 'package:twonly/src/services/signal/prekeys.signal.dart';
|
import 'package:twonly/src/services/signal/prekeys.signal.dart';
|
||||||
import 'package:twonly/src/services/signal/utils.signal.dart';
|
import 'package:twonly/src/services/signal/utils.signal.dart';
|
||||||
import 'package:twonly/src/utils/hive.dart';
|
|
||||||
import 'package:twonly/src/services/fcm.service.dart';
|
import 'package:twonly/src/services/fcm.service.dart';
|
||||||
import 'package:twonly/src/services/flame.service.dart';
|
import 'package:twonly/src/services/flame.service.dart';
|
||||||
|
import 'package:twonly/src/utils/keyvalue.dart';
|
||||||
import 'package:twonly/src/utils/log.dart';
|
import 'package:twonly/src/utils/log.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';
|
||||||
|
|
@ -38,6 +38,7 @@ import 'package:libsignal_protocol_dart/src/ecc/ed25519.dart';
|
||||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||||
|
|
||||||
final lockConnecting = Mutex();
|
final lockConnecting = Mutex();
|
||||||
|
final lockRetransStore = Mutex();
|
||||||
|
|
||||||
/// The ApiProvider is responsible for communicating with the server.
|
/// The ApiProvider is responsible for communicating with the server.
|
||||||
/// It handles errors and does automatically tries to reconnect on
|
/// It handles errors and does automatically tries to reconnect on
|
||||||
|
|
@ -203,39 +204,43 @@ class ApiService {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Map<String, dynamic>> getRetransmission() async {
|
Future<Map<String, dynamic>> getRetransmission() async {
|
||||||
final box = await getMediaStorage();
|
return await KeyValueStore.get("rawbytes-to-retransmit") ?? {};
|
||||||
try {
|
|
||||||
return box.get("rawbytes-to-retransmit");
|
|
||||||
} catch (e) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future retransmitRawBytes() async {
|
Future retransmitRawBytes() async {
|
||||||
var retransmit = await getRetransmission();
|
await lockRetransStore.protect(() async {
|
||||||
Log.info("retransmitting ${retransmit.keys.length} messages");
|
var retransmit = await getRetransmission();
|
||||||
for (final seq in retransmit.keys) {
|
Log.info("retransmitting ${retransmit.keys.length} messages");
|
||||||
try {
|
bool gotError = false;
|
||||||
_channel!.sink.add(base64Decode(retransmit[seq]));
|
for (final seq in retransmit.keys) {
|
||||||
} catch (e) {
|
try {
|
||||||
Log.error("$e");
|
_channel!.sink.add(base64Decode(retransmit[seq]));
|
||||||
|
} catch (e) {
|
||||||
|
gotError = true;
|
||||||
|
Log.error("$e");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
if (!gotError) {
|
||||||
|
KeyValueStore.put("rawbytes-to-retransmit", {});
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future addToRetransmissionBuffer(Int64 seq, Uint8List bytes) async {
|
Future addToRetransmissionBuffer(Int64 seq, Uint8List bytes) async {
|
||||||
var retransmit = await getRetransmission();
|
await lockRetransStore.protect(() async {
|
||||||
retransmit[seq.toString()] = base64Encode(bytes);
|
var retransmit = await getRetransmission();
|
||||||
final box = await getMediaStorage();
|
retransmit[seq.toString()] = base64Encode(bytes);
|
||||||
box.put("rawbytes-to-retransmit", retransmit);
|
KeyValueStore.put("rawbytes-to-retransmit", retransmit);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future removeFromRetransmissionBuffer(Int64 seq) async {
|
Future removeFromRetransmissionBuffer(Int64 seq) async {
|
||||||
var retransmit = await getRetransmission();
|
await lockRetransStore.protect(() async {
|
||||||
if (retransmit.isEmpty) return;
|
var retransmit = await getRetransmission();
|
||||||
retransmit.remove(seq.toString());
|
if (retransmit.isEmpty) return;
|
||||||
final box = await getMediaStorage();
|
retransmit.remove(seq.toString());
|
||||||
box.put("rawbytes-to-retransmit", retransmit);
|
KeyValueStore.put("rawbytes-to-retransmit", retransmit);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Result> sendRequestSync(
|
Future<Result> sendRequestSync(
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:math';
|
import 'dart:io';
|
||||||
import 'package:drift/drift.dart';
|
import 'package:drift/drift.dart';
|
||||||
import 'package:hive/hive.dart';
|
|
||||||
import 'package:mutex/mutex.dart';
|
|
||||||
import 'package:twonly/globals.dart';
|
import 'package:twonly/globals.dart';
|
||||||
import 'package:twonly/src/database/twonly_database.dart';
|
import 'package:twonly/src/database/twonly_database.dart';
|
||||||
import 'package:twonly/src/database/tables/messages_table.dart';
|
import 'package:twonly/src/database/tables/messages_table.dart';
|
||||||
|
|
@ -11,192 +9,79 @@ import 'package:twonly/src/model/json/userdata.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/services/api/utils.dart';
|
import 'package:twonly/src/services/api/utils.dart';
|
||||||
import 'package:twonly/src/services/signal/encryption.signal.dart';
|
import 'package:twonly/src/services/signal/encryption.signal.dart';
|
||||||
import 'package:twonly/src/utils/hive.dart';
|
|
||||||
import 'package:twonly/src/services/notification.service.dart';
|
import 'package:twonly/src/services/notification.service.dart';
|
||||||
import 'package:twonly/src/utils/log.dart';
|
import 'package:twonly/src/utils/log.dart';
|
||||||
import 'package:twonly/src/utils/storage.dart';
|
import 'package:twonly/src/utils/storage.dart';
|
||||||
|
|
||||||
final lockSendingMessages = Mutex();
|
|
||||||
|
|
||||||
Future tryTransmitMessages() async {
|
Future tryTransmitMessages() async {
|
||||||
lockSendingMessages.protect(() async {
|
final retransIds =
|
||||||
Map<String, dynamic> retransmit = await getAllMessagesForRetransmitting();
|
await twonlyDB.messageRetransmissionDao.getRetransmitAbleMessages();
|
||||||
|
|
||||||
if (retransmit.isEmpty) return;
|
for (final retransId in retransIds) {
|
||||||
|
sendRetransmitMessage(retransId);
|
||||||
Log.info("try sending messages: ${retransmit.length}");
|
|
||||||
|
|
||||||
Map<String, dynamic> failed = {};
|
|
||||||
|
|
||||||
List<MapEntry<String, dynamic>> sortedList = retransmit.entries.toList()
|
|
||||||
..sort((a, b) => int.parse(a.key).compareTo(int.parse(b.key)));
|
|
||||||
|
|
||||||
for (final element in sortedList) {
|
|
||||||
RetransmitMessage msg =
|
|
||||||
RetransmitMessage.fromJson(jsonDecode(element.value));
|
|
||||||
|
|
||||||
Result resp = await apiService.sendTextMessage(
|
|
||||||
msg.userId,
|
|
||||||
msg.bytes,
|
|
||||||
msg.pushData,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (resp.isSuccess) {
|
|
||||||
if (msg.messageId != null) {
|
|
||||||
await twonlyDB.messagesDao.updateMessageByMessageId(
|
|
||||||
msg.messageId!,
|
|
||||||
MessagesCompanion(
|
|
||||||
acknowledgeByServer: Value(true),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
failed[element.key] = element.value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Box box = await getMediaStorage();
|
|
||||||
box.put("messages-to-retransmit", jsonEncode(failed));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
class RetransmitMessage {
|
|
||||||
int? messageId;
|
|
||||||
int userId;
|
|
||||||
Uint8List bytes;
|
|
||||||
List<int>? pushData;
|
|
||||||
RetransmitMessage({
|
|
||||||
this.messageId,
|
|
||||||
required this.userId,
|
|
||||||
required this.bytes,
|
|
||||||
this.pushData,
|
|
||||||
});
|
|
||||||
|
|
||||||
// From JSON constructor
|
|
||||||
factory RetransmitMessage.fromJson(Map<String, dynamic> json) {
|
|
||||||
return RetransmitMessage(
|
|
||||||
messageId: json['messageId'],
|
|
||||||
userId: json['userId'],
|
|
||||||
bytes: base64Decode(json['bytes']),
|
|
||||||
pushData: json['pushData'] == null
|
|
||||||
? null
|
|
||||||
: List<int>.from(json['pushData'].map((item) => item as int)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// To JSON method
|
|
||||||
Map<String, dynamic> toJson() {
|
|
||||||
return {
|
|
||||||
'messageId': messageId,
|
|
||||||
'userId': userId,
|
|
||||||
'bytes': base64Encode(bytes),
|
|
||||||
'pushData': pushData,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Map<String, dynamic>> getAllMessagesForRetransmitting() async {
|
Future sendRetransmitMessage(int retransId) async {
|
||||||
Box box = await getMediaStorage();
|
MessageRetransmission? retrans = await twonlyDB.messageRetransmissionDao
|
||||||
String? retransmitJson = box.get("messages-to-retransmit");
|
.getRetransmissionById(retransId)
|
||||||
Map<String, dynamic> retransmit = {};
|
.getSingleOrNull();
|
||||||
|
|
||||||
if (retransmitJson != null) {
|
if (retrans == null) {
|
||||||
try {
|
Log.error("$retransId not found in database");
|
||||||
retransmit = jsonDecode(retransmitJson);
|
return;
|
||||||
} catch (e) {
|
|
||||||
Log.error("Could not decode the retransmit messages: $e");
|
|
||||||
await box.delete("messages-to-retransmit");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return retransmit;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<Result> sendRetransmitMessage(
|
Uint8List? encryptedBytes = await signalEncryptMessage(
|
||||||
String stateId, RetransmitMessage msg) async {
|
retrans.contactId,
|
||||||
Log.info("Sending ${msg.messageId}");
|
retrans.plaintextContent,
|
||||||
Result resp =
|
);
|
||||||
await apiService.sendTextMessage(msg.userId, msg.bytes, msg.pushData);
|
|
||||||
|
if (encryptedBytes == null) {
|
||||||
|
Log.error("Could not encrypt the message. Aborting and trying again.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Result resp = await apiService.sendTextMessage(
|
||||||
|
retrans.contactId,
|
||||||
|
encryptedBytes,
|
||||||
|
retrans.pushData,
|
||||||
|
);
|
||||||
|
|
||||||
bool retry = true;
|
bool retry = true;
|
||||||
|
|
||||||
if (resp.isError) {
|
if (resp.isError) {
|
||||||
if (resp.error == ErrorCode.UserIdNotFound) {
|
if (resp.error == ErrorCode.UserIdNotFound) {
|
||||||
retry = false;
|
retry = false;
|
||||||
if (msg.messageId != null) {
|
if (retrans.messageId != null) {
|
||||||
await twonlyDB.messagesDao.updateMessageByMessageId(
|
await twonlyDB.messagesDao.updateMessageByMessageId(
|
||||||
msg.messageId!,
|
retrans.messageId!,
|
||||||
MessagesCompanion(errorWhileSending: Value(true)),
|
MessagesCompanion(errorWhileSending: Value(true)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
await twonlyDB.contactsDao.updateContact(
|
||||||
|
retrans.contactId,
|
||||||
|
ContactsCompanion(deleted: Value(true)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resp.isSuccess) {
|
if (resp.isSuccess) {
|
||||||
retry = false;
|
retry = false;
|
||||||
if (msg.messageId != null) {
|
if (retrans.messageId != null) {
|
||||||
await twonlyDB.messagesDao.updateMessageByMessageId(
|
await twonlyDB.messagesDao.updateMessageByMessageId(
|
||||||
msg.messageId!,
|
retrans.messageId!,
|
||||||
MessagesCompanion(acknowledgeByServer: Value(true)),
|
MessagesCompanion(
|
||||||
|
acknowledgeByServer: Value(true),
|
||||||
|
errorWhileSending: Value(false),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!retry) {
|
if (!retry) {
|
||||||
{
|
await twonlyDB.messageRetransmissionDao.deleteRetransmissionById(retransId);
|
||||||
var retransmit = await getAllMessagesForRetransmitting();
|
|
||||||
retransmit.remove(stateId);
|
|
||||||
Box box = await getMediaStorage();
|
|
||||||
box.put("messages-to-retransmit", jsonEncode(retransmit));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return resp;
|
|
||||||
}
|
|
||||||
|
|
||||||
// this functions ensures that the message is received by the server and in case of errors will try again later
|
|
||||||
Future<(String, RetransmitMessage)?> encryptMessage(
|
|
||||||
int? messageId, int userId, MessageJson msg,
|
|
||||||
{PushKind? pushKind}) async {
|
|
||||||
return await lockSendingMessages
|
|
||||||
.protect<(String, RetransmitMessage)?>(() async {
|
|
||||||
Uint8List? bytes = await signalEncryptMessage(userId, msg);
|
|
||||||
|
|
||||||
if (bytes == null) {
|
|
||||||
Log.error("Error encryption message!");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var retransmit = await getAllMessagesForRetransmitting();
|
|
||||||
|
|
||||||
int currentMaxStateId = messageId ?? 60000;
|
|
||||||
if (retransmit.isNotEmpty && messageId == null) {
|
|
||||||
currentMaxStateId = retransmit.keys.map((x) => int.parse(x)).reduce(max);
|
|
||||||
if (currentMaxStateId < 60000) {
|
|
||||||
currentMaxStateId = 60000;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String stateId = (currentMaxStateId + 1).toString();
|
|
||||||
|
|
||||||
Box box = await getMediaStorage();
|
|
||||||
|
|
||||||
List<int>? pushData;
|
|
||||||
if (pushKind != null) {
|
|
||||||
pushData = await getPushData(userId, pushKind);
|
|
||||||
}
|
|
||||||
|
|
||||||
RetransmitMessage encryptedMessage = RetransmitMessage(
|
|
||||||
messageId: messageId,
|
|
||||||
userId: userId,
|
|
||||||
bytes: bytes,
|
|
||||||
pushData: pushData,
|
|
||||||
);
|
|
||||||
|
|
||||||
{
|
|
||||||
retransmit[stateId] = jsonEncode(encryptedMessage.toJson());
|
|
||||||
box.put("messages-to-retransmit", jsonEncode(retransmit));
|
|
||||||
}
|
|
||||||
|
|
||||||
return (stateId, encryptedMessage);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// encrypts and stores the message and then sends it in the background
|
// encrypts and stores the message and then sends it in the background
|
||||||
|
|
@ -205,16 +90,38 @@ Future encryptAndSendMessageAsync(int? messageId, int userId, MessageJson msg,
|
||||||
if (gIsDemoUser) {
|
if (gIsDemoUser) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
(String, RetransmitMessage)? stateData =
|
|
||||||
await encryptMessage(messageId, userId, msg, pushKind: pushKind);
|
Uint8List? pushData;
|
||||||
if (stateData != null) {
|
if (pushKind != null) {
|
||||||
final (stateId, message) = stateData;
|
pushData = await getPushData(userId, pushKind);
|
||||||
sendRetransmitMessage(stateId, message);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Uint8List plaintextContent =
|
||||||
|
Uint8List.fromList(gzip.encode(utf8.encode(jsonEncode(msg.toJson()))));
|
||||||
|
|
||||||
|
int? retransId = await twonlyDB.messageRetransmissionDao.insertRetransmission(
|
||||||
|
MessageRetransmissionsCompanion(
|
||||||
|
contactId: Value(userId),
|
||||||
|
messageId: Value(messageId),
|
||||||
|
plaintextContent: Value(plaintextContent),
|
||||||
|
pushData: Value(pushData),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (retransId == null) {
|
||||||
|
Log.error("Could not insert the message into the retransmission database");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// this can now be done in the background...
|
||||||
|
sendRetransmitMessage(retransId);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future sendTextMessage(
|
Future sendTextMessage(
|
||||||
int target, TextMessageContent content, PushKind? pushKind) async {
|
int target,
|
||||||
|
TextMessageContent content,
|
||||||
|
PushKind? pushKind,
|
||||||
|
) async {
|
||||||
DateTime messageSendAt = DateTime.now();
|
DateTime messageSendAt = DateTime.now();
|
||||||
|
|
||||||
int? messageId = await twonlyDB.messagesDao.insertMessage(
|
int? messageId = await twonlyDB.messagesDao.insertMessage(
|
||||||
|
|
|
||||||
|
|
@ -100,8 +100,9 @@ Future<client.Response> handleNewMessage(int fromUserId, Uint8List body) async {
|
||||||
case MessageKind.opened:
|
case MessageKind.opened:
|
||||||
if (message.messageId != null) {
|
if (message.messageId != null) {
|
||||||
final update = MessagesCompanion(
|
final update = MessagesCompanion(
|
||||||
openedAt: Value(message.timestamp),
|
openedAt: Value(message.timestamp),
|
||||||
errorWhileSending: Value(false));
|
errorWhileSending: Value(false),
|
||||||
|
);
|
||||||
await twonlyDB.messagesDao.updateMessageByOtherUser(
|
await twonlyDB.messagesDao.updateMessageByOtherUser(
|
||||||
fromUserId,
|
fromUserId,
|
||||||
message.messageId!,
|
message.messageId!,
|
||||||
|
|
@ -214,8 +215,11 @@ Future<client.Response> handleNewMessage(int fromUserId, Uint8List body) async {
|
||||||
fromUserId,
|
fromUserId,
|
||||||
responseToMessageId,
|
responseToMessageId,
|
||||||
MessagesCompanion(
|
MessagesCompanion(
|
||||||
errorWhileSending: Value(false),
|
errorWhileSending: Value(false),
|
||||||
),
|
openedAt: Value(
|
||||||
|
DateTime.now(),
|
||||||
|
) // when a user reacted to the media file, it should be marked as opened
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -259,16 +263,16 @@ Future<client.Response> handleNewMessage(int fromUserId, Uint8List body) async {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// await encryptAndSendMessageAsync(
|
await encryptAndSendMessageAsync(
|
||||||
// message.messageId!,
|
message.messageId!,
|
||||||
// fromUserId,
|
fromUserId,
|
||||||
// MessageJson(
|
MessageJson(
|
||||||
// kind: MessageKind.ack,
|
kind: MessageKind.ack,
|
||||||
// messageId: message.messageId!,
|
messageId: message.messageId!,
|
||||||
// content: MessageContent(),
|
content: MessageContent(),
|
||||||
// timestamp: DateTime.now(),
|
timestamp: DateTime.now(),
|
||||||
// ),
|
),
|
||||||
// );
|
);
|
||||||
|
|
||||||
// unarchive contact when receiving a new message
|
// unarchive contact when receiving a new message
|
||||||
await twonlyDB.contactsDao.updateContact(
|
await twonlyDB.contactsDao.updateContact(
|
||||||
|
|
|
||||||
|
|
@ -238,7 +238,7 @@ class PushNotification {
|
||||||
|
|
||||||
/// this will trigger a push notification
|
/// this will trigger a push notification
|
||||||
/// push notification only containing the message kind and username
|
/// push notification only containing the message kind and username
|
||||||
Future<List<int>?> getPushData(int toUserId, PushKind kind) async {
|
Future<Uint8List?> getPushData(int toUserId, PushKind kind) async {
|
||||||
final Map<int, PushUser> pushKeys = await getPushKeys("sendingPushKeys");
|
final Map<int, PushUser> pushKeys = await getPushKeys("sendingPushKeys");
|
||||||
|
|
||||||
List<int> key = "InsecureOnlyUsedForAddingContact".codeUnits;
|
List<int> key = "InsecureOnlyUsedForAddingContact".codeUnits;
|
||||||
|
|
@ -278,8 +278,7 @@ Future<List<int>?> getPushData(int toUserId, PushKind kind) async {
|
||||||
cipherText: secretBox.cipherText,
|
cipherText: secretBox.cipherText,
|
||||||
mac: secretBox.mac.bytes,
|
mac: secretBox.mac.bytes,
|
||||||
);
|
);
|
||||||
|
return Utf8Encoder().convert(jsonEncode(res.toJson()));
|
||||||
return jsonEncode(res.toJson()).codeUnits;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<PushKind?> tryDecryptMessage(
|
Future<PushKind?> tryDecryptMessage(
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
import 'package:libsignal_protocol_dart/libsignal_protocol_dart.dart';
|
import 'package:libsignal_protocol_dart/libsignal_protocol_dart.dart';
|
||||||
|
import 'package:mutex/mutex.dart';
|
||||||
import 'package:twonly/src/database/twonly_database.dart';
|
import 'package:twonly/src/database/twonly_database.dart';
|
||||||
import 'package:twonly/src/model/json/message.dart';
|
import 'package:twonly/src/model/json/message.dart';
|
||||||
import 'package:twonly/src/database/signal/connect_signal_protocol_store.dart';
|
import 'package:twonly/src/database/signal/connect_signal_protocol_store.dart';
|
||||||
|
|
@ -11,82 +12,87 @@ import 'package:twonly/src/services/signal/utils.signal.dart';
|
||||||
import 'package:twonly/src/utils/log.dart';
|
import 'package:twonly/src/utils/log.dart';
|
||||||
import 'package:twonly/src/utils/misc.dart';
|
import 'package:twonly/src/utils/misc.dart';
|
||||||
|
|
||||||
Future<Uint8List?> signalEncryptMessage(int target, MessageJson msg) async {
|
/// This caused some troubles, so protection the encryption...
|
||||||
try {
|
final lockingSignalEncryption = Mutex();
|
||||||
ConnectSignalProtocolStore signalStore = (await getSignalStore())!;
|
|
||||||
final address = SignalProtocolAddress(target.toString(), defaultDeviceId);
|
|
||||||
|
|
||||||
SessionCipher session = SessionCipher.fromStore(signalStore, address);
|
Future<Uint8List?> signalEncryptMessage(
|
||||||
|
int target, Uint8List plaintextContent) async {
|
||||||
|
return await lockingSignalEncryption.protect<Uint8List?>(() async {
|
||||||
|
try {
|
||||||
|
ConnectSignalProtocolStore signalStore = (await getSignalStore())!;
|
||||||
|
final address = SignalProtocolAddress(target.toString(), defaultDeviceId);
|
||||||
|
|
||||||
SignalContactPreKey? preKey = await getPreKeyByContactId(target);
|
SessionCipher session = SessionCipher.fromStore(signalStore, address);
|
||||||
SignalContactSignedPreKey? signedPreKey = await getSignedPreKeyByContactId(
|
|
||||||
target,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (signedPreKey != null) {
|
SignalContactPreKey? preKey = await getPreKeyByContactId(target);
|
||||||
SessionBuilder sessionBuilder = SessionBuilder.fromSignalStore(
|
SignalContactSignedPreKey? signedPreKey =
|
||||||
signalStore,
|
await getSignedPreKeyByContactId(
|
||||||
address,
|
target,
|
||||||
);
|
);
|
||||||
|
|
||||||
ECPublicKey? tempPrePublicKey;
|
if (signedPreKey != null) {
|
||||||
|
SessionBuilder sessionBuilder = SessionBuilder.fromSignalStore(
|
||||||
|
signalStore,
|
||||||
|
address,
|
||||||
|
);
|
||||||
|
|
||||||
if (preKey != null) {
|
ECPublicKey? tempPrePublicKey;
|
||||||
tempPrePublicKey = Curve.decodePoint(
|
|
||||||
DjbECPublicKey(
|
if (preKey != null) {
|
||||||
Uint8List.fromList(preKey.preKey),
|
tempPrePublicKey = Curve.decodePoint(
|
||||||
).serialize(),
|
DjbECPublicKey(
|
||||||
|
Uint8List.fromList(preKey.preKey),
|
||||||
|
).serialize(),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ECPublicKey? tempSignedPreKeyPublic = Curve.decodePoint(
|
||||||
|
DjbECPublicKey(Uint8List.fromList(signedPreKey.signedPreKey))
|
||||||
|
.serialize(),
|
||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
ECPublicKey? tempSignedPreKeyPublic = Curve.decodePoint(
|
Uint8List? tempSignedPreKeySignature = Uint8List.fromList(
|
||||||
DjbECPublicKey(Uint8List.fromList(signedPreKey.signedPreKey))
|
signedPreKey.signedPreKeySignature,
|
||||||
.serialize(),
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
|
|
||||||
Uint8List? tempSignedPreKeySignature = Uint8List.fromList(
|
|
||||||
signedPreKey.signedPreKeySignature,
|
|
||||||
);
|
|
||||||
|
|
||||||
final IdentityKey? tempIdentityKey =
|
|
||||||
await signalStore.getIdentity(address);
|
|
||||||
if (tempIdentityKey != null) {
|
|
||||||
PreKeyBundle preKeyBundle = PreKeyBundle(
|
|
||||||
target,
|
|
||||||
defaultDeviceId,
|
|
||||||
preKey?.preKeyId,
|
|
||||||
tempPrePublicKey,
|
|
||||||
signedPreKey.signedPreKeyId,
|
|
||||||
tempSignedPreKeyPublic,
|
|
||||||
tempSignedPreKeySignature,
|
|
||||||
tempIdentityKey,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
final IdentityKey? tempIdentityKey =
|
||||||
await sessionBuilder.processPreKeyBundle(preKeyBundle);
|
await signalStore.getIdentity(address);
|
||||||
} catch (e) {
|
if (tempIdentityKey != null) {
|
||||||
Log.error("could not process pre key bundle: $e");
|
PreKeyBundle preKeyBundle = PreKeyBundle(
|
||||||
|
target,
|
||||||
|
defaultDeviceId,
|
||||||
|
preKey?.preKeyId,
|
||||||
|
tempPrePublicKey,
|
||||||
|
signedPreKey.signedPreKeyId,
|
||||||
|
tempSignedPreKeyPublic,
|
||||||
|
tempSignedPreKeySignature,
|
||||||
|
tempIdentityKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sessionBuilder.processPreKeyBundle(preKeyBundle);
|
||||||
|
} catch (e) {
|
||||||
|
Log.error("could not process pre key bundle: $e");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.error("did not get the identity of the remote address");
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
Log.error("did not get the identity of the remote address");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final ciphertext = await session.encrypt(plaintextContent);
|
||||||
|
|
||||||
|
var b = BytesBuilder();
|
||||||
|
b.add(ciphertext.serialize());
|
||||||
|
b.add(intToBytes(ciphertext.getType()));
|
||||||
|
|
||||||
|
return b.takeBytes();
|
||||||
|
} catch (e) {
|
||||||
|
Log.error(e.toString());
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
});
|
||||||
final ciphertext = await session.encrypt(
|
|
||||||
Uint8List.fromList(gzip.encode(utf8.encode(jsonEncode(msg.toJson())))),
|
|
||||||
);
|
|
||||||
|
|
||||||
var b = BytesBuilder();
|
|
||||||
b.add(ciphertext.serialize());
|
|
||||||
b.add(intToBytes(ciphertext.getType()));
|
|
||||||
|
|
||||||
return b.takeBytes();
|
|
||||||
} catch (e) {
|
|
||||||
Log.error(e.toString());
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<MessageJson?> signalDecryptMessage(int source, Uint8List msg) async {
|
Future<MessageJson?> signalDecryptMessage(int source, Uint8List msg) async {
|
||||||
|
|
@ -115,7 +121,12 @@ Future<MessageJson?> signalDecryptMessage(int source, Uint8List msg) async {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return MessageJson.fromJson(
|
return MessageJson.fromJson(
|
||||||
jsonDecode(utf8.decode(gzip.decode(plaintext))));
|
jsonDecode(
|
||||||
|
utf8.decode(
|
||||||
|
gzip.decode(plaintext),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
} on InvalidKeyIdException catch (_) {
|
} on InvalidKeyIdException catch (_) {
|
||||||
return null; // got the same message again
|
return null; // got the same message again
|
||||||
} on DuplicateMessageException catch (_) {
|
} on DuplicateMessageException catch (_) {
|
||||||
|
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
import 'dart:convert';
|
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|
||||||
import 'package:hive/hive.dart';
|
|
||||||
import 'package:path_provider/path_provider.dart';
|
|
||||||
import 'package:twonly/src/services/notification.service.dart';
|
|
||||||
import 'package:twonly/src/utils/log.dart';
|
|
||||||
|
|
||||||
Future initMediaStorage() async {
|
|
||||||
final storage = FlutterSecureStorage();
|
|
||||||
var containsEncryptionKey =
|
|
||||||
await storage.containsKey(key: 'hive_encryption_key');
|
|
||||||
if (!containsEncryptionKey) {
|
|
||||||
var key = Hive.generateSecureKey();
|
|
||||||
await storage.write(
|
|
||||||
key: 'hive_encryption_key',
|
|
||||||
value: base64UrlEncode(key),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
final dir = await getApplicationSupportDirectory();
|
|
||||||
Hive.init(dir.path);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<Box> getMediaStorage() async {
|
|
||||||
try {
|
|
||||||
await initMediaStorage();
|
|
||||||
final storage = FlutterSecureStorage();
|
|
||||||
|
|
||||||
var encryptionKey =
|
|
||||||
base64Url.decode((await storage.read(key: 'hive_encryption_key'))!);
|
|
||||||
|
|
||||||
return await Hive.openBox(
|
|
||||||
'media_storage',
|
|
||||||
encryptionCipher: HiveAesCipher(encryptionKey),
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
await customLocalPushNotification("Secure Storage Error",
|
|
||||||
"Settings > Apps > twonly > Storage and Cache > Press clear on both");
|
|
||||||
Log.error(e);
|
|
||||||
throw Exception(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
44
lib/src/utils/keyvalue.dart
Normal file
44
lib/src/utils/keyvalue.dart
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:twonly/src/utils/log.dart';
|
||||||
|
|
||||||
|
class KeyValueStore {
|
||||||
|
static Future<String> _getFilePath(String key) async {
|
||||||
|
final directory = await getApplicationSupportDirectory();
|
||||||
|
return '${directory.path}/keyvalue/$key.json';
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<Map<String, dynamic>?> get(String key) async {
|
||||||
|
try {
|
||||||
|
final filePath = await _getFilePath(key);
|
||||||
|
final file = File(filePath);
|
||||||
|
|
||||||
|
// Check if the file exists
|
||||||
|
if (await file.exists()) {
|
||||||
|
final contents = await file.readAsString();
|
||||||
|
return jsonDecode(contents);
|
||||||
|
} else {
|
||||||
|
return null; // File does not exist
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
Log.error('Error reading file: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> put(String key, Map<String, dynamic> value) async {
|
||||||
|
try {
|
||||||
|
final filePath = await _getFilePath(key);
|
||||||
|
final file = File(filePath);
|
||||||
|
|
||||||
|
// Create the directory if it doesn't exist
|
||||||
|
await file.parent.create(recursive: true);
|
||||||
|
|
||||||
|
// Write the JSON data to the file
|
||||||
|
await file.writeAsString(jsonEncode(value));
|
||||||
|
} catch (e) {
|
||||||
|
Log.error('Error writing file: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -146,8 +146,10 @@ class _ChatMessagesViewState extends State<ChatMessagesView> {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (openedMessageOtherIds.isNotEmpty) {
|
if (openedMessageOtherIds.isNotEmpty) {
|
||||||
notifyContactAboutOpeningMessage(
|
await notifyContactAboutOpeningMessage(
|
||||||
widget.contact.userId, openedMessageOtherIds);
|
widget.contact.userId,
|
||||||
|
openedMessageOtherIds,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
twonlyDB.messagesDao.openedAllNonMediaMessages(widget.contact.userId);
|
twonlyDB.messagesDao.openedAllNonMediaMessages(widget.contact.userId);
|
||||||
|
|
|
||||||
|
|
@ -210,7 +210,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
notifyContactAboutOpeningMessage(
|
await notifyContactAboutOpeningMessage(
|
||||||
current.contactId,
|
current.contactId,
|
||||||
[current.messageOtherId!],
|
[current.messageOtherId!],
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -779,14 +779,6 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.3"
|
version: "3.0.3"
|
||||||
hive:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: hive
|
|
||||||
sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.2.3"
|
|
||||||
html:
|
html:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,6 @@ dependencies:
|
||||||
font_awesome_flutter: ^10.8.0
|
font_awesome_flutter: ^10.8.0
|
||||||
gal: ^2.3.1
|
gal: ^2.3.1
|
||||||
hand_signature: ^3.0.3
|
hand_signature: ^3.0.3
|
||||||
hive: ^2.2.3
|
|
||||||
image: ^4.3.0
|
image: ^4.3.0
|
||||||
intl: ^0.20.2
|
intl: ^0.20.2
|
||||||
introduction_screen: ^3.1.14
|
introduction_screen: ^3.1.14
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue