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"]
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"]
path = lib/src/localization/translations
# url = ssh://git@git.twonly.eu:22222/twonly/twonly-translations.git

View file

@ -1,5 +1,14 @@
# 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
- 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
- The backend is hosted exclusively in Europe
## Planned
## Roadmap
- For Android: Optional support for [UnifiedPush](https://unifiedpush.org/)
- For Android: Reproducible Builds
- Implementing [Sealed Sender](https://signal.org/blog/sealed-sender/) to minimize metadata
- Switch from the Signal-Protocol to [MLS](https://github.com/openmls/openmls) for Post-Quantum-Crypto support
- And, of course, many more features such as dog filters, E2EE cloud backup, and more.
### Currently
- Focus on user-friendliness so that people enjoy using the app
- User discovery without a phone number
- 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
@ -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
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

View file

@ -37,7 +37,6 @@
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" />
<data android:scheme="https" />
<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();
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.receiptsDao.purgeReceivedReceipts();
unawaited(MediaFileService.purgeTempFolder());
await initFileDownloader();

View file

@ -131,4 +131,18 @@ class MediaFilesDao extends DatabaseAccessor<TwonlyDB>
..limit(100))
.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) {
final updates = MessagesCompanion(openedAt: Value(clock.now()));
return (update(messages)
@ -322,32 +275,6 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
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(
String messageId,
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) {
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();
}
// 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) {
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)]))
.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();
}
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 {
try {
var insertEntry = entry;

View file

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

View file

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

View file

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

View file

@ -313,7 +313,7 @@ class AppLocalizationsSv extends AppLocalizations {
String get settingsProfileEditDisplayNameNew => 'New Displayname';
@override
String get settingsAccount => 'Konto';
String get settingsAccount => 'Account';
@override
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/model/protobuf/client/generated/messages.pb.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/flame.service.dart';
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
@ -178,9 +179,10 @@ Future<void> handleMediaUpdate(
await twonlyDB.mediaFilesDao.updateMedia(
mediaFile.mediaId,
MediaFilesCompanion(
uploadState: const Value(UploadState.uploading),
uploadState: const Value(UploadState.preprocessing),
reuploadRequestedBy: Value(reuploadRequestedBy),
),
);
unawaited(startBackgroundMediaUpload(MediaFileService(mediaFile)));
}
}

View file

@ -23,10 +23,44 @@ Future<void> tryDownloadAllMediaFiles({bool force = false}) async {
await twonlyDB.mediaFilesDao.getAllMediaFilesPendingDownload();
for (final mediaFile in mediaFiles) {
await startDownloadMedia(mediaFile, force);
if (await canMediaFileBeDownloaded(mediaFile)) {
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 {
video,
image,
@ -90,11 +124,9 @@ Future<void> handleDownloadStatusUpdate(TaskStatusUpdate update) async {
failed = false;
} else {
failed = true;
if (update.responseStatusCode != null) {
Log.error(
'Got invalid response status code: ${update.responseStatusCode}',
);
}
Log.error(
'Got invalid response status code: ${update.responseStatusCode}',
);
}
} else {
Log.info('Got ${update.status} for $mediaId');

View file

@ -74,6 +74,14 @@ Future<(Uint8List, Uint8List?)?> tryToSendCompleteMessage({
}
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 &&
receipt.ackByServerAt != null &&
receipt.markForRetry == null) {
@ -177,9 +185,6 @@ Future<(Uint8List, Uint8List?)?> tryToSendCompleteMessage({
if (receiptId != null) {
await twonlyDB.receiptsDao.deleteReceipt(receiptId);
}
if (receipt != null) {
await twonlyDB.receiptsDao.deleteReceipt(receipt.receiptId);
}
}
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/public_profile.view.dart';
Future<void> handleIntentUrl(BuildContext context, Uri uri) async {
if (!uri.scheme.startsWith('http')) return;
if (uri.host != 'me.twonly.eu') return;
if (uri.hasEmptyPath) return;
Future<bool> handleIntentUrl(BuildContext context, Uri uri) async {
if (!uri.scheme.startsWith('http')) return false;
if (uri.host != 'me.twonly.eu') return false;
if (uri.hasEmptyPath) return false;
final publicKey = uri.hasFragment ? uri.fragment : null;
final userPaths = uri.path.split('/');
if (userPaths.length != 2) return;
if (userPaths.length != 2) return false;
final username = userPaths[1];
if (!context.mounted) return;
if (!context.mounted) return false;
if (username == gUser.username) {
await Navigator.push(
@ -40,7 +40,7 @@ Future<void> handleIntentUrl(BuildContext context, Uri uri) async {
},
),
);
return;
return true;
}
Log.info(
@ -48,7 +48,7 @@ Future<void> handleIntentUrl(BuildContext context, Uri uri) async {
);
final contacts = await twonlyDB.contactsDao.getContactsByUsername(username);
if (contacts.isEmpty) {
if (!context.mounted) return;
if (!context.mounted) return true;
Uint8List? publicKeyBytes;
if (publicKey != null) {
publicKeyBytes = base64Url.decode(publicKey);
@ -72,7 +72,7 @@ Future<void> handleIntentUrl(BuildContext context, Uri uri) async {
if (storedPublicKey == null ||
receivedPublicKey.isEmpty ||
!context.mounted) {
return;
return true;
}
if (storedPublicKey.equals(receivedPublicKey)) {
if (!contact.verified) {
@ -112,6 +112,7 @@ Future<void> handleIntentUrl(BuildContext context, Uri uri) async {
Log.warn(e);
}
}
return true;
}
Future<void> handleIntentMediaFile(
@ -160,12 +161,15 @@ Future<void> handleIntentSharedFile(
);
continue;
}
Log.info('got file via intent ${file.type} ${file.value}');
Log.info('got file via intent ${file.type}');
switch (file.type) {
case SharedMediaType.URL:
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:
var type = MediaType.image;

View file

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

View file

@ -5,6 +5,7 @@ import 'package:twonly/src/views/camera/camera_preview_components/painters/face_
enum FaceFilterType {
none,
dogBrown,
beardUpperLipGreen,
beardUpperLip,
}
@ -27,7 +28,9 @@ extension FaceFilterTypeExtension on FaceFilterType {
case FaceFilterType.dogBrown:
return DogFilterPainter.getPreview();
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();
}
void onImageSend() {
scannedUrl = '';
setState();
}
final BarcodeScanner _barcodeScanner = BarcodeScanner();
final FaceDetector _faceDetector = FaceDetector(
options: FaceDetectorOptions(
@ -78,7 +83,7 @@ class MainCameraController {
CustomPaint? facePaint;
Offset? focusPointOffset;
FaceFilterType _currentFilterType = FaceFilterType.beardUpperLip;
FaceFilterType _currentFilterType = FaceFilterType.none;
FaceFilterType get currentFilterType => _currentFilterType;
Future<void> closeCamera() async {
@ -392,20 +397,25 @@ class MainCameraController {
cameraController != null) {
if (faces.isNotEmpty) {
CustomPainter? painter;
if (_currentFilterType == FaceFilterType.dogBrown) {
painter = DogFilterPainter(
faces,
inputImage.metadata!.size,
inputImage.metadata!.rotation,
cameraController!.description.lensDirection,
);
} else if (_currentFilterType == FaceFilterType.beardUpperLip) {
painter = BeardFilterPainter(
faces,
inputImage.metadata!.size,
inputImage.metadata!.rotation,
cameraController!.description.lensDirection,
);
switch (_currentFilterType) {
case FaceFilterType.dogBrown:
painter = DogFilterPainter(
faces,
inputImage.metadata!.size,
inputImage.metadata!.rotation,
cameraController!.description.lensDirection,
);
case FaceFilterType.beardUpperLip:
case FaceFilterType.beardUpperLipGreen:
painter = BeardFilterPainter(
_currentFilterType,
faces,
inputImage.metadata!.size,
inputImage.metadata!.rotation,
cameraController!.description.lensDirection,
);
case FaceFilterType.none:
break;
}
if (painter != null) {

View file

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

View file

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

View file

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

View file

@ -33,7 +33,7 @@ class _BackgroundLayerState extends State<BackgroundLayer> {
width: widget.layerData.image.width.toDouble(),
height: widget.layerData.image.height.toDouble(),
padding: EdgeInsets.zero,
color: Colors.green,
color: Colors.transparent,
child: CustomPaint(
painter: UiImagePainter(scImage.image!),
),
@ -47,16 +47,25 @@ class UiImagePainter extends CustomPainter {
@override
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(
image,
Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble()),
Rect.fromLTWH(0, 0, size.width, size.height),
Rect.fromLTWH(0, 0, imageSize.width, imageSize.height),
destRect,
Paint(),
);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return false;
bool shouldRepaint(covariant UiImagePainter oldDelegate) {
return image != oldDelegate.image;
}
}

View file

@ -48,7 +48,10 @@ class MastodonPostCard extends StatelessWidget {
const SizedBox(height: 4),
if (info.desc != null && info.desc != 'null')
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),
),
if (info.image != null && info.image != 'null')
@ -57,7 +60,7 @@ class MastodonPostCard extends StatelessWidget {
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 250),
constraints: const BoxConstraints(maxHeight: 200),
child: CachedNetworkImage(
imageUrl: info.image!,
fit: BoxFit.contain,

View file

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

View file

@ -87,6 +87,7 @@ class HomeViewState extends State<HomeView> {
if (offsetRatio == 1) {
disableCameraTimer = Timer(const Duration(milliseconds: 500), () async {
await _mainCameraController.closeCamera();
_mainCameraController.sharedLinkForPreview = null;
disableCameraTimer = null;
});
}
@ -115,7 +116,14 @@ class HomeViewState extends State<HomeView> {
// Subscribe to all events (initial link and further)
_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(

View file

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

View file

@ -16,8 +16,6 @@ class UserStudyQuestionnaire extends StatefulWidget {
class _UserStudyQuestionnaireState extends State<UserStudyQuestionnaire> {
final Map<String, dynamic> _responses = {
'gender': null,
'gender_free': '',
'age': null,
'education': null,
'education_free': '',
@ -51,7 +49,9 @@ class _UserStudyQuestionnaireState extends State<UserStudyQuestionnaire> {
await updateUserdata((u) {
// 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;
});
@ -75,15 +75,6 @@ class _UserStudyQuestionnaireState extends State<UserStudyQuestionnaire> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_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?'),
_buildRadioList(
[

View file

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

View file

@ -3,7 +3,7 @@ description: "twonly, a privacy-friendly way to connect with friends through sec
publish_to: 'none'
version: 0.0.87+87
version: 0.0.90+90
environment:
sdk: ^3.6.0