mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-04-20 08:22:54 +00:00
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
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run
This commit is contained in:
parent
cdaca940df
commit
cc0b88718b
13 changed files with 285 additions and 192 deletions
13
.github/workflows/dev_github.yml
vendored
13
.github/workflows/dev_github.yml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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(')', '');
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue