Merge pull request 'dev' (#1) from dev into main

- Fixes issue that media files where not reuploaded
- Fixes iOS zooming issue when switching between .5 and x1
- Fixes biometric auth bypass when opening a twonly/reopen send image
- Fixes that media files could not be downloaded in case the contact deleted his account
- Fixes database issue in case twonly is opened multiple times
- Fixes typos in translation
This commit is contained in:
tsmr 2026-02-07 23:17:50 +00:00
commit 8fe00cadbe
34 changed files with 248 additions and 228 deletions

3
.gitmodules vendored
View file

@ -1,7 +1,8 @@
[submodule "dependencies"] [submodule "dependencies"]
path = dependencies path = dependencies
url = https://github.com/twonlyapp/twonly-app-dependencies.git # url = ssh://git@git.twonly.eu:22222/twonly/twonly-app-dependencies.git
url = https://git.twonly.eu/twonly/twonly-app-dependencies.git
[submodule "lib/src/localization/translations"] [submodule "lib/src/localization/translations"]
path = lib/src/localization/translations path = lib/src/localization/translations
# url = ssh://git@git.twonly.eu:22222/twonly/twonly-translations.git # url = ssh://git@git.twonly.eu:22222/twonly/twonly-translations.git

View file

@ -1,5 +1,14 @@
# Changelog # Changelog
## 0.0.90
- Fixes issue that media files where not reuploaded
- Fixes iOS zooming issue when switching between .5 and x1
- Fixes biometric auth bypass when opening a twonly/reopen send image
- Fixes that media files could not be downloaded in case the contact deleted his account
- Fixes database issue in case twonly is opened multiple times
- Fixes typos in translation
## 0.0.87 ## 0.0.87
- Adds link preview to images - Adds link preview to images

View file

@ -32,13 +32,24 @@ If you decide to give twonly a try, please keep in mind that it is still in its
- Privacy friendly - Everything is stored on the device - Privacy friendly - Everything is stored on the device
- The backend is hosted exclusively in Europe - The backend is hosted exclusively in Europe
## Planned ## Roadmap
- For Android: Optional support for [UnifiedPush](https://unifiedpush.org/) ### Currently
- For Android: Reproducible Builds
- Implementing [Sealed Sender](https://signal.org/blog/sealed-sender/) to minimize metadata - Focus on user-friendliness so that people enjoy using the app
- Switch from the Signal-Protocol to [MLS](https://github.com/openmls/openmls) for Post-Quantum-Crypto support - User discovery without a phone number
- And, of course, many more features such as dog filters, E2EE cloud backup, and more. - Passwordless recovery without a phone number
- Implementation of features so that Snapchat can actually be replaced
- E2EE cloud backup of memories
- Importing memories from Snapchat
### Next on the bucket list
- For Android: Support for [UnifiedPush] (https://unifiedpush.org/)
- For Android: Reproducible builds
- Implementation of [Sealed Sender](https://signal.org/blog/sealed-sender/) (or a similar protocol) to minimize metadata
- Switch from the Signal protocol to [MLS](https://github.com/openmls/openmls) for post-quantum crypto support
- Decentralize the server so that anyone can run their own server
## Security Issues ## Security Issues
@ -46,9 +57,9 @@ If you discover a security issue in twonly, please adhere to the coordinated vul
us your report to security@twonly.eu. We also offer for critical security issues a small bug bounties, but we can not us your report to security@twonly.eu. We also offer for critical security issues a small bug bounties, but we can not
guarantee a bounty currently :/ guarantee a bounty currently :/
## Contribution <!-- ## Contribution
If you have any questions or feature requests, feel free to start a new discussion. Issues are limited to bugs, and for maintainers only. If you have any questions or feature requests, feel free to start a new discussion. Issues are limited to bugs, and for maintainers only. -->
## Development ## Development

View file

@ -37,7 +37,6 @@
<intent-filter> <intent-filter>
<action android:name="android.intent.action.SEND" /> <action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" /> <data android:scheme="http" />
<data android:scheme="https" /> <data android:scheme="https" />
<data android:host="*" /> <data android:host="*" />

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

@ -1 +1 @@
Subproject commit 7930d9727019344238297d810661bc3e8f724c37 Subproject commit 3a3a7e5a6323da5413e3dd8c21abfa7cbe1c3a6f

View file

@ -64,7 +64,19 @@ void main() async {
apiService = ApiService(); apiService = ApiService();
twonlyDB = TwonlyDB(); twonlyDB = TwonlyDB();
if (user != null) {
if (gUser.appVersion < 90) {
// BUG: Requested media files for reupload where not reuploaded because the wrong state...
await twonlyDB.mediaFilesDao.updateAllRetransmissionUploadingState();
await updateUserdata((u) {
u.appVersion = 90;
return u;
});
}
}
await twonlyDB.messagesDao.purgeMessageTable(); await twonlyDB.messagesDao.purgeMessageTable();
await twonlyDB.receiptsDao.purgeReceivedReceipts();
unawaited(MediaFileService.purgeTempFolder()); unawaited(MediaFileService.purgeTempFolder());
await initFileDownloader(); await initFileDownloader();

View file

@ -131,4 +131,18 @@ class MediaFilesDao extends DatabaseAccessor<TwonlyDB>
..limit(100)) ..limit(100))
.watch(); .watch();
} }
Future<void> updateAllRetransmissionUploadingState() async {
await (update(mediaFiles)
..where(
(t) =>
t.uploadState.equals(UploadState.uploading.name) &
t.reuploadRequestedBy.isNotNull(),
))
.write(
const MediaFilesCompanion(
uploadState: Value(UploadState.preprocessing),
),
);
}
} }

View file

@ -131,53 +131,6 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
} }
} }
// Future<List<Message>> getAllMessagesPendingDownloading() {
// return (select(messages)
// ..where(
// (t) =>
// t.downloadState.equals(DownloadState.downloaded.index).not() &
// t.messageOtherId.isNotNull() &
// t.errorWhileSending.equals(false) &
// t.kind.equals(MessageKind.media.name),
// ))
// .get();
// }
// Future<List<Message>> getAllNonACKMessagesFromUser() {
// return (select(messages)
// ..where(
// (t) =>
// t.acknowledgeByUser.equals(false) &
// t.messageOtherId.isNull() &
// t.errorWhileSending.equals(false) &
// t.sendAt.isBiggerThanValue(
// clock.now().subtract(const Duration(minutes: 10)),
// ),
// ))
// .get();
// }
// Stream<List<Message>> getAllStoredMediaFiles() {
// return (select(messages)
// ..where((t) => t.mediaStored.equals(true))
// ..orderBy([(t) => OrderingTerm.desc(t.sendAt)]))
// .watch();
// }
// Future<List<Message>> getAllMessagesPendingUpload() {
// return (select(messages)
// ..where(
// (t) =>
// t.acknowledgeByServer.equals(false) &
// t.messageOtherId.isNull() &
// t.mediaUploadId.isNotNull() &
// t.downloadState.equals(DownloadState.pending.index) &
// t.errorWhileSending.equals(false) &
// t.kind.equals(MessageKind.media.name),
// ))
// .get();
// }
Future<void> openedAllTextMessages(String groupId) { Future<void> openedAllTextMessages(String groupId) {
final updates = MessagesCompanion(openedAt: Value(clock.now())); final updates = MessagesCompanion(openedAt: Value(clock.now()));
return (update(messages) return (update(messages)
@ -322,32 +275,6 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
return members.length == actions.length; return members.length == actions.length;
} }
// Future<void> updateMessageByOtherUser(
// int userId,
// int messageId,
// MessagesCompanion updatedValues,
// ) {
// return (update(messages)
// ..where(
// (c) => c.contactId.equals(userId) & c.messageId.equals(messageId),
// ))
// .write(updatedValues);
// }
// Future<void> updateMessageByOtherMessageId(
// int userId,
// int messageOtherId,
// MessagesCompanion updatedValues,
// ) {
// return (update(messages)
// ..where(
// (c) =>
// c.contactId.equals(userId) &
// c.messageOtherId.equals(messageOtherId),
// ))
// .write(updatedValues);
// }
Future<void> updateMessageId( Future<void> updateMessageId(
String messageId, String messageId,
MessagesCompanion updatedValues, MessagesCompanion updatedValues,
@ -445,27 +372,6 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
}); });
} }
// Future<void> deleteMessagesByContactId(int contactId) {
// return (delete(messages)
// ..where(
// (t) => t.contactId.equals(contactId) & t.mediaStored.equals(false),
// ))
// .go();
// }
// Future<void> deleteMessagesByContactIdAndOtherMessageId(
// int contactId,
// int messageOtherId,
// ) {
// return (delete(messages)
// ..where(
// (t) =>
// t.contactId.equals(contactId) &
// t.messageOtherId.equals(messageOtherId),
// ))
// .go();
// }
Future<void> deleteMessagesById(String messageId) { Future<void> deleteMessagesById(String messageId) {
return (delete(messages)..where((t) => t.messageId.equals(messageId))).go(); return (delete(messages)..where((t) => t.messageId.equals(messageId))).go();
} }
@ -474,24 +380,6 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
return (delete(messages)..where((t) => t.groupId.equals(groupId))).go(); return (delete(messages)..where((t) => t.groupId.equals(groupId))).go();
} }
// Future<void> deleteAllMessagesByContactId(int contactId) {
// return (delete(messages)..where((t) => t.contactId.equals(contactId))).go();
// }
// Future<bool> containsOtherMessageId(
// int fromUserId,
// int messageOtherId,
// ) async {
// final query = select(messages)
// ..where(
// (t) =>
// t.messageOtherId.equals(messageOtherId) &
// t.contactId.equals(fromUserId),
// );
// final entry = await query.get();
// return entry.isNotEmpty;
// }
SingleOrNullSelectable<Message> getMessageById(String messageId) { SingleOrNullSelectable<Message> getMessageById(String messageId) {
return select(messages)..where((t) => t.messageId.equals(messageId)); return select(messages)..where((t) => t.messageId.equals(messageId));
} }
@ -519,31 +407,4 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
..orderBy([(t) => OrderingTerm.desc(t.createdAt)])) ..orderBy([(t) => OrderingTerm.desc(t.createdAt)]))
.watch(); .watch();
} }
// Future<List<Message>> getMessagesByMediaUploadId(int mediaUploadId) async {
// return (select(messages)
// ..where((t) => t.mediaUploadId.equals(mediaUploadId)))
// .get();
// }
// SingleOrNullSelectable<Message> getMessageByOtherMessageId(
// int fromUserId,
// int messageId,
// ) {
// return select(messages)
// ..where(
// (t) =>
// t.messageOtherId.equals(messageId) & t.contactId.equals(fromUserId),
// );
// }
// SingleOrNullSelectable<Message> getMessageByIdAndContactId(
// int fromUserId,
// int messageId,
// ) {
// return select(messages)
// ..where(
// (t) => t.messageId.equals(messageId) & t.contactId.equals(fromUserId),
// );
// }
} }

View file

@ -51,6 +51,18 @@ class ReceiptsDao extends DatabaseAccessor<TwonlyDB> with _$ReceiptsDaoMixin {
.go(); .go();
} }
Future<void> purgeReceivedReceipts() async {
await (delete(receivedReceipts)
..where(
(t) => (t.createdAt.isSmallerThanValue(
clock.now().subtract(
const Duration(days: 25),
),
)),
))
.go();
}
Future<Receipt?> insertReceipt(ReceiptsCompanion entry) async { Future<Receipt?> insertReceipt(ReceiptsCompanion entry) async {
try { try {
var insertEntry = entry; var insertEntry = entry;

View file

@ -75,6 +75,7 @@ class TwonlyDB extends _$TwonlyDB {
name: 'twonly', name: 'twonly',
native: const DriftNativeOptions( native: const DriftNativeOptions(
databaseDirectory: getApplicationSupportDirectory, databaseDirectory: getApplicationSupportDirectory,
shareAcrossIsolates: true,
), ),
); );
} }
@ -166,6 +167,7 @@ class TwonlyDB extends _$TwonlyDB {
)) ))
.go(); .go();
await delete(receipts).go(); await delete(receipts).go();
await delete(receivedReceipts).go();
await update(contacts).write( await update(contacts).write(
const ContactsCompanion( const ContactsCompanion(
avatarSvgCompressed: Value(null), avatarSvgCompressed: Value(null),

View file

@ -667,7 +667,7 @@ abstract class AppLocalizations {
/// No description provided for @settingsAccount. /// No description provided for @settingsAccount.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Konto'** /// **'Account'**
String get settingsAccount; String get settingsAccount;
/// No description provided for @settingsSubscription. /// No description provided for @settingsSubscription.

View file

@ -313,7 +313,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get settingsProfileEditDisplayNameNew => 'New Displayname'; String get settingsProfileEditDisplayNameNew => 'New Displayname';
@override @override
String get settingsAccount => 'Konto'; String get settingsAccount => 'Account';
@override @override
String get settingsSubscription => 'Subscription'; String get settingsSubscription => 'Subscription';

View file

@ -313,7 +313,7 @@ class AppLocalizationsSv extends AppLocalizations {
String get settingsProfileEditDisplayNameNew => 'New Displayname'; String get settingsProfileEditDisplayNameNew => 'New Displayname';
@override @override
String get settingsAccount => 'Konto'; String get settingsAccount => 'Account';
@override @override
String get settingsSubscription => 'Subscription'; String get settingsSubscription => 'Subscription';

@ -1 +1 @@
Subproject commit 9d04e9e1d0cdba8f1be4b0cbba341706c3cffac9 Subproject commit 4caaa3d91aaf1ac2f13160ba770a2880c26bd229

View file

@ -6,6 +6,7 @@ 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';
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/utils.dart'; import 'package:twonly/src/services/api/utils.dart';
import 'package:twonly/src/services/flame.service.dart'; import 'package:twonly/src/services/flame.service.dart';
import 'package:twonly/src/services/mediafiles/mediafile.service.dart'; import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
@ -178,9 +179,10 @@ Future<void> handleMediaUpdate(
await twonlyDB.mediaFilesDao.updateMedia( await twonlyDB.mediaFilesDao.updateMedia(
mediaFile.mediaId, mediaFile.mediaId,
MediaFilesCompanion( MediaFilesCompanion(
uploadState: const Value(UploadState.uploading), uploadState: const Value(UploadState.preprocessing),
reuploadRequestedBy: Value(reuploadRequestedBy), reuploadRequestedBy: Value(reuploadRequestedBy),
), ),
); );
unawaited(startBackgroundMediaUpload(MediaFileService(mediaFile)));
} }
} }

View file

@ -23,9 +23,43 @@ Future<void> tryDownloadAllMediaFiles({bool force = false}) async {
await twonlyDB.mediaFilesDao.getAllMediaFilesPendingDownload(); await twonlyDB.mediaFilesDao.getAllMediaFilesPendingDownload();
for (final mediaFile in mediaFiles) { for (final mediaFile in mediaFiles) {
if (await canMediaFileBeDownloaded(mediaFile)) {
await startDownloadMedia(mediaFile, force); await startDownloadMedia(mediaFile, force);
} }
} }
}
Future<bool> canMediaFileBeDownloaded(MediaFile mediaFile) async {
final messages =
await twonlyDB.messagesDao.getMessagesByMediaId(mediaFile.mediaId);
// Verify that the sender of the original image / message does still exists.
// If not delete the message as it can not be downloaded from the server anymore.
if (messages.length != 1) {
Log.error('A media for download must have one original message.');
return false;
}
if (messages.first.senderId == null) {
Log.error('A media for download must have a sender id.');
return false;
}
final contact =
await twonlyDB.contactsDao.getContactById(messages.first.senderId!);
if (contact == null || contact.accountDeleted) {
Log.info(
'Sender does not exists anymore. Delete media file and message.',
);
await twonlyDB.mediaFilesDao.deleteMediaFile(mediaFile.mediaId);
await twonlyDB.messagesDao.deleteMessagesById(messages.first.messageId);
return false;
}
return true;
}
enum DownloadMediaTypes { enum DownloadMediaTypes {
video, video,
@ -90,12 +124,10 @@ Future<void> handleDownloadStatusUpdate(TaskStatusUpdate update) async {
failed = false; failed = false;
} else { } else {
failed = true; failed = true;
if (update.responseStatusCode != null) {
Log.error( Log.error(
'Got invalid response status code: ${update.responseStatusCode}', 'Got invalid response status code: ${update.responseStatusCode}',
); );
} }
}
} else { } else {
Log.info('Got ${update.status} for $mediaId'); Log.info('Got ${update.status} for $mediaId');
return; return;

View file

@ -74,6 +74,14 @@ Future<(Uint8List, Uint8List?)?> tryToSendCompleteMessage({
} }
receiptId = receipt.receiptId; receiptId = receipt.receiptId;
final contact =
await twonlyDB.contactsDao.getContactById(receipt.contactId);
if (contact == null || contact.accountDeleted) {
Log.warn('Will not send message again as user does not exist anymore.');
await twonlyDB.receiptsDao.deleteReceipt(receiptId);
return null;
}
if (!onlyReturnEncryptedData && if (!onlyReturnEncryptedData &&
receipt.ackByServerAt != null && receipt.ackByServerAt != null &&
receipt.markForRetry == null) { receipt.markForRetry == null) {
@ -177,9 +185,6 @@ Future<(Uint8List, Uint8List?)?> tryToSendCompleteMessage({
if (receiptId != null) { if (receiptId != null) {
await twonlyDB.receiptsDao.deleteReceipt(receiptId); await twonlyDB.receiptsDao.deleteReceipt(receiptId);
} }
if (receipt != null) {
await twonlyDB.receiptsDao.deleteReceipt(receipt.receiptId);
}
} }
return null; return null;
} }

View file

@ -19,17 +19,17 @@ import 'package:twonly/src/views/components/alert_dialog.dart';
import 'package:twonly/src/views/contact/contact.view.dart'; import 'package:twonly/src/views/contact/contact.view.dart';
import 'package:twonly/src/views/public_profile.view.dart'; import 'package:twonly/src/views/public_profile.view.dart';
Future<void> handleIntentUrl(BuildContext context, Uri uri) async { Future<bool> handleIntentUrl(BuildContext context, Uri uri) async {
if (!uri.scheme.startsWith('http')) return; if (!uri.scheme.startsWith('http')) return false;
if (uri.host != 'me.twonly.eu') return; if (uri.host != 'me.twonly.eu') return false;
if (uri.hasEmptyPath) return; if (uri.hasEmptyPath) return false;
final publicKey = uri.hasFragment ? uri.fragment : null; final publicKey = uri.hasFragment ? uri.fragment : null;
final userPaths = uri.path.split('/'); final userPaths = uri.path.split('/');
if (userPaths.length != 2) return; if (userPaths.length != 2) return false;
final username = userPaths[1]; final username = userPaths[1];
if (!context.mounted) return; if (!context.mounted) return false;
if (username == gUser.username) { if (username == gUser.username) {
await Navigator.push( await Navigator.push(
@ -40,7 +40,7 @@ Future<void> handleIntentUrl(BuildContext context, Uri uri) async {
}, },
), ),
); );
return; return true;
} }
Log.info( Log.info(
@ -48,7 +48,7 @@ Future<void> handleIntentUrl(BuildContext context, Uri uri) async {
); );
final contacts = await twonlyDB.contactsDao.getContactsByUsername(username); final contacts = await twonlyDB.contactsDao.getContactsByUsername(username);
if (contacts.isEmpty) { if (contacts.isEmpty) {
if (!context.mounted) return; if (!context.mounted) return true;
Uint8List? publicKeyBytes; Uint8List? publicKeyBytes;
if (publicKey != null) { if (publicKey != null) {
publicKeyBytes = base64Url.decode(publicKey); publicKeyBytes = base64Url.decode(publicKey);
@ -72,7 +72,7 @@ Future<void> handleIntentUrl(BuildContext context, Uri uri) async {
if (storedPublicKey == null || if (storedPublicKey == null ||
receivedPublicKey.isEmpty || receivedPublicKey.isEmpty ||
!context.mounted) { !context.mounted) {
return; return true;
} }
if (storedPublicKey.equals(receivedPublicKey)) { if (storedPublicKey.equals(receivedPublicKey)) {
if (!contact.verified) { if (!contact.verified) {
@ -112,6 +112,7 @@ Future<void> handleIntentUrl(BuildContext context, Uri uri) async {
Log.warn(e); Log.warn(e);
} }
} }
return true;
} }
Future<void> handleIntentMediaFile( Future<void> handleIntentMediaFile(
@ -160,12 +161,15 @@ Future<void> handleIntentSharedFile(
); );
continue; continue;
} }
Log.info('got file via intent ${file.type} ${file.value}');
Log.info('got file via intent ${file.type}');
switch (file.type) { switch (file.type) {
case SharedMediaType.URL: case SharedMediaType.URL:
if (file.value?.startsWith('http') ?? false) { if (file.value?.startsWith('http') ?? false) {
onUrlCallBack(Uri.parse(file.value!)); final uri = Uri.parse(file.value!);
Log.info('Got link via handle intent share file: ${uri.scheme}');
onUrlCallBack(uri);
} }
case SharedMediaType.IMAGE: case SharedMediaType.IMAGE:
var type = MediaType.image; var type = MediaType.image;

View file

@ -159,10 +159,14 @@ Future<bool> authenticateUser(
} }
} on LocalAuthException catch (e) { } on LocalAuthException catch (e) {
Log.error(e.toString()); Log.error(e.toString());
if (e.code == LocalAuthExceptionCode.noBiometricHardware ||
e.code == LocalAuthExceptionCode.noBiometricsEnrolled ||
e.code == LocalAuthExceptionCode.noCredentialsSet) {
if (!force) { if (!force) {
return true; return true;
} }
} }
}
return false; return false;
} }

View file

@ -5,6 +5,7 @@ import 'package:twonly/src/views/camera/camera_preview_components/painters/face_
enum FaceFilterType { enum FaceFilterType {
none, none,
dogBrown, dogBrown,
beardUpperLipGreen,
beardUpperLip, beardUpperLip,
} }
@ -27,7 +28,9 @@ extension FaceFilterTypeExtension on FaceFilterType {
case FaceFilterType.dogBrown: case FaceFilterType.dogBrown:
return DogFilterPainter.getPreview(); return DogFilterPainter.getPreview();
case FaceFilterType.beardUpperLip: case FaceFilterType.beardUpperLip:
return BeardFilterPainter.getPreview(); return BeardFilterPainter.getPreview(this);
case FaceFilterType.beardUpperLipGreen:
return BeardFilterPainter.getPreview(this);
} }
} }
} }

View file

@ -65,6 +65,11 @@ class MainCameraController {
setState(); setState();
} }
void onImageSend() {
scannedUrl = '';
setState();
}
final BarcodeScanner _barcodeScanner = BarcodeScanner(); final BarcodeScanner _barcodeScanner = BarcodeScanner();
final FaceDetector _faceDetector = FaceDetector( final FaceDetector _faceDetector = FaceDetector(
options: FaceDetectorOptions( options: FaceDetectorOptions(
@ -78,7 +83,7 @@ class MainCameraController {
CustomPaint? facePaint; CustomPaint? facePaint;
Offset? focusPointOffset; Offset? focusPointOffset;
FaceFilterType _currentFilterType = FaceFilterType.beardUpperLip; FaceFilterType _currentFilterType = FaceFilterType.none;
FaceFilterType get currentFilterType => _currentFilterType; FaceFilterType get currentFilterType => _currentFilterType;
Future<void> closeCamera() async { Future<void> closeCamera() async {
@ -392,20 +397,25 @@ class MainCameraController {
cameraController != null) { cameraController != null) {
if (faces.isNotEmpty) { if (faces.isNotEmpty) {
CustomPainter? painter; CustomPainter? painter;
if (_currentFilterType == FaceFilterType.dogBrown) { switch (_currentFilterType) {
case FaceFilterType.dogBrown:
painter = DogFilterPainter( painter = DogFilterPainter(
faces, faces,
inputImage.metadata!.size, inputImage.metadata!.size,
inputImage.metadata!.rotation, inputImage.metadata!.rotation,
cameraController!.description.lensDirection, cameraController!.description.lensDirection,
); );
} else if (_currentFilterType == FaceFilterType.beardUpperLip) { case FaceFilterType.beardUpperLip:
case FaceFilterType.beardUpperLipGreen:
painter = BeardFilterPainter( painter = BeardFilterPainter(
_currentFilterType,
faces, faces,
inputImage.metadata!.size, inputImage.metadata!.size,
inputImage.metadata!.rotation, inputImage.metadata!.rotation,
cameraController!.description.lensDirection, cameraController!.description.lensDirection,
); );
case FaceFilterType.none:
break;
} }
if (painter != null) { if (painter != null) {

View file

@ -6,27 +6,45 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:google_mlkit_face_detection/google_mlkit_face_detection.dart'; import 'package:google_mlkit_face_detection/google_mlkit_face_detection.dart';
import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/views/camera/camera_preview_components/face_filters.dart';
import 'package:twonly/src/views/camera/camera_preview_components/painters/coordinates_translator.dart'; import 'package:twonly/src/views/camera/camera_preview_components/painters/coordinates_translator.dart';
import 'package:twonly/src/views/camera/camera_preview_components/painters/face_filters/face_filter_painter.dart'; import 'package:twonly/src/views/camera/camera_preview_components/painters/face_filters/face_filter_painter.dart';
class BeardFilterPainter extends FaceFilterPainter { class BeardFilterPainter extends FaceFilterPainter {
BeardFilterPainter( BeardFilterPainter(
FaceFilterType beardType,
super.faces, super.faces,
super.imageSize, super.imageSize,
super.rotation, super.rotation,
super.cameraLensDirection, super.cameraLensDirection,
) { ) {
_loadAssets(); _loadAssets(beardType);
} }
static FaceFilterType? _lastLoadedBeardType;
static ui.Image? _beardImage; static ui.Image? _beardImage;
static bool _loading = false; static bool _loading = false;
static Future<void> _loadAssets() async { static String getAssetPath(FaceFilterType beardType) {
if (_loading || _beardImage != null) return; switch (beardType) {
case FaceFilterType.beardUpperLip:
return 'assets/filters/beard_upper_lip.webp';
case FaceFilterType.beardUpperLipGreen:
return 'assets/filters/beard_upper_lip_green.webp';
case FaceFilterType.dogBrown:
case FaceFilterType.none:
return '';
}
}
static Future<void> _loadAssets(FaceFilterType beardType) async {
if ((_loading || _beardImage != null) &&
_lastLoadedBeardType == beardType) {
return;
}
_loading = true; _loading = true;
try { try {
_beardImage = await _loadImage('assets/filters/beard_upper_lip.webp'); _beardImage = await _loadImage(getAssetPath(beardType));
} catch (e) { } catch (e) {
Log.error('Failed to load filter assets: $e'); Log.error('Failed to load filter assets: $e');
} finally { } finally {
@ -161,12 +179,12 @@ class BeardFilterPainter extends FaceFilterPainter {
..restore(); ..restore();
} }
static Widget getPreview() { static Widget getPreview(FaceFilterType beardType) {
return Preview( return Preview(
child: Padding( child: Padding(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
child: Image.asset( child: Image.asset(
'assets/filters/beard_upper_lip.webp', getAssetPath(beardType),
fit: BoxFit.contain, fit: BoxFit.contain,
), ),
), ),

View file

@ -152,7 +152,8 @@ class _CameraZoomButtonsState extends State<CameraZoomButtons> {
), ),
onPressed: () async { onPressed: () async {
if (showWideAngleZoomIOS && if (showWideAngleZoomIOS &&
widget.selectedCameraDetails.cameraId == 2) { widget.selectedCameraDetails.cameraId ==
_wideCameraIndex) {
await widget.selectCamera(0, true); await widget.selectCamera(0, true);
} else { } else {
widget.updateScaleFactor(1.0); widget.updateScaleFactor(1.0);
@ -175,6 +176,12 @@ class _CameraZoomButtonsState extends State<CameraZoomButtons> {
final level = final level =
min(await widget.controller.getMaxZoomLevel(), 2) min(await widget.controller.getMaxZoomLevel(), 2)
.toDouble(); .toDouble();
if (showWideAngleZoomIOS &&
widget.selectedCameraDetails.cameraId ==
_wideCameraIndex) {
await widget.selectCamera(0, true);
}
widget.updateScaleFactor(level); widget.updateScaleFactor(level);
}, },
child: Text( child: Text(

View file

@ -424,6 +424,7 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
), ),
) as bool?; ) as bool?;
if (wasSend != null && wasSend && mounted) { if (wasSend != null && wasSend && mounted) {
widget.mainCameraController?.onImageSend();
Navigator.pop(context, true); Navigator.pop(context, true);
} else { } else {
await videoController?.play(); await videoController?.play();
@ -591,6 +592,8 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
if (!context.mounted) return; if (!context.mounted) return;
widget.mainCameraController?.onImageSend();
// must be awaited so the widget for the screenshot is not already disposed when sending.. // must be awaited so the widget for the screenshot is not already disposed when sending..
await storeImageAsOriginal(); await storeImageAsOriginal();

View file

@ -33,7 +33,7 @@ class _BackgroundLayerState extends State<BackgroundLayer> {
width: widget.layerData.image.width.toDouble(), width: widget.layerData.image.width.toDouble(),
height: widget.layerData.image.height.toDouble(), height: widget.layerData.image.height.toDouble(),
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
color: Colors.green, color: Colors.transparent,
child: CustomPaint( child: CustomPaint(
painter: UiImagePainter(scImage.image!), painter: UiImagePainter(scImage.image!),
), ),
@ -47,16 +47,25 @@ class UiImagePainter extends CustomPainter {
@override @override
void paint(Canvas canvas, Size size) { void paint(Canvas canvas, Size size) {
final imageSize = Size(image.width.toDouble(), image.height.toDouble());
final sizes = applyBoxFit(BoxFit.contain, imageSize, size);
final destRect = Alignment.center.inscribe(
sizes.destination,
Rect.fromLTWH(0, 0, size.width, size.height),
);
canvas.drawImageRect( canvas.drawImageRect(
image, image,
Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble()), Rect.fromLTWH(0, 0, imageSize.width, imageSize.height),
Rect.fromLTWH(0, 0, size.width, size.height), destRect,
Paint(), Paint(),
); );
} }
@override @override
bool shouldRepaint(covariant CustomPainter oldDelegate) { bool shouldRepaint(covariant UiImagePainter oldDelegate) {
return false; return image != oldDelegate.image;
} }
} }

View file

@ -48,7 +48,10 @@ class MastodonPostCard extends StatelessWidget {
const SizedBox(height: 4), const SizedBox(height: 4),
if (info.desc != null && info.desc != 'null') if (info.desc != null && info.desc != 'null')
Text( Text(
substringBy(info.desc!, 1000), substringBy(
info.desc!.replaceAll('Attached: 1 image', '').trim(),
info.image == null ? 500 : 300,
),
style: const TextStyle(color: Colors.white, fontSize: 14), style: const TextStyle(color: Colors.white, fontSize: 14),
), ),
if (info.image != null && info.image != 'null') if (info.image != null && info.image != 'null')
@ -57,7 +60,7 @@ class MastodonPostCard extends StatelessWidget {
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 250), constraints: const BoxConstraints(maxHeight: 200),
child: CachedNetworkImage( child: CachedNetworkImage(
imageUrl: info.image!, imageUrl: info.image!,
fit: BoxFit.contain, fit: BoxFit.contain,

View file

@ -55,7 +55,7 @@ class TwitterPostCard extends StatelessWidget {
const SizedBox(height: 8), const SizedBox(height: 8),
if (info.desc != null && info.desc != 'null') if (info.desc != null && info.desc != 'null')
Text( Text(
substringBy(info.desc!, 1000), substringBy(info.desc!, info.image == null ? 500 : 300),
style: const TextStyle( style: const TextStyle(
color: primaryText, color: primaryText,
fontSize: 15, fontSize: 15,
@ -73,7 +73,7 @@ class TwitterPostCard extends StatelessWidget {
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
), ),
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 300), constraints: const BoxConstraints(maxHeight: 200),
child: CachedNetworkImage( child: CachedNetworkImage(
imageUrl: info.image!, imageUrl: info.image!,
fit: BoxFit.cover, fit: BoxFit.cover,

View file

@ -87,6 +87,7 @@ class HomeViewState extends State<HomeView> {
if (offsetRatio == 1) { if (offsetRatio == 1) {
disableCameraTimer = Timer(const Duration(milliseconds: 500), () async { disableCameraTimer = Timer(const Duration(milliseconds: 500), () async {
await _mainCameraController.closeCamera(); await _mainCameraController.closeCamera();
_mainCameraController.sharedLinkForPreview = null;
disableCameraTimer = null; disableCameraTimer = null;
}); });
} }
@ -115,7 +116,14 @@ class HomeViewState extends State<HomeView> {
// Subscribe to all events (initial link and further) // Subscribe to all events (initial link and further)
_deepLinkSub = AppLinks().uriLinkStream.listen((uri) async { _deepLinkSub = AppLinks().uriLinkStream.listen((uri) async {
if (mounted) await handleIntentUrl(context, uri); if (mounted) {
Log.info('Got link via app links: ${uri.scheme}');
if (!await handleIntentUrl(context, uri)) {
if (uri.scheme.startsWith('http')) {
_mainCameraController.setSharedLinkForPreview(uri);
}
}
}
}); });
_intentStreamSub = FlutterSharingIntent.instance.getMediaStream().listen( _intentStreamSub = FlutterSharingIntent.instance.getMediaStream().listen(

View file

@ -4,6 +4,7 @@ import 'package:http/http.dart' as http;
import 'package:twonly/globals.dart'; import 'package:twonly/globals.dart';
import 'package:twonly/src/utils/keyvalue.dart'; import 'package:twonly/src/utils/keyvalue.dart';
import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/utils/storage.dart'; import 'package:twonly/src/utils/storage.dart';
const userStudySurveyKey = 'user_study_survey'; const userStudySurveyKey = 'user_study_survey';
@ -34,9 +35,8 @@ Future<void> handleUserStudyUpload() async {
await KeyValueStore.delete(userStudySurveyKey); await KeyValueStore.delete(userStudySurveyKey);
} }
if (gUser.lastUserStudyDataUpload if (gUser.lastUserStudyDataUpload != null &&
?.isAfter(DateTime.now().subtract(const Duration(days: 1))) ?? isToday(gUser.lastUserStudyDataUpload!)) {
false) {
// Only send updates once a day. // Only send updates once a day.
// This enables to see if improvements to actually work. // This enables to see if improvements to actually work.
return; return;

View file

@ -16,8 +16,6 @@ class UserStudyQuestionnaire extends StatefulWidget {
class _UserStudyQuestionnaireState extends State<UserStudyQuestionnaire> { class _UserStudyQuestionnaireState extends State<UserStudyQuestionnaire> {
final Map<String, dynamic> _responses = { final Map<String, dynamic> _responses = {
'gender': null,
'gender_free': '',
'age': null, 'age': null,
'education': null, 'education': null,
'education_free': '', 'education_free': '',
@ -51,7 +49,9 @@ class _UserStudyQuestionnaireState extends State<UserStudyQuestionnaire> {
await updateUserdata((u) { await updateUserdata((u) {
// generate a random participants id to identify data send later while keeping the user anonym // generate a random participants id to identify data send later while keeping the user anonym
u.userStudyParticipantsToken = getRandomString(25); u
..userStudyParticipantsToken = getRandomString(25)
..askedForUserStudyPermission = true;
return u; return u;
}); });
@ -75,15 +75,6 @@ class _UserStudyQuestionnaireState extends State<UserStudyQuestionnaire> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_sectionTitle('Demografische Daten'), _sectionTitle('Demografische Daten'),
_questionText('Was ist dein Geschlecht?'),
_buildRadioList(
['Männlich', 'Weiblich', 'Divers', 'Keine Angabe'],
'gender',
),
_buildTextField(
'Freitext (optional)',
(val) => _responses['gender_free'] = val,
),
_questionText('Wie alt bist du?'), _questionText('Wie alt bist du?'),
_buildRadioList( _buildRadioList(
[ [

View file

@ -905,7 +905,7 @@ packages:
path: "dependencies/hashlib" path: "dependencies/hashlib"
relative: true relative: true
source: path source: path
version: "2.2.0" version: "2.3.0"
hashlib_codecs: hashlib_codecs:
dependency: "direct overridden" dependency: "direct overridden"
description: description:
@ -1259,7 +1259,7 @@ packages:
path: "dependencies/no_screenshot" path: "dependencies/no_screenshot"
relative: true relative: true
source: path source: path
version: "0.3.2-beta.3" version: "0.3.2"
objective_c: objective_c:
dependency: transitive dependency: transitive
description: description:

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.87+87 version: 0.0.90+90
environment: environment:
sdk: ^3.6.0 sdk: ^3.6.0