fix: media file appears as a white square and is not listed.
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run

This commit is contained in:
otsmr 2026-03-14 22:18:29 +01:00
parent cdaca940df
commit cc0b88718b
13 changed files with 285 additions and 192 deletions

View file

@ -25,8 +25,11 @@ jobs:
- name: Cloning sub-repos - name: Cloning sub-repos
run: git submodule update --init --recursive run: git submodule update --init --recursive
- name: Check flutter code - name: flutter pub get
run: | run: flutter pub get
flutter pub get
flutter analyze - name: flutter analyze
flutter test run: flutter analyze
- name: flutter test
run: flutter test

View file

@ -1,6 +1,6 @@
# Changelog # Changelog
## 0.0.98 ## 0.0.99
- New: Groups can now collect flames as well - New: Groups can now collect flames as well
- New: Background execution to pre-load messages - New: Background execution to pre-load messages
@ -8,10 +8,12 @@
- Improve: Video compression with progress updates - Improve: Video compression with progress updates
- Improve: Show message "Flames restored" - Improve: Show message "Flames restored"
- Improve: Show toast message if user was added via QR - Improve: Show toast message if user was added via QR
- Fix: Media file appears as a white square and is not listed.
- Fix: Issue with media files required to be reuploaded - Fix: Issue with media files required to be reuploaded
- Fix: Problem during contact requests - Fix: Problem during contact requests
- Fix: Problem with deleting a contact - Fix: Problem with deleting a contact
- Fix: Problem with restoring from backup - Fix: Problem with restoring from backup
- Fix: Issue with the log file
## 0.0.96 ## 0.0.96

View file

@ -29,6 +29,10 @@ import 'package:twonly/src/utils/storage.dart';
void main() async { void main() async {
SentryWidgetsFlutterBinding.ensureInitialized(); SentryWidgetsFlutterBinding.ensureInitialized();
globalApplicationCacheDirectory = (await getApplicationCacheDirectory()).path;
globalApplicationSupportDirectory =
(await getApplicationSupportDirectory()).path;
await initFCMService(); await initFCMService();
final user = await getUser(); final user = await getUser();
@ -53,10 +57,6 @@ void main() async {
await deleteLocalUserData(); await deleteLocalUserData();
} }
globalApplicationCacheDirectory = (await getApplicationCacheDirectory()).path;
globalApplicationSupportDirectory =
(await getApplicationSupportDirectory()).path;
initLogger(); initLogger();
final settingsController = SettingsChangeProvider(); final settingsController = SettingsChangeProvider();

View file

@ -1,6 +1,7 @@
import 'package:clock/clock.dart'; import 'package:clock/clock.dart';
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:hashlib/random.dart'; import 'package:hashlib/random.dart';
import 'package:twonly/src/database/tables/contacts.table.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/receipts.table.dart'; import 'package:twonly/src/database/tables/receipts.table.dart';
import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/database/twonly.db.dart';
@ -9,7 +10,9 @@ import 'package:twonly/src/utils/log.dart';
part 'receipts.dao.g.dart'; part 'receipts.dao.g.dart';
@DriftAccessor(tables: [Receipts, Messages, MessageActions, ReceivedReceipts]) @DriftAccessor(
tables: [Receipts, Messages, MessageActions, ReceivedReceipts, Contacts],
)
class ReceiptsDao extends DatabaseAccessor<TwonlyDB> with _$ReceiptsDaoMixin { class ReceiptsDao extends DatabaseAccessor<TwonlyDB> with _$ReceiptsDaoMixin {
// this constructor is required so that the main database can create an instance // this constructor is required so that the main database can create an instance
// of this object. // of this object.
@ -60,6 +63,17 @@ class ReceiptsDao extends DatabaseAccessor<TwonlyDB> with _$ReceiptsDaoMixin {
)), )),
)) ))
.go(); .go();
final deletedContacts = await (select(
contacts,
)..where((t) => t.accountDeleted.equals(true))).get();
for (final contact in deletedContacts) {
await (delete(receipts)..where(
(t) => t.contactId.equals(contact.userId),
))
.go();
}
} }
Future<Receipt?> insertReceipt(ReceiptsCompanion entry) async { Future<Receipt?> insertReceipt(ReceiptsCompanion entry) async {

View file

@ -46,6 +46,7 @@ import 'package:web_socket_channel/io.dart';
final lockConnecting = Mutex(); final lockConnecting = Mutex();
final lockRetransStore = Mutex(); final lockRetransStore = Mutex();
final lockAuthentication = 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
@ -86,7 +87,6 @@ class ApiService {
// Function is called after the user is authenticated at the server // Function is called after the user is authenticated at the server
Future<void> onAuthenticated() async { Future<void> onAuthenticated() async {
isAuthenticated = true;
await initFCMAfterAuthenticated(); await initFCMAfterAuthenticated();
globalCallbackConnectionState(isConnected: true); globalCallbackConnectionState(isConnected: true);
@ -157,8 +157,9 @@ class ApiService {
if (connectivitySubscription != null) { if (connectivitySubscription != null) {
return; return;
} }
connectivitySubscription = connectivitySubscription = Connectivity().onConnectivityChanged.listen((
Connectivity().onConnectivityChanged.listen((result) async { result,
) async {
if (!result.contains(ConnectivityResult.none)) { if (!result.contains(ConnectivityResult.none)) {
await connect(); await connect();
} }
@ -355,7 +356,6 @@ class ApiService {
return Result.error(ErrorCode.InternalError); return Result.error(ErrorCode.InternalError);
} }
if (res.error == ErrorCode.SessionNotAuthenticated) { if (res.error == ErrorCode.SessionNotAuthenticated) {
isAuthenticated = false;
if (authenticated) { if (authenticated) {
await authenticate(); await authenticate();
if (isAuthenticated) { if (isAuthenticated) {
@ -387,8 +387,9 @@ class ApiService {
Future<bool> tryAuthenticateWithToken(int userId) async { Future<bool> tryAuthenticateWithToken(int userId) async {
const storage = FlutterSecureStorage(); const storage = FlutterSecureStorage();
final apiAuthToken = final apiAuthToken = await storage.read(
await storage.read(key: SecureStorageKeys.apiAuthToken); key: SecureStorageKeys.apiAuthToken,
);
final user = await getUser(); final user = await getUser();
if (apiAuthToken != null && user != null) { if (apiAuthToken != null && user != null) {
@ -412,6 +413,7 @@ class ApiService {
if (result.isSuccess) { if (result.isSuccess) {
Log.info('websocket is authenticated'); Log.info('websocket is authenticated');
isAuthenticated = true;
if (globalIsInBackgroundTask) { if (globalIsInBackgroundTask) {
await onAuthenticated(); await onAuthenticated();
} else { } else {
@ -433,60 +435,66 @@ class ApiService {
} }
Future<void> authenticate() async { Future<void> authenticate() async {
if (isAuthenticated) return; return lockAuthentication.protect(() async {
if (await getSignalIdentity() == null) { if (isAuthenticated) return;
return; if (await getSignalIdentity() == null) {
} return;
}
final userData = await getUser(); final userData = await getUser();
if (userData == null) return; if (userData == null) return;
if (await tryAuthenticateWithToken(userData.userId)) { if (await tryAuthenticateWithToken(userData.userId)) {
return; return;
} }
final handshake = Handshake() final handshake = Handshake()
..getAuthChallenge = Handshake_GetAuthChallenge(); ..getAuthChallenge = Handshake_GetAuthChallenge();
final req = createClientToServerFromHandshake(handshake); final req = createClientToServerFromHandshake(handshake);
final result = await sendRequestSync(req, authenticated: false); final result = await sendRequestSync(req, authenticated: false);
if (result.isError) { if (result.isError) {
Log.warn('could not request auth challenge', result); Log.warn('could not request auth challenge', result);
return; return;
} }
final challenge = result.value.authchallenge; final challenge = result.value.authchallenge;
var privKey = (await getSignalIdentityKeyPair())?.getPrivateKey(); var privKey = (await getSignalIdentityKeyPair())?.getPrivateKey();
if (privKey == null) return; if (privKey == null) return;
final random = getRandomUint8List(32); final random = getRandomUint8List(32);
final signature = sign(privKey.serialize(), challenge as Uint8List, random); final signature = sign(
privKey = null; privKey.serialize(),
challenge as Uint8List,
random,
);
privKey = null;
final getAuthToken = Handshake_GetAuthToken() final getAuthToken = Handshake_GetAuthToken()
..response = signature ..response = signature
..userId = Int64(userData.userId); ..userId = Int64(userData.userId);
final getauthtoken = Handshake()..getAuthToken = getAuthToken; final getauthtoken = Handshake()..getAuthToken = getAuthToken;
final req2 = createClientToServerFromHandshake(getauthtoken); final req2 = createClientToServerFromHandshake(getauthtoken);
final result2 = await sendRequestSync(req2, authenticated: false); final result2 = await sendRequestSync(req2, authenticated: false);
if (result2.isError) { if (result2.isError) {
Log.error('could not send auth response: ${result2.error}'); Log.error('could not send auth response: ${result2.error}');
return; return;
} }
final apiAuthToken = result2.value.authtoken as Uint8List; final apiAuthToken = result2.value.authtoken as Uint8List;
final apiAuthTokenB64 = base64Encode(apiAuthToken); final apiAuthTokenB64 = base64Encode(apiAuthToken);
const storage = FlutterSecureStorage(); const storage = FlutterSecureStorage();
await storage.write( await storage.write(
key: SecureStorageKeys.apiAuthToken, key: SecureStorageKeys.apiAuthToken,
value: apiAuthTokenB64, value: apiAuthTokenB64,
); );
await tryAuthenticateWithToken(userData.userId); await tryAuthenticateWithToken(userData.userId);
});
} }
Future<Result> register( Future<Result> register(
@ -505,8 +513,9 @@ class ApiService {
final register = Handshake_Register() final register = Handshake_Register()
..username = username ..username = username
..publicIdentityKey = ..publicIdentityKey = (await signalStore.getIdentityKeyPair())
(await signalStore.getIdentityKeyPair()).getPublicKey().serialize() .getPublicKey()
.serialize()
..registrationId = Int64(signalIdentity.registrationId) ..registrationId = Int64(signalIdentity.registrationId)
..signedPrekey = signedPreKey.getKeyPair().publicKey.serialize() ..signedPrekey = signedPreKey.getKeyPair().publicKey.serialize()
..signedPrekeySignature = signedPreKey.signature ..signedPrekeySignature = signedPreKey.signature
@ -526,8 +535,10 @@ class ApiService {
} }
Future<void> checkForDeletedUsernames() async { Future<void> checkForDeletedUsernames() async {
final users = await twonlyDB.contactsDao final users = await twonlyDB.contactsDao.getContactsByUsername(
.getContactsByUsername('[deleted]', username2: '[Unknown]'); '[deleted]',
username2: '[Unknown]',
);
for (final user in users) { for (final user in users) {
final userData = await getUserById(user.userId); final userData = await getUserById(user.userId);
if (userData != null) { if (userData != null) {

View file

@ -4,7 +4,8 @@ import 'package:twonly/globals.dart';
import 'package:twonly/src/database/tables/mediafiles.table.dart'; import 'package:twonly/src/database/tables/mediafiles.table.dart';
import 'package:twonly/src/database/tables/messages.table.dart'; import 'package:twonly/src/database/tables/messages.table.dart';
import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart'; import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart'
hide Message;
import 'package:twonly/src/services/api/mediafiles/download.service.dart'; import 'package:twonly/src/services/api/mediafiles/download.service.dart';
import 'package:twonly/src/services/api/mediafiles/upload.service.dart'; import 'package:twonly/src/services/api/mediafiles/upload.service.dart';
import 'package:twonly/src/services/api/utils.dart'; import 'package:twonly/src/services/api/utils.dart';
@ -31,7 +32,7 @@ Future<void> handleMedia(
message.senderId != fromUserId || message.senderId != fromUserId ||
message.mediaId == null) { message.mediaId == null) {
Log.warn( Log.warn(
'Got reupload for a message that either does not exists or sender != fromUserId or not a media file', 'Got reupload from $fromUserId for a message that either does not exists (${message == null}) or senderId = ${message?.senderId}',
); );
return; return;
} }
@ -53,8 +54,9 @@ Future<void> handleMedia(
), ),
); );
final mediaFile = final mediaFile = await twonlyDB.mediaFilesDao.getMediaFileById(
await twonlyDB.mediaFilesDao.getMediaFileById(message.mediaId!); message.mediaId!,
);
if (mediaFile != null) { if (mediaFile != null) {
unawaited(startDownloadMedia(mediaFile, false)); unawaited(startDownloadMedia(mediaFile, false));
@ -89,56 +91,64 @@ Future<void> handleMedia(
} }
} }
final mediaFile = await twonlyDB.mediaFilesDao.insertMedia( late MediaFile? mediaFile;
MediaFilesCompanion( late Message? message;
downloadState: const Value(DownloadState.pending),
type: Value(mediaType),
requiresAuthentication: Value(media.requiresAuthentication),
displayLimitInMilliseconds: Value(
displayLimitInMilliseconds,
),
downloadToken: Value(Uint8List.fromList(media.downloadToken)),
encryptionKey: Value(Uint8List.fromList(media.encryptionKey)),
encryptionMac: Value(Uint8List.fromList(media.encryptionMac)),
encryptionNonce: Value(Uint8List.fromList(media.encryptionNonce)),
createdAt: Value(fromTimestamp(media.timestamp)),
),
);
if (mediaFile == null) { await twonlyDB.transaction(() async {
Log.error('Could not insert media file into database'); mediaFile = await twonlyDB.mediaFilesDao.insertMedia(
return; MediaFilesCompanion(
} downloadState: const Value(DownloadState.pending),
type: Value(mediaType),
requiresAuthentication: Value(media.requiresAuthentication),
displayLimitInMilliseconds: Value(
displayLimitInMilliseconds,
),
downloadToken: Value(Uint8List.fromList(media.downloadToken)),
encryptionKey: Value(Uint8List.fromList(media.encryptionKey)),
encryptionMac: Value(Uint8List.fromList(media.encryptionMac)),
encryptionNonce: Value(Uint8List.fromList(media.encryptionNonce)),
createdAt: Value(fromTimestamp(media.timestamp)),
),
);
final message = await twonlyDB.messagesDao.insertMessage( if (mediaFile == null) {
MessagesCompanion( Log.error('Could not insert media file into database');
messageId: Value(media.senderMessageId), return;
senderId: Value(fromUserId), }
groupId: Value(groupId),
mediaId: Value(mediaFile.mediaId), message = await twonlyDB.messagesDao.insertMessage(
type: Value(MessageType.media.name), MessagesCompanion(
additionalMessageData: Value.absentIfNull( messageId: Value(media.senderMessageId),
media.hasAdditionalMessageData() senderId: Value(fromUserId),
? Uint8List.fromList(media.additionalMessageData) groupId: Value(groupId),
: null, mediaId: Value(mediaFile!.mediaId),
type: Value(MessageType.media.name),
additionalMessageData: Value.absentIfNull(
media.hasAdditionalMessageData()
? Uint8List.fromList(media.additionalMessageData)
: null,
),
quotesMessageId: Value(
media.hasQuoteMessageId() ? media.quoteMessageId : null,
),
createdAt: Value(fromTimestamp(media.timestamp)),
), ),
quotesMessageId: Value( );
media.hasQuoteMessageId() ? media.quoteMessageId : null, });
),
createdAt: Value(fromTimestamp(media.timestamp)),
),
);
if (message != null) { if (message != null) {
await twonlyDB.groupsDao await twonlyDB.groupsDao.increaseLastMessageExchange(
.increaseLastMessageExchange(groupId, fromTimestamp(media.timestamp)); groupId,
Log.info('Inserted a new media message with ID: ${message.messageId}'); fromTimestamp(media.timestamp),
);
Log.info('Inserted a new media message with ID: ${message!.messageId}');
await incFlameCounter( await incFlameCounter(
message.groupId, message!.groupId,
true, true,
fromTimestamp(media.timestamp), fromTimestamp(media.timestamp),
); );
unawaited(startDownloadMedia(mediaFile, false)); unawaited(startDownloadMedia(mediaFile!, false));
} }
} }
@ -163,8 +173,9 @@ Future<void> handleMediaUpdate(
); );
return; return;
} }
final mediaFile = final mediaFile = await twonlyDB.mediaFilesDao.getMediaFileById(
await twonlyDB.mediaFilesDao.getMediaFileById(message.mediaId!); message.mediaId!,
);
if (mediaFile == null) { if (mediaFile == null) {
Log.info( Log.info(
'Got media file update, but media file was not found ${message.mediaId}', 'Got media file update, but media file was not found ${message.mediaId}',
@ -203,8 +214,9 @@ Future<void> handleMediaUpdate(
reuploadRequestedBy: Value(reuploadRequestedBy), reuploadRequestedBy: Value(reuploadRequestedBy),
), ),
); );
final mediaFileUpdated = final mediaFileUpdated = await MediaFileService.fromMediaId(
await MediaFileService.fromMediaId(mediaFile.mediaId); mediaFile.mediaId,
);
if (mediaFileUpdated != null) { if (mediaFileUpdated != null) {
if (mediaFileUpdated.uploadRequestPath.existsSync()) { if (mediaFileUpdated.uploadRequestPath.existsSync()) {
mediaFileUpdated.uploadRequestPath.deleteSync(); mediaFileUpdated.uploadRequestPath.deleteSync();

View file

@ -10,7 +10,6 @@ import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart'; import 'package:drift_flutter/drift_flutter.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:path/path.dart'; import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:twonly/globals.dart'; import 'package:twonly/globals.dart';
import 'package:twonly/src/constants/secure_storage_keys.dart'; import 'package:twonly/src/constants/secure_storage_keys.dart';
import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/database/twonly.db.dart';
@ -42,15 +41,16 @@ Future<void> performTwonlySafeBackup({bool force = false}) async {
Log.info('Starting new twonly Backup!'); Log.info('Starting new twonly Backup!');
final baseDir = (await getApplicationSupportDirectory()).path; final baseDir = globalApplicationSupportDirectory;
final backupDir = Directory(join(baseDir, 'backup_twonly_safe/')); final backupDir = Directory(join(baseDir, 'backup_twonly_safe/'));
await backupDir.create(recursive: true); await backupDir.create(recursive: true);
final backupDatabaseFile = File(join(backupDir.path, 'twonly.backup.sqlite')); final backupDatabaseFile = File(join(backupDir.path, 'twonly.backup.sqlite'));
final backupDatabaseFileCleaned = final backupDatabaseFileCleaned = File(
File(join(backupDir.path, 'twonly.backup.cleaned.sqlite')); join(backupDir.path, 'twonly.backup.cleaned.sqlite'),
);
// copy database // copy database
final originalDatabase = File(join(baseDir, 'twonly.sqlite')); final originalDatabase = File(join(baseDir, 'twonly.sqlite'));
@ -70,8 +70,9 @@ Future<void> performTwonlySafeBackup({bool force = false}) async {
await backupDB.deleteDataForTwonlySafe(); await backupDB.deleteDataForTwonlySafe();
await backupDB await backupDB.customStatement('VACUUM INTO ?', [
.customStatement('VACUUM INTO ?', [backupDatabaseFileCleaned.path]); backupDatabaseFileCleaned.path,
]);
await backupDB.printTableSizes(); await backupDB.printTableSizes();
@ -80,10 +81,11 @@ Future<void> performTwonlySafeBackup({bool force = false}) async {
// ignore: inference_failure_on_collection_literal // ignore: inference_failure_on_collection_literal
final secureStorageBackup = {}; final secureStorageBackup = {};
const storage = FlutterSecureStorage(); const storage = FlutterSecureStorage();
secureStorageBackup[SecureStorageKeys.signalIdentity] = secureStorageBackup[SecureStorageKeys.signalIdentity] = await storage.read(
await storage.read(key: SecureStorageKeys.signalIdentity); key: SecureStorageKeys.signalIdentity,
secureStorageBackup[SecureStorageKeys.signalSignedPreKey] = );
await storage.read(key: SecureStorageKeys.signalSignedPreKey); secureStorageBackup[SecureStorageKeys.signalSignedPreKey] = await storage
.read(key: SecureStorageKeys.signalSignedPreKey);
final userBackup = await getUser(); final userBackup = await getUser();
if (userBackup == null) return; if (userBackup == null) return;
@ -117,13 +119,15 @@ Future<void> performTwonlySafeBackup({bool force = false}) async {
final backupHash = uint8ListToHex((await Sha256().hash(backupBytes)).bytes); final backupHash = uint8ListToHex((await Sha256().hash(backupBytes)).bytes);
if (gUser.twonlySafeBackup!.lastBackupDone == null || if (gUser.twonlySafeBackup!.lastBackupDone == null ||
gUser.twonlySafeBackup!.lastBackupDone! gUser.twonlySafeBackup!.lastBackupDone!.isAfter(
.isAfter(clock.now().subtract(const Duration(days: 90)))) { clock.now().subtract(const Duration(days: 90)),
)) {
force = true; force = true;
} }
final lastHash = final lastHash = await storage.read(
await storage.read(key: SecureStorageKeys.twonlySafeLastBackupHash); key: SecureStorageKeys.twonlySafeLastBackupHash,
);
if (lastHash != null && !force) { if (lastHash != null && !force) {
if (backupHash == lastHash) { if (backupHash == lastHash) {
@ -155,8 +159,9 @@ Future<void> performTwonlySafeBackup({bool force = false}) async {
Log.info('Backup files created.'); Log.info('Backup files created.');
final encryptedBackupBytesFile = final encryptedBackupBytesFile = File(
File(join(backupDir.path, 'twonly_safe.backup')); join(backupDir.path, 'twonly_safe.backup'),
);
await encryptedBackupBytesFile.writeAsBytes(encryptedBackupBytes); await encryptedBackupBytesFile.writeAsBytes(encryptedBackupBytes);

View file

@ -8,7 +8,7 @@ import 'package:drift/drift.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:path/path.dart'; import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart'; import 'package:twonly/globals.dart';
import 'package:twonly/src/constants/secure_storage_keys.dart'; import 'package:twonly/src/constants/secure_storage_keys.dart';
import 'package:twonly/src/model/json/userdata.dart'; import 'package:twonly/src/model/json/userdata.dart';
import 'package:twonly/src/model/protobuf/client/generated/backup.pb.dart'; import 'package:twonly/src/model/protobuf/client/generated/backup.pb.dart';
@ -23,8 +23,10 @@ Future<void> recoverBackup(
) async { ) async {
final (backupId, encryptionKey) = await getMasterKey(password, username); final (backupId, encryptionKey) = await getMasterKey(password, username);
final backupServerUrl = final backupServerUrl = await getTwonlySafeBackupUrlFromServer(
await getTwonlySafeBackupUrlFromServer(backupId, server); backupId,
server,
);
if (backupServerUrl == null) { if (backupServerUrl == null) {
Log.error('Could not create backup url'); Log.error('Could not create backup url');
@ -87,8 +89,9 @@ Future<void> handleBackupData(
plaintextBytes, plaintextBytes,
); );
final baseDir = (await getApplicationSupportDirectory()).path; final originalDatabase = File(
final originalDatabase = File(join(baseDir, 'twonly.sqlite')); join(globalApplicationSupportDirectory, 'twonly.sqlite'),
);
await originalDatabase.writeAsBytes(backupContent.twonlyDatabase); await originalDatabase.writeAsBytes(backupContent.twonlyDatabase);
const storage = FlutterSecureStorage(); const storage = FlutterSecureStorage();

View file

@ -1,12 +1,11 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:path_provider/path_provider.dart'; import 'package:twonly/globals.dart';
import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/log.dart';
class KeyValueStore { class KeyValueStore {
static Future<File> _getFilePath(String key) async { static Future<File> _getFilePath(String key) async {
final directory = await getApplicationSupportDirectory(); return File('$globalApplicationSupportDirectory/keyvalue/$key.json');
return File('${directory.path}/keyvalue/$key.json');
} }
static Future<void> delete(String key) async { static Future<void> delete(String key) async {

View file

@ -1,9 +1,9 @@
import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:clock/clock.dart'; import 'package:clock/clock.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:mutex/mutex.dart'; import 'package:mutex/mutex.dart';
import 'package:path_provider/path_provider.dart';
import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:twonly/globals.dart'; import 'package:twonly/globals.dart';
@ -11,7 +11,7 @@ void initLogger() {
// Logger.root.level = kReleaseMode ? Level.INFO : Level.ALL; // Logger.root.level = kReleaseMode ? Level.INFO : Level.ALL;
Logger.root.level = Level.ALL; Logger.root.level = Level.ALL;
Logger.root.onRecord.listen((record) async { Logger.root.onRecord.listen((record) async {
await _writeLogToFile(record); unawaited(_writeLogToFile(record));
if (!kReleaseMode) { if (!kReleaseMode) {
// ignore: avoid_print // ignore: avoid_print
print( print(
@ -67,83 +67,123 @@ class Log {
} }
Future<String> loadLogFile() async { Future<String> loadLogFile() async {
final directory = await getApplicationSupportDirectory(); return _protectFileAccess(() async {
final logFile = File('${directory.path}/app.log'); final logFile = File('$globalApplicationSupportDirectory/app.log');
if (logFile.existsSync()) { if (logFile.existsSync()) {
return logFile.readAsString(); return logFile.readAsString();
} else { } else {
return 'Log file does not exist.'; return 'Log file does not exist.';
} }
});
} }
Future<String> readLast1000Lines() async { Future<String> readLast1000Lines() async {
final dir = await getApplicationSupportDirectory(); return _protectFileAccess(() async {
final file = File('${dir.path}/app.log'); final file = File('$globalApplicationSupportDirectory/app.log');
if (!file.existsSync()) return ''; if (!file.existsSync()) return '';
final all = await file.readAsLines(); final all = await file.readAsLines();
final start = all.length > 1000 ? all.length - 1000 : 0; final start = all.length > 1000 ? all.length - 1000 : 0;
return all.sublist(start).join('\n'); return all.sublist(start).join('\n');
});
} }
Mutex sameProcessProtection = Mutex(); final Mutex _logMutex = Mutex();
Future<T> _protectFileAccess<T>(Future<T> Function() action) async {
return _logMutex.protect(() async {
final lockFile = File('$globalApplicationSupportDirectory/app.log.lock');
var lockAcquired = false;
while (!lockAcquired) {
try {
lockFile.createSync(exclusive: true);
lockAcquired = true;
} on FileSystemException catch (e) {
final isExists = e is PathExistsException || e.osError?.errorCode == 17;
if (!isExists) {
break;
}
try {
final stat = lockFile.statSync();
if (stat.type != FileSystemEntityType.notFound) {
final age = DateTime.now().difference(stat.modified).inMilliseconds;
if (age > 1000) {
lockFile.deleteSync();
continue;
}
}
} catch (_) {}
await Future.delayed(const Duration(milliseconds: 50));
} catch (_) {
break;
}
}
try {
return await action();
} finally {
if (lockAcquired) {
try {
if (lockFile.existsSync()) {
lockFile.deleteSync();
}
} catch (_) {}
}
}
});
}
Future<void> _writeLogToFile(LogRecord record) async { Future<void> _writeLogToFile(LogRecord record) async {
final directory = await getApplicationSupportDirectory(); final logFile = File('$globalApplicationSupportDirectory/app.log');
final logFile = File('${directory.path}/app.log');
if (!logFile.existsSync()) {
logFile.createSync(recursive: true);
}
final logMessage = final logMessage =
'${clock.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';
// > Note that this does not actually lock the file for access. Also note that advisory locks are on a process level. return _protectFileAccess(() async {
// > This means that several isolates in the same process can obtain an exclusive lock on the same file. if (!logFile.existsSync()) {
return sameProcessProtection.protect(() async { logFile.createSync(recursive: true);
}
final raf = await logFile.open(mode: FileMode.writeOnlyAppend); final raf = await logFile.open(mode: FileMode.writeOnlyAppend);
try { try {
// Use FileLock.blockingExclusive to wait until the lock is available
await raf.lock(FileLock.blockingExclusive);
await raf.writeString(logMessage); await raf.writeString(logMessage);
await raf.flush(); await raf.flush();
} catch (e) { } catch (e) {
// ignore: avoid_print // ignore: avoid_print
print('Error during file access: $e'); print('Error during file access: $e');
} finally { } finally {
await raf.unlock();
await raf.close(); await raf.close();
} }
}); });
} }
Future<void> cleanLogFile() async { Future<void> cleanLogFile() async {
final directory = await getApplicationSupportDirectory(); return _protectFileAccess(() async {
final logFile = File('${directory.path}/app.log'); final logFile = File('$globalApplicationSupportDirectory/app.log');
if (logFile.existsSync()) { if (logFile.existsSync()) {
final lines = await logFile.readAsLines(); final lines = await logFile.readAsLines();
if (lines.length <= 10000) return; if (lines.length <= 10000) return;
final removeCount = lines.length - 10000; final removeCount = lines.length - 10000;
final remaining = lines.sublist(removeCount, lines.length); final remaining = lines.sublist(removeCount, lines.length);
final sink = logFile.openWrite()..writeAll(remaining, '\n'); final sink = logFile.openWrite()..writeAll(remaining, '\n');
await sink.close(); await sink.close();
} }
});
} }
Future<bool> deleteLogFile() async { Future<bool> deleteLogFile() async {
final directory = await getApplicationSupportDirectory(); return _protectFileAccess(() async {
final logFile = File('${directory.path}/app.log'); final logFile = File('$globalApplicationSupportDirectory/app.log');
if (logFile.existsSync()) { if (logFile.existsSync()) {
await logFile.delete(); await logFile.delete();
return true; return true;
} }
return false; return false;
});
} }
String _getCallerSourceCodeFilename() { String _getCallerSourceCodeFilename() {
@ -159,8 +199,11 @@ String _getCallerSourceCodeFilename() {
lineNumber = parts.last.split(':')[1]; // Extract the line number lineNumber = parts.last.split(':')[1]; // Extract the line number
} else { } else {
final firstLine = stackTraceString.split('\n')[0]; final firstLine = stackTraceString.split('\n')[0];
fileName = fileName = firstLine
firstLine.split('/').last.split(':').first; // Extract the file name .split('/')
.last
.split(':')
.first; // Extract the file name
lineNumber = firstLine.split(':')[1]; // Extract the line number lineNumber = firstLine.split(':')[1]; // Extract the line number
} }
lineNumber = lineNumber.replaceAll(')', ''); lineNumber = lineNumber.replaceAll(')', '');

View file

@ -88,9 +88,8 @@ class MainCameraController {
scannedUrl = null; scannedUrl = null;
try { try {
await cameraController?.stopImageStream(); await cameraController?.stopImageStream();
} catch (e) { // ignore: empty_catches
Log.warn(e); } catch (e) {}
}
final cameraControllerTemp = cameraController; final cameraControllerTemp = cameraController;
cameraController = null; cameraController = null;
// prevents: CameraException(Disposed CameraController, buildPreview() was called on a disposed CameraController.) // prevents: CameraException(Disposed CameraController, buildPreview() was called on a disposed CameraController.)
@ -166,7 +165,8 @@ class MainCameraController {
selectedCameraDetails.minAvailableZoom = selectedCameraDetails.minAvailableZoom =
await cameraController?.getMinZoomLevel() ?? 1; await cameraController?.getMinZoomLevel() ?? 1;
selectedCameraDetails selectedCameraDetails
..isZoomAble = selectedCameraDetails.maxAvailableZoom != ..isZoomAble =
selectedCameraDetails.maxAvailableZoom !=
selectedCameraDetails.minAvailableZoom selectedCameraDetails.minAvailableZoom
..cameraLoaded = true ..cameraLoaded = true
..cameraId = cameraId; ..cameraId = cameraId;
@ -323,8 +323,9 @@ class MainCameraController {
customPaint = CustomPaint(painter: painter); customPaint = CustomPaint(painter: painter);
if (barcodes.isEmpty && timeSharedLinkWasSetWithQr != null) { if (barcodes.isEmpty && timeSharedLinkWasSetWithQr != null) {
if (timeSharedLinkWasSetWithQr! if (timeSharedLinkWasSetWithQr!.isAfter(
.isAfter(DateTime.now().subtract(const Duration(seconds: 2)))) { DateTime.now().subtract(const Duration(seconds: 2)),
)) {
setSharedLinkForPreview(null); setSharedLinkForPreview(null);
} }
} }
@ -376,8 +377,8 @@ class MainCameraController {
content: Text( content: Text(
globalRootScaffoldMessengerKey.currentContext?.lang globalRootScaffoldMessengerKey.currentContext?.lang
.verifiedPublicKey( .verifiedPublicKey(
getContactDisplayName(contact), getContactDisplayName(contact),
) ?? ) ??
'', '',
), ),
duration: const Duration(seconds: 6), duration: const Duration(seconds: 6),

View file

@ -3,7 +3,7 @@ description: "twonly, a privacy-friendly way to connect with friends through sec
publish_to: 'none' publish_to: 'none'
version: 0.0.97+97 version: 0.0.99+99
environment: environment:
sdk: ^3.11.0 sdk: ^3.11.0

View file

@ -38,7 +38,7 @@ void main() {
image: image:
'https://files.mastodon.social/media_attachments/files/115/883/317/526/523/824/original/6fa7ef90ec68f1f1.jpg', 'https://files.mastodon.social/media_attachments/files/115/883/317/526/523/824/original/6fa7ef90ec68f1f1.jpg',
vendor: Vendor.mastodonSocialMediaPosting, vendor: Vendor.mastodonSocialMediaPosting,
shareAction: 90, shareAction: 80,
likeAction: 290, likeAction: 290,
), ),
LinkParserTest( LinkParserTest(