From 840dfed9507bcb25c5d3f1f51b82f83e84f184be Mon Sep 17 00:00:00 2001 From: otsmr Date: Fri, 10 Apr 2026 19:15:51 +0200 Subject: [PATCH] fixes android video compression issues --- CHANGELOG.md | 4 +- .../eu/twonly/VideoCompressionChannel.kt | 23 +-- lib/src/database/daos/receipts.dao.dart | 7 + .../generated/app_localizations_de.dart | 2 +- lib/src/services/api/messages.dart | 4 +- lib/src/views/chats/chat_messages.view.dart | 2 +- .../message_input.dart | 2 +- .../typing_indicator.dart | 11 +- .../developer/retransmission_data.view.dart | 193 ++++++++++++------ 9 files changed, 162 insertions(+), 86 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72e0f89..2814eef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,9 @@ - New: Typing and chat open indicator - New: Screen lock for twonly (Can be enabled in the settings.) -- Fix: Several minor issues with the user interface +- Improve: Visual indication when connected to the server +- Improve: Several minor issues with the user interface +- Fix: Poor audio quality and edge distortions in videos sent from Android ## 0.1.3 diff --git a/android/app/src/main/kotlin/eu/twonly/VideoCompressionChannel.kt b/android/app/src/main/kotlin/eu/twonly/VideoCompressionChannel.kt index fc75e25..3958f70 100644 --- a/android/app/src/main/kotlin/eu/twonly/VideoCompressionChannel.kt +++ b/android/app/src/main/kotlin/eu/twonly/VideoCompressionChannel.kt @@ -6,8 +6,8 @@ import android.os.Handler import android.os.Looper import com.otaliastudios.transcoder.Transcoder import com.otaliastudios.transcoder.TranscoderListener -import com.otaliastudios.transcoder.strategy.DefaultAudioStrategy import com.otaliastudios.transcoder.strategy.DefaultVideoStrategy +import com.otaliastudios.transcoder.strategy.PassThroughTrackStrategy import com.otaliastudios.transcoder.strategy.TrackStrategy import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodChannel @@ -17,11 +17,6 @@ object VideoCompressionChannel { // Compression parameters defined natively (as requested) private const val VIDEO_BITRATE = 2_000_000L // 2 Mbps - - // Audio parameters defined natively - private const val AUDIO_BITRATE = 128_000L // 128 kbps - private const val AUDIO_SAMPLE_RATE = 44_100 - private const val AUDIO_CHANNELS = 2 fun configure(flutterEngine: FlutterEngine, context: Context) { val channel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL) @@ -54,6 +49,14 @@ object VideoCompressionChannel { val result = if (args != null) method.invoke(baseVideoStrategy, *args) else method.invoke(baseVideoStrategy) if (method.name == "createOutputFormat" && result is MediaFormat) { result.setString(MediaFormat.KEY_MIME, MediaFormat.MIMETYPE_VIDEO_HEVC) + + if (result.containsKey(MediaFormat.KEY_WIDTH) && result.containsKey(MediaFormat.KEY_HEIGHT)) { + val width = result.getInteger(MediaFormat.KEY_WIDTH) + val height = result.getInteger(MediaFormat.KEY_HEIGHT) + // Align dimensions to a multiple of 16 to prevent edge artifacts (green lines/distortions) + result.setInteger(MediaFormat.KEY_WIDTH, width - (width % 16)) + result.setInteger(MediaFormat.KEY_HEIGHT, height - (height % 16)) + } } result } as TrackStrategy @@ -61,13 +64,7 @@ object VideoCompressionChannel { Transcoder.into(outputPath) .addDataSource(inputPath) .setVideoTrackStrategy(hevcStrategy) - .setAudioTrackStrategy( - DefaultAudioStrategy.builder() - .channels(AUDIO_CHANNELS) - .sampleRate(AUDIO_SAMPLE_RATE) - .bitRate(AUDIO_BITRATE) - .build() - ) + .setAudioTrackStrategy(PassThroughTrackStrategy()) .setListener(object : TranscoderListener { override fun onTranscodeProgress(progress: Double) { mainHandler.post { diff --git a/lib/src/database/daos/receipts.dao.dart b/lib/src/database/daos/receipts.dao.dart index 34bdf56..d3e4b78 100644 --- a/lib/src/database/daos/receipts.dao.dart +++ b/lib/src/database/daos/receipts.dao.dart @@ -54,6 +54,13 @@ class ReceiptsDao extends DatabaseAccessor with _$ReceiptsDaoMixin { .go(); } + Future deleteReceiptForUser(int contactId) async { + await (delete(receipts)..where( + (t) => t.contactId.equals(contactId), + )) + .go(); + } + Future purgeReceivedReceipts() async { await (delete(receivedReceipts)..where( (t) => (t.createdAt.isSmallerThanValue( diff --git a/lib/src/localization/generated/app_localizations_de.dart b/lib/src/localization/generated/app_localizations_de.dart index 2b9e2c8..eb3a0e8 100644 --- a/lib/src/localization/generated/app_localizations_de.dart +++ b/lib/src/localization/generated/app_localizations_de.dart @@ -1760,5 +1760,5 @@ class AppLocalizationsDe extends AppLocalizations { @override String get settingsTypingIndicationSubtitle => - 'Bei deaktiviertem Tipp-Indikatoren kannst du nicht sehen, wenn andere gerade eine Nachricht tippen.'; + 'Bei deaktivierten Tipp-Indikatoren kannst du nicht sehen, wenn andere gerade eine Nachricht tippen.'; } diff --git a/lib/src/services/api/messages.dart b/lib/src/services/api/messages.dart index d4af38d..d0cca10 100644 --- a/lib/src/services/api/messages.dart +++ b/lib/src/services/api/messages.dart @@ -95,8 +95,6 @@ Future<(Uint8List, Uint8List?)?> tryToSendCompleteMessage({ return null; } - Log.info('Uploading $receiptId'); - final message = pb.Message.fromBuffer(receipt.message) ..receiptId = receiptId; @@ -110,6 +108,8 @@ Future<(Uint8List, Uint8List?)?> tryToSendCompleteMessage({ encryptedContent, ); + Log.info('Uploading $receiptId. (${pushNotification?.kind})'); + Uint8List? pushData; if (pushNotification != null && receipt.retryCount <= 1) { // Only show the push notification the first two time. diff --git a/lib/src/views/chats/chat_messages.view.dart b/lib/src/views/chats/chat_messages.view.dart index 9aa9c91..c3c3ac1 100644 --- a/lib/src/views/chats/chat_messages.view.dart +++ b/lib/src/views/chats/chat_messages.view.dart @@ -124,7 +124,7 @@ class _ChatMessagesViewState extends State { if (gUser.typingIndicators) { unawaited(sendTypingIndication(widget.groupId, false)); - _nextTypingIndicator = Timer.periodic(const Duration(seconds: 8), ( + _nextTypingIndicator = Timer.periodic(const Duration(seconds: 5), ( _, ) async { await sendTypingIndication(widget.groupId, false); diff --git a/lib/src/views/chats/chat_messages_components/message_input.dart b/lib/src/views/chats/chat_messages_components/message_input.dart index 9792809..8b19a12 100644 --- a/lib/src/views/chats/chat_messages_components/message_input.dart +++ b/lib/src/views/chats/chat_messages_components/message_input.dart @@ -73,7 +73,7 @@ class _MessageInputState extends State { } widget.textFieldFocus.addListener(_handleTextFocusChange); if (gUser.typingIndicators) { - _nextTypingIndicator = Timer.periodic(const Duration(seconds: 5), ( + _nextTypingIndicator = Timer.periodic(const Duration(seconds: 1), ( _, ) async { if (widget.textFieldFocus.hasFocus) { diff --git a/lib/src/views/chats/chat_messages_components/typing_indicator.dart b/lib/src/views/chats/chat_messages_components/typing_indicator.dart index 1dfe36f..695b897 100644 --- a/lib/src/views/chats/chat_messages_components/typing_indicator.dart +++ b/lib/src/views/chats/chat_messages_components/typing_indicator.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:clock/clock.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:twonly/globals.dart'; @@ -94,22 +95,24 @@ class _TypingIndicatorState extends State bool isTyping(GroupMember member) { return member.lastTypeIndicator != null && - DateTime.now() + clock + .now() .difference( member.lastTypeIndicator!, ) .inSeconds <= - 12; + 2; } bool hasChatOpen(GroupMember member) { return member.lastChatOpened != null && - DateTime.now() + clock + .now() .difference( member.lastChatOpened!, ) .inSeconds <= - 12; + 8; } @override diff --git a/lib/src/views/settings/developer/retransmission_data.view.dart b/lib/src/views/settings/developer/retransmission_data.view.dart index bc4615b..b15446a 100644 --- a/lib/src/views/settings/developer/retransmission_data.view.dart +++ b/lib/src/views/settings/developer/retransmission_data.view.dart @@ -4,11 +4,14 @@ import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:hashlib/random.dart'; import 'package:twonly/globals.dart'; +import 'package:twonly/src/database/daos/contacts.dao.dart'; import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart' as pb; import 'package:twonly/src/services/api/messages.dart'; import 'package:twonly/src/services/notifications/pushkeys.notifications.dart'; +import 'package:twonly/src/views/components/alert_dialog.dart'; +import 'package:twonly/src/views/components/avatar_icon.component.dart'; class RetransmissionDataView extends StatefulWidget { const RetransmissionDataView({super.key}); @@ -49,6 +52,10 @@ class _RetransmissionDataViewState extends State { StreamSubscription>? subscriptionContacts; List messages = []; + Map _contactCount = {}; + + int? _filterForUserId; + @override void initState() { super.initState(); @@ -77,16 +84,39 @@ class _RetransmissionDataViewState extends State { subscriptionRetransmission = twonlyDB.receiptsDao.watchAll().listen(( updated, ) { - retransmissions = updated; + retransmissions = updated.reversed.toList(); if (contacts.isNotEmpty) { messages = RetransMsg.fromRaw(retransmissions, contacts); } + _contactCount = {}; + for (final retransmission in updated) { + _contactCount[retransmission.contactId] = + (_contactCount[retransmission.contactId] ?? 0) + 1; + } setState(() {}); }); } + Future deleteAllForSelectedUser() async { + final ok = await showAlertDialog( + context, + 'Sure?', + 'This will delete all retransmission messages for ${contacts[_filterForUserId!]!.username}', + ); + if (ok) { + await twonlyDB.receiptsDao.deleteReceiptForUser(_filterForUserId!); + } + } + @override Widget build(BuildContext context) { + var messagesToShow = messages; + if (_filterForUserId != null) { + messagesToShow = messagesToShow + .where((m) => m.contact?.userId == _filterForUserId) + .toList(); + } + return Scaffold( appBar: AppBar( title: const Text('Retransmission Database'), @@ -94,69 +124,106 @@ class _RetransmissionDataViewState extends State { body: Column( children: [ Expanded( - child: ListView( - reverse: true, - children: messages - .map( - (retrans) => ListTile( - title: Text( - retrans.receipt.receiptId, - ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'To ${retrans.contact?.username}', - ), - Text( - 'Server-Ack: ${retrans.receipt.ackByServerAt}', - ), - if (retrans.receipt.messageId != null) - Text( - 'MessageId: ${retrans.receipt.messageId}', - ), - if (retrans.receipt.messageId != null) - FutureBuilder( - future: getPushNotificationFromEncryptedContent( - retrans.receipt.contactId, - retrans.receipt.messageId, - pb.EncryptedContent.fromBuffer( - pb.Message.fromBuffer( - retrans.receipt.message, - ).encryptedContent, - ), - ), - builder: (d, a) { - if (!a.hasData) return Container(); - return Text( - 'PushKind: ${a.data?.kind}', - ); - }, - ), - Text( - 'Retry: ${retrans.receipt.retryCount} : ${retrans.receipt.lastRetry}', - ), - ], - ), - trailing: FilledButton.icon( - onPressed: () async { - final newReceiptId = uuid.v4(); - await twonlyDB.receiptsDao.updateReceipt( - retrans.receipt.receiptId, - ReceiptsCompanion( - receiptId: Value(newReceiptId), - ackByServerAt: const Value(null), - ), - ); - await tryToSendCompleteMessage( - receiptId: newReceiptId, - ); - }, - label: const FaIcon(FontAwesomeIcons.arrowRotateRight), - ), + child: ListView.builder( + itemCount: 1 + messagesToShow.length + _contactCount.length, + itemBuilder: (context, index) { + if (index == 0) { + return Center( + child: FilledButton( + onPressed: _filterForUserId == null + ? null + : deleteAllForSelectedUser, + child: const Text('Delete all shown entries'), ), - ) - .toList(), + ); + } + index -= 1; + if (index < _contactCount.length) { + final contact = contacts[_contactCount.keys.elementAt(index)]; + if (contact == null) return Container(); + return ListTile( + leading: AvatarIcon( + contactId: contact.userId, + ), + title: Text( + getContactDisplayName(contact), + ), + trailing: Text( + _contactCount.values.elementAt(index).toString(), + ), + onTap: () { + if (_filterForUserId == contact.userId) { + setState(() { + _filterForUserId = null; + }); + } else { + setState(() { + _filterForUserId = contact.userId; + }); + } + }, + ); + } + index -= _contactCount.length; + final retrans = messagesToShow[index]; + return ListTile( + title: Text( + retrans.receipt.receiptId, + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'To ${retrans.contact?.username}', + ), + Text( + 'Server-Ack: ${retrans.receipt.ackByServerAt}', + ), + if (retrans.receipt.messageId != null) + Text( + 'MessageId: ${retrans.receipt.messageId}', + ), + if (retrans.receipt.messageId != null) + FutureBuilder( + future: getPushNotificationFromEncryptedContent( + retrans.receipt.contactId, + retrans.receipt.messageId, + pb.EncryptedContent.fromBuffer( + pb.Message.fromBuffer( + retrans.receipt.message, + ).encryptedContent, + ), + ), + builder: (d, a) { + if (!a.hasData) return Container(); + return Text( + 'PushKind: ${a.data?.kind}', + ); + }, + ), + Text( + 'Retry: ${retrans.receipt.retryCount} : ${retrans.receipt.lastRetry}', + ), + ], + ), + trailing: FilledButton.icon( + onPressed: () async { + final newReceiptId = uuid.v4(); + await twonlyDB.receiptsDao.updateReceipt( + retrans.receipt.receiptId, + ReceiptsCompanion( + receiptId: Value(newReceiptId), + ackByServerAt: const Value(null), + ), + ); + await tryToSendCompleteMessage( + receiptId: newReceiptId, + ); + }, + label: const FaIcon(FontAwesomeIcons.arrowRotateRight), + ), + ); + }, ), ), ],