mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-01-15 06:28:41 +00:00
add flame counter test
Some checks failed
Flutter analyze & test / flutter_analyze_and_test (push) Has been cancelled
Some checks failed
Flutter analyze & test / flutter_analyze_and_test (push) Has been cancelled
This commit is contained in:
parent
ad0ef841cc
commit
585f577f89
42 changed files with 407 additions and 247 deletions
|
|
@ -99,7 +99,7 @@ class ContactsDao extends DatabaseAccessor<TwonlyDB> with _$ContactsDaoMixin {
|
|||
.watchSingleOrNull();
|
||||
}
|
||||
|
||||
Future<List<Contact>> getAllNotBlockedContacts() {
|
||||
Future<List<Contact>> getAllContacts() {
|
||||
return select(contacts).get();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import 'package:hashlib/random.dart';
|
|||
import 'package:twonly/globals.dart';
|
||||
import 'package:twonly/src/database/tables/groups.table.dart';
|
||||
import 'package:twonly/src/database/twonly.db.dart';
|
||||
import 'package:twonly/src/services/flame.service.dart';
|
||||
import 'package:twonly/src/utils/log.dart';
|
||||
import 'package:twonly/src/utils/misc.dart';
|
||||
|
||||
|
|
@ -278,89 +279,6 @@ class GroupsDao extends DatabaseAccessor<TwonlyDB> with _$GroupsDaoMixin {
|
|||
return query.map((row) => row.readTable(groups)).getSingleOrNull();
|
||||
}
|
||||
|
||||
Future<void> incFlameCounter(
|
||||
String groupId,
|
||||
bool received,
|
||||
DateTime timestamp,
|
||||
) async {
|
||||
final group = await (select(groups)
|
||||
..where((t) => t.groupId.equals(groupId)))
|
||||
.getSingle();
|
||||
|
||||
final totalMediaCounter = group.totalMediaCounter + 1;
|
||||
var flameCounter = group.flameCounter;
|
||||
var maxFlameCounter = group.maxFlameCounter;
|
||||
var maxFlameCounterFrom = group.maxFlameCounterFrom;
|
||||
|
||||
if (group.lastMessageReceived != null && group.lastMessageSend != null) {
|
||||
final now = DateTime.now();
|
||||
final startOfToday = DateTime(now.year, now.month, now.day);
|
||||
final twoDaysAgo = startOfToday.subtract(const Duration(days: 2));
|
||||
if (group.lastMessageSend!.isBefore(twoDaysAgo) ||
|
||||
group.lastMessageReceived!.isBefore(twoDaysAgo)) {
|
||||
flameCounter = 0;
|
||||
}
|
||||
}
|
||||
|
||||
var lastMessageSend = const Value<DateTime?>.absent();
|
||||
var lastMessageReceived = const Value<DateTime?>.absent();
|
||||
var lastFlameCounterChange = const Value<DateTime?>.absent();
|
||||
|
||||
if (group.lastFlameCounterChange != null) {
|
||||
final now = DateTime.now();
|
||||
final startOfToday = DateTime(now.year, now.month, now.day);
|
||||
|
||||
if (group.lastFlameCounterChange!.isBefore(startOfToday)) {
|
||||
// last flame update was yesterday. check if it can be updated.
|
||||
var updateFlame = false;
|
||||
if (received) {
|
||||
if (group.lastMessageSend != null &&
|
||||
group.lastMessageSend!.isAfter(startOfToday)) {
|
||||
// today a message was already send -> update flame
|
||||
updateFlame = true;
|
||||
}
|
||||
} else if (group.lastMessageReceived != null &&
|
||||
group.lastMessageReceived!.isAfter(startOfToday)) {
|
||||
// today a message was already received -> update flame
|
||||
updateFlame = true;
|
||||
}
|
||||
if (updateFlame) {
|
||||
flameCounter += 1;
|
||||
lastFlameCounterChange = Value(timestamp);
|
||||
// Overwrite max flame counter either the current is bigger or the th max flame counter is older then 4 days
|
||||
if (flameCounter >= maxFlameCounter ||
|
||||
maxFlameCounterFrom == null ||
|
||||
maxFlameCounterFrom
|
||||
.isBefore(DateTime.now().subtract(const Duration(days: 5)))) {
|
||||
maxFlameCounter = flameCounter;
|
||||
maxFlameCounterFrom = DateTime.now();
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// There where no message until no...
|
||||
lastFlameCounterChange = Value(timestamp);
|
||||
}
|
||||
|
||||
if (received) {
|
||||
lastMessageReceived = Value(timestamp);
|
||||
} else {
|
||||
lastMessageSend = Value(timestamp);
|
||||
}
|
||||
|
||||
await (update(groups)..where((t) => t.groupId.equals(groupId))).write(
|
||||
GroupsCompanion(
|
||||
totalMediaCounter: Value(totalMediaCounter),
|
||||
lastFlameCounterChange: lastFlameCounterChange,
|
||||
lastMessageReceived: lastMessageReceived,
|
||||
lastMessageSend: lastMessageSend,
|
||||
flameCounter: Value(flameCounter),
|
||||
maxFlameCounter: Value(maxFlameCounter),
|
||||
maxFlameCounterFrom: Value(maxFlameCounterFrom),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Stream<int> watchSumTotalMediaCounter() {
|
||||
final query = selectOnly(groups)
|
||||
..addColumns([groups.totalMediaCounter.sum()]);
|
||||
|
|
@ -383,23 +301,3 @@ class GroupsDao extends DatabaseAccessor<TwonlyDB> with _$GroupsDaoMixin {
|
|||
.write(GroupsCompanion(lastMessageExchange: Value(newLastMessage)));
|
||||
}
|
||||
}
|
||||
|
||||
int getFlameCounterFromGroup(Group? group) {
|
||||
if (group == null) return 0;
|
||||
if (group.lastMessageSend == null ||
|
||||
group.lastMessageReceived == null ||
|
||||
group.lastFlameCounterChange == null) {
|
||||
return 0;
|
||||
}
|
||||
final now = DateTime.now();
|
||||
final startOfToday = DateTime(now.year, now.month, now.day);
|
||||
final twoDaysAgo = startOfToday.subtract(const Duration(days: 2));
|
||||
final oneDayAgo = startOfToday.subtract(const Duration(days: 1));
|
||||
if (group.lastMessageSend!.isAfter(twoDaysAgo) &&
|
||||
group.lastMessageReceived!.isAfter(twoDaysAgo) ||
|
||||
group.lastFlameCounterChange!.isAfter(oneDayAgo)) {
|
||||
return group.flameCounter + 1;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import 'package:clock/clock.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:hashlib/random.dart';
|
||||
import 'package:twonly/globals.dart';
|
||||
|
|
@ -110,11 +111,11 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
|
|||
final allGroups = await select(groups).get();
|
||||
|
||||
for (final group in allGroups) {
|
||||
final deletionTime = DateTime.now().subtract(
|
||||
Duration(
|
||||
milliseconds: group.deleteMessagesAfterMilliseconds,
|
||||
),
|
||||
);
|
||||
final deletionTime = clock.now().subtract(
|
||||
Duration(
|
||||
milliseconds: group.deleteMessagesAfterMilliseconds,
|
||||
),
|
||||
);
|
||||
await (delete(messages)
|
||||
..where(
|
||||
(m) =>
|
||||
|
|
@ -150,7 +151,7 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
|
|||
// t.messageOtherId.isNull() &
|
||||
// t.errorWhileSending.equals(false) &
|
||||
// t.sendAt.isBiggerThanValue(
|
||||
// DateTime.now().subtract(const Duration(minutes: 10)),
|
||||
// clock.now().subtract(const Duration(minutes: 10)),
|
||||
// ),
|
||||
// ))
|
||||
// .get();
|
||||
|
|
@ -178,7 +179,7 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
|
|||
// }
|
||||
|
||||
Future<void> openedAllTextMessages(String groupId) {
|
||||
final updates = MessagesCompanion(openedAt: Value(DateTime.now()));
|
||||
final updates = MessagesCompanion(openedAt: Value(clock.now()));
|
||||
return (update(messages)
|
||||
..where(
|
||||
(t) =>
|
||||
|
|
@ -272,12 +273,12 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
|
|||
// Directly show as message opened as soon as one person has opened it
|
||||
final openedByAll =
|
||||
await haveAllMembers(messageId, MessageActionType.openedAt)
|
||||
? DateTime.now()
|
||||
? clock.now()
|
||||
: null;
|
||||
await twonlyDB.messagesDao.updateMessageId(
|
||||
messageId,
|
||||
MessagesCompanion(
|
||||
openedAt: Value(DateTime.now()),
|
||||
openedAt: Value(clock.now()),
|
||||
openedByAll: Value(openedByAll),
|
||||
),
|
||||
);
|
||||
|
|
@ -298,7 +299,7 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
|
|||
);
|
||||
await twonlyDB.messagesDao.updateMessageId(
|
||||
messageId,
|
||||
MessagesCompanion(ackByServer: Value(DateTime.now())),
|
||||
MessagesCompanion(ackByServer: Value(clock.now())),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -378,7 +379,7 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
|
|||
await twonlyDB.groupsDao.updateGroup(
|
||||
message.groupId.value,
|
||||
GroupsCompanion(
|
||||
lastMessageExchange: Value(DateTime.now()),
|
||||
lastMessageExchange: Value(clock.now()),
|
||||
archived: const Value(false),
|
||||
deletedContent: const Value(false),
|
||||
),
|
||||
|
|
@ -389,7 +390,7 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
|
|||
message.groupId.value,
|
||||
message.senderId.value!,
|
||||
GroupMembersCompanion(
|
||||
lastMessage: Value(DateTime.now()),
|
||||
lastMessage: Value(clock.now()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import 'package:clock/clock.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:hashlib/random.dart';
|
||||
import 'package:twonly/src/database/tables/messages.table.dart';
|
||||
|
|
@ -81,12 +82,12 @@ class ReceiptsDao extends DatabaseAccessor<TwonlyDB> with _$ReceiptsDaoMixin {
|
|||
}
|
||||
|
||||
Future<List<Receipt>> getReceiptsForRetransmission() async {
|
||||
final markedRetriesTime = DateTime.now().subtract(
|
||||
const Duration(
|
||||
// give the server time to transmit all messages to the client
|
||||
seconds: 20,
|
||||
),
|
||||
);
|
||||
final markedRetriesTime = clock.now().subtract(
|
||||
const Duration(
|
||||
// give the server time to transmit all messages to the client
|
||||
seconds: 20,
|
||||
),
|
||||
);
|
||||
return (select(receipts)
|
||||
..where(
|
||||
(t) =>
|
||||
|
|
@ -111,7 +112,7 @@ class ReceiptsDao extends DatabaseAccessor<TwonlyDB> with _$ReceiptsDaoMixin {
|
|||
Future<void> markMessagesForRetry(int contactId) async {
|
||||
await (update(receipts)..where((c) => c.contactId.equals(contactId))).write(
|
||||
ReceiptsCompanion(
|
||||
markForRetry: Value(DateTime.now()),
|
||||
markForRetry: Value(clock.now()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import 'package:clock/clock.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:twonly/globals.dart';
|
||||
import 'package:twonly/src/database/tables/signal_contact_prekey.table.dart';
|
||||
|
|
@ -107,9 +108,9 @@ class SignalDao extends DatabaseAccessor<TwonlyDB> with _$SignalDaoMixin {
|
|||
await (delete(signalContactPreKeys)
|
||||
..where(
|
||||
(t) => (t.createdAt.isSmallerThanValue(
|
||||
DateTime.now().subtract(
|
||||
const Duration(days: 100),
|
||||
),
|
||||
clock.now().subtract(
|
||||
const Duration(days: 100),
|
||||
),
|
||||
)),
|
||||
))
|
||||
.go();
|
||||
|
|
@ -117,9 +118,9 @@ class SignalDao extends DatabaseAccessor<TwonlyDB> with _$SignalDaoMixin {
|
|||
await (delete(twonlyDB.signalPreKeyStores)
|
||||
..where(
|
||||
(t) => (t.createdAt.isSmallerThanValue(
|
||||
DateTime.now().subtract(
|
||||
const Duration(days: 365),
|
||||
),
|
||||
clock.now().subtract(
|
||||
const Duration(days: 365),
|
||||
),
|
||||
)),
|
||||
))
|
||||
.go();
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import 'package:clock/clock.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:drift_flutter/drift_flutter.dart'
|
||||
show DriftNativeOptions, driftDatabase;
|
||||
|
|
@ -160,9 +161,9 @@ class TwonlyDB extends _$TwonlyDB {
|
|||
await (delete(signalPreKeyStores)
|
||||
..where(
|
||||
(t) => (t.createdAt.isSmallerThanValue(
|
||||
DateTime.now().subtract(
|
||||
const Duration(days: 25),
|
||||
),
|
||||
clock.now().subtract(
|
||||
const Duration(days: 25),
|
||||
),
|
||||
)),
|
||||
))
|
||||
.go();
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import 'package:clock/clock.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:drift_flutter/drift_flutter.dart'
|
||||
show DriftNativeOptions, driftDatabase;
|
||||
|
|
@ -191,9 +192,9 @@ class TwonlyDatabaseOld extends _$TwonlyDatabaseOld {
|
|||
await (delete(signalPreKeyStores)
|
||||
..where(
|
||||
(t) => (t.createdAt.isSmallerThanValue(
|
||||
DateTime.now().subtract(
|
||||
const Duration(days: 25),
|
||||
),
|
||||
clock.now().subtract(
|
||||
const Duration(days: 25),
|
||||
),
|
||||
)),
|
||||
))
|
||||
.go();
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import 'dart:io';
|
|||
import 'dart:math';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:fixnum/fixnum.dart';
|
||||
|
|
@ -206,7 +207,7 @@ class ApiService {
|
|||
}
|
||||
|
||||
Future<server.ServerToClient?> _waitForResponse(Int64 seq) async {
|
||||
final startTime = DateTime.now();
|
||||
final startTime = clock.now();
|
||||
|
||||
const timeout = Duration(seconds: 60);
|
||||
|
||||
|
|
@ -216,7 +217,7 @@ class ApiService {
|
|||
messagesV0.remove(seq);
|
||||
return tmp;
|
||||
}
|
||||
if (DateTime.now().difference(startTime) > timeout) {
|
||||
if (clock.now().difference(startTime) > timeout) {
|
||||
Log.warn('Timeout for message $seq');
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import 'package:twonly/src/database/twonly.db.dart';
|
|||
import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart';
|
||||
import 'package:twonly/src/services/api/mediafiles/download.service.dart';
|
||||
import 'package:twonly/src/services/api/utils.dart';
|
||||
import 'package:twonly/src/services/flame.service.dart';
|
||||
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
|
||||
import 'package:twonly/src/utils/log.dart';
|
||||
|
||||
|
|
@ -114,7 +115,7 @@ Future<void> handleMedia(
|
|||
await twonlyDB.groupsDao
|
||||
.increaseLastMessageExchange(groupId, fromTimestamp(media.timestamp));
|
||||
Log.info('Inserted a new media message with ID: ${message.messageId}');
|
||||
await twonlyDB.groupsDao.incFlameCounter(
|
||||
await incFlameCounter(
|
||||
message.groupId,
|
||||
true,
|
||||
fromTimestamp(media.timestamp),
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart';
|
||||
import 'package:twonly/src/services/notifications/pushkeys.notifications.dart';
|
||||
import 'package:twonly/src/utils/log.dart';
|
||||
|
||||
DateTime lastPushKeyRequest = DateTime.now().subtract(const Duration(hours: 1));
|
||||
DateTime lastPushKeyRequest = clock.now().subtract(const Duration(hours: 1));
|
||||
|
||||
Future<void> handlePushKey(
|
||||
int contactId,
|
||||
|
|
@ -14,8 +15,8 @@ Future<void> handlePushKey(
|
|||
case EncryptedContent_PushKeys_Type.REQUEST:
|
||||
Log.info('Got a pushkey request from $contactId');
|
||||
if (lastPushKeyRequest
|
||||
.isBefore(DateTime.now().subtract(const Duration(seconds: 60)))) {
|
||||
lastPushKeyRequest = DateTime.now();
|
||||
.isBefore(clock.now().subtract(const Duration(seconds: 60)))) {
|
||||
lastPushKeyRequest = clock.now();
|
||||
unawaited(setupNotificationWithUsers(forceContact: contactId));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import 'package:clock/clock.dart';
|
||||
import 'package:twonly/globals.dart';
|
||||
import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart';
|
||||
import 'package:twonly/src/utils/log.dart';
|
||||
|
|
@ -17,7 +18,6 @@ Future<void> handleReaction(
|
|||
);
|
||||
|
||||
if (!reaction.remove) {
|
||||
await twonlyDB.groupsDao
|
||||
.increaseLastMessageExchange(groupId, DateTime.now());
|
||||
await twonlyDB.groupsDao.increaseLastMessageExchange(groupId, clock.now());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import 'package:clock/clock.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:twonly/globals.dart';
|
||||
import 'package:twonly/src/database/tables/messages.table.dart';
|
||||
|
|
@ -26,7 +27,7 @@ Future<void> handleTextMessage(
|
|||
textMessage.hasQuoteMessageId() ? textMessage.quoteMessageId : null,
|
||||
),
|
||||
createdAt: Value(fromTimestamp(textMessage.timestamp)),
|
||||
ackByServer: Value(DateTime.now()),
|
||||
ackByServer: Value(clock.now()),
|
||||
),
|
||||
);
|
||||
await twonlyDB.groupsDao.increaseLastMessageExchange(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import 'dart:async';
|
||||
import 'package:background_downloader/background_downloader.dart';
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:drift/drift.dart' show Value;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:twonly/globals.dart';
|
||||
|
|
@ -92,7 +93,7 @@ Future<void> handleUploadStatusUpdate(TaskStatusUpdate update) async {
|
|||
await twonlyDB.messagesDao.handleMessageAckByServer(
|
||||
contact.contactId,
|
||||
message.messageId,
|
||||
DateTime.now(),
|
||||
clock.now(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:background_downloader/background_downloader.dart';
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:cryptography_flutter_plus/cryptography_flutter_plus.dart';
|
||||
import 'package:cryptography_plus/cryptography_plus.dart';
|
||||
|
|
@ -18,6 +19,7 @@ import 'package:twonly/src/model/protobuf/api/http/http_requests.pb.dart';
|
|||
import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart';
|
||||
import 'package:twonly/src/services/api/mediafiles/media_background.service.dart';
|
||||
import 'package:twonly/src/services/api/messages.dart';
|
||||
import 'package:twonly/src/services/flame.service.dart';
|
||||
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
|
||||
import 'package:twonly/src/utils/log.dart';
|
||||
import 'package:twonly/src/utils/misc.dart';
|
||||
|
|
@ -101,8 +103,7 @@ Future<void> insertMediaFileInMessagesTable(
|
|||
type: const Value(MessageType.media),
|
||||
),
|
||||
);
|
||||
await twonlyDB.groupsDao
|
||||
.increaseLastMessageExchange(groupId, DateTime.now());
|
||||
await twonlyDB.groupsDao.increaseLastMessageExchange(groupId, clock.now());
|
||||
if (message != null) {
|
||||
// de-archive contact when sending a new message
|
||||
await twonlyDB.groupsDao.updateGroup(
|
||||
|
|
@ -207,11 +208,7 @@ Future<void> _createUploadRequest(MediaFileService media) async {
|
|||
await twonlyDB.groupsDao.getGroupNonLeftMembers(message.groupId);
|
||||
|
||||
if (media.mediaFile.reuploadRequestedBy == null) {
|
||||
await twonlyDB.groupsDao.incFlameCounter(
|
||||
message.groupId,
|
||||
false,
|
||||
message.createdAt,
|
||||
);
|
||||
await incFlameCounter(message.groupId, false, message.createdAt);
|
||||
}
|
||||
|
||||
for (final groupMember in groupMembers) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:fixnum/fixnum.dart';
|
||||
import 'package:libsignal_protocol_dart/libsignal_protocol_dart.dart';
|
||||
|
|
@ -131,7 +132,7 @@ Future<(Uint8List, Uint8List?)?> tryToSendCompleteMessage({
|
|||
await twonlyDB.messagesDao.handleMessageAckByServer(
|
||||
receipt.contactId,
|
||||
receipt.messageId!,
|
||||
DateTime.now(),
|
||||
clock.now(),
|
||||
);
|
||||
}
|
||||
if (!receipt.contactWillSendsReceipt) {
|
||||
|
|
@ -140,9 +141,9 @@ Future<(Uint8List, Uint8List?)?> tryToSendCompleteMessage({
|
|||
await twonlyDB.receiptsDao.updateReceipt(
|
||||
receiptId,
|
||||
ReceiptsCompanion(
|
||||
ackByServerAt: Value(DateTime.now()),
|
||||
ackByServerAt: Value(clock.now()),
|
||||
retryCount: Value(receipt.retryCount + 1),
|
||||
lastRetry: Value(DateTime.now()),
|
||||
lastRetry: Value(clock.now()),
|
||||
markForRetry: const Value(null),
|
||||
),
|
||||
);
|
||||
|
|
@ -210,7 +211,7 @@ Future<void> sendCipherTextToGroup(
|
|||
}) async {
|
||||
final groupMembers = await twonlyDB.groupsDao.getGroupNonLeftMembers(groupId);
|
||||
|
||||
await twonlyDB.groupsDao.increaseLastMessageExchange(groupId, DateTime.now());
|
||||
await twonlyDB.groupsDao.increaseLastMessageExchange(groupId, clock.now());
|
||||
|
||||
encryptedContent.groupId = groupId;
|
||||
|
||||
|
|
@ -242,7 +243,7 @@ Future<(Uint8List, Uint8List?)?> sendCipherText(
|
|||
contactId: Value(contactId),
|
||||
message: Value(response.writeToBuffer()),
|
||||
messageId: Value(messageId),
|
||||
ackByServerAt: Value(onlyReturnEncryptedData ? DateTime.now() : null),
|
||||
ackByServerAt: Value(onlyReturnEncryptedData ? clock.now() : null),
|
||||
),
|
||||
);
|
||||
|
||||
|
|
@ -268,7 +269,7 @@ Future<void> notifyContactAboutOpeningMessage(
|
|||
}
|
||||
Log.info('Opened messages: $messageOtherIds');
|
||||
|
||||
final actionAt = DateTime.now();
|
||||
final actionAt = clock.now();
|
||||
|
||||
await sendCipherText(
|
||||
contactId,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:hashlib/random.dart';
|
||||
import 'package:mutex/mutex.dart';
|
||||
|
|
@ -60,7 +61,7 @@ Future<void> handleServerMessage(server.ServerToClient msg) async {
|
|||
await apiService.sendResponse(ClientToServer()..v0 = v0);
|
||||
}
|
||||
|
||||
DateTime lastPushKeyRequest = DateTime.now().subtract(const Duration(hours: 1));
|
||||
DateTime lastPushKeyRequest = clock.now().subtract(const Duration(hours: 1));
|
||||
|
||||
Mutex protectReceiptCheck = Mutex();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import 'package:clock/clock.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:fixnum/fixnum.dart';
|
||||
import 'package:twonly/globals.dart';
|
||||
import 'package:twonly/src/database/daos/groups.dao.dart';
|
||||
import 'package:twonly/src/database/twonly.db.dart';
|
||||
import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart';
|
||||
import 'package:twonly/src/services/api/messages.dart';
|
||||
|
|
@ -32,10 +32,10 @@ Future<void> syncFlameCounters({String? forceForGroup}) async {
|
|||
}
|
||||
}
|
||||
|
||||
final flameCounter = getFlameCounterFromGroup(group) - 1;
|
||||
final flameCounter = getFlameCounterFromGroup(group);
|
||||
|
||||
// only sync when flame counter is higher than three days or when they are bestFriends
|
||||
if (flameCounter < 1 && bestFriend.groupId != group.groupId) continue;
|
||||
// only sync when flame counter is higher three or when they are bestFriends
|
||||
if (flameCounter <= 2 && bestFriend.groupId != group.groupId) continue;
|
||||
|
||||
final groupMembers =
|
||||
await twonlyDB.groupsDao.getGroupNonLeftMembers(group.groupId);
|
||||
|
|
@ -59,8 +59,125 @@ Future<void> syncFlameCounters({String? forceForGroup}) async {
|
|||
await twonlyDB.groupsDao.updateGroup(
|
||||
group.groupId,
|
||||
GroupsCompanion(
|
||||
lastFlameSync: Value(DateTime.now()),
|
||||
lastFlameSync: Value(clock.now()),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
int getFlameCounterFromGroup(Group? group) {
|
||||
if (group == null) return 0;
|
||||
if (group.lastMessageSend == null ||
|
||||
group.lastMessageReceived == null ||
|
||||
group.lastFlameCounterChange == null) {
|
||||
return 0;
|
||||
}
|
||||
final now = clock.now();
|
||||
final startOfToday = DateTime(now.year, now.month, now.day);
|
||||
final twoDaysAgo = startOfToday.subtract(const Duration(days: 2));
|
||||
final oneDayAgo = startOfToday.subtract(const Duration(days: 1));
|
||||
if (group.lastMessageSend!.isAfter(twoDaysAgo) &&
|
||||
group.lastMessageReceived!.isAfter(twoDaysAgo) ||
|
||||
group.lastFlameCounterChange!.isAfter(oneDayAgo)) {
|
||||
return group.flameCounter;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> incFlameCounter(
|
||||
String groupId,
|
||||
bool received,
|
||||
DateTime timestamp,
|
||||
) async {
|
||||
final group = await twonlyDB.groupsDao.getGroup(groupId);
|
||||
if (group == null) return;
|
||||
|
||||
final totalMediaCounter = group.totalMediaCounter + 1;
|
||||
var flameCounter = group.flameCounter;
|
||||
var maxFlameCounter = group.maxFlameCounter;
|
||||
var maxFlameCounterFrom = group.maxFlameCounterFrom;
|
||||
|
||||
if (group.lastMessageReceived != null && group.lastMessageSend != null) {
|
||||
final now = clock.now();
|
||||
final startOfToday = DateTime(now.year, now.month, now.day);
|
||||
final twoDaysAgo = startOfToday.subtract(const Duration(days: 2));
|
||||
if (group.lastMessageSend!.isBefore(twoDaysAgo) ||
|
||||
group.lastMessageReceived!.isBefore(twoDaysAgo)) {
|
||||
flameCounter = 0;
|
||||
}
|
||||
}
|
||||
|
||||
var lastMessageSend = const Value<DateTime?>.absent();
|
||||
var lastMessageReceived = const Value<DateTime?>.absent();
|
||||
var lastFlameCounterChange = const Value<DateTime?>.absent();
|
||||
|
||||
final now = clock.now();
|
||||
final startOfToday = DateTime(now.year, now.month, now.day);
|
||||
|
||||
if (group.lastFlameCounterChange == null ||
|
||||
group.lastFlameCounterChange!.isBefore(startOfToday)) {
|
||||
// last flame update was yesterday. check if it can be updated.
|
||||
var updateFlame = false;
|
||||
if (received) {
|
||||
if (group.lastMessageSend != null &&
|
||||
group.lastMessageSend!.isAfter(startOfToday)) {
|
||||
// today a message was already send -> update flame
|
||||
updateFlame = true;
|
||||
}
|
||||
} else if (group.lastMessageReceived != null &&
|
||||
group.lastMessageReceived!.isAfter(startOfToday)) {
|
||||
// today a message was already received -> update flame
|
||||
updateFlame = true;
|
||||
}
|
||||
if (updateFlame) {
|
||||
flameCounter += 1;
|
||||
if (group.lastFlameCounterChange == null ||
|
||||
group.lastFlameCounterChange!.isBefore(timestamp)) {
|
||||
// only update if the timestamp is newer
|
||||
lastFlameCounterChange = Value(timestamp);
|
||||
}
|
||||
// Overwrite max flame counter either the current is bigger or the the max flame counter is older then 4 days
|
||||
if (flameCounter >= maxFlameCounter ||
|
||||
maxFlameCounterFrom == null ||
|
||||
maxFlameCounterFrom
|
||||
.isBefore(clock.now().subtract(const Duration(days: 5)))) {
|
||||
maxFlameCounter = flameCounter;
|
||||
maxFlameCounterFrom = clock.now();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (received) {
|
||||
if (group.lastMessageReceived == null ||
|
||||
group.lastMessageReceived!.isBefore(timestamp)) {
|
||||
lastMessageReceived = Value(timestamp);
|
||||
}
|
||||
} else {
|
||||
if (group.lastMessageSend == null ||
|
||||
group.lastMessageSend!.isBefore(timestamp)) {
|
||||
lastMessageSend = Value(timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
await twonlyDB.groupsDao.updateGroup(
|
||||
groupId,
|
||||
GroupsCompanion(
|
||||
totalMediaCounter: Value(totalMediaCounter),
|
||||
lastFlameCounterChange: lastFlameCounterChange,
|
||||
lastMessageReceived: lastMessageReceived,
|
||||
lastMessageSend: lastMessageSend,
|
||||
flameCounter: Value(flameCounter),
|
||||
maxFlameCounter: Value(maxFlameCounter),
|
||||
maxFlameCounterFrom: Value(maxFlameCounterFrom),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool isItPossibleToRestoreFlames(Group group) {
|
||||
final flameCounter = getFlameCounterFromGroup(group);
|
||||
return group.maxFlameCounter > 2 &&
|
||||
flameCounter < group.maxFlameCounter &&
|
||||
group.maxFlameCounterFrom!
|
||||
.isAfter(clock.now().subtract(const Duration(days: 5)));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:cryptography_flutter_plus/cryptography_flutter_plus.dart';
|
||||
import 'package:cryptography_plus/cryptography_plus.dart';
|
||||
|
|
@ -158,7 +159,7 @@ Future<void> fetchMissingGroupPublicKey() async {
|
|||
if (member.lastMessage == null) continue;
|
||||
// only request if the users has send a message in the last two days.
|
||||
if (member.lastMessage!
|
||||
.isAfter(DateTime.now().subtract(const Duration(days: 2)))) {
|
||||
.isAfter(clock.now().subtract(const Duration(days: 2)))) {
|
||||
await sendCipherText(
|
||||
member.contactId,
|
||||
EncryptedContent(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:path/path.dart';
|
||||
import 'package:twonly/globals.dart';
|
||||
|
|
@ -61,7 +62,7 @@ class MediaFileService {
|
|||
// delete = true; // do not overwrite a previous delete = false
|
||||
// this is just to make it easier to understand :)
|
||||
} else if (message.openedAt!
|
||||
.isAfter(DateTime.now().subtract(const Duration(days: 2)))) {
|
||||
.isAfter(clock.now().subtract(const Duration(days: 2)))) {
|
||||
// In case the image was opened, but send with unlimited time or no authentication.
|
||||
if (message.senderId == null) {
|
||||
delete = false;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:cryptography_flutter_plus/cryptography_flutter_plus.dart';
|
||||
import 'package:cryptography_plus/cryptography_plus.dart';
|
||||
|
|
@ -46,7 +47,7 @@ Future<void> setupNotificationWithUsers({
|
|||
|
||||
final random = Random.secure();
|
||||
|
||||
final contacts = await twonlyDB.contactsDao.getAllNotBlockedContacts();
|
||||
final contacts = await twonlyDB.contactsDao.getAllContacts();
|
||||
for (final contact in contacts) {
|
||||
final pushUser =
|
||||
pushUsers.firstWhereOrNull((x) => x.userId == contact.userId);
|
||||
|
|
@ -54,7 +55,7 @@ Future<void> setupNotificationWithUsers({
|
|||
if (pushUser != null && pushUser.pushKeys.isNotEmpty) {
|
||||
// make it harder to predict the change of the key
|
||||
final timeBefore =
|
||||
DateTime.now().subtract(Duration(days: 5 + random.nextInt(5)));
|
||||
clock.now().subtract(Duration(days: 10 + random.nextInt(5)));
|
||||
final lastKey = pushUser.pushKeys.last;
|
||||
final createdAt = DateTime.fromMillisecondsSinceEpoch(
|
||||
lastKey.createdAtUnixTimestamp.toInt(),
|
||||
|
|
@ -66,7 +67,7 @@ Future<void> setupNotificationWithUsers({
|
|||
final pushKey = PushKey(
|
||||
id: lastKey.id + random.nextInt(5),
|
||||
key: List<int>.generate(32, (index) => random.nextInt(256)),
|
||||
createdAtUnixTimestamp: Int64(DateTime.now().millisecondsSinceEpoch),
|
||||
createdAtUnixTimestamp: Int64(clock.now().millisecondsSinceEpoch),
|
||||
);
|
||||
await sendNewPushKey(contact.userId, pushKey);
|
||||
// only store a maximum of two keys
|
||||
|
|
@ -86,7 +87,7 @@ Future<void> setupNotificationWithUsers({
|
|||
final pushKey = PushKey(
|
||||
id: Int64(1),
|
||||
key: List<int>.generate(32, (index) => random.nextInt(256)),
|
||||
createdAtUnixTimestamp: Int64(DateTime.now().millisecondsSinceEpoch),
|
||||
createdAtUnixTimestamp: Int64(clock.now().millisecondsSinceEpoch),
|
||||
);
|
||||
await sendNewPushKey(contact.userId, pushKey);
|
||||
pushUsers.add(
|
||||
|
|
@ -173,7 +174,7 @@ Future<void> handleNewPushKey(int fromUserId, int keyId, List<int> key) async {
|
|||
PushKey(
|
||||
id: Int64(keyId),
|
||||
key: key,
|
||||
createdAtUnixTimestamp: Int64(DateTime.now().millisecondsSinceEpoch),
|
||||
createdAtUnixTimestamp: Int64(clock.now().millisecondsSinceEpoch),
|
||||
),
|
||||
);
|
||||
|
||||
|
|
@ -341,7 +342,7 @@ Future<Uint8List?> encryptPushNotification(
|
|||
final createdAt = DateTime.fromMillisecondsSinceEpoch(
|
||||
pushUser.pushKeys.last.createdAtUnixTimestamp.toInt(),
|
||||
);
|
||||
final timeBefore = DateTime.now().subtract(const Duration(days: 8));
|
||||
final timeBefore = clock.now().subtract(const Duration(days: 8));
|
||||
if (createdAt.isBefore(timeBefore)) {
|
||||
await requestNewPushKeysForUser(toUserId);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:libsignal_protocol_dart/libsignal_protocol_dart.dart';
|
||||
import 'package:twonly/globals.dart';
|
||||
|
|
@ -21,8 +21,7 @@ Future<IdentityKeyPair?> getSignalIdentityKeyPair() async {
|
|||
// It then checks if it should update a new session key
|
||||
Future<void> signalHandleNewServerConnection() async {
|
||||
if (gUser.signalLastSignedPreKeyUpdated != null) {
|
||||
final fortyEightHoursAgo =
|
||||
DateTime.now().subtract(const Duration(hours: 48));
|
||||
final fortyEightHoursAgo = clock.now().subtract(const Duration(hours: 48));
|
||||
final isYoungerThan48Hours =
|
||||
(gUser.signalLastSignedPreKeyUpdated!).isAfter(fortyEightHoursAgo);
|
||||
if (isYoungerThan48Hours) {
|
||||
|
|
@ -36,7 +35,7 @@ Future<void> signalHandleNewServerConnection() async {
|
|||
return;
|
||||
}
|
||||
await updateUserdata((user) {
|
||||
user.signalLastSignedPreKeyUpdated = DateTime.now();
|
||||
user.signalLastSignedPreKeyUpdated = clock.now();
|
||||
return user;
|
||||
});
|
||||
final res = await apiService.updateSignedPreKey(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:mutex/mutex.dart';
|
||||
import 'package:twonly/globals.dart';
|
||||
|
|
@ -22,17 +23,17 @@ class OtherPreKeys {
|
|||
}
|
||||
|
||||
Mutex requestNewKeys = Mutex();
|
||||
DateTime lastPreKeyRequest = DateTime.now().subtract(const Duration(hours: 1));
|
||||
DateTime lastPreKeyRequest = clock.now().subtract(const Duration(hours: 1));
|
||||
DateTime lastSignedPreKeyRequest =
|
||||
DateTime.now().subtract(const Duration(hours: 1));
|
||||
clock.now().subtract(const Duration(hours: 1));
|
||||
|
||||
Future<void> requestNewPrekeysForContact(int contactId) async {
|
||||
if (lastPreKeyRequest
|
||||
.isAfter(DateTime.now().subtract(const Duration(seconds: 60)))) {
|
||||
.isAfter(clock.now().subtract(const Duration(seconds: 60)))) {
|
||||
return;
|
||||
}
|
||||
Log.info('[PREKEY] Requesting new PREKEYS for $contactId');
|
||||
lastPreKeyRequest = DateTime.now();
|
||||
lastPreKeyRequest = clock.now();
|
||||
await requestNewKeys.protect(() async {
|
||||
final otherKeys = await apiService.getPreKeysByUserId(contactId);
|
||||
if (otherKeys != null) {
|
||||
|
|
@ -65,11 +66,11 @@ Future<SignalContactPreKey?> getPreKeyByContactId(int contactId) async {
|
|||
|
||||
Future<void> requestNewSignedPreKeyForContact(int contactId) async {
|
||||
if (lastSignedPreKeyRequest
|
||||
.isAfter(DateTime.now().subtract(const Duration(seconds: 60)))) {
|
||||
.isAfter(clock.now().subtract(const Duration(seconds: 60)))) {
|
||||
Log.info('last signed pre request was 60s before');
|
||||
return;
|
||||
}
|
||||
lastSignedPreKeyRequest = DateTime.now();
|
||||
lastSignedPreKeyRequest = clock.now();
|
||||
await requestNewKeys.protect(() async {
|
||||
final signedPreKey = await apiService.getSignedKeyByUserId(contactId);
|
||||
if (signedPreKey != null) {
|
||||
|
|
@ -96,8 +97,7 @@ Future<SignalContactSignedPreKey?> getSignedPreKeyByContactId(
|
|||
await twonlyDB.signalDao.getSignedPreKeyByContactId(contactId);
|
||||
|
||||
if (signedPreKey != null) {
|
||||
final fortyEightHoursAgo =
|
||||
DateTime.now().subtract(const Duration(hours: 48));
|
||||
final fortyEightHoursAgo = clock.now().subtract(const Duration(hours: 48));
|
||||
final isOlderThan48Hours =
|
||||
signedPreKey.createdAt.isBefore(fortyEightHoursAgo);
|
||||
if (isOlderThan48Hours) {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:background_downloader/background_downloader.dart';
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:cryptography_flutter_plus/cryptography_flutter_plus.dart';
|
||||
import 'package:cryptography_plus/cryptography_plus.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
|
|
@ -34,8 +35,7 @@ Future<void> performTwonlySafeBackup({bool force = false}) async {
|
|||
|
||||
final lastUpdateTime = gUser.twonlySafeBackup!.lastBackupDone;
|
||||
if (!force && lastUpdateTime != null) {
|
||||
if (lastUpdateTime
|
||||
.isAfter(DateTime.now().subtract(const Duration(days: 1)))) {
|
||||
if (lastUpdateTime.isAfter(clock.now().subtract(const Duration(days: 1)))) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -118,7 +118,7 @@ Future<void> performTwonlySafeBackup({bool force = false}) async {
|
|||
|
||||
if (gUser.twonlySafeBackup!.lastBackupDone == null ||
|
||||
gUser.twonlySafeBackup!.lastBackupDone!
|
||||
.isAfter(DateTime.now().subtract(const Duration(days: 90)))) {
|
||||
.isAfter(clock.now().subtract(const Duration(days: 90)))) {
|
||||
force = true;
|
||||
}
|
||||
|
||||
|
|
@ -190,7 +190,7 @@ Future<void> performTwonlySafeBackup({bool force = false}) async {
|
|||
Log.info('Starting upload from twonly Backup.');
|
||||
await updateUserdata((user) {
|
||||
user.twonlySafeBackup!.backupUploadState = LastBackupUploadState.pending;
|
||||
user.twonlySafeBackup!.lastBackupDone = DateTime.now();
|
||||
user.twonlySafeBackup!.lastBackupDone = clock.now();
|
||||
user.twonlySafeBackup!.lastBackupSize = encryptedBackupBytes.length;
|
||||
return user;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import 'package:twonly/globals.dart';
|
|||
import 'package:twonly/src/utils/misc.dart';
|
||||
|
||||
Future<void> createPushAvatars() async {
|
||||
final contacts = await twonlyDB.contactsDao.getAllNotBlockedContacts();
|
||||
final contacts = await twonlyDB.contactsDao.getAllContacts();
|
||||
|
||||
for (final contact in contacts) {
|
||||
if (contact.avatarSvgCompressed == null) continue;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import 'dart:io';
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:mutex/mutex.dart';
|
||||
|
|
@ -96,7 +97,7 @@ Future<void> _writeLogToFile(LogRecord record) async {
|
|||
|
||||
// Prepare the log message
|
||||
final logMessage =
|
||||
'${DateTime.now().toString().split(".")[0]} ${record.level.name} [twonly] ${record.loggerName} > ${record.message}\n';
|
||||
'${clock.now().toString().split(".")[0]} ${record.level.name} [twonly] ${record.loggerName} > ${record.message}\n';
|
||||
|
||||
await writeToLogGuard.protect(() async {
|
||||
// Append the log message to the file
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:convert/convert.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
|
@ -197,7 +198,7 @@ bool isDarkMode(BuildContext context) {
|
|||
}
|
||||
|
||||
bool isToday(DateTime lastImageSend) {
|
||||
final now = DateTime.now();
|
||||
final now = clock.now();
|
||||
return lastImageSend.year == now.year &&
|
||||
lastImageSend.month == now.month &&
|
||||
lastImageSend.day == now.day;
|
||||
|
|
@ -235,7 +236,7 @@ String formatDateTime(BuildContext context, DateTime? dateTime) {
|
|||
if (dateTime == null) {
|
||||
return 'Never';
|
||||
}
|
||||
final now = DateTime.now();
|
||||
final now = clock.now();
|
||||
final difference = now.difference(dateTime);
|
||||
|
||||
final date = DateFormat.yMd(Localizations.localeOf(context).toLanguageTag())
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:camera/camera.dart';
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
|
@ -165,7 +166,7 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
|
|||
DateTime? _videoRecordingStarted;
|
||||
Timer? _videoRecordingTimer;
|
||||
|
||||
DateTime _currentTime = DateTime.now();
|
||||
DateTime _currentTime = clock.now();
|
||||
final GlobalKey keyTriggerButton = GlobalKey();
|
||||
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
|
||||
|
||||
|
|
@ -519,7 +520,7 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
|
|||
_videoRecordingTimer =
|
||||
Timer.periodic(const Duration(milliseconds: 15), (timer) {
|
||||
setState(() {
|
||||
_currentTime = DateTime.now();
|
||||
_currentTime = clock.now();
|
||||
});
|
||||
if (_videoRecordingStarted != null &&
|
||||
_currentTime.difference(_videoRecordingStarted!).inSeconds >=
|
||||
|
|
@ -530,7 +531,7 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
|
|||
}
|
||||
});
|
||||
setState(() {
|
||||
_videoRecordingStarted = DateTime.now();
|
||||
_videoRecordingStarted = clock.now();
|
||||
_isVideoRecording = true;
|
||||
});
|
||||
} on CameraException catch (e) {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import 'package:clock/clock.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class VideoRecordingTimer extends StatelessWidget {
|
||||
|
|
@ -12,7 +13,7 @@ class VideoRecordingTimer extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (videoRecordingStarted != null) {
|
||||
final currentTime = DateTime.now();
|
||||
final currentTime = clock.now();
|
||||
return Positioned(
|
||||
top: 50,
|
||||
left: 0,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import 'package:clock/clock.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:twonly/src/views/camera/image_editor/layers/filter_layer.dart';
|
||||
|
|
@ -9,8 +10,8 @@ class DateTimeFilter extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final currentTime = DateFormat('HH:mm').format(DateTime.now());
|
||||
final currentDate = DateFormat('dd.MM.yyyy').format(DateTime.now());
|
||||
final currentTime = DateFormat('HH:mm').format(clock.now());
|
||||
final currentDate = DateFormat('dd.MM.yyyy').format(clock.now());
|
||||
return FilterSkeleton(
|
||||
child: Positioned(
|
||||
bottom: 80,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import 'dart:convert';
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
|
@ -133,7 +134,7 @@ Future<List<Sticker>> getStickerIndex() async {
|
|||
|
||||
if (indexFile.existsSync() && kReleaseMode) {
|
||||
final lastModified = indexFile.lastModifiedSync();
|
||||
final difference = DateTime.now().difference(lastModified);
|
||||
final difference = clock.now().difference(lastModified);
|
||||
final content = await indexFile.readAsString();
|
||||
final jsonList = json.decode(content) as List;
|
||||
res = jsonList
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:drift/drift.dart' show Value;
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
|
@ -509,7 +510,7 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
|
|||
final mediaFile = await twonlyDB.mediaFilesDao.insertMedia(
|
||||
MediaFilesCompanion(
|
||||
type: Value(mediaService.mediaFile.type),
|
||||
createdAt: Value(DateTime.now()),
|
||||
createdAt: Value(clock.now()),
|
||||
stored: const Value(true),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -7,10 +7,10 @@ import 'package:flutter/material.dart';
|
|||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||
import 'package:twonly/globals.dart';
|
||||
import 'package:twonly/src/database/daos/contacts.dao.dart';
|
||||
import 'package:twonly/src/database/daos/groups.dao.dart';
|
||||
import 'package:twonly/src/database/tables/mediafiles.table.dart';
|
||||
import 'package:twonly/src/database/twonly.db.dart';
|
||||
import 'package:twonly/src/services/api/mediafiles/upload.service.dart';
|
||||
import 'package:twonly/src/services/flame.service.dart';
|
||||
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
|
||||
import 'package:twonly/src/utils/misc.dart';
|
||||
import 'package:twonly/src/views/camera/share_image_components/best_friends_selector.dart';
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:twonly/globals.dart';
|
||||
import 'package:twonly/src/database/twonly.db.dart';
|
||||
|
|
@ -28,12 +29,13 @@ class _LastMessageTimeState extends State<LastMessageTime> {
|
|||
if (widget.message != null) {
|
||||
final lastAction = await twonlyDB.messagesDao
|
||||
.getLastMessageAction(widget.message!.messageId);
|
||||
lastMessageInSeconds = DateTime.now()
|
||||
lastMessageInSeconds = clock
|
||||
.now()
|
||||
.difference(lastAction?.actionAt ?? widget.message!.createdAt)
|
||||
.inSeconds;
|
||||
} else if (widget.dateTime != null) {
|
||||
lastMessageInSeconds =
|
||||
DateTime.now().difference(widget.dateTime!).inSeconds;
|
||||
clock.now().difference(widget.dateTime!).inSeconds;
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import 'package:clock/clock.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||
import 'package:intl/intl.dart' show DateFormat;
|
||||
|
|
@ -51,7 +52,7 @@ class FriendlyMessageTime extends StatelessWidget {
|
|||
}
|
||||
|
||||
String friendlyTime(BuildContext context, DateTime dt) {
|
||||
final now = DateTime.now();
|
||||
final now = clock.now();
|
||||
final diff = now.difference(dt);
|
||||
|
||||
if (diff.inMinutes >= 0 && diff.inMinutes < 60) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
// ignore_for_file: inference_failure_on_function_invocation
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:fixnum/fixnum.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
|
@ -113,7 +114,7 @@ class MessageContextMenu extends StatelessWidget {
|
|||
await twonlyDB.messagesDao.handleMessageDeletion(
|
||||
null,
|
||||
message.messageId,
|
||||
DateTime.now(),
|
||||
clock.now(),
|
||||
);
|
||||
await sendCipherTextToGroup(
|
||||
message.groupId,
|
||||
|
|
@ -203,7 +204,7 @@ Future<void> editTextMessage(BuildContext context, Message message) async {
|
|||
if (newText != null &&
|
||||
newText != message.content &&
|
||||
newText != '') {
|
||||
final timestamp = DateTime.now();
|
||||
final timestamp = clock.now();
|
||||
|
||||
await twonlyDB.messagesDao.handleTextEdit(
|
||||
null,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:drift/drift.dart' show Value;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||
|
|
@ -291,12 +292,12 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
|||
}).catchError(Log.error);
|
||||
} else {
|
||||
if (currentMediaLocal.mediaFile.displayLimitInMilliseconds != null) {
|
||||
canBeSeenUntil = DateTime.now().add(
|
||||
Duration(
|
||||
milliseconds:
|
||||
currentMediaLocal.mediaFile.displayLimitInMilliseconds!,
|
||||
),
|
||||
);
|
||||
canBeSeenUntil = clock.now().add(
|
||||
Duration(
|
||||
milliseconds:
|
||||
currentMediaLocal.mediaFile.displayLimitInMilliseconds!,
|
||||
),
|
||||
);
|
||||
timerRequired = true;
|
||||
}
|
||||
}
|
||||
|
|
@ -314,7 +315,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
|||
nextMediaTimer?.cancel();
|
||||
progressTimer?.cancel();
|
||||
if (canBeSeenUntil != null) {
|
||||
nextMediaTimer = Timer(canBeSeenUntil!.difference(DateTime.now()), () {
|
||||
nextMediaTimer = Timer(canBeSeenUntil!.difference(clock.now()), () {
|
||||
if (context.mounted) {
|
||||
nextMediaOrExit();
|
||||
}
|
||||
|
|
@ -326,7 +327,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
|||
canBeSeenUntil == null) {
|
||||
return;
|
||||
}
|
||||
final difference = canBeSeenUntil!.difference(DateTime.now());
|
||||
final difference = canBeSeenUntil!.difference(clock.now());
|
||||
// Calculate the progress as a value between 0.0 and 1.0
|
||||
progress =
|
||||
difference.inMilliseconds / (mediaFile.displayLimitInMilliseconds!);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import 'dart:async';
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:drift/drift.dart' show Value;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:twonly/globals.dart';
|
||||
|
|
@ -23,39 +24,22 @@ class MaxFlameListTitle extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _MaxFlameListTitleState extends State<MaxFlameListTitle> {
|
||||
int _flameCounter = 0;
|
||||
Group? _directChat;
|
||||
Group? _group;
|
||||
late String _groupId;
|
||||
|
||||
late StreamSubscription<int> _flameCounterSub;
|
||||
late StreamSubscription<Group?> _groupSub;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
_groupId = getUUIDforDirectChat(widget.contactId, gUser.userId);
|
||||
final stream = twonlyDB.groupsDao.watchFlameCounter(_groupId);
|
||||
_flameCounterSub = stream.listen((counter) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
// in the watchFlameCounter a one is added, so remove this here
|
||||
_flameCounter = counter - 1;
|
||||
});
|
||||
}
|
||||
});
|
||||
final stream2 = twonlyDB.groupsDao.watchGroup(_groupId);
|
||||
_groupSub = stream2.listen((update) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_directChat = update;
|
||||
});
|
||||
}
|
||||
final stream = twonlyDB.groupsDao.watchGroup(_groupId);
|
||||
_groupSub = stream.listen((update) {
|
||||
if (mounted) setState(() => _group = update);
|
||||
});
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_flameCounterSub.cancel();
|
||||
_groupSub.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
|
@ -73,13 +57,13 @@ class _MaxFlameListTitleState extends State<MaxFlameListTitle> {
|
|||
return;
|
||||
}
|
||||
Log.info(
|
||||
'Restoring flames from ${_directChat!.flameCounter} to ${_directChat!.maxFlameCounter}',
|
||||
'Restoring flames from ${_group!.flameCounter} to ${_group!.maxFlameCounter}',
|
||||
);
|
||||
await twonlyDB.groupsDao.updateGroup(
|
||||
_groupId,
|
||||
GroupsCompanion(
|
||||
flameCounter: Value(_directChat!.maxFlameCounter),
|
||||
lastFlameCounterChange: Value(DateTime.now()),
|
||||
flameCounter: Value(_group!.maxFlameCounter),
|
||||
lastFlameCounterChange: Value(clock.now()),
|
||||
),
|
||||
);
|
||||
await syncFlameCounters(forceForGroup: _groupId);
|
||||
|
|
@ -87,11 +71,7 @@ class _MaxFlameListTitleState extends State<MaxFlameListTitle> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_directChat == null ||
|
||||
_directChat!.maxFlameCounter <= 2 ||
|
||||
_flameCounter >= _directChat!.maxFlameCounter ||
|
||||
_directChat!.maxFlameCounterFrom!
|
||||
.isBefore(DateTime.now().subtract(const Duration(days: 4)))) {
|
||||
if (_group == null || !isItPossibleToRestoreFlames(_group!)) {
|
||||
return Container();
|
||||
}
|
||||
return BetterListTile(
|
||||
|
|
@ -102,7 +82,7 @@ class _MaxFlameListTitleState extends State<MaxFlameListTitle> {
|
|||
emoji: '🔥',
|
||||
),
|
||||
),
|
||||
text: 'Restore your ${_directChat!.maxFlameCounter + 1} lost flames',
|
||||
text: 'Restore your ${_group!.maxFlameCounter} lost flames',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import 'dart:async';
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:twonly/globals.dart';
|
||||
|
|
@ -65,7 +66,7 @@ class MemoriesViewState extends State<MemoriesView> {
|
|||
var lastMonth = '';
|
||||
galleryItems = [];
|
||||
|
||||
final now = DateTime.now();
|
||||
final now = clock.now();
|
||||
|
||||
for (final mediaFile in mediaFiles) {
|
||||
final mediaService = MediaFileService(mediaFile);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import 'dart:collection' show HashSet;
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:cryptography_plus/cryptography_plus.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
|
@ -81,7 +82,7 @@ class _DatabaseMigrationViewState extends State<DatabaseMigrationView> {
|
|||
lastMessageSend: Value(oldContact.lastMessageSend),
|
||||
flameCounter: Value(oldContact.flameCounter),
|
||||
maxFlameCounter: Value(oldContact.flameCounter),
|
||||
maxFlameCounterFrom: Value(DateTime.now()),
|
||||
maxFlameCounterFrom: Value(clock.now()),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -266,7 +266,7 @@ packages:
|
|||
source: hosted
|
||||
version: "0.4.2"
|
||||
clock:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: clock
|
||||
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ dependencies:
|
|||
web_socket_channel: ^3.0.1
|
||||
convert: ^3.1.2
|
||||
crypto: ^3.0.7
|
||||
clock: ^1.1.2
|
||||
|
||||
|
||||
# Trusted publisher flutter.dev
|
||||
|
|
|
|||
139
test/features/flame_counter_test.dart
Normal file
139
test/features/flame_counter_test.dart
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import 'package:clock/clock.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mutex/mutex.dart';
|
||||
import 'package:twonly/globals.dart';
|
||||
import 'package:twonly/src/database/daos/contacts.dao.dart';
|
||||
import 'package:twonly/src/database/twonly.db.dart';
|
||||
import 'package:twonly/src/model/json/userdata.dart';
|
||||
import 'package:twonly/src/services/flame.service.dart';
|
||||
|
||||
Future<void> expectFlame(DateTime time, String groupId, int counter) async {
|
||||
await withClock(
|
||||
Clock.fixed(time),
|
||||
() async {
|
||||
final group = (await twonlyDB.groupsDao.getGroup(groupId))!;
|
||||
expect(
|
||||
getFlameCounterFromGroup(group),
|
||||
counter,
|
||||
reason: StackTrace.current.toString(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
final mutex = Mutex();
|
||||
var usedUserIds = 0;
|
||||
|
||||
Future<int> getAndCreateUserId() async {
|
||||
return mutex.protect<int>(() async {
|
||||
final userId = usedUserIds += 1;
|
||||
await twonlyDB.contactsDao.insertContact(
|
||||
ContactsCompanion(userId: Value(userId), username: Value('$userId')),
|
||||
);
|
||||
return userId;
|
||||
});
|
||||
}
|
||||
|
||||
setUp(() async {
|
||||
twonlyDB = TwonlyDB.forTesting(
|
||||
DatabaseConnection(
|
||||
NativeDatabase.memory(),
|
||||
// Recommended for widget tests to avoid test errors.
|
||||
closeStreamsSynchronously: true,
|
||||
),
|
||||
);
|
||||
|
||||
gUser = UserData(
|
||||
userId: 0x133337,
|
||||
username: 'test_user',
|
||||
displayName: 'Test User',
|
||||
subscriptionPlan: 'Free',
|
||||
)..appVersion = 62;
|
||||
});
|
||||
|
||||
test('test flame counter', () async {
|
||||
final contactId = await getAndCreateUserId();
|
||||
final contact = (await twonlyDB.contactsDao.getContactById(contactId))!;
|
||||
await twonlyDB.groupsDao.createNewDirectChat(
|
||||
contactId,
|
||||
GroupsCompanion(
|
||||
groupName: Value(
|
||||
getContactDisplayName(contact),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final group = (await twonlyDB.groupsDao.getDirectChat(contactId))!;
|
||||
|
||||
await expectFlame(DateTime(2026, 2, 3, 16), group.groupId, 0);
|
||||
|
||||
await withClock(
|
||||
Clock.fixed(DateTime(2026, 2, 2, 16)),
|
||||
() async {
|
||||
await incFlameCounter(group.groupId, true, DateTime(2026, 2, 2, 10));
|
||||
await incFlameCounter(group.groupId, false, DateTime(2026, 2, 2, 15));
|
||||
},
|
||||
);
|
||||
|
||||
await expectFlame(DateTime(2026, 2, 2, 18), group.groupId, 1);
|
||||
await expectFlame(DateTime(2026, 2, 3, 16), group.groupId, 1);
|
||||
await expectFlame(DateTime(2026, 2, 4, 16), group.groupId, 1);
|
||||
await expectFlame(DateTime(2026, 2, 5, 16), group.groupId, 0);
|
||||
|
||||
await withClock(
|
||||
Clock.fixed(DateTime(2026, 2, 5, 16)),
|
||||
() async {
|
||||
await incFlameCounter(group.groupId, true, DateTime(2026, 2, 5, 17));
|
||||
await incFlameCounter(group.groupId, false, DateTime(2026, 2, 5, 18));
|
||||
},
|
||||
);
|
||||
|
||||
await expectFlame(DateTime(2026, 2, 5, 19), group.groupId, 1);
|
||||
|
||||
await withClock(
|
||||
Clock.fixed(DateTime(2026, 2, 6, 1)),
|
||||
() async {
|
||||
await incFlameCounter(group.groupId, true, DateTime(2026, 2, 6, 1));
|
||||
await incFlameCounter(group.groupId, false, DateTime(2026, 2, 6, 2));
|
||||
},
|
||||
);
|
||||
|
||||
await expectFlame(DateTime(2026, 2, 6, 19), group.groupId, 2);
|
||||
await expectFlame(DateTime(2026, 3, 1, 19), group.groupId, 0);
|
||||
|
||||
for (var i = 1; i <= 20; i++) {
|
||||
await withClock(
|
||||
Clock.fixed(DateTime(2026, 3, i, 1)),
|
||||
() async {
|
||||
await incFlameCounter(group.groupId, true, DateTime(2026, 3, i, 2));
|
||||
await incFlameCounter(group.groupId, false, DateTime(2026, 3, i, 3));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
await expectFlame(DateTime(2026, 3, 20, 19), group.groupId, 20);
|
||||
await expectFlame(DateTime(2026, 3, 23, 19), group.groupId, 0);
|
||||
|
||||
await withClock(
|
||||
Clock.fixed(DateTime(2026, 3, 24, 19)),
|
||||
() async {
|
||||
final group2 = (await twonlyDB.groupsDao.getGroup(group.groupId))!;
|
||||
expect(isItPossibleToRestoreFlames(group2), true);
|
||||
},
|
||||
);
|
||||
await withClock(
|
||||
Clock.fixed(DateTime(2026, 3, 25, 19)),
|
||||
() async {
|
||||
final group2 = (await twonlyDB.groupsDao.getGroup(group.groupId))!;
|
||||
expect(isItPossibleToRestoreFlames(group2), false);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await twonlyDB.close();
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue