From bd198acd0c7257c871b3d6a3354d4c06c8027336 Mon Sep 17 00:00:00 2001 From: otsmr Date: Mon, 31 Aug 2026 15:24:18 +0200 Subject: [PATCH] direct media upload --- android/app/build.gradle | 7 +- android/app/src/main/AndroidManifest.xml | 7 + .../src/main/kotlin/eu/twonly/MainActivity.kt | 1 - .../main/kotlin/eu/twonly/MyApplication.kt | 12 +- .../eu/twonly/VideoCompressionChannel.kt | 104 -- .../twonly/directmedia/DirectMediaTransfer.kt | 64 + .../directmedia/DirectMediaUploadWorker.kt | 110 ++ .../kotlin/eu/twonly/media/NativeGallery.kt | 79 + .../eu/twonly/media/NativeImageCodec.kt | 34 + .../eu/twonly/media/NativeVideoCodec.kt | 331 +++++ integration_test/api_service.test.dart | 10 +- ios/Podfile.lock | 44 - ios/Runner.xcodeproj/project.pbxproj | 20 +- ios/Runner/AppDelegate.swift | 41 +- ios/Runner/DirectMediaTransfer.swift | 155 ++ ios/Runner/Info.plist | 7 +- ios/Runner/NativeGallery.swift | 48 + ios/Runner/NativeImageCodec.swift | 38 + ios/Runner/NativeVideoCodec.swift | 320 ++++ ios/Runner/VideoCompressionChannel.swift | 253 ---- lib/core/bridge/api.dart | 144 +- lib/core/bridge/callbacks.dart | 12 +- lib/core/bridge/wrapper/key_manager.dart | 3 - lib/core/frb_generated.dart | 1320 ++++++++++++----- lib/core/frb_generated.io.dart | 63 +- lib/core/frb_generated.web.dart | 63 +- lib/core/services/media_upload.dart | 32 + lib/main.dart | 11 +- lib/src/callbacks/callbacks.dart | 32 +- .../channels/video_compression.channel.dart | 46 - lib/src/constants/keyvalue.keys.dart | 2 - lib/src/database/daos/mediafiles.dao.dart | 13 - lib/src/database/daos/messages.dao.dart | 2 +- .../generated/app_localizations.dart | 36 + .../generated/app_localizations_de.dart | 24 + .../generated/app_localizations_en.dart | 24 + lib/src/services/api/api.service.dart | 5 +- .../api/mediafiles/media_background.api.dart | 137 -- .../services/api/mediafiles/upload.api.dart | 884 ----------- lib/src/services/api/messages.api.dart | 19 - lib/src/services/api/rust_api_result.dart | 13 +- lib/src/services/api/utils.api.dart | 116 -- .../callback_dispatcher.background.dart | 183 --- lib/src/services/backup.service.dart | 47 +- .../services/key_verification.service.dart | 62 +- .../mediafiles/compression.service.dart | 147 -- .../media_download_policy.dart} | 11 - .../mediafiles/mediafile.service.dart | 537 +------ .../mediafiles/thumbnail.service.dart | 217 --- .../services/memories/memories.service.dart | 4 +- lib/src/services/migrations.service.dart | 8 +- lib/src/utils/avatars.dart | 147 -- lib/src/utils/misc.dart | 97 -- lib/src/utils/qr.utils.dart | 26 +- .../visual/components/animate_icon.comp.dart | 20 +- .../visual/components/avatar_icon.comp.dart | 135 +- .../components/profile_qr_code.comp.dart | 4 +- .../context_menu/context_menu.helper.dart | 33 +- .../context_menu/group.context_menu.dart | 6 +- .../context_menu/user.context_menu.dart | 6 +- .../visual/elements/better_text.element.dart | 93 +- .../visual/elements/contact_chip.element.dart | 6 +- .../camera_preview_controller_view.dart | 9 +- .../main_camera_controller.dart | 4 +- .../share_image_contact_selection.view.dart | 11 +- .../views/camera/share_image_editor.view.dart | 9 +- .../group_list_item.comp.dart | 2 +- .../views/chats/chat_messages.view.dart | 14 +- .../chat_list_entry.dart | 15 +- .../entries/chat_ask_a_friend.entry.dart | 8 +- .../entries/chat_audio_entry.dart | 2 +- .../entries/chat_contacts.entry.dart | 60 +- .../entries/chat_flame_restored.entry.dart | 2 +- .../entries/chat_media_entry.dart | 75 +- .../entries/chat_text_entry.dart | 2 +- .../entries/chat_unknown.entry.dart | 2 +- .../entries/common.dart | 24 +- .../entries/friendly_message_time.comp.dart | 21 +- .../file_limit_reached.dialog.dart | 79 + .../message_context_menu.dart | 6 +- .../message_input.dart | 68 +- .../message_send_state_icon.dart | 5 + .../response_container.dart | 4 +- .../typing_indicator.dart | 38 +- .../visual/views/chats/media_viewer.view.dart | 9 +- .../visual/views/chats/message_info.view.dart | 29 +- .../contact/add_contact_via_qr_link.view.dart | 6 +- .../views/contact/add_new_contact.view.dart | 8 +- .../friend_suggestions.comp.dart | 8 +- .../views/groups/group_member.context.dart | 6 +- lib/src/visual/views/home.view.dart | 12 +- .../components/memory_thumbnail.comp.dart | 6 +- .../visual/views/memories/memories.view.dart | 18 +- .../memories/synchronized_viewer.view.dart | 27 +- .../onboarding/recover_passwordless.view.dart | 5 +- .../views/settings/backup/backup_utils.dart | 2 +- .../views/settings/data_and_storage.view.dart | 2 +- .../import_from_gallery.view.dart | 4 +- .../storage_contents.view.dart | 4 +- .../developer/retransmission_data.view.dart | 5 +- .../views/settings/help/contact_us.view.dart | 9 +- pubspec.lock | 95 -- pubspec.yaml | 8 - ...da6b06f826ad9500012cc539b345acf541e31.json | 26 + ...ca4a0dd66b7a8a14fe654e3996484e2da7b55.json | 21 + ...c7d56665033c64c180972ebd3bf44cbc34a54.json | 38 + ...dc7f58bc3147ddfa96a0e1067a5302694f1e0.json | 12 + ...509a62222baebc54743f3485368fdf27c167a.json | 12 - ...cf6c33cfb6885d2add6be4438b1e2e639ea49.json | 26 + ...ce55c7d81f48f24b3209139a74ddb9a65a46a.json | 12 + ...770c9ce478f28f89a46be6f1f875a099bb61c.json | 26 + ...c6f5e54ef1be1b13433d3226bf5b3459f2119.json | 12 + ...11b9a8a67801326b8286cef264fb6d0aa758a.json | 26 + ...884c9dedb86737e0e9e57100d00ae0fcc9df9.json | 74 + ...b485dea137371d504e1d3edf724174dff0264.json | 12 - ...961e1f332c51556ea52543937224fd5f96426.json | 12 + ...69fe7f1063f6d29b3d371af4e3a73fec69892.json | 21 + ...05c5f030a15a0ac5d4177ae2385a8f4ad4c2d.json | 26 + ...ce90d7c817188bf715c183a9fd291912ee351.json | 12 + ...884ad777e768b2275ce5995767912d8376e81.json | 12 + ...4d374c03a156bb55ff5368f47d0c046191d74.json | 26 + ...2cf6e4aa80baa39a8dce7c7ae029c94a62341.json | 12 + ...a04b78752136eb500a404a8b81e1cc8e7034b.json | 12 + ...a76a603da3bab19543ba9c7a36b33c171122a.json | 12 + ...9cade39a69b4b7882fe6949f90029d15993ee.json | 86 ++ ...29eb77ab35a1dcaf3c1f129b83c331da33329.json | 26 - ...3df70c43890d26eb6d4fd593d5a59ce3cc261.json | 21 + ...b55789f8f7388b1b8d66677f62ac3ea8d973d.json | 26 + ...79b75d76e8e5d64cfba5a120c03e9cf60b924.json | 86 ++ ...4709af3aa142fb9516c8058a01fdc6e04dc0a.json | 12 + ...181138d9a68fe0f49acde06d3d2539c59822e.json | 26 + ...2b3534599a7e4ea290120083b058532a305d9.json | 21 + rust/Cargo.lock | 145 +- rust/Cargo.toml | 7 +- rust/build.rs | 1 + rust/src/api/messages/incoming.rs | 39 +- .../api/messages/incoming/additional_data.rs | 5 + rust/src/api/messages/incoming/contact.rs | 19 +- rust/src/api/messages/incoming/media.rs | 80 +- rust/src/api/messages/incoming/messages.rs | 77 +- .../src/api/messages/incoming/verification.rs | 78 +- .../api/proto/api/http/http_requests.proto | 50 + rust/src/api/proto/api/websocket/error.proto | 1 + rust/src/api/runtime/helpers.rs | 14 + rust/src/api/server/prekeys.rs | 49 +- rust/src/bridge/api.rs | 220 ++- rust/src/bridge/callbacks.rs | 5 +- rust/src/bridge/wrapper/key_manager.rs | 5 - rust/src/context.rs | 9 +- .../migrations/0005_direct_media_upload.sql | 28 + .../0006_defer_receipts_missing_bundle.sql | 7 + rust/src/database/app/mod.rs | 2 +- rust/src/database/app/tables/groups.rs | 210 +++ .../database/app/tables/key_verification.rs | 2 + rust/src/database/app/tables/mediafiles.rs | 10 + rust/src/error.rs | 11 + rust/src/frb_generated.rs | 1278 ++++++++++++---- rust/src/lib.rs | 2 +- rust/src/native/gallery.rs | 92 ++ rust/src/native/image.rs | 89 ++ rust/src/native/mod.rs | 17 + .../notifications.rs} | 0 rust/src/native/transfer.rs | 167 +++ rust/src/native/video.rs | 291 ++++ rust/src/services/avatars.rs | 284 ++++ rust/src/services/contacts.rs | 40 +- rust/src/services/direct_media_upload.rs | 787 ++++++++++ rust/src/services/groups/crypto.rs | 52 + rust/src/services/groups/mod.rs | 70 +- rust/src/services/media_codec.rs | 261 ++++ rust/src/services/media_exif.rs | 336 +++++ rust/src/services/media_upload.rs | 1191 +++++++++++++++ rust/src/services/mediafiles.rs | 59 +- rust/src/services/messages.rs | 26 +- rust/src/services/mod.rs | 5 + rust/src/services/notifications.rs | 38 +- rust/src/services/privacy_pass.rs | 106 +- rust/src/utils.rs | 6 + rust/tests/api.rs | 2 + rust/tests/api/direct_media_upload.rs | 249 ++++ rust/tests/api/media.rs | 40 +- rust/tests/api/tester.rs | 96 ++ test/mocks/platform_channels.dart | 7 - test/mocks/user_config.dart | 1 + test/visual/chat_contacts_entry_test.dart | 113 ++ 185 files changed, 10330 insertions(+), 4393 deletions(-) delete mode 100644 android/app/src/main/kotlin/eu/twonly/VideoCompressionChannel.kt create mode 100644 android/app/src/main/kotlin/eu/twonly/directmedia/DirectMediaTransfer.kt create mode 100644 android/app/src/main/kotlin/eu/twonly/directmedia/DirectMediaUploadWorker.kt create mode 100644 android/app/src/main/kotlin/eu/twonly/media/NativeGallery.kt create mode 100644 android/app/src/main/kotlin/eu/twonly/media/NativeImageCodec.kt create mode 100644 android/app/src/main/kotlin/eu/twonly/media/NativeVideoCodec.kt create mode 100644 ios/Runner/DirectMediaTransfer.swift create mode 100644 ios/Runner/NativeGallery.swift create mode 100644 ios/Runner/NativeImageCodec.swift create mode 100644 ios/Runner/NativeVideoCodec.swift delete mode 100644 ios/Runner/VideoCompressionChannel.swift create mode 100644 lib/core/services/media_upload.dart delete mode 100644 lib/src/channels/video_compression.channel.dart delete mode 100644 lib/src/services/api/mediafiles/media_background.api.dart delete mode 100644 lib/src/services/api/mediafiles/upload.api.dart delete mode 100644 lib/src/services/api/messages.api.dart delete mode 100644 lib/src/services/api/utils.api.dart delete mode 100644 lib/src/services/background/callback_dispatcher.background.dart delete mode 100644 lib/src/services/mediafiles/compression.service.dart rename lib/src/services/{api/mediafiles/download.api.dart => mediafiles/media_download_policy.dart} (85%) delete mode 100644 lib/src/services/mediafiles/thumbnail.service.dart delete mode 100644 lib/src/utils/avatars.dart create mode 100644 lib/src/visual/views/chats/chat_messages_components/file_limit_reached.dialog.dart create mode 100644 rust/.sqlx/query-0c67ef7dacbe15ba15e0251896fda6b06f826ad9500012cc539b345acf541e31.json create mode 100644 rust/.sqlx/query-11d039e3120bb525a51abef30edca4a0dd66b7a8a14fe654e3996484e2da7b55.json create mode 100644 rust/.sqlx/query-171f02051bfe0b07af544652524c7d56665033c64c180972ebd3bf44cbc34a54.json create mode 100644 rust/.sqlx/query-1838231f7a50c805bea5bea556ddc7f58bc3147ddfa96a0e1067a5302694f1e0.json delete mode 100644 rust/.sqlx/query-19645cdadf2646e2c3afb1f09dd509a62222baebc54743f3485368fdf27c167a.json create mode 100644 rust/.sqlx/query-2cd83f566f77c7f91ad0ceccf7bcf6c33cfb6885d2add6be4438b1e2e639ea49.json create mode 100644 rust/.sqlx/query-3fb06e1bcd7f022aefe5f96121bce55c7d81f48f24b3209139a74ddb9a65a46a.json create mode 100644 rust/.sqlx/query-4697f56357a2f8f6b68eeb3150f770c9ce478f28f89a46be6f1f875a099bb61c.json create mode 100644 rust/.sqlx/query-4b435d279b535dd175cda868bc0c6f5e54ef1be1b13433d3226bf5b3459f2119.json create mode 100644 rust/.sqlx/query-640d4ca86b7c1d4b4836216e0bf11b9a8a67801326b8286cef264fb6d0aa758a.json create mode 100644 rust/.sqlx/query-691fd5c99b857c046398f68bb59884c9dedb86737e0e9e57100d00ae0fcc9df9.json delete mode 100644 rust/.sqlx/query-6ccbb2b713538eff9f1f875d34db485dea137371d504e1d3edf724174dff0264.json create mode 100644 rust/.sqlx/query-7b99510fb5ac60158de57314bba961e1f332c51556ea52543937224fd5f96426.json create mode 100644 rust/.sqlx/query-849126eacdf201dfda1c306bfa469fe7f1063f6d29b3d371af4e3a73fec69892.json create mode 100644 rust/.sqlx/query-89e33f7bbef216fbb377471cf8d05c5f030a15a0ac5d4177ae2385a8f4ad4c2d.json create mode 100644 rust/.sqlx/query-8b9b84ef9e2621d83b53aec4798ce90d7c817188bf715c183a9fd291912ee351.json create mode 100644 rust/.sqlx/query-8e15426be4c8d85e4c8cd6380c0884ad777e768b2275ce5995767912d8376e81.json create mode 100644 rust/.sqlx/query-9827adc146370a1683144faef554d374c03a156bb55ff5368f47d0c046191d74.json create mode 100644 rust/.sqlx/query-995548e67942e10d3d6334f66aa2cf6e4aa80baa39a8dce7c7ae029c94a62341.json create mode 100644 rust/.sqlx/query-9d453175e0cbbc67cce408e838fa04b78752136eb500a404a8b81e1cc8e7034b.json create mode 100644 rust/.sqlx/query-9dd7161496c2e88db0b7851661ba76a603da3bab19543ba9c7a36b33c171122a.json create mode 100644 rust/.sqlx/query-aeb01be35d655b1c6f0675dcd3b9cade39a69b4b7882fe6949f90029d15993ee.json delete mode 100644 rust/.sqlx/query-bdf69a9fec2ffc402c655567c8b29eb77ab35a1dcaf3c1f129b83c331da33329.json create mode 100644 rust/.sqlx/query-d124b935c2f00e351d97f4413093df70c43890d26eb6d4fd593d5a59ce3cc261.json create mode 100644 rust/.sqlx/query-e4de0df8339d2993da31717738cb55789f8f7388b1b8d66677f62ac3ea8d973d.json create mode 100644 rust/.sqlx/query-e585b96b1b6bf08be34cbd2363279b75d76e8e5d64cfba5a120c03e9cf60b924.json create mode 100644 rust/.sqlx/query-f99a73efe31db041db33f6bffac4709af3aa142fb9516c8058a01fdc6e04dc0a.json create mode 100644 rust/.sqlx/query-fbc1e1631eb0d9c0f6ee05c97ed181138d9a68fe0f49acde06d3d2539c59822e.json create mode 100644 rust/.sqlx/query-fe8a8fe73432311204a95160b282b3534599a7e4ea290120083b058532a305d9.json create mode 100644 rust/src/database/app/migrations/0005_direct_media_upload.sql create mode 100644 rust/src/database/app/migrations/0006_defer_receipts_missing_bundle.sql create mode 100644 rust/src/native/gallery.rs create mode 100644 rust/src/native/image.rs create mode 100644 rust/src/native/mod.rs rename rust/src/{native_notifications.rs => native/notifications.rs} (100%) create mode 100644 rust/src/native/transfer.rs create mode 100644 rust/src/native/video.rs create mode 100644 rust/src/services/avatars.rs create mode 100644 rust/src/services/direct_media_upload.rs create mode 100644 rust/src/services/media_codec.rs create mode 100644 rust/src/services/media_exif.rs create mode 100644 rust/src/services/media_upload.rs create mode 100644 rust/tests/api/direct_media_upload.rs create mode 100644 test/visual/chat_contacts_entry_test.dart diff --git a/android/app/build.gradle b/android/app/build.gradle index 11f479be..674b2bd6 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -76,6 +76,11 @@ dependencies { implementation platform('com.google.firebase:firebase-bom:34.9.0') implementation 'com.google.firebase:firebase-messaging' implementation 'androidx.work:work-runtime:2.10.2' - implementation 'com.otaliastudios:transcoder:0.11.0' + // Hardware video composition and transcoding: MediaCodec for decode/encode, + // OpenGL for the overlay. Replaces both the software Flutter editor pass and + // the previous third-party transcoder. + implementation 'androidx.media3:media3-transformer:1.11.0' + implementation 'androidx.media3:media3-effect:1.11.0' + implementation 'androidx.media3:media3-common:1.11.0' implementation 'androidx.core:core-splashscreen:1.0.1' } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 76b2cfaf..224bb24c 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -78,6 +78,10 @@ + @@ -96,6 +100,9 @@ + + + diff --git a/android/app/src/main/kotlin/eu/twonly/MainActivity.kt b/android/app/src/main/kotlin/eu/twonly/MainActivity.kt index 24205927..6c208218 100644 --- a/android/app/src/main/kotlin/eu/twonly/MainActivity.kt +++ b/android/app/src/main/kotlin/eu/twonly/MainActivity.kt @@ -68,7 +68,6 @@ class MainActivity : FlutterFragmentActivity() { Keyring.initializeNdkContext(applicationContext) - VideoCompressionChannel.configure(flutterEngine, applicationContext) NotificationTapChannel.configure(flutterEngine, applicationContext) diff --git a/android/app/src/main/kotlin/eu/twonly/MyApplication.kt b/android/app/src/main/kotlin/eu/twonly/MyApplication.kt index 9bd91360..268eb43b 100644 --- a/android/app/src/main/kotlin/eu/twonly/MyApplication.kt +++ b/android/app/src/main/kotlin/eu/twonly/MyApplication.kt @@ -1,15 +1,17 @@ package eu.twonly import io.flutter.app.FlutterApplication -import dev.fluttercommunity.workmanager.WorkmanagerDebug -import dev.fluttercommunity.workmanager.LoggingDebugHandler import io.crates.keyring.Keyring class MyApplication : FlutterApplication() { + companion object { + lateinit var instance: MyApplication + private set + } + override fun onCreate() { super.onCreate() + instance = this Keyring.initializeNdkContext(this) - // This enables the internal plugin logging to Logcat - WorkmanagerDebug.setCurrent(LoggingDebugHandler()) } -} \ No newline at end of file +} diff --git a/android/app/src/main/kotlin/eu/twonly/VideoCompressionChannel.kt b/android/app/src/main/kotlin/eu/twonly/VideoCompressionChannel.kt deleted file mode 100644 index 3958f706..00000000 --- a/android/app/src/main/kotlin/eu/twonly/VideoCompressionChannel.kt +++ /dev/null @@ -1,104 +0,0 @@ -package eu.twonly - -import android.content.Context -import android.media.MediaFormat -import android.os.Handler -import android.os.Looper -import com.otaliastudios.transcoder.Transcoder -import com.otaliastudios.transcoder.TranscoderListener -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 - -object VideoCompressionChannel { - private const val CHANNEL = "eu.twonly/videoCompression" - - // Compression parameters defined natively (as requested) - private const val VIDEO_BITRATE = 2_000_000L // 2 Mbps - - fun configure(flutterEngine: FlutterEngine, context: Context) { - val channel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL) - - channel.setMethodCallHandler { call, result -> - try { - if (call.method == "compressVideo") { - val arguments = call.arguments>() ?: emptyMap() - val inputPath = arguments["input"] as? String - val outputPath = arguments["output"] as? String - - if (inputPath == null || outputPath == null) { - result.error("INVALID_ARGS", "Input or output path missing", null) - return@setMethodCallHandler - } - - val mainHandler = Handler(Looper.getMainLooper()) - - val baseVideoStrategy = DefaultVideoStrategy.Builder() - .keyFrameInterval(3f) - .bitRate(VIDEO_BITRATE) - .addResizer(com.otaliastudios.transcoder.resize.AtMostResizer(1920, 1080)) - .build() - - val trackStrategyClass = TrackStrategy::class.java - val hevcStrategy = java.lang.reflect.Proxy.newProxyInstance( - trackStrategyClass.classLoader, - arrayOf(trackStrategyClass) - ) { _, method, args -> - 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 - - Transcoder.into(outputPath) - .addDataSource(inputPath) - .setVideoTrackStrategy(hevcStrategy) - .setAudioTrackStrategy(PassThroughTrackStrategy()) - .setListener(object : TranscoderListener { - override fun onTranscodeProgress(progress: Double) { - mainHandler.post { - val mappedProgress = (progress * 100).toInt() - channel.invokeMethod("onProgress", mapOf("progress" to mappedProgress)) - } - } - - override fun onTranscodeCompleted(successCode: Int) { - mainHandler.post { - result.success(outputPath) - } - } - - override fun onTranscodeCanceled() { - mainHandler.post { - result.error("CANCELED", "Video compression canceled", null) - } - } - - override fun onTranscodeFailed(exception: Throwable) { - mainHandler.post { - result.error("FAILED", exception.message, null) - } - } - }) - .transcode() - - } else { - result.notImplemented() - } - } catch (e: Exception) { - result.error("EXCEPTION", e.message, null) - } - } - } -} diff --git a/android/app/src/main/kotlin/eu/twonly/directmedia/DirectMediaTransfer.kt b/android/app/src/main/kotlin/eu/twonly/directmedia/DirectMediaTransfer.kt new file mode 100644 index 00000000..751d3929 --- /dev/null +++ b/android/app/src/main/kotlin/eu/twonly/directmedia/DirectMediaTransfer.kt @@ -0,0 +1,64 @@ +package eu.twonly.directmedia + +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.Data +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequest +import androidx.work.WorkManager +import eu.twonly.MyApplication +import java.io.File +import java.util.concurrent.TimeUnit +import org.json.JSONObject + +/** Called directly from Rust/JNI. It persists the descriptor in app-private + * storage and enqueues both OS-owned transfers before reporting success. */ +object DirectMediaTransfer { + @JvmStatic + fun schedule(descriptorJson: String): Boolean = try { + val descriptor = JSONObject(descriptorJson) + val attachmentId = descriptor.getString("attachment_id") + val expiresAt = descriptor.getLong("expires_at") + if (expiresAt <= System.currentTimeMillis() / 1_000) return false + val directory = File(MyApplication.instance.noBackupFilesDir, "direct-media/$attachmentId") + if (!directory.exists() && !directory.mkdirs()) return false + val descriptorFile = File(directory, "descriptor.json") + descriptorFile.writeText(descriptorJson, Charsets.UTF_8) + + val manager = WorkManager.getInstance(MyApplication.instance) + val media = request(attachmentId, "media", descriptorFile, expiresAt) + val manifest = request(attachmentId, "manifest", descriptorFile, expiresAt) + manager.enqueueUniqueWork("direct-media-$attachmentId-media", ExistingWorkPolicy.KEEP, media) + manager.enqueueUniqueWork( + "direct-media-$attachmentId-manifest", + ExistingWorkPolicy.KEEP, + manifest, + ) + true + } catch (_: Throwable) { + false + } + + private fun request( + attachmentId: String, + role: String, + descriptorFile: File, + expiresAt: Long, + ): OneTimeWorkRequest { + val data = Data.Builder() + .putString(DirectMediaUploadWorker.ATTACHMENT_ID, attachmentId) + .putString(DirectMediaUploadWorker.ROLE, role) + .putString(DirectMediaUploadWorker.DESCRIPTOR_PATH, descriptorFile.absolutePath) + .putLong(DirectMediaUploadWorker.EXPIRES_AT, expiresAt) + .build() + return OneTimeWorkRequest.Builder(DirectMediaUploadWorker::class.java) + .setInputData(data) + .setConstraints( + Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build(), + ) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 15, TimeUnit.SECONDS) + .addTag("direct-media-$attachmentId") + .build() + } +} diff --git a/android/app/src/main/kotlin/eu/twonly/directmedia/DirectMediaUploadWorker.kt b/android/app/src/main/kotlin/eu/twonly/directmedia/DirectMediaUploadWorker.kt new file mode 100644 index 00000000..1e0af529 --- /dev/null +++ b/android/app/src/main/kotlin/eu/twonly/directmedia/DirectMediaUploadWorker.kt @@ -0,0 +1,110 @@ +package eu.twonly.directmedia + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.content.pm.ServiceInfo +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.work.ForegroundInfo +import androidx.work.Worker +import androidx.work.WorkerParameters +import eu.twonly.R +import java.io.File +import java.net.HttpURLConnection +import java.net.URL +import org.json.JSONObject + +/** Transport-only worker: request construction, protobufs, encryption and + * policy decisions have already been completed and persisted by Rust. */ +class DirectMediaUploadWorker( + appContext: Context, + params: WorkerParameters, +) : Worker(appContext, params) { + override fun doWork(): Result { + val attachmentId = inputData.getString(ATTACHMENT_ID) ?: return Result.failure() + val role = inputData.getString(ROLE) ?: return Result.failure() + val expiresAt = inputData.getLong(EXPIRES_AT, 0) + if (expiresAt <= System.currentTimeMillis() / 1_000) return Result.failure() + return try { + // Become a data-sync foreground service before opening the file/network + // streams, otherwise Android may stop long uploads in the background. + setForegroundAsync(foregroundInfo(attachmentId)).get() + val path = inputData.getString(DESCRIPTOR_PATH) ?: return Result.failure() + val descriptor = JSONObject(File(path).readText(Charsets.UTF_8)) + val request = descriptor.getJSONObject(role) + val status = upload(request) + if (status in 200..299) { + if (role == "media") { + // Best effort only. A 202 is success because server reconciliation + // owns completion when the manifest has not arrived yet. + upload(descriptor.getJSONObject("complete")) + } + Result.success() + } else if (retryable(status) && expiresAt > System.currentTimeMillis() / 1_000) { + Result.retry() + } else { + Result.failure() + } + } catch (_: Throwable) { + if (expiresAt > System.currentTimeMillis() / 1_000) Result.retry() else Result.failure() + } + } + + private fun upload(request: JSONObject): Int { + val file = File(request.getString("body_path")) + if (!file.isFile) return 0 + val connection = (URL(request.getString("url")).openConnection() as HttpURLConnection).apply { + requestMethod = request.optString("method", "POST") + connectTimeout = 30_000 + readTimeout = 60_000 + doOutput = true + instanceFollowRedirects = false + val headers = request.getJSONObject("headers") + for (name in headers.keys()) setRequestProperty(name, headers.getString(name)) + setFixedLengthStreamingMode(file.length()) + } + connection.outputStream.use { output -> + file.inputStream().use { input -> input.copyTo(output, DEFAULT_BUFFER_SIZE) } + } + val status = connection.responseCode + try { + (if (status >= 400) connection.errorStream else connection.inputStream)?.close() + } finally { + connection.disconnect() + } + return status + } + + private fun retryable(status: Int): Boolean = + status == 0 || status == 408 || status == 429 || status >= 500 + + private fun foregroundInfo(attachmentId: String): ForegroundInfo { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + applicationContext.getSystemService(NotificationManager::class.java).createNotificationChannel( + NotificationChannel(CHANNEL_ID, "Media uploads", NotificationManager.IMPORTANCE_LOW), + ) + } + val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_launcher_foreground) + .setContentTitle("Uploading encrypted media") + .setContentText("twonly will finish this upload in the background") + .setOngoing(true) + .setOnlyAlertOnce(true) + .build() + val id = attachmentId.hashCode() and 0x7fffffff + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ForegroundInfo(id, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) + } else { + ForegroundInfo(id, notification) + } + } + + companion object { + const val ATTACHMENT_ID = "attachment_id" + const val ROLE = "role" + const val DESCRIPTOR_PATH = "descriptor_path" + const val EXPIRES_AT = "expires_at" + private const val CHANNEL_ID = "twonly_direct_media_upload" + } +} diff --git a/android/app/src/main/kotlin/eu/twonly/media/NativeGallery.kt b/android/app/src/main/kotlin/eu/twonly/media/NativeGallery.kt new file mode 100644 index 00000000..a33a0204 --- /dev/null +++ b/android/app/src/main/kotlin/eu/twonly/media/NativeGallery.kt @@ -0,0 +1,79 @@ +package eu.twonly.media + +import android.content.ContentValues +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import eu.twonly.MyApplication +import java.io.File + +/** + * Writes a finished media file into the user's photo library. + * + * Called directly from Rust/JNI. Rust decides whether an export should happen, + * embeds the EXIF metadata beforehand, and owns the resulting state; this only + * hands the bytes to MediaStore. + */ +object NativeGallery { + private const val ALBUM = "twonly" + + @JvmStatic + fun save(path: String, isVideo: Boolean, displayName: String, createdAtMillis: Long): Boolean { + val source = File(path) + if (!source.isFile) return false + val resolver = MyApplication.instance.contentResolver + val collection = if (isVideo) { + MediaStore.Video.Media.EXTERNAL_CONTENT_URI + } else { + MediaStore.Images.Media.EXTERNAL_CONTENT_URI + } + val extension = source.extension.ifEmpty { if (isVideo) "mp4" else "webp" } + val values = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, "$displayName.$extension") + put(MediaStore.MediaColumns.MIME_TYPE, mimeType(isVideo, extension)) + // Galleries sort on this, so it has to carry the capture time + // rather than the moment the file was exported. + put(MediaStore.MediaColumns.DATE_ADDED, createdAtMillis / 1000) + put(MediaStore.MediaColumns.DATE_MODIFIED, createdAtMillis / 1000) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val directory = if (isVideo) Environment.DIRECTORY_MOVIES else Environment.DIRECTORY_PICTURES + put(MediaStore.MediaColumns.RELATIVE_PATH, "$directory/$ALBUM") + // Hide the entry until the bytes are fully written. + put(MediaStore.MediaColumns.IS_PENDING, 1) + } + put( + if (isVideo) MediaStore.Video.Media.DATE_TAKEN else MediaStore.Images.Media.DATE_TAKEN, + createdAtMillis, + ) + } + + val uri = runCatching { resolver.insert(collection, values) }.getOrNull() ?: return false + return try { + resolver.openOutputStream(uri).use { output -> + if (output == null) return@use false + source.inputStream().use { input -> input.copyTo(output) } + true + }.also { written -> + if (written && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + resolver.update( + uri, + ContentValues().apply { put(MediaStore.MediaColumns.IS_PENDING, 0) }, + null, + null, + ) + } + if (!written) resolver.delete(uri, null, null) + } + } catch (_: Throwable) { + runCatching { resolver.delete(uri, null, null) } + false + } + } + + private fun mimeType(isVideo: Boolean, extension: String): String = when { + isVideo -> "video/mp4" + extension.equals("gif", ignoreCase = true) -> "image/gif" + extension.equals("png", ignoreCase = true) -> "image/png" + else -> "image/webp" + } +} diff --git a/android/app/src/main/kotlin/eu/twonly/media/NativeImageCodec.kt b/android/app/src/main/kotlin/eu/twonly/media/NativeImageCodec.kt new file mode 100644 index 00000000..ac38fe9d --- /dev/null +++ b/android/app/src/main/kotlin/eu/twonly/media/NativeImageCodec.kt @@ -0,0 +1,34 @@ +package eu.twonly.media + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import java.io.File + +/** + * Called directly from Rust/JNI for the container formats Rust has no decoder + * for, HEIC/HEIF above all. Rust owns every decision around this; the only job + * here is to hand back pixels in a format Rust can read. + * + * HEIF decoding needs API 28. Below that the decode fails and Rust falls back + * to sending the original file, exactly as it does for any unreadable input. + */ +object NativeImageCodec { + @JvmStatic + fun decodeToPng(inputPath: String, outputPath: String): Boolean { + var bitmap: Bitmap? = null + return try { + bitmap = BitmapFactory.decodeFile(inputPath) ?: return false + val output = File(outputPath) + output.parentFile?.mkdirs() + output.outputStream().use { stream -> + // PNG is lossless, so nothing is thrown away before Rust + // re-encodes to WebP at the quality it chose. + bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream) + } + } catch (_: Throwable) { + false + } finally { + bitmap?.recycle() + } + } +} diff --git a/android/app/src/main/kotlin/eu/twonly/media/NativeVideoCodec.kt b/android/app/src/main/kotlin/eu/twonly/media/NativeVideoCodec.kt new file mode 100644 index 00000000..dba8ef60 --- /dev/null +++ b/android/app/src/main/kotlin/eu/twonly/media/NativeVideoCodec.kt @@ -0,0 +1,331 @@ +package eu.twonly.media + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.media.MediaExtractor +import android.media.MediaFormat +import android.media.MediaMetadataRetriever +import android.util.Log +import android.os.Handler +import android.os.Looper +import androidx.media3.common.MediaItem +import androidx.media3.common.MimeTypes +import androidx.media3.common.util.UnstableApi +import androidx.media3.effect.BitmapOverlay +import androidx.media3.effect.FrameDropEffect +import androidx.media3.effect.OverlayEffect +import androidx.media3.effect.Presentation +import androidx.media3.effect.TextureOverlay +import androidx.media3.transformer.Composition +import androidx.media3.transformer.DefaultEncoderFactory +import androidx.media3.transformer.EditedMediaItem +import androidx.media3.transformer.Effects +import androidx.media3.transformer.ExportException +import androidx.media3.transformer.ExportResult +import androidx.media3.transformer.ProgressHolder +import androidx.media3.transformer.Transformer +import androidx.media3.transformer.VideoEncoderSettings +import com.google.common.collect.ImmutableList +import eu.twonly.MyApplication +import java.io.File +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +/** + * Burns the editor's overlay into the video and transcodes it, in a single + * hardware pass. Media3 `Transformer` decodes and encodes through MediaCodec and + * composites the overlay on the GPU with OpenGL, so no software codec and no + * Flutter engine is involved. + * + * Called directly from Rust/JNI. Rust decides whether a render is needed at all + * and owns every state transition around it; this only performs the work. + */ +@UnstableApi +object NativeVideoCodec { + private const val TAG = "NativeVideoCodec" + + /** + * Every send is normalised to 720p30 regardless of plan. The server caps a + * single media object at 50MB on the free plan and 100MB on the paid ones, + * and 720p30 is the largest format that keeps a clip of ordinary length + * comfortably under the smaller of the two. + */ + private const val SHORT_SIDE = 720 + private const val MAX_FRAME_RATE = 30.0 + + /** + * Bits per pixel per frame asked of the encoder. HEVC stays close to the + * source at roughly this rate; below it motion smears into blocks, and above + * it the extra bits go to detail a phone camera never recorded. The bitrate + * is derived from the output size and frame rate rather than fixed, so a + * clip that is downscaled hard is not given the same budget as one that is + * already 720p. Kept in sync with the same constants in the iOS renderer so + * the same clip looks the same whichever platform sent it. + */ + private const val BITS_PER_PIXEL_PER_FRAME = 0.12 + private const val MIN_BITRATE = 1_500_000 + private const val MAX_BITRATE = 4_000_000 + /** 720p30 at the rate above, used when the source cannot be probed. */ + private const val DEFAULT_BITRATE = 3_300_000 + private const val DEFAULT_FRAME_RATE = 30.0 + private const val PROGRESS_INTERVAL_MS = 500L + /** No send should hold a background worker hostage indefinitely. */ + private const val RENDER_TIMEOUT_MINUTES = 30L + + @JvmStatic + external fun reportProgress(mediaId: String, percent: Int) + + @JvmStatic + fun render( + inputPath: String, + overlayPath: String?, + outputPath: String, + removeAudio: Boolean, + mediaId: String, + ): Boolean { + val context = MyApplication.instance + val output = File(outputPath) + output.parentFile?.mkdirs() + // Transformer refuses to write over an existing file. + output.delete() + + val source = probe(inputPath) + val bitrate = bitrateFor(source) + + val finished = CountDownLatch(1) + var succeeded = false + // Transformer posts its callbacks through a Looper, so it has to be + // driven from the main thread while the calling Rust thread waits. + val handler = Handler(Looper.getMainLooper()) + var transformer: Transformer? = null + + handler.post { + try { + val effects = buildEffects(overlayPath, source) + val editedItem = EditedMediaItem.Builder(MediaItem.fromUri(File(inputPath).toURI().toString())) + .setRemoveAudio(removeAudio) + .setEffects(effects) + .build() + + val built = Transformer.Builder(context) + .setVideoMimeType(MimeTypes.VIDEO_H265) + // Without this the source audio is transmuxed untouched, and + // Android cameras record AMR-NB, which iOS cannot decode at + // all: AVPlayer then refuses the whole asset and the video + // never appears. AAC is the only audio codec both platforms + // are guaranteed to support. + .setAudioMimeType(MimeTypes.AUDIO_AAC) + .setEncoderFactory( + DefaultEncoderFactory.Builder(context) + .setRequestedVideoEncoderSettings( + VideoEncoderSettings.Builder().setBitrate(bitrate).build(), + ) + // Falling back lets a device without an HEVC encoder + // still produce a file rather than failing the send. + .setEnableFallback(true) + .build(), + ) + .addListener( + object : Transformer.Listener { + override fun onCompleted(composition: Composition, result: ExportResult) { + // Recorded so a file the recipient cannot play + // can be diagnosed from a log rather than a + // round trip between two devices. + Log.i( + TAG, + "rendered ${result.width}x${result.height} " + + "${result.videoMimeType}/${result.audioMimeType} " + + "@ ${result.averageVideoBitrate}bps " + + "(asked ${bitrate}bps for $source)", + ) + succeeded = true + finished.countDown() + } + + override fun onError( + composition: Composition, + result: ExportResult, + exception: ExportException, + ) { + Log.e(TAG, "video render failed", exception) + succeeded = false + finished.countDown() + } + }, + ) + .build() + transformer = built + built.start(editedItem, outputPath) + pollProgress(handler, built, mediaId, finished) + } catch (_: Throwable) { + succeeded = false + finished.countDown() + } + } + + val completed = finished.await(RENDER_TIMEOUT_MINUTES, TimeUnit.MINUTES) + if (!completed) { + handler.post { runCatching { transformer?.cancel() } } + return false + } + return succeeded && output.isFile && output.length() > 0 + } + + /** + * Grabs the first frame as a PNG. Only the decode needs the platform; Rust + * scales and encodes the thumbnail itself, exactly as it does for stills. + */ + @JvmStatic + fun extractFrame(inputPath: String, outputPath: String): Boolean { + val retriever = MediaMetadataRetriever() + var frame: Bitmap? = null + return try { + retriever.setDataSource(inputPath) + frame = retriever.getFrameAtTime(0, MediaMetadataRetriever.OPTION_CLOSEST_SYNC) + ?: return false + val output = File(outputPath) + output.parentFile?.mkdirs() + output.outputStream().use { stream -> + frame.compress(Bitmap.CompressFormat.PNG, 100, stream) + } + } catch (_: Throwable) { + false + } finally { + frame?.recycle() + runCatching { retriever.release() } + } + } + + /** What the source actually is, as far as the platform will tell us. */ + private data class SourceVideo(val width: Int, val height: Int, val frameRate: Double) + + /** + * Reads the displayed size and the frame rate. Both only steer the encoder + * settings, so a source that refuses to be probed still renders — it just + * falls back to the 1080p30 defaults. + */ + private fun probe(inputPath: String): SourceVideo? { + val retriever = MediaMetadataRetriever() + return try { + retriever.setDataSource(inputPath) + fun key(id: Int) = retriever.extractMetadata(id)?.toIntOrNull() + val storedWidth = key(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH) ?: return null + val storedHeight = key(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT) ?: return null + if (storedWidth <= 0 || storedHeight <= 0) return null + // The frame is stored unrotated; a portrait clip from the camera is + // a landscape frame plus a rotation, and it is the displayed size + // that gets scaled and encoded. + val rotation = key(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION) ?: 0 + val sideways = rotation % 180 != 0 + SourceVideo( + width = if (sideways) storedHeight else storedWidth, + height = if (sideways) storedWidth else storedHeight, + frameRate = frameRate(inputPath), + ) + } catch (_: Throwable) { + null + } finally { + runCatching { retriever.release() } + } + } + + /** + * `MediaMetadataRetriever` only exposes the frame rate from API 30, so it is + * read off the track format instead, which every supported release has. + */ + private fun frameRate(inputPath: String): Double { + val extractor = MediaExtractor() + return try { + extractor.setDataSource(inputPath) + (0 until extractor.trackCount) + .asSequence() + .map { extractor.getTrackFormat(it) } + .firstOrNull { it.getString(MediaFormat.KEY_MIME)?.startsWith("video/") == true } + // The key is optional, and is an int in practice but a float by + // specification, so neither accessor can be relied on alone. + ?.let { format -> + runCatching { format.getInteger(MediaFormat.KEY_FRAME_RATE).toDouble() } + .recoverCatching { format.getFloat(MediaFormat.KEY_FRAME_RATE).toDouble() } + .getOrNull() + } + ?.takeIf { it > 0 } + ?: DEFAULT_FRAME_RATE + } catch (_: Throwable) { + DEFAULT_FRAME_RATE + } finally { + runCatching { extractor.release() } + } + } + + /** + * A rate the output size and frame rate actually justify. A fixed bitrate + * either starves a 1080p60 clip or wastes bits on a 480p one; this spends + * the same amount per pixel either way, within bounds that keep a send both + * watchable and small enough to upload. + */ + private fun bitrateFor(source: SourceVideo?): Int { + if (source == null) return DEFAULT_BITRATE + val scale = outputScale(source) + val pixels = source.width * scale * source.height * scale + val bits = pixels * outputFrameRate(source) * BITS_PER_PIXEL_PER_FRAME + return bits.coerceIn(MIN_BITRATE.toDouble(), MAX_BITRATE.toDouble()).toInt() + } + + /** The rate frames actually leave the pipeline at; never above the cap. */ + private fun outputFrameRate(source: SourceVideo) = minOf(source.frameRate, MAX_FRAME_RATE) + + /** How much the source is shrunk to meet [SHORT_SIDE]; never above 1. */ + private fun outputScale(source: SourceVideo): Double { + val shortSide = minOf(source.width, source.height) + return if (shortSide > SHORT_SIDE) SHORT_SIDE.toDouble() / shortSide else 1.0 + } + + private fun buildEffects(overlayPath: String?, source: SourceVideo?): Effects { + val videoEffects = mutableListOf() + // Cap the resolution before the overlay so both scale together. A source + // already below the cap is left alone: scaling it up would only spend + // bits on pixels the camera never recorded. + if (source == null || outputScale(source) < 1.0) { + videoEffects.add(Presentation.createForShortSide(SHORT_SIDE)) + } + // A 60fps clip encoded at 30fps spends its whole budget on the frames it + // keeps instead of halving the bits every frame gets. Dropping is only + // asked for when there is something to drop: targeting 30 on a 24fps + // source would make the effect duplicate frames back up to the target. + if (source != null && source.frameRate > MAX_FRAME_RATE) { + videoEffects.add(FrameDropEffect.createDefaultFrameDropEffect(MAX_FRAME_RATE.toFloat())) + } + if (overlayPath != null) { + val bitmap = BitmapFactory.decodeFile(overlayPath) + if (bitmap != null) { + val overlays: ImmutableList = + ImmutableList.of(BitmapOverlay.createStaticBitmapOverlay(bitmap)) + videoEffects.add(OverlayEffect(overlays)) + } + } + return Effects(ImmutableList.of(), ImmutableList.copyOf(videoEffects)) + } + + private fun pollProgress( + handler: Handler, + transformer: Transformer, + mediaId: String, + finished: CountDownLatch, + ) { + val holder = ProgressHolder() + handler.postDelayed( + object : Runnable { + override fun run() { + if (finished.count == 0L) return + runCatching { + if (transformer.getProgress(holder) == Transformer.PROGRESS_STATE_AVAILABLE) { + reportProgress(mediaId, holder.progress) + } + } + handler.postDelayed(this, PROGRESS_INTERVAL_MS) + } + }, + PROGRESS_INTERVAL_MS, + ) + } +} diff --git a/integration_test/api_service.test.dart b/integration_test/api_service.test.dart index bd351b66..fb53f795 100644 --- a/integration_test/api_service.test.dart +++ b/integration_test/api_service.test.dart @@ -1,16 +1,16 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; -import 'package:twonly/core/frb_generated.dart'; +import 'package:twonly/globals.dart'; import 'package:twonly/locator.dart'; -import 'package:twonly/src/services/background/callback_dispatcher.background.dart'; +import 'package:twonly/main.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - setUpAll(() async => RustLib.init()); test('Can initialize twonlyDB and connect to api server', () async { - // Initialize global variables - await initBackgroundExecution(); + await AppEnvironment.init(); + expect(await twonlyMinimumInitialization(), isFalse); + await userService.tryInit(); // Check the API connection state final state = await RustApi.connectionState(); diff --git a/ios/Podfile.lock b/ios/Podfile.lock index b9c4642b..cfe88b64 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -4,11 +4,6 @@ PODS: - cryptography_flutter_plus (0.2.0): - Flutter - Flutter (1.0.0) - - flutter_image_compress_common (1.0.0): - - Flutter - - Mantle - - SDWebImage - - SDWebImageWebPCoder - flutter_sharing_intent (1.0.1): - Flutter - flutter_volume_controller (0.0.1): @@ -50,21 +45,6 @@ PODS: - GoogleUtilities/Logger - GoogleUtilities/Privacy - GTMSessionFetcher/Core (3.5.0) - - libwebp (1.5.0): - - libwebp/demux (= 1.5.0) - - libwebp/mux (= 1.5.0) - - libwebp/sharpyuv (= 1.5.0) - - libwebp/webp (= 1.5.0) - - libwebp/demux (1.5.0): - - libwebp/webp - - libwebp/mux (1.5.0): - - libwebp/demux - - libwebp/sharpyuv (1.5.0) - - libwebp/webp (1.5.0): - - libwebp/sharpyuv - - Mantle (2.2.0): - - Mantle/extobjc (= 2.2.0) - - Mantle/extobjc (2.2.0) - MLImage (1.0.0-beta8) - MLKitBarcodeScanning (8.0.0): - MLKitCommon (~> 14.0) @@ -98,21 +78,12 @@ PODS: - Flutter - ScreenProtectorKit (= 1.5.1) - ScreenProtectorKit (1.5.1) - - SDWebImage (5.21.7): - - SDWebImage/Core (= 5.21.7) - - SDWebImage/Core (5.21.7) - - SDWebImageWebPCoder (0.15.0): - - libwebp (~> 1.0) - - SDWebImage/Core (~> 5.17) - SwiftProtobuf (1.38.0) - - workmanager_apple (0.0.1): - - Flutter DEPENDENCIES: - audio_waveforms (from `.symlinks/plugins/audio_waveforms/ios`) - cryptography_flutter_plus (from `.symlinks/plugins/cryptography_flutter_plus/ios`) - Flutter (from `Flutter`) - - flutter_image_compress_common (from `.symlinks/plugins/flutter_image_compress_common/ios`) - flutter_sharing_intent (from `.symlinks/plugins/flutter_sharing_intent/ios`) - flutter_volume_controller (from `.symlinks/plugins/flutter_volume_controller/ios`) - google_mlkit_barcode_scanning (from `.symlinks/plugins/google_mlkit_barcode_scanning/ios`) @@ -122,7 +93,6 @@ DEPENDENCIES: - rust_lib_twonly (from `.symlinks/plugins/rust_lib_twonly/ios`) - screen_protector (from `.symlinks/plugins/screen_protector/ios`) - SwiftProtobuf - - workmanager_apple (from `.symlinks/plugins/workmanager_apple/ios`) SPEC REPOS: trunk: @@ -131,8 +101,6 @@ SPEC REPOS: - GoogleToolboxForMac - GoogleUtilities - GTMSessionFetcher - - libwebp - - Mantle - MLImage - MLKitBarcodeScanning - MLKitCommon @@ -141,8 +109,6 @@ SPEC REPOS: - nanopb - PromisesObjC - ScreenProtectorKit - - SDWebImage - - SDWebImageWebPCoder - SwiftProtobuf EXTERNAL SOURCES: @@ -152,8 +118,6 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/cryptography_flutter_plus/ios" Flutter: :path: Flutter - flutter_image_compress_common: - :path: ".symlinks/plugins/flutter_image_compress_common/ios" flutter_sharing_intent: :path: ".symlinks/plugins/flutter_sharing_intent/ios" flutter_volume_controller: @@ -170,14 +134,11 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/rust_lib_twonly/ios" screen_protector: :path: ".symlinks/plugins/screen_protector/ios" - workmanager_apple: - :path: ".symlinks/plugins/workmanager_apple/ios" SPEC CHECKSUMS: audio_waveforms: a6dde7fe7c0ea05f06ffbdb0f7c1b2b2ba6cedcf cryptography_flutter_plus: 44f4e9e4079395fcbb3e7809c0ac2c6ae2d9576f Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47 - flutter_image_compress_common: 1697a328fd72bfb335507c6bca1a65fa5ad87df1 flutter_sharing_intent: 0c1e53949f09fa8df8ac2268505687bde8ff264c flutter_volume_controller: c2be490cb0487e8b88d0d9fc2b7e1c139a4ebccb google_mlkit_barcode_scanning: 12d8422d8f7b00726dedf9cac00188a2b98750c2 @@ -188,8 +149,6 @@ SPEC CHECKSUMS: GoogleToolboxForMac: d1a2cbf009c453f4d6ded37c105e2f67a32206d8 GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 - libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 - Mantle: c5aa8794a29a022dfbbfc9799af95f477a69b62d MLImage: 0de5c6c2bf9e93b80ef752e2797f0836f03b58c0 MLKitBarcodeScanning: 39de223e7b1b8a8fbf10816a536dd292d8a39343 MLKitCommon: 47d47b50a031d00db62f1b0efe5a1d8b09a3b2e6 @@ -201,10 +160,7 @@ SPEC CHECKSUMS: rust_lib_twonly: 6586fdf02e31cd8a3ad9f1ee84796da6bbf289bb screen_protector: 18c6aca2dc5d2a832f6787a5318f97f03e9d3150 ScreenProtectorKit: 6ceb3e0808341a9bc15d175bff40dfdd4b32da71 - SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf - SDWebImageWebPCoder: 0e06e365080397465cc73a7a9b472d8a3bd0f377 SwiftProtobuf: d724b5145bfc609d9a49c1e3e3a3dabb07273ffb - workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778 PODFILE CHECKSUM: f83bbaaed0b8c29b006472e50864d40e54617e24 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 054601de..2fba3030 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -22,7 +22,10 @@ D21FCEAB2D9F2B750088701D /* NotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D21FCEA42D9F2B750088701D /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; D25D4D1E2EF626E30029F805 /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D25D4D1D2EF626E30029F805 /* StoreKit.framework */; }; D25D4D7A2EFF41DB0029F805 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D25D4D702EFF41DB0029F805 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; - D2B2E0FF2F63819600E729C1 /* VideoCompressionChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2B2E0FE2F63819600E729C1 /* VideoCompressionChannel.swift */; }; + D3A100022F70000100D1A001 /* DirectMediaTransfer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A100012F70000100D1A001 /* DirectMediaTransfer.swift */; }; + D3A100032F70000100D1A002 /* NativeImageCodec.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A100042F70000100D1A002 /* NativeImageCodec.swift */; }; + D3A100052F70000100D1A003 /* NativeVideoCodec.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A100062F70000100D1A003 /* NativeVideoCodec.swift */; }; + D3A100072F70000100D1A004 /* NativeGallery.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A100082F70000100D1A004 /* NativeGallery.swift */; }; F3C66D726A2EB28484DF0B10 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 16FBC6F5B58E1C6646F5D447 /* GoogleService-Info.plist */; }; /* End PBXBuildFile section */ @@ -110,7 +113,10 @@ D25D4D1D2EF626E30029F805 /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = System/Library/Frameworks/StoreKit.framework; sourceTree = SDKROOT; }; D25D4D702EFF41DB0029F805 /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; D25D4D802EFF437F0029F805 /* RunnerDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerDebug.entitlements; sourceTree = ""; }; - D2B2E0FE2F63819600E729C1 /* VideoCompressionChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoCompressionChannel.swift; sourceTree = ""; }; + D3A100012F70000100D1A001 /* DirectMediaTransfer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectMediaTransfer.swift; sourceTree = ""; }; + D3A100042F70000100D1A002 /* NativeImageCodec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeImageCodec.swift; sourceTree = ""; }; + D3A100062F70000100D1A003 /* NativeVideoCodec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeVideoCodec.swift; sourceTree = ""; }; + D3A100082F70000100D1A004 /* NativeGallery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeGallery.swift; sourceTree = ""; }; DC1EE71614E1B4F84D6FDC2D /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; E190E82D9973B318A389650B /* Pods_ShareExtension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_ShareExtension.framework; sourceTree = BUILT_PRODUCTS_DIR; }; E96A5ACA32A7118204F050A5 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; @@ -241,7 +247,10 @@ 97C146F01CF9000F007C117D /* Runner */ = { isa = PBXGroup; children = ( - D2B2E0FE2F63819600E729C1 /* VideoCompressionChannel.swift */, + D3A100012F70000100D1A001 /* DirectMediaTransfer.swift */, + D3A100042F70000100D1A002 /* NativeImageCodec.swift */, + D3A100062F70000100D1A003 /* NativeVideoCodec.swift */, + D3A100082F70000100D1A004 /* NativeGallery.swift */, D24E27CC2F38ABC10055D9D1 /* RunnerRelease.entitlements */, D25D4D802EFF437F0029F805 /* RunnerDebug.entitlements */, D2265DD42D920142000D99BB /* Runner.entitlements */, @@ -637,7 +646,10 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - D2B2E0FF2F63819600E729C1 /* VideoCompressionChannel.swift in Sources */, + D3A100022F70000100D1A001 /* DirectMediaTransfer.swift in Sources */, + D3A100032F70000100D1A002 /* NativeImageCodec.swift in Sources */, + D3A100052F70000100D1A003 /* NativeVideoCodec.swift in Sources */, + D3A100072F70000100D1A004 /* NativeGallery.swift in Sources */, 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, ); diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index c0f7cd81..cacce4a0 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -4,7 +4,6 @@ import Foundation import UIKit import UserNotifications import flutter_sharing_intent -import workmanager_apple @main @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { @@ -14,31 +13,27 @@ import workmanager_apple ) -> Bool { UNUserNotificationCenter.current().delegate = self - if let registrar = self.registrar(forPlugin: "VideoCompressionChannel") { - VideoCompressionChannel.register(with: registrar.messenger()) - } - - WorkmanagerDebug.setCurrent(LoggingDebugHandler()) - - WorkmanagerPlugin.setPluginRegistrantCallback { registry in - GeneratedPluginRegistrant.register(with: registry) - // Background tasks call AppEnvironment.init() too, so this engine needs - // the runtime storage channel just as much as the implicit one. - RuntimeStorageChannel.register(with: registry) - } - - WorkmanagerPlugin.registerPeriodicTask( - withIdentifier: "eu.twonly.periodic_task", - frequency: NSNumber(value: 20 * 60) - ) - - WorkmanagerPlugin.registerBGProcessingTask( - withIdentifier: "eu.twonly.processing_task" - ) - return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + override func application( + _ application: UIApplication, + handleEventsForBackgroundURLSession identifier: String, + completionHandler: @escaping () -> Void + ) { + if DirectMediaTransfer.shared.handleEvents( + identifier: identifier, + completion: completionHandler + ) { + return + } + super.application( + application, + handleEventsForBackgroundURLSession: identifier, + completionHandler: completionHandler + ) + } + override func application( _ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:] ) -> Bool { diff --git a/ios/Runner/DirectMediaTransfer.swift b/ios/Runner/DirectMediaTransfer.swift new file mode 100644 index 00000000..37e078b8 --- /dev/null +++ b/ios/Runner/DirectMediaTransfer.swift @@ -0,0 +1,155 @@ +import Foundation + +private struct DirectMediaRequest: Codable { + let role: String + let url: String + let method: String + let headers: [String: String] + let bodyPath: String +} + +private struct DirectMediaDescriptor: Codable { + let attachmentId: String + let expiresAt: Int64 + let media: DirectMediaRequest + let manifest: DirectMediaRequest + let complete: DirectMediaRequest +} + +/// Thin transport-only adapter. Rust supplies immutable request files and all +/// retry/expiry metadata; Swift only gives those files to background URLSession. +final class DirectMediaTransfer: NSObject, URLSessionTaskDelegate, URLSessionDelegate { + static let shared = DirectMediaTransfer() + static let sessionIdentifier = "eu.twonly.direct-media-transfer" + + private let defaults = UserDefaults.standard + private let descriptorPrefix = "direct-media-descriptor-" + private var backgroundCompletion: (() -> Void)? + private var sessionStorage: URLSession? + + private lazy var decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + return decoder + }() + + private var session: URLSession { + if let sessionStorage { return sessionStorage } + let configuration = URLSessionConfiguration.background( + withIdentifier: Self.sessionIdentifier + ) + configuration.sessionSendsLaunchEvents = true + configuration.isDiscretionary = false + configuration.waitsForConnectivity = true + configuration.allowsCellularAccess = true + let created = URLSession(configuration: configuration, delegate: self, delegateQueue: nil) + sessionStorage = created + return created + } + + func schedule(json: String) -> Bool { + guard + let data = json.data(using: .utf8), + let descriptor = try? decoder.decode(DirectMediaDescriptor.self, from: data), + descriptor.expiresAt > Int64(Date().timeIntervalSince1970), + FileManager.default.fileExists(atPath: descriptor.media.bodyPath), + FileManager.default.fileExists(atPath: descriptor.manifest.bodyPath) + else { return false } + + defaults.set(data, forKey: descriptorPrefix + descriptor.attachmentId) + // Both durable transfers are created before either is resumed, so process + // death cannot leave a media object with no corresponding manifest task. + guard + let mediaTask = makeTask(descriptor.media, attachmentId: descriptor.attachmentId), + let manifestTask = makeTask(descriptor.manifest, attachmentId: descriptor.attachmentId) + else { return false } + mediaTask.resume() + manifestTask.resume() + return true + } + + func handleEvents(identifier: String, completion: @escaping () -> Void) -> Bool { + guard identifier == Self.sessionIdentifier else { return false } + backgroundCompletion = completion + _ = session + return true + } + + private func makeTask(_ request: DirectMediaRequest, attachmentId: String) -> URLSessionUploadTask? { + guard let url = URL(string: request.url) else { return nil } + let fileURL = URL(fileURLWithPath: request.bodyPath) + guard FileManager.default.fileExists(atPath: fileURL.path) else { return nil } + var urlRequest = URLRequest(url: url) + urlRequest.httpMethod = request.method + for (name, value) in request.headers { + urlRequest.setValue(value, forHTTPHeaderField: name) + } + let task = session.uploadTask(with: urlRequest, fromFile: fileURL) + task.taskDescription = "\(attachmentId)|\(request.role)" + return task + } + + private func descriptor(attachmentId: String) -> DirectMediaDescriptor? { + guard let data = defaults.data(forKey: descriptorPrefix + attachmentId) else { return nil } + return try? decoder.decode(DirectMediaDescriptor.self, from: data) + } + + private func request(_ role: String, from descriptor: DirectMediaDescriptor) -> DirectMediaRequest? { + switch role { + case "media": return descriptor.media + case "manifest": return descriptor.manifest + case "complete": return descriptor.complete + default: return nil + } + } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + didCompleteWithError error: Error? + ) { + guard + let description = task.taskDescription, + description.split(separator: "|", maxSplits: 1).count == 2 + else { return } + let parts = description.split(separator: "|", maxSplits: 1) + let attachmentId = String(parts[0]) + let role = String(parts[1]) + guard let descriptor = descriptor(attachmentId: attachmentId) else { return } + let status = (task.response as? HTTPURLResponse)?.statusCode ?? 0 + let expired = descriptor.expiresAt <= Int64(Date().timeIntervalSince1970) + let success = error == nil && (200...299).contains(status) + + if success && role == "media" { + makeTask(descriptor.complete, attachmentId: attachmentId)?.resume() + return + } + if success { + return + } + // Authentication/policy failures are permanent. Connectivity failures, + // throttling and server failures remain retryable for the slot lifetime. + let retryable = error != nil || status == 0 || status == 408 || status == 429 || status >= 500 + if !expired, retryable, let retry = request(role, from: descriptor) { + let retryTask = makeTask(retry, attachmentId: attachmentId) + retryTask?.earliestBeginDate = Date().addingTimeInterval(15) + retryTask?.resume() + } + } + + func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { + DispatchQueue.main.async { [weak self] in + let completion = self?.backgroundCompletion + self?.backgroundCompletion = nil + completion?() + } + } +} + +/// C ABI called directly by Rust. Flutter and Dart never participate in +/// scheduling or executing these background uploads. +@_cdecl("twonly_schedule_direct_media_uploads") +func twonlyScheduleDirectMediaUploads(_ descriptor: UnsafePointer?) -> Bool { + guard let descriptor else { return false } + return DirectMediaTransfer.shared.schedule(json: String(cString: descriptor)) +} diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 8e45f74f..26d64017 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -59,6 +59,8 @@ Use your microphone to enable audio when making videos. NSPhotoLibraryUsageDescription twonly will save photos or videos to your library. + NSPhotoLibraryAddUsageDescription + twonly will save photos or videos to your library. NSLocationWhenInUseUsageDescription This app does not use or store your location information. UIApplicationSceneManifest @@ -90,11 +92,6 @@ remote-notification processing - BGTaskSchedulerPermittedIdentifiers - - eu.twonly.periodic_task - eu.twonly.processing_task - UILaunchStoryboardName LaunchScreen UIMainStoryboardFile diff --git a/ios/Runner/NativeGallery.swift b/ios/Runner/NativeGallery.swift new file mode 100644 index 00000000..2d3fd6f8 --- /dev/null +++ b/ios/Runner/NativeGallery.swift @@ -0,0 +1,48 @@ +import Foundation +import Photos + +/// Writes a finished media file into the user's photo library. +/// +/// Called directly from Rust. Rust decides whether an export should happen, +/// embeds the EXIF metadata beforehand, and owns the resulting state; this only +/// hands the file to Photos. +@_cdecl("twonly_save_to_gallery") +func twonlySaveToGallery( + _ path: UnsafePointer?, + _ isVideo: Bool, + _ createdAtMillis: Int64 +) -> Bool { + guard let path else { return false } + let url = URL(fileURLWithPath: String(cString: path)) + guard FileManager.default.fileExists(atPath: url.path) else { return false } + + // Authorisation is requested for adding only; a denied library never blocks + // the send itself, the export just fails. + let status = PHPhotoLibrary.authorizationStatus(for: .addOnly) + if status == .notDetermined { + let waiting = DispatchSemaphore(value: 0) + PHPhotoLibrary.requestAuthorization(for: .addOnly) { _ in waiting.signal() } + waiting.wait() + } + switch PHPhotoLibrary.authorizationStatus(for: .addOnly) { + case .authorized, .limited: break + default: return false + } + + var saved = false + let finished = DispatchSemaphore(value: 0) + PHPhotoLibrary.shared().performChanges { + let request: PHAssetChangeRequest? = + isVideo + ? PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: url) + : PHAssetChangeRequest.creationRequestForAssetFromImage(atFileURL: url) + // Photos sorts on this, so it has to carry the capture time rather than + // the moment the file was exported. + request?.creationDate = Date(timeIntervalSince1970: Double(createdAtMillis) / 1000) + } completionHandler: { success, _ in + saved = success + finished.signal() + } + finished.wait() + return saved +} diff --git a/ios/Runner/NativeImageCodec.swift b/ios/Runner/NativeImageCodec.swift new file mode 100644 index 00000000..cde14bd8 --- /dev/null +++ b/ios/Runner/NativeImageCodec.swift @@ -0,0 +1,38 @@ +import Foundation +import ImageIO +import UniformTypeIdentifiers + +/// C ABI called directly by Rust for the container formats Rust has no decoder +/// for — HEIC/HEIF from the camera above all. ImageIO decodes every format the +/// OS knows; Rust re-encodes the result to WebP itself. Flutter is not involved. +@_cdecl("twonly_decode_image_to_png") +func twonlyDecodeImageToPng( + _ input: UnsafePointer?, + _ output: UnsafePointer? +) -> Bool { + guard let input, let output else { return false } + let inputURL = URL(fileURLWithPath: String(cString: input)) + let outputURL = URL(fileURLWithPath: String(cString: output)) + + guard + let source = CGImageSourceCreateWithURL(inputURL as CFURL, nil), + let image = CGImageSourceCreateImageAtIndex(source, 0, nil) + else { return false } + + try? FileManager.default.createDirectory( + at: outputURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + // PNG is lossless, so the quality decision stays with Rust's WebP encode. + let type: CFString + if #available(iOS 14.0, *) { + type = UTType.png.identifier as CFString + } else { + type = "public.png" as CFString + } + guard + let destination = CGImageDestinationCreateWithURL(outputURL as CFURL, type, 1, nil) + else { return false } + CGImageDestinationAddImage(destination, image, nil) + return CGImageDestinationFinalize(destination) +} diff --git a/ios/Runner/NativeVideoCodec.swift b/ios/Runner/NativeVideoCodec.swift new file mode 100644 index 00000000..3adbb0b4 --- /dev/null +++ b/ios/Runner/NativeVideoCodec.swift @@ -0,0 +1,320 @@ +import AVFoundation +import CoreImage +import Foundation +import ImageIO + +/// Burns the editor's overlay into the video and transcodes it in one pass: +/// Core Image composites each frame on the GPU and VideoToolbox encodes it. +/// No ffmpeg and no Flutter engine, so a send can finish in the background. +/// +/// Called directly from Rust. Rust decides whether a render is needed and owns +/// every state transition around it; this only performs the work. +enum NativeVideoCodec { + /// Every send is normalised to 720p30 regardless of plan. The server caps a + /// single media object at 50MB on the free plan and 100MB on the paid ones, + /// and 720p30 is the largest format that keeps a clip of ordinary length + /// comfortably under the smaller of the two. + private static let maxLongSide: CGFloat = 1280 + private static let maxShortSide: CGFloat = 720 + private static let maxFrameRate: Double = 30 + + /// Bits per pixel per frame asked of VideoToolbox. HEVC stays close to the + /// source at roughly this rate; below it motion smears into blocks, and above + /// it the extra bits go to detail a phone camera never recorded. The bitrate + /// is derived from the output size and frame rate rather than fixed, so a clip + /// that is downscaled hard is not given the same budget as one that is already + /// 720p. Kept in sync with the same constants in the Android renderer so the + /// same clip looks the same whichever platform sent it. + /// + /// `AVAssetExportSession` presets cannot express any of this, which is why + /// the reader/writer pair is driven by hand. + private static let bitsPerPixelPerFrame: Double = 0.12 + private static let minBitrate = 1_500_000 + private static let maxBitrate = 4_000_000 + private static let defaultFrameRate: Double = 30 + private static let audioBitrate = 128_000 + + static func render( + inputPath: String, + overlayPath: String?, + outputPath: String, + removeAudio: Bool, + onProgress: @escaping (Int) -> Void + ) -> Bool { + let asset = AVURLAsset(url: URL(fileURLWithPath: inputPath)) + guard let videoTrack = asset.tracks(withMediaType: .video).first else { return false } + + let outputURL = URL(fileURLWithPath: outputPath) + try? FileManager.default.removeItem(at: outputURL) + try? FileManager.default.createDirectory( + at: outputURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + + // The frame is stored unrotated; its displayed size is what has to be + // scaled, and what the overlay was drawn against. + let transformed = videoTrack.naturalSize.applying(videoTrack.preferredTransform) + let displayed = CGSize(width: abs(transformed.width), height: abs(transformed.height)) + let target = evenScaledSize(displayed) + guard target.width > 0, target.height > 0 else { return false } + + // A track with no declared frame rate still has to be given a budget. + let nominal = Double(videoTrack.nominalFrameRate) + let sourceFrameRate = nominal > 0 ? nominal : defaultFrameRate + let frameRate = min(sourceFrameRate, maxFrameRate) + let videoBitrate = bitrate(for: target, frameRate: frameRate) + + let overlay = overlayPath.flatMap { CIImage(contentsOf: URL(fileURLWithPath: $0)) } + let composition = ciComposition( + for: asset, + target: target, + overlay: overlay, + // A 60fps clip encoded at 30fps spends its whole budget on the frames it + // keeps instead of halving the bits every frame gets. The source rate is + // left alone when it is already at or below the cap, so a 24fps clip is + // not resampled up to 30. + frameRate: sourceFrameRate > maxFrameRate ? maxFrameRate : nil + ) + + do { + let reader = try AVAssetReader(asset: asset) + let writer = try AVAssetWriter(outputURL: outputURL, fileType: .mp4) + writer.shouldOptimizeForNetworkUse = true + + let videoOutput = AVAssetReaderVideoCompositionOutput( + videoTracks: asset.tracks(withMediaType: .video), + videoSettings: [ + kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA + ] + ) + videoOutput.videoComposition = composition + videoOutput.alwaysCopiesSampleData = false + + let videoInput = AVAssetWriterInput( + mediaType: .video, + outputSettings: [ + AVVideoCodecKey: AVVideoCodecType.hevc, + AVVideoWidthKey: Int(target.width), + AVVideoHeightKey: Int(target.height), + // The source frame rate lets rate control budget a whole second + // instead of guessing from the samples it has seen so far. + AVVideoCompressionPropertiesKey: [ + AVVideoAverageBitRateKey: videoBitrate, + AVVideoExpectedSourceFrameRateKey: Int(frameRate.rounded()), + ], + ] + ) + videoInput.expectsMediaDataInRealTime = false + // The composition already applied the track's transform, so tagging the + // output again would rotate it a second time on playback. + + guard reader.canAdd(videoOutput), writer.canAdd(videoInput) else { return false } + reader.add(videoOutput) + writer.add(videoInput) + + var audioOutput: AVAssetReaderTrackOutput? + var audioInput: AVAssetWriterInput? + if !removeAudio, let audioTrack = asset.tracks(withMediaType: .audio).first { + let output = AVAssetReaderTrackOutput( + track: audioTrack, + outputSettings: [AVFormatIDKey: kAudioFormatLinearPCM] + ) + let input = AVAssetWriterInput( + mediaType: .audio, + outputSettings: [ + AVFormatIDKey: kAudioFormatMPEG4AAC, + AVNumberOfChannelsKey: 2, + AVSampleRateKey: 44100, + AVEncoderBitRateKey: audioBitrate, + ] + ) + input.expectsMediaDataInRealTime = false + if reader.canAdd(output), writer.canAdd(input) { + reader.add(output) + writer.add(input) + audioOutput = output + audioInput = input + } + } + + guard reader.startReading(), writer.startWriting() else { return false } + writer.startSession(atSourceTime: .zero) + + let duration = CMTimeGetSeconds(asset.duration) + let group = DispatchGroup() + pump( + input: videoInput, + output: videoOutput, + label: "video", + group: group, + duration: duration, + onProgress: onProgress + ) + if let audioInput, let audioOutput { + pump( + input: audioInput, + output: audioOutput, + label: "audio", + group: group, + duration: nil, + onProgress: nil + ) + } + group.wait() + + guard reader.status != .failed else { + writer.cancelWriting() + return false + } + let finished = DispatchSemaphore(value: 0) + writer.finishWriting { finished.signal() } + finished.wait() + + guard writer.status == .completed else { return false } + let size = try FileManager.default.attributesOfItem(atPath: outputPath)[.size] as? Int + return (size ?? 0) > 0 + } catch { + return false + } + } + + /// Core Image runs on the GPU, and unlike `AVVideoCompositionCoreAnimationTool` + /// it also works with a reader/writer pair, which is what lets the bitrate + /// stay under our control. + private static func ciComposition( + for asset: AVAsset, + target: CGSize, + overlay: CIImage?, + frameRate: Double? + ) -> AVMutableVideoComposition { + let composition = AVMutableVideoComposition(asset: asset) { request in + let source = request.sourceImage + var frame = source + if source.extent.width > 0, source.extent.height > 0 { + frame = source.transformed( + by: CGAffineTransform( + scaleX: target.width / source.extent.width, + y: target.height / source.extent.height + ) + ) + } + if let overlay, overlay.extent.width > 0, overlay.extent.height > 0 { + let scaled = overlay.transformed( + by: CGAffineTransform( + scaleX: target.width / overlay.extent.width, + y: target.height / overlay.extent.height + ) + ) + frame = scaled.composited(over: frame) + } + request.finish(with: frame, context: nil) + } + composition.renderSize = target + if let frameRate { + composition.frameDuration = CMTime(value: 1, timescale: CMTimeScale(frameRate)) + } + return composition + } + + /// A rate the output size and frame rate actually justify. A fixed bitrate + /// either starves a 1080p60 clip or wastes bits on a 480p one; this spends the + /// same amount per pixel either way, within bounds that keep a send both + /// watchable and small enough to upload. + private static func bitrate(for size: CGSize, frameRate: Double) -> Int { + let bits = Double(size.width) * Double(size.height) * frameRate * bitsPerPixelPerFrame + return min(maxBitrate, max(minBitrate, Int(bits.rounded()))) + } + + /// Caps the long and short side the way the previous exporter did, and never + /// scales a smaller source up. Hardware encoders produce edge artifacts on + /// odd dimensions. + private static func evenScaledSize(_ size: CGSize) -> CGSize { + guard size.width > 0, size.height > 0 else { return .zero } + let scale = min( + 1, + min(maxLongSide / max(size.width, size.height), maxShortSide / min(size.width, size.height)) + ) + let width = (size.width * scale).rounded(.down) + let height = (size.height * scale).rounded(.down) + return CGSize( + width: width - width.truncatingRemainder(dividingBy: 2), + height: height - height.truncatingRemainder(dividingBy: 2) + ) + } + + private static func pump( + input: AVAssetWriterInput, + output: AVAssetReaderOutput, + label: String, + group: DispatchGroup, + duration: Double?, + onProgress: ((Int) -> Void)? + ) { + group.enter() + let queue = DispatchQueue(label: "eu.twonly.video.\(label)") + input.requestMediaDataWhenReady(on: queue) { + while input.isReadyForMoreMediaData { + guard let sample = output.copyNextSampleBuffer() else { + input.markAsFinished() + group.leave() + return + } + if let duration, duration > 0, let onProgress { + let seconds = CMTimeGetSeconds(CMSampleBufferGetPresentationTimeStamp(sample)) + onProgress(Int((seconds / duration * 100).rounded())) + } + input.append(sample) + } + } + } +} + +/// C ABI called directly by Rust. +@_cdecl("twonly_render_video") +func twonlyRenderVideo( + _ input: UnsafePointer?, + _ overlay: UnsafePointer?, + _ output: UnsafePointer?, + _ removeAudio: Bool, + _ mediaId: UnsafePointer?, + _ progress: @convention(c) (UnsafePointer?, Int32) -> Void +) -> Bool { + guard let input, let output, let mediaId else { return false } + let mediaIdString = String(cString: mediaId) + return NativeVideoCodec.render( + inputPath: String(cString: input), + overlayPath: overlay.map { String(cString: $0) }, + outputPath: String(cString: output), + removeAudio: removeAudio, + onProgress: { percent in + mediaIdString.withCString { progress($0, Int32(percent)) } + } + ) +} + +/// Grabs the first frame as a PNG. Only the decode needs the platform; Rust +/// scales and encodes the thumbnail itself, exactly as it does for stills. +@_cdecl("twonly_extract_video_frame") +func twonlyExtractVideoFrame( + _ input: UnsafePointer?, + _ output: UnsafePointer? +) -> Bool { + guard let input, let output else { return false } + let asset = AVURLAsset(url: URL(fileURLWithPath: String(cString: input))) + let generator = AVAssetImageGenerator(asset: asset) + // The frame has to arrive upright, the way the video is played back. + generator.appliesPreferredTrackTransform = true + guard let frame = try? generator.copyCGImage(at: .zero, actualTime: nil) else { return false } + + let outputURL = URL(fileURLWithPath: String(cString: output)) + try? FileManager.default.createDirectory( + at: outputURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + guard + let destination = CGImageDestinationCreateWithURL( + outputURL as CFURL, "public.png" as CFString, 1, nil) + else { return false } + CGImageDestinationAddImage(destination, frame, nil) + return CGImageDestinationFinalize(destination) +} diff --git a/ios/Runner/VideoCompressionChannel.swift b/ios/Runner/VideoCompressionChannel.swift deleted file mode 100644 index 95c2dfb1..00000000 --- a/ios/Runner/VideoCompressionChannel.swift +++ /dev/null @@ -1,253 +0,0 @@ -import Foundation -import Flutter -import AVFoundation - -class VideoCompressionChannel { - private let channelName = "eu.twonly/videoCompression" - - // Hold a strong reference so the instance isn't immediately deallocated - private static var activeInstance: VideoCompressionChannel? - - static func register(with messenger: FlutterBinaryMessenger) { - let instance = VideoCompressionChannel() - activeInstance = instance - - let channel = FlutterMethodChannel(name: instance.channelName, binaryMessenger: messenger) - - print("[VideoCompressionChannel] Registered channel: \(instance.channelName)") - - channel.setMethodCallHandler { [weak instance] (call: FlutterMethodCall, result: @escaping FlutterResult) in - print("[VideoCompressionChannel] Received method call: \(call.method)") - instance?.handle(call, result: result, channel: channel) - } - } - - init() {} - - func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult, channel: FlutterMethodChannel) { - if call.method == "compressVideo" { - guard let args = call.arguments as? [String: Any], - let inputPath = args["input"] as? String, - let outputPath = args["output"] as? String else { - print("[VideoCompressionChannel] Error: Missing input or output path in arguments") - result(FlutterError(code: "INVALID_ARGS", message: "Input or output path missing", details: nil)) - return - } - - print("[VideoCompressionChannel] Starting compressVideo from \(inputPath) to \(outputPath)") - compress(inputPath: inputPath, outputPath: outputPath, channel: channel, result: result) - } else { - print("[VideoCompressionChannel] Method not implemented: \(call.method)") - result(FlutterMethodNotImplemented) - } - } - - func compress(inputPath: String, outputPath: String, channel: FlutterMethodChannel, result: @escaping FlutterResult) { - let inputURL = URL(fileURLWithPath: inputPath) - let outputURL = URL(fileURLWithPath: outputPath) - - if FileManager.default.fileExists(atPath: outputURL.path) { - print("[VideoCompressionChannel] Removing existing file at output path") - try? FileManager.default.removeItem(at: outputURL) - } - - let asset = AVAsset(url: inputURL) - guard let videoTrack = asset.tracks(withMediaType: .video).first else { - print("[VideoCompressionChannel] Error: No video track found in asset") - result(FlutterError(code: "NO_VIDEO_TRACK", message: "Video track not found", details: nil)) - return - } - - let naturalSize = videoTrack.naturalSize - let transform = videoTrack.preferredTransform - let isPortrait = transform.a == 0 && abs(transform.b) == 1.0 && abs(transform.c) == 1.0 && transform.d == 0 - - let originalWidth = isPortrait ? naturalSize.height : naturalSize.width - let originalHeight = isPortrait ? naturalSize.width : naturalSize.height - - let maxDimension: CGFloat = 1920.0 - let minDimension: CGFloat = 1080.0 - - var targetWidth = originalWidth - var targetHeight = originalHeight - - if targetWidth > maxDimension || targetHeight > maxDimension { - let widthRatio = maxDimension / targetWidth - let heightRatio = minDimension / targetHeight - let scaleFactor = min(widthRatio, heightRatio) - - targetWidth *= scaleFactor - targetHeight *= scaleFactor - } - - let targetBitrate = 3_000_000 - - do { - let reader = try AVAssetReader(asset: asset) - let writer = try AVAssetWriter(outputURL: outputURL, fileType: .mp4) - writer.shouldOptimizeForNetworkUse = true - - let videoSettings: [String: Any] = [ - AVVideoCodecKey: AVVideoCodecType.hevc, - AVVideoWidthKey: Int(targetWidth), - AVVideoHeightKey: Int(targetHeight), - AVVideoCompressionPropertiesKey: [ - AVVideoAverageBitRateKey: targetBitrate - ] - ] - - let readerOutput = AVAssetReaderTrackOutput(track: videoTrack, outputSettings: [ - kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_420YpCbCr8BiPlanarFullRange - ]) - - let writerInput = AVAssetWriterInput(mediaType: .video, outputSettings: videoSettings) - writerInput.expectsMediaDataInRealTime = false - writerInput.transform = videoTrack.preferredTransform - - guard writer.canAdd(writerInput) else { - result(FlutterError(code: "WRITER_ERROR", message: "Cannot add video writer input", details: nil)) - return - } - - guard reader.canAdd(readerOutput) else { - result(FlutterError(code: "READER_ERROR", message: "Cannot add video reader output", details: nil)) - return - } - reader.add(readerOutput) - writer.add(writerInput) - - // Audio processing (re-encode to AAC) - var audioReaderOutput: AVAssetReaderTrackOutput? - var audioWriterInput: AVAssetWriterInput? - - if let audioTrack = asset.tracks(withMediaType: .audio).first { - let audioReaderSettings: [String: Any] = [ - AVFormatIDKey: kAudioFormatLinearPCM - ] - let aReaderOutput = AVAssetReaderTrackOutput(track: audioTrack, outputSettings: audioReaderSettings) - - let audioWriterSettings: [String: Any] = [ - AVFormatIDKey: kAudioFormatMPEG4AAC, - AVNumberOfChannelsKey: 2, - AVSampleRateKey: 44100, - AVEncoderBitRateKey: 128000 - ] - let aWriterInput = AVAssetWriterInput(mediaType: .audio, outputSettings: audioWriterSettings) - aWriterInput.expectsMediaDataInRealTime = false - - if reader.canAdd(aReaderOutput) && writer.canAdd(aWriterInput) { - reader.add(aReaderOutput) - writer.add(aWriterInput) - audioReaderOutput = aReaderOutput - audioWriterInput = aWriterInput - } else { - print("[VideoCompressionChannel] Warning: Cannot add audio tracks, proceeding without audio") - } - } - - guard reader.startReading() else { - result(FlutterError(code: "READER_ERROR", message: "Cannot start reading: \(reader.error?.localizedDescription ?? "unknown error")", details: nil)) - return - } - - guard writer.startWriting() else { - result(FlutterError(code: "WRITER_ERROR", message: "Cannot start writing: \(writer.error?.localizedDescription ?? "unknown error")", details: nil)) - return - } - writer.startSession(atSourceTime: .zero) - - let duration = CMTimeGetSeconds(asset.duration) - let videoQueue = DispatchQueue(label: "videoQueue") - let audioQueue = DispatchQueue(label: "audioQueue") - let group = DispatchGroup() - - // State tracking flag to avoid sending completed messages prematurely - var isVideoCompleted = false - var isAudioCompleted = audioWriterInput == nil - - group.enter() - writerInput.requestMediaDataWhenReady(on: videoQueue) { - while writerInput.isReadyForMoreMediaData { - if reader.status != .reading { - if !isVideoCompleted { - isVideoCompleted = true - writerInput.markAsFinished() - group.leave() - } - return - } - - if let sampleBuffer = readerOutput.copyNextSampleBuffer() { - let presentationTime = CMSampleBufferGetPresentationTimeStamp(sampleBuffer) - let timeInSeconds = CMTimeGetSeconds(presentationTime) - - if duration > 0 { - let progress = Int((timeInSeconds / duration) * 100) - DispatchQueue.main.async { - channel.invokeMethod("onProgress", arguments: ["progress": progress]) - } - } - - writerInput.append(sampleBuffer) - } else { - if !isVideoCompleted { - isVideoCompleted = true - writerInput.markAsFinished() - group.leave() - } - break - } - } - } - - if let audioWriterInput = audioWriterInput, let audioReaderOutput = audioReaderOutput { - group.enter() - audioWriterInput.requestMediaDataWhenReady(on: audioQueue) { - while audioWriterInput.isReadyForMoreMediaData { - if reader.status != .reading { - if !isAudioCompleted { - isAudioCompleted = true - audioWriterInput.markAsFinished() - group.leave() - } - return - } - - if let sampleBuffer = audioReaderOutput.copyNextSampleBuffer() { - audioWriterInput.append(sampleBuffer) - } else { - if !isAudioCompleted { - isAudioCompleted = true - audioWriterInput.markAsFinished() - group.leave() - } - break - } - } - } - } - - group.notify(queue: .main) { - if reader.status == .completed { - writer.finishWriting { - if writer.status == .completed { - print("[VideoCompressionChannel] Compression completed successfully!") - result(outputPath) - } else { - print("[VideoCompressionChannel] Writer Error: \(writer.error?.localizedDescription ?? "Unknown error")") - result(FlutterError(code: "WRITER_ERROR", message: writer.error?.localizedDescription, details: nil)) - } - } - } else { - writer.cancelWriting() - print("[VideoCompressionChannel] Reader Error: \(reader.error?.localizedDescription ?? "Unknown error")") - result(FlutterError(code: "READER_ERROR", message: reader.error?.localizedDescription, details: nil)) - } - } - - } catch { - print("[VideoCompressionChannel] Exception: \(error.localizedDescription)") - result(FlutterError(code: "COMPRESS_ERROR", message: error.localizedDescription, details: nil)) - } - } -} diff --git a/lib/core/bridge/api.dart b/lib/core/bridge/api.dart index 848fabbe..db4938a0 100644 --- a/lib/core/bridge/api.dart +++ b/lib/core/bridge/api.dart @@ -5,6 +5,7 @@ import '../api/server/prekeys.dart'; import '../frb_generated.dart'; +import '../services/media_upload.dart'; import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; // These functions are ignored because they are not marked as `pub`: `api_result`, `empty_api_response`, `from_rust_state` @@ -262,6 +263,11 @@ class FrbUserData { class RustApi { const RustApi(); + /// Gives up on a media file whose source could not be produced, marking + /// its messages as deleted by the sender instead of retrying forever. + static Future abandonMedia({required String mediaId}) => + RustLib.instance.api.crateBridgeApiRustApiAbandonMedia(mediaId: mediaId); + static Future addAdditionalUser({required PlatformInt64 userId}) => RustLib.instance.api.crateBridgeApiRustApiAddAdditionalUser( userId: userId, @@ -273,6 +279,17 @@ class RustApi { static String apiBaseUrl({required String protocol}) => RustLib.instance.api.crateBridgeApiRustApiApiBaseUrl(protocol: protocol); + static Future> authenticationHeaders() => + RustLib.instance.api.crateBridgeApiRustApiAuthenticationHeaders(); + + static String avatarPngPath({ + required PlatformInt64 contactId, + required PlatformInt64 profileCounter, + }) => RustLib.instance.api.crateBridgeApiRustApiAvatarPngPath( + contactId: contactId, + profileCounter: profileCounter, + ); + static Future changeUsername({required String username}) => RustLib .instance .api @@ -323,6 +340,21 @@ class RustApi { static Future connectionState() => RustLib.instance.api.crateBridgeApiRustApiConnectionState(); + /// Trims fully transparent borders an editor left around a stored image and + /// refreshes the preview and content hash derived from it. + static Future cropMediaTransparentBorders({required String mediaId}) => + RustLib.instance.api.crateBridgeApiRustApiCropMediaTransparentBorders( + mediaId: mediaId, + ); + + static Future currentUserAvatarPath() => + RustLib.instance.api.crateBridgeApiRustApiCurrentUserAvatarPath(); + + static String decodeAvatarSvg({required List avatarSvgCompressed}) => + RustLib.instance.api.crateBridgeApiRustApiDecodeAvatarSvg( + avatarSvgCompressed: avatarSvgCompressed, + ); + static Future deleteAccount() => RustLib.instance.api.crateBridgeApiRustApiDeleteAccount(); @@ -341,6 +373,13 @@ class RustApi { static Future downloadPendingMedia() => RustLib.instance.api.crateBridgeApiRustApiDownloadPendingMedia(); + /// Path to a contact's avatar PNG, rendered from the stored SVG if it is + /// missing. Returns `None` when the contact has no avatar at all. + static Future ensureAvatarPng({required PlatformInt64 contactId}) => + RustLib.instance.api.crateBridgeApiRustApiEnsureAvatarPng( + contactId: contactId, + ); + static Future establishSignalSession({ required PlatformInt64 contactId, Uint8List? expectedPublicKey, @@ -352,6 +391,11 @@ class RustApi { static Stream events() => RustLib.instance.api.crateBridgeApiRustApiEvents(); + /// Settles every background transfer this device believes is still in + /// flight and resumes any upload a terminated process left behind. + static Future finishStartedMediaUploads() => + RustLib.instance.api.crateBridgeApiRustApiFinishStartedMediaUploads(); + static Future forceIpaCheck() => RustLib.instance.api.crateBridgeApiRustApiForceIpaCheck(); @@ -399,6 +443,18 @@ class RustApi { username: username, ); + /// Creates the media row and its content-encryption material and returns + /// the media id the UI addresses every later step by. + static Future initializeMediaUpload({ + required String mediaType, + PlatformInt64? displayLimitInMilliseconds, + required bool isDraftMedia, + }) => RustLib.instance.api.crateBridgeApiRustApiInitializeMediaUpload( + mediaType: mediaType, + displayLimitInMilliseconds: displayLimitInMilliseconds, + isDraftMedia: isDraftMedia, + ); + static Future insertAndSendAdditionalData({ required String groupId, required String messageType, @@ -448,6 +504,24 @@ class RustApi { static Future loadPlanBalance() => RustLib.instance.api.crateBridgeApiRustApiLoadPlanBalance(); + /// Explains a send that stopped because the media was too large: the size + /// it reached and the largest single object the plan accepts. + static Future mediaSizeLimitReport({ + required String mediaId, + }) => RustLib.instance.api.crateBridgeApiRustApiMediaSizeLimitReport( + mediaId: mediaId, + ); + + /// Reports that a Flutter plugin step finished so Rust can record the + /// derived state (thumbnail present, crop analyzed, new size and hash). + static Future mediaStepFinished({ + required String mediaId, + required String kind, + }) => RustLib.instance.api.crateBridgeApiRustApiMediaStepFinished( + mediaId: mediaId, + kind: kind, + ); + /// The number of pending notification events. iOS cannot derive its app /// icon badge from the delivered alerts, so the running app pushes this /// into `UNUserNotificationCenter` whenever the outbox changes. @@ -467,10 +541,9 @@ class RustApi { .api .crateBridgeApiRustApiPerformPasswordlessRecoveryHeartbeat(); - static Future prepareQueuedMessage({required String receiptId}) => - RustLib.instance.api.crateBridgeApiRustApiPrepareQueuedMessage( - receiptId: receiptId, - ); + /// Deletes temporary media whose messages are finished with it. + static Future purgeMediaTempFolder() => + RustLib.instance.api.crateBridgeApiRustApiPurgeMediaTempFolder(); static Future register({ required String username, @@ -515,6 +588,12 @@ class RustApi { userId: userId, ); + /// Deletes every file of a media item while keeping its row. + static Future removeMediaFiles({required String mediaId}) => RustLib + .instance + .api + .crateBridgeApiRustApiRemoveMediaFiles(mediaId: mediaId); + static Future reportUser({ required PlatformInt64 userId, required String reason, @@ -551,6 +630,19 @@ class RustApi { static Future retransmitAllMessages() => RustLib.instance.api.crateBridgeApiRustApiRetransmitAllMessages(); + static Future retryPendingMediaReuploads() => + RustLib.instance.api.crateBridgeApiRustApiRetryPendingMediaReuploads(); + + /// Retries the media sends whose receipts are still marked for retry. + static Future reuploadPendingMedia() => + RustLib.instance.api.crateBridgeApiRustApiReuploadPendingMedia(); + + /// Exports a stored media file to the user's photo library. + static Future saveMediaToGallery({required String mediaId}) => RustLib + .instance + .api + .crateBridgeApiRustApiSaveMediaToGallery(mediaId: mediaId); + static Future sendBinary({required List bytes}) => RustLib.instance.api.crateBridgeApiRustApiSendBinary(bytes: bytes); @@ -587,6 +679,17 @@ class RustApi { onlySendIfNoReceiptsAreOpen: onlySendIfNoReceiptsAreOpen, ); + /// Creates one outgoing message per selected group and starts the upload. + static Future sendMediaToGroups({ + required String mediaId, + required List groupIds, + Uint8List? additionalMessageData, + }) => RustLib.instance.api.crateBridgeApiRustApiSendMediaToGroups( + mediaId: mediaId, + groupIds: groupIds, + additionalMessageData: additionalMessageData, + ); + static Future sendQueuedMessage({required String receiptId}) => RustLib .instance .api @@ -616,11 +719,31 @@ class RustApi { static Future setLoginToken({required List token}) => RustLib.instance.api.crateBridgeApiRustApiSetLoginToken(token: token); + static Future setMediaDisplayLimit({ + required String mediaId, + PlatformInt64? displayLimitInMilliseconds, + }) => RustLib.instance.api.crateBridgeApiRustApiSetMediaDisplayLimit( + mediaId: mediaId, + displayLimitInMilliseconds: displayLimitInMilliseconds, + ); + + static Future setMediaRequiresAuthentication({ + required String mediaId, + required bool requiresAuthentication, + }) => + RustLib.instance.api.crateBridgeApiRustApiSetMediaRequiresAuthentication( + mediaId: mediaId, + requiresAuthentication: requiresAuthentication, + ); + static Future setNetworkAvailable({required bool available}) => RustLib .instance .api .crateBridgeApiRustApiSetNetworkAvailable(available: available); + static Future storeMedia({required String mediaId}) => + RustLib.instance.api.crateBridgeApiRustApiStoreMedia(mediaId: mediaId); + static Future submitRecoveryShare({ required String notificationId, required List encryptedMessage, @@ -629,6 +752,19 @@ class RustApi { encryptedMessage: encryptedMessage, ); + static Future toggleMediaRemoveAudio({required String mediaId}) => + RustLib.instance.api.crateBridgeApiRustApiToggleMediaRemoveAudio( + mediaId: mediaId, + ); + + static Future tryRequestContactById({ + required PlatformInt64 contactId, + required List expectedPublicKey, + }) => RustLib.instance.api.crateBridgeApiRustApiTryRequestContactById( + contactId: contactId, + expectedPublicKey: expectedPublicKey, + ); + static Future updateFcmToken({required String token}) => RustLib.instance.api.crateBridgeApiRustApiUpdateFcmToken(token: token); diff --git a/lib/core/bridge/callbacks.dart b/lib/core/bridge/callbacks.dart index 071b9676..cd94212f 100644 --- a/lib/core/bridge/callbacks.dart +++ b/lib/core/bridge/callbacks.dart @@ -14,19 +14,11 @@ import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; Future initFlutterCallbacks({ required int callbackId, required FutureOr> Function() loggingGetStreamSink, - required FutureOr Function(String, String, PlatformInt64, String) - apiMediaAction, - required FutureOr Function(PlatformInt64, Uint8List) - apiVerificationProof, - required FutureOr Function(PlatformInt64) apiCreatePushAvatars, - required FutureOr Function(String, PlatformInt64) apiMediaReceived, + required FutureOr Function(PlatformInt64) apiVerificationSucceeded, required FutureOr Function(UserConfig) apiUserConfigChanged, }) => RustLib.instance.api.crateBridgeCallbacksInitFlutterCallbacks( callbackId: callbackId, loggingGetStreamSink: loggingGetStreamSink, - apiMediaAction: apiMediaAction, - apiVerificationProof: apiVerificationProof, - apiCreatePushAvatars: apiCreatePushAvatars, - apiMediaReceived: apiMediaReceived, + apiVerificationSucceeded: apiVerificationSucceeded, apiUserConfigChanged: apiUserConfigChanged, ); diff --git a/lib/core/bridge/wrapper/key_manager.dart b/lib/core/bridge/wrapper/key_manager.dart index e1b4bf0f..70422ccb 100644 --- a/lib/core/bridge/wrapper/key_manager.dart +++ b/lib/core/bridge/wrapper/key_manager.dart @@ -27,9 +27,6 @@ class RustKeyManager { addition: addition, ); - static Future getLoginToken() => RustLib.instance.api - .crateBridgeWrapperKeyManagerRustKeyManagerGetLoginToken(); - static Future<(Uint8List, PlatformInt64)> getSignalIdentity() => RustLib .instance .api diff --git a/lib/core/frb_generated.dart b/lib/core/frb_generated.dart index 9ecee7ae..ec48b684 100644 --- a/lib/core/frb_generated.dart +++ b/lib/core/frb_generated.dart @@ -25,6 +25,7 @@ import 'frb_generated.io.dart' import 'keys/backup_password_keys.dart'; import 'lib.dart'; import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; +import 'services/media_upload.dart'; import 'signal/engine.dart'; import 'user_config.dart'; @@ -85,7 +86,7 @@ class RustLib extends BaseEntrypoint { String get codegenVersion => '2.12.0'; @override - int get rustContentHash => 463166747; + int get rustContentHash => 640399573; static const kDefaultExternalLibraryLoaderConfig = ExternalLibraryLoaderConfig( @@ -142,12 +143,7 @@ abstract class RustLibApi extends BaseApi { Future crateBridgeCallbacksInitFlutterCallbacks({ required int callbackId, required FutureOr> Function() loggingGetStreamSink, - required FutureOr Function(String, String, PlatformInt64, String) - apiMediaAction, - required FutureOr Function(PlatformInt64, Uint8List) - apiVerificationProof, - required FutureOr Function(PlatformInt64) apiCreatePushAvatars, - required FutureOr Function(String, PlatformInt64) apiMediaReceived, + required FutureOr Function(PlatformInt64) apiVerificationSucceeded, required FutureOr Function(UserConfig) apiUserConfigChanged, }); @@ -170,6 +166,8 @@ abstract class RustLibApi extends BaseApi { required PlatformInt64 contactId, }); + Future crateBridgeApiRustApiAbandonMedia({required String mediaId}); + Future crateBridgeApiRustApiAddAdditionalUser({ required PlatformInt64 userId, }); @@ -178,6 +176,13 @@ abstract class RustLibApi extends BaseApi { String crateBridgeApiRustApiApiBaseUrl({required String protocol}); + Future> crateBridgeApiRustApiAuthenticationHeaders(); + + String crateBridgeApiRustApiAvatarPngPath({ + required PlatformInt64 contactId, + required PlatformInt64 profileCounter, + }); + Future crateBridgeApiRustApiChangeUsername({required String username}); Future crateBridgeApiRustApiCheckForDeletedUsernames(); @@ -205,6 +210,16 @@ abstract class RustLibApi extends BaseApi { Future crateBridgeApiRustApiConnectionState(); + Future crateBridgeApiRustApiCropMediaTransparentBorders({ + required String mediaId, + }); + + Future crateBridgeApiRustApiCurrentUserAvatarPath(); + + String crateBridgeApiRustApiDecodeAvatarSvg({ + required List avatarSvgCompressed, + }); + Future crateBridgeApiRustApiDeleteAccount(); Future crateBridgeApiRustApiDeleteMemory({required String mediaId}); @@ -217,6 +232,10 @@ abstract class RustLibApi extends BaseApi { Future crateBridgeApiRustApiDownloadPendingMedia(); + Future crateBridgeApiRustApiEnsureAvatarPng({ + required PlatformInt64 contactId, + }); + Future crateBridgeApiRustApiEstablishSignalSession({ required PlatformInt64 contactId, Uint8List? expectedPublicKey, @@ -224,6 +243,8 @@ abstract class RustLibApi extends BaseApi { Stream crateBridgeApiRustApiEvents(); + Future crateBridgeApiRustApiFinishStartedMediaUploads(); + Future crateBridgeApiRustApiForceIpaCheck(); Future crateBridgeApiRustApiGetMemoriesUrl({ @@ -257,6 +278,12 @@ abstract class RustLibApi extends BaseApi { required String username, }); + Future crateBridgeApiRustApiInitializeMediaUpload({ + required String mediaType, + PlatformInt64? displayLimitInMilliseconds, + required bool isDraftMedia, + }); + Future crateBridgeApiRustApiInsertAndSendAdditionalData({ required String groupId, required String messageType, @@ -287,6 +314,15 @@ abstract class RustLibApi extends BaseApi { Future crateBridgeApiRustApiLoadPlanBalance(); + Future crateBridgeApiRustApiMediaSizeLimitReport({ + required String mediaId, + }); + + Future crateBridgeApiRustApiMediaStepFinished({ + required String mediaId, + required String kind, + }); + Future crateBridgeApiRustApiNotificationBadgeCount(); Future crateBridgeApiRustApiNotifyMessagesOpened({ @@ -296,9 +332,7 @@ abstract class RustLibApi extends BaseApi { Future crateBridgeApiRustApiPerformPasswordlessRecoveryHeartbeat(); - Future crateBridgeApiRustApiPrepareQueuedMessage({ - required String receiptId, - }); + Future crateBridgeApiRustApiPurgeMediaTempFolder(); Future crateBridgeApiRustApiRegister({ required String username, @@ -325,6 +359,8 @@ abstract class RustLibApi extends BaseApi { required PlatformInt64 userId, }); + Future crateBridgeApiRustApiRemoveMediaFiles({required String mediaId}); + Future crateBridgeApiRustApiReportUser({ required PlatformInt64 userId, required String reason, @@ -350,6 +386,14 @@ abstract class RustLibApi extends BaseApi { Future crateBridgeApiRustApiRetransmitAllMessages(); + Future crateBridgeApiRustApiRetryPendingMediaReuploads(); + + Future crateBridgeApiRustApiReuploadPendingMedia(); + + Future crateBridgeApiRustApiSaveMediaToGallery({ + required String mediaId, + }); + Future crateBridgeApiRustApiSendBinary({required List bytes}); Future crateBridgeApiRustApiSendContactProfile({ @@ -372,6 +416,12 @@ abstract class RustLibApi extends BaseApi { bool? onlySendIfNoReceiptsAreOpen, }); + Future crateBridgeApiRustApiSendMediaToGroups({ + required String mediaId, + required List groupIds, + Uint8List? additionalMessageData, + }); + Future crateBridgeApiRustApiSendQueuedMessage({ required String receiptId, }); @@ -390,15 +440,36 @@ abstract class RustLibApi extends BaseApi { Future crateBridgeApiRustApiSetLoginToken({required List token}); + Future crateBridgeApiRustApiSetMediaDisplayLimit({ + required String mediaId, + PlatformInt64? displayLimitInMilliseconds, + }); + + Future crateBridgeApiRustApiSetMediaRequiresAuthentication({ + required String mediaId, + required bool requiresAuthentication, + }); + Future crateBridgeApiRustApiSetNetworkAvailable({ required bool available, }); + Future crateBridgeApiRustApiStoreMedia({required String mediaId}); + Future crateBridgeApiRustApiSubmitRecoveryShare({ required String notificationId, required List encryptedMessage, }); + Future crateBridgeApiRustApiToggleMediaRemoveAudio({ + required String mediaId, + }); + + Future crateBridgeApiRustApiTryRequestContactById({ + required PlatformInt64 contactId, + required List expectedPublicKey, + }); + Future crateBridgeApiRustApiUpdateFcmToken({required String token}); Future crateBridgeApiRustApiUpdateSignedPreKey({ @@ -487,8 +558,6 @@ abstract class RustLibApi extends BaseApi { required String addition, }); - Future crateBridgeWrapperKeyManagerRustKeyManagerGetLoginToken(); - Future<(Uint8List, PlatformInt64)> crateBridgeWrapperKeyManagerRustKeyManagerGetSignalIdentity(); @@ -922,12 +991,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { Future crateBridgeCallbacksInitFlutterCallbacks({ required int callbackId, required FutureOr> Function() loggingGetStreamSink, - required FutureOr Function(String, String, PlatformInt64, String) - apiMediaAction, - required FutureOr Function(PlatformInt64, Uint8List) - apiVerificationProof, - required FutureOr Function(PlatformInt64) apiCreatePushAvatars, - required FutureOr Function(String, PlatformInt64) apiMediaReceived, + required FutureOr Function(PlatformInt64) apiVerificationSucceeded, required FutureOr Function(UserConfig) apiUserConfigChanged, }) { return handler.executeNormal( @@ -939,20 +1003,8 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { loggingGetStreamSink, serializer, ); - sse_encode_DartFn_Inputs_String_String_i_64_String_Output_unit_AnyhowException( - apiMediaAction, - serializer, - ); - sse_encode_DartFn_Inputs_i_64_list_prim_u_8_strict_Output_unit_AnyhowException( - apiVerificationProof, - serializer, - ); sse_encode_DartFn_Inputs_i_64_Output_unit_AnyhowException( - apiCreatePushAvatars, - serializer, - ); - sse_encode_DartFn_Inputs_String_i_64_Output_unit_AnyhowException( - apiMediaReceived, + apiVerificationSucceeded, serializer, ); sse_encode_DartFn_Inputs_user_config_Output_unit_AnyhowException( @@ -974,10 +1026,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argValues: [ callbackId, loggingGetStreamSink, - apiMediaAction, - apiVerificationProof, - apiCreatePushAvatars, - apiMediaReceived, + apiVerificationSucceeded, apiUserConfigChanged, ], apiImpl: this, @@ -991,10 +1040,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: [ "callbackId", "loggingGetStreamSink", - "apiMediaAction", - "apiVerificationProof", - "apiCreatePushAvatars", - "apiMediaReceived", + "apiVerificationSucceeded", "apiUserConfigChanged", ], ); @@ -1168,6 +1214,37 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: ["groupId", "contactId"], ); + @override + Future crateBridgeApiRustApiAbandonMedia({required String mediaId}) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(mediaId, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 16, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: kCrateBridgeApiRustApiAbandonMediaConstMeta, + argValues: [mediaId], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateBridgeApiRustApiAbandonMediaConstMeta => + const TaskConstMeta( + debugName: "rust_api_abandon_media", + argNames: ["mediaId"], + ); + @override Future crateBridgeApiRustApiAddAdditionalUser({ required PlatformInt64 userId, @@ -1180,7 +1257,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 16, + funcId: 17, port: port_, ); }, @@ -1210,7 +1287,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 17, + funcId: 18, port: port_, ); }, @@ -1238,7 +1315,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_String(protocol, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 18)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 19)!; }, codec: SseCodec( decodeSuccessData: sse_decode_String, @@ -1257,6 +1334,66 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: ["protocol"], ); + @override + Future> crateBridgeApiRustApiAuthenticationHeaders() { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 20, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_Map_String_String_None, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: kCrateBridgeApiRustApiAuthenticationHeadersConstMeta, + argValues: [], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateBridgeApiRustApiAuthenticationHeadersConstMeta => + const TaskConstMeta( + debugName: "rust_api_authentication_headers", + argNames: [], + ); + + @override + String crateBridgeApiRustApiAvatarPngPath({ + required PlatformInt64 contactId, + required PlatformInt64 profileCounter, + }) { + return handler.executeSync( + SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_i_64(contactId, serializer); + sse_encode_i_64(profileCounter, serializer); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 21)!; + }, + codec: SseCodec( + decodeSuccessData: sse_decode_String, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: kCrateBridgeApiRustApiAvatarPngPathConstMeta, + argValues: [contactId, profileCounter], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateBridgeApiRustApiAvatarPngPathConstMeta => + const TaskConstMeta( + debugName: "rust_api_avatar_png_path", + argNames: ["contactId", "profileCounter"], + ); + @override Future crateBridgeApiRustApiChangeUsername({required String username}) { return handler.executeNormal( @@ -1267,7 +1404,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 19, + funcId: 22, port: port_, ); }, @@ -1297,7 +1434,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 20, + funcId: 23, port: port_, ); }, @@ -1338,7 +1475,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 21, + funcId: 24, port: port_, ); }, @@ -1379,7 +1516,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 22, + funcId: 25, port: port_, ); }, @@ -1414,7 +1551,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 23, + funcId: 26, port: port_, ); }, @@ -1446,7 +1583,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 24, + funcId: 27, port: port_, ); }, @@ -1478,7 +1615,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 25, + funcId: 28, port: port_, ); }, @@ -1508,7 +1645,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 26, + funcId: 29, port: port_, ); }, @@ -1538,7 +1675,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 27, + funcId: 30, port: port_, ); }, @@ -1559,6 +1696,98 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: [], ); + @override + Future crateBridgeApiRustApiCropMediaTransparentBorders({ + required String mediaId, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(mediaId, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 31, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: kCrateBridgeApiRustApiCropMediaTransparentBordersConstMeta, + argValues: [mediaId], + apiImpl: this, + ), + ); + } + + TaskConstMeta + get kCrateBridgeApiRustApiCropMediaTransparentBordersConstMeta => + const TaskConstMeta( + debugName: "rust_api_crop_media_transparent_borders", + argNames: ["mediaId"], + ); + + @override + Future crateBridgeApiRustApiCurrentUserAvatarPath() { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 32, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_opt_String, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: kCrateBridgeApiRustApiCurrentUserAvatarPathConstMeta, + argValues: [], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateBridgeApiRustApiCurrentUserAvatarPathConstMeta => + const TaskConstMeta( + debugName: "rust_api_current_user_avatar_path", + argNames: [], + ); + + @override + String crateBridgeApiRustApiDecodeAvatarSvg({ + required List avatarSvgCompressed, + }) { + return handler.executeSync( + SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_list_prim_u_8_loose(avatarSvgCompressed, serializer); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 33)!; + }, + codec: SseCodec( + decodeSuccessData: sse_decode_String, + decodeErrorData: null, + ), + constMeta: kCrateBridgeApiRustApiDecodeAvatarSvgConstMeta, + argValues: [avatarSvgCompressed], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateBridgeApiRustApiDecodeAvatarSvgConstMeta => + const TaskConstMeta( + debugName: "rust_api_decode_avatar_svg", + argNames: ["avatarSvgCompressed"], + ); + @override Future crateBridgeApiRustApiDeleteAccount() { return handler.executeNormal( @@ -1568,7 +1797,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 28, + funcId: 34, port: port_, ); }, @@ -1599,7 +1828,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 29, + funcId: 35, port: port_, ); }, @@ -1629,7 +1858,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 30, + funcId: 36, port: port_, ); }, @@ -1660,7 +1889,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 31, + funcId: 37, port: port_, ); }, @@ -1691,7 +1920,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 32, + funcId: 38, port: port_, ); }, @@ -1721,7 +1950,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 33, + funcId: 39, port: port_, ); }, @@ -1742,6 +1971,39 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: [], ); + @override + Future crateBridgeApiRustApiEnsureAvatarPng({ + required PlatformInt64 contactId, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_i_64(contactId, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 40, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_opt_String, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: kCrateBridgeApiRustApiEnsureAvatarPngConstMeta, + argValues: [contactId], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateBridgeApiRustApiEnsureAvatarPngConstMeta => + const TaskConstMeta( + debugName: "rust_api_ensure_avatar_png", + argNames: ["contactId"], + ); + @override Future crateBridgeApiRustApiEstablishSignalSession({ required PlatformInt64 contactId, @@ -1756,7 +2018,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 34, + funcId: 41, port: port_, ); }, @@ -1789,7 +2051,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 35, + funcId: 42, port: port_, ); }, @@ -1812,6 +2074,36 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: ["sink"], ); + @override + Future crateBridgeApiRustApiFinishStartedMediaUploads() { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 43, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: kCrateBridgeApiRustApiFinishStartedMediaUploadsConstMeta, + argValues: [], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateBridgeApiRustApiFinishStartedMediaUploadsConstMeta => + const TaskConstMeta( + debugName: "rust_api_finish_started_media_uploads", + argNames: [], + ); + @override Future crateBridgeApiRustApiForceIpaCheck() { return handler.executeNormal( @@ -1821,7 +2113,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 36, + funcId: 44, port: port_, ); }, @@ -1856,7 +2148,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 37, + funcId: 45, port: port_, ); }, @@ -1886,7 +2178,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 38, + funcId: 46, port: port_, ); }, @@ -1916,7 +2208,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 39, + funcId: 47, port: port_, ); }, @@ -1946,7 +2238,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 40, + funcId: 48, port: port_, ); }, @@ -1987,7 +2279,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 41, + funcId: 49, port: port_, ); }, @@ -2034,7 +2326,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 42, + funcId: 50, port: port_, ); }, @@ -2067,7 +2359,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 43, + funcId: 51, port: port_, ); }, @@ -2100,7 +2392,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 44, + funcId: 52, port: port_, ); }, @@ -2121,6 +2413,46 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: ["username"], ); + @override + Future crateBridgeApiRustApiInitializeMediaUpload({ + required String mediaType, + PlatformInt64? displayLimitInMilliseconds, + required bool isDraftMedia, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(mediaType, serializer); + sse_encode_opt_box_autoadd_i_64( + displayLimitInMilliseconds, + serializer, + ); + sse_encode_bool(isDraftMedia, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 53, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_String, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: kCrateBridgeApiRustApiInitializeMediaUploadConstMeta, + argValues: [mediaType, displayLimitInMilliseconds, isDraftMedia], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateBridgeApiRustApiInitializeMediaUploadConstMeta => + const TaskConstMeta( + debugName: "rust_api_initialize_media_upload", + argNames: ["mediaType", "displayLimitInMilliseconds", "isDraftMedia"], + ); + @override Future crateBridgeApiRustApiInsertAndSendAdditionalData({ required String groupId, @@ -2137,7 +2469,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 45, + funcId: 54, port: port_, ); }, @@ -2173,7 +2505,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 46, + funcId: 55, port: port_, ); }, @@ -2208,7 +2540,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 47, + funcId: 56, port: port_, ); }, @@ -2245,7 +2577,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 48, + funcId: 57, port: port_, ); }, @@ -2282,7 +2614,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 49, + funcId: 58, port: port_, ); }, @@ -2312,7 +2644,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 50, + funcId: 59, port: port_, ); }, @@ -2333,6 +2665,74 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: [], ); + @override + Future crateBridgeApiRustApiMediaSizeLimitReport({ + required String mediaId, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(mediaId, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 60, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_media_size_report, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: kCrateBridgeApiRustApiMediaSizeLimitReportConstMeta, + argValues: [mediaId], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateBridgeApiRustApiMediaSizeLimitReportConstMeta => + const TaskConstMeta( + debugName: "rust_api_media_size_limit_report", + argNames: ["mediaId"], + ); + + @override + Future crateBridgeApiRustApiMediaStepFinished({ + required String mediaId, + required String kind, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(mediaId, serializer); + sse_encode_String(kind, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 61, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: kCrateBridgeApiRustApiMediaStepFinishedConstMeta, + argValues: [mediaId, kind], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateBridgeApiRustApiMediaStepFinishedConstMeta => + const TaskConstMeta( + debugName: "rust_api_media_step_finished", + argNames: ["mediaId", "kind"], + ); + @override Future crateBridgeApiRustApiNotificationBadgeCount() { return handler.executeNormal( @@ -2342,7 +2742,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 51, + funcId: 62, port: port_, ); }, @@ -2377,7 +2777,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 52, + funcId: 63, port: port_, ); }, @@ -2407,7 +2807,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 53, + funcId: 64, port: port_, ); }, @@ -2431,36 +2831,33 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ); @override - Future crateBridgeApiRustApiPrepareQueuedMessage({ - required String receiptId, - }) { + Future crateBridgeApiRustApiPurgeMediaTempFolder() { return handler.executeNormal( NormalTask( callFfi: (port_) { final serializer = SseSerializer(generalizedFrbRustBinding); - sse_encode_String(receiptId, serializer); pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 54, + funcId: 65, port: port_, ); }, codec: SseCodec( - decodeSuccessData: sse_decode_opt_list_prim_u_8_strict, + decodeSuccessData: sse_decode_unit, decodeErrorData: sse_decode_AnyhowException, ), - constMeta: kCrateBridgeApiRustApiPrepareQueuedMessageConstMeta, - argValues: [receiptId], + constMeta: kCrateBridgeApiRustApiPurgeMediaTempFolderConstMeta, + argValues: [], apiImpl: this, ), ); } - TaskConstMeta get kCrateBridgeApiRustApiPrepareQueuedMessageConstMeta => + TaskConstMeta get kCrateBridgeApiRustApiPurgeMediaTempFolderConstMeta => const TaskConstMeta( - debugName: "rust_api_prepare_queued_message", - argNames: ["receiptId"], + debugName: "rust_api_purge_media_temp_folder", + argNames: [], ); @override @@ -2481,7 +2878,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 55, + funcId: 66, port: port_, ); }, @@ -2520,7 +2917,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 56, + funcId: 67, port: port_, ); }, @@ -2562,7 +2959,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 57, + funcId: 68, port: port_, ); }, @@ -2593,7 +2990,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 58, + funcId: 69, port: port_, ); }, @@ -2626,7 +3023,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 59, + funcId: 70, port: port_, ); }, @@ -2647,6 +3044,39 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: ["userId"], ); + @override + Future crateBridgeApiRustApiRemoveMediaFiles({ + required String mediaId, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(mediaId, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 71, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: kCrateBridgeApiRustApiRemoveMediaFilesConstMeta, + argValues: [mediaId], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateBridgeApiRustApiRemoveMediaFilesConstMeta => + const TaskConstMeta( + debugName: "rust_api_remove_media_files", + argNames: ["mediaId"], + ); + @override Future crateBridgeApiRustApiReportUser({ required PlatformInt64 userId, @@ -2661,7 +3091,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 60, + funcId: 72, port: port_, ); }, @@ -2694,7 +3124,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 61, + funcId: 73, port: port_, ); }, @@ -2727,7 +3157,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 62, + funcId: 74, port: port_, ); }, @@ -2760,7 +3190,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 63, + funcId: 75, port: port_, ); }, @@ -2797,7 +3227,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 64, + funcId: 76, port: port_, ); }, @@ -2827,7 +3257,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 65, + funcId: 77, port: port_, ); }, @@ -2848,6 +3278,99 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: [], ); + @override + Future crateBridgeApiRustApiRetryPendingMediaReuploads() { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 78, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: kCrateBridgeApiRustApiRetryPendingMediaReuploadsConstMeta, + argValues: [], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateBridgeApiRustApiRetryPendingMediaReuploadsConstMeta => + const TaskConstMeta( + debugName: "rust_api_retry_pending_media_reuploads", + argNames: [], + ); + + @override + Future crateBridgeApiRustApiReuploadPendingMedia() { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 79, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: kCrateBridgeApiRustApiReuploadPendingMediaConstMeta, + argValues: [], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateBridgeApiRustApiReuploadPendingMediaConstMeta => + const TaskConstMeta( + debugName: "rust_api_reupload_pending_media", + argNames: [], + ); + + @override + Future crateBridgeApiRustApiSaveMediaToGallery({ + required String mediaId, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(mediaId, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 80, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: kCrateBridgeApiRustApiSaveMediaToGalleryConstMeta, + argValues: [mediaId], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateBridgeApiRustApiSaveMediaToGalleryConstMeta => + const TaskConstMeta( + debugName: "rust_api_save_media_to_gallery", + argNames: ["mediaId"], + ); + @override Future crateBridgeApiRustApiSendBinary({required List bytes}) { return handler.executeNormal( @@ -2858,7 +3381,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 66, + funcId: 81, port: port_, ); }, @@ -2891,7 +3414,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 67, + funcId: 82, port: port_, ); }, @@ -2937,7 +3460,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 68, + funcId: 83, port: port_, ); }, @@ -2993,7 +3516,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 69, + funcId: 84, port: port_, ); }, @@ -3020,6 +3543,46 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ], ); + @override + Future crateBridgeApiRustApiSendMediaToGroups({ + required String mediaId, + required List groupIds, + Uint8List? additionalMessageData, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(mediaId, serializer); + sse_encode_list_String(groupIds, serializer); + sse_encode_opt_list_prim_u_8_strict( + additionalMessageData, + serializer, + ); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 85, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: kCrateBridgeApiRustApiSendMediaToGroupsConstMeta, + argValues: [mediaId, groupIds, additionalMessageData], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateBridgeApiRustApiSendMediaToGroupsConstMeta => + const TaskConstMeta( + debugName: "rust_api_send_media_to_groups", + argNames: ["mediaId", "groupIds", "additionalMessageData"], + ); + @override Future crateBridgeApiRustApiSendQueuedMessage({ required String receiptId, @@ -3032,7 +3595,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 70, + funcId: 86, port: port_, ); }, @@ -3067,7 +3630,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 71, + funcId: 87, port: port_, ); }, @@ -3102,7 +3665,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 72, + funcId: 88, port: port_, ); }, @@ -3135,7 +3698,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 73, + funcId: 89, port: port_, ); }, @@ -3166,7 +3729,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 74, + funcId: 90, port: port_, ); }, @@ -3187,6 +3750,81 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: ["token"], ); + @override + Future crateBridgeApiRustApiSetMediaDisplayLimit({ + required String mediaId, + PlatformInt64? displayLimitInMilliseconds, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(mediaId, serializer); + sse_encode_opt_box_autoadd_i_64( + displayLimitInMilliseconds, + serializer, + ); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 91, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: kCrateBridgeApiRustApiSetMediaDisplayLimitConstMeta, + argValues: [mediaId, displayLimitInMilliseconds], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateBridgeApiRustApiSetMediaDisplayLimitConstMeta => + const TaskConstMeta( + debugName: "rust_api_set_media_display_limit", + argNames: ["mediaId", "displayLimitInMilliseconds"], + ); + + @override + Future crateBridgeApiRustApiSetMediaRequiresAuthentication({ + required String mediaId, + required bool requiresAuthentication, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(mediaId, serializer); + sse_encode_bool(requiresAuthentication, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 92, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: + kCrateBridgeApiRustApiSetMediaRequiresAuthenticationConstMeta, + argValues: [mediaId, requiresAuthentication], + apiImpl: this, + ), + ); + } + + TaskConstMeta + get kCrateBridgeApiRustApiSetMediaRequiresAuthenticationConstMeta => + const TaskConstMeta( + debugName: "rust_api_set_media_requires_authentication", + argNames: ["mediaId", "requiresAuthentication"], + ); + @override Future crateBridgeApiRustApiSetNetworkAvailable({ required bool available, @@ -3199,7 +3837,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 75, + funcId: 93, port: port_, ); }, @@ -3220,6 +3858,37 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: ["available"], ); + @override + Future crateBridgeApiRustApiStoreMedia({required String mediaId}) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(mediaId, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 94, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: kCrateBridgeApiRustApiStoreMediaConstMeta, + argValues: [mediaId], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateBridgeApiRustApiStoreMediaConstMeta => + const TaskConstMeta( + debugName: "rust_api_store_media", + argNames: ["mediaId"], + ); + @override Future crateBridgeApiRustApiSubmitRecoveryShare({ required String notificationId, @@ -3234,7 +3903,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 76, + funcId: 95, port: port_, ); }, @@ -3255,6 +3924,74 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: ["notificationId", "encryptedMessage"], ); + @override + Future crateBridgeApiRustApiToggleMediaRemoveAudio({ + required String mediaId, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(mediaId, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 96, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: kCrateBridgeApiRustApiToggleMediaRemoveAudioConstMeta, + argValues: [mediaId], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateBridgeApiRustApiToggleMediaRemoveAudioConstMeta => + const TaskConstMeta( + debugName: "rust_api_toggle_media_remove_audio", + argNames: ["mediaId"], + ); + + @override + Future crateBridgeApiRustApiTryRequestContactById({ + required PlatformInt64 contactId, + required List expectedPublicKey, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_i_64(contactId, serializer); + sse_encode_list_prim_u_8_loose(expectedPublicKey, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 97, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_bool, + decodeErrorData: null, + ), + constMeta: kCrateBridgeApiRustApiTryRequestContactByIdConstMeta, + argValues: [contactId, expectedPublicKey], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateBridgeApiRustApiTryRequestContactByIdConstMeta => + const TaskConstMeta( + debugName: "rust_api_try_request_contact_by_id", + argNames: ["contactId", "expectedPublicKey"], + ); + @override Future crateBridgeApiRustApiUpdateFcmToken({required String token}) { return handler.executeNormal( @@ -3265,7 +4002,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 77, + funcId: 98, port: port_, ); }, @@ -3302,7 +4039,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 78, + funcId: 99, port: port_, ); }, @@ -3354,7 +4091,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 79, + funcId: 100, port: port_, ); }, @@ -3407,7 +4144,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 80, + funcId: 101, port: port_, ); }, @@ -3447,7 +4184,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 81, + funcId: 102, port: port_, ); }, @@ -3480,7 +4217,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 82, + funcId: 103, port: port_, ); }, @@ -3513,7 +4250,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 83, + funcId: 104, port: port_, ); }, @@ -3550,7 +4287,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 84, + funcId: 105, port: port_, ); }, @@ -3582,7 +4319,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 85, + funcId: 106, port: port_, ); }, @@ -3615,7 +4352,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 86, + funcId: 107, port: port_, ); }, @@ -3650,7 +4387,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 87, + funcId: 108, port: port_, ); }, @@ -3682,7 +4419,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 88, + funcId: 109, port: port_, ); }, @@ -3720,7 +4457,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 89, + funcId: 110, port: port_, ); }, @@ -3753,7 +4490,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 90, + funcId: 111, port: port_, ); }, @@ -3791,7 +4528,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 91, + funcId: 112, port: port_, ); }, @@ -3828,7 +4565,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 92, + funcId: 113, port: port_, ); }, @@ -3865,7 +4602,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 93, + funcId: 114, port: port_, ); }, @@ -3903,7 +4640,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 94, + funcId: 115, port: port_, ); }, @@ -3941,7 +4678,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 95, + funcId: 116, port: port_, ); }, @@ -3964,38 +4701,6 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: ["mediaKey", "addition"], ); - @override - Future crateBridgeWrapperKeyManagerRustKeyManagerGetLoginToken() { - return handler.executeNormal( - NormalTask( - callFfi: (port_) { - final serializer = SseSerializer(generalizedFrbRustBinding); - pdeCallFfi( - generalizedFrbRustBinding, - serializer, - funcId: 96, - port: port_, - ); - }, - codec: SseCodec( - decodeSuccessData: sse_decode_list_prim_u_8_strict, - decodeErrorData: sse_decode_AnyhowException, - ), - constMeta: - kCrateBridgeWrapperKeyManagerRustKeyManagerGetLoginTokenConstMeta, - argValues: [], - apiImpl: this, - ), - ); - } - - TaskConstMeta - get kCrateBridgeWrapperKeyManagerRustKeyManagerGetLoginTokenConstMeta => - const TaskConstMeta( - debugName: "rust_key_manager_get_login_token", - argNames: [], - ); - @override Future<(Uint8List, PlatformInt64)> crateBridgeWrapperKeyManagerRustKeyManagerGetSignalIdentity() { @@ -4006,7 +4711,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 97, + funcId: 117, port: port_, ); }, @@ -4038,7 +4743,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 98, + funcId: 118, port: port_, ); }, @@ -4073,7 +4778,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 99, + funcId: 119, port: port_, ); }, @@ -4110,7 +4815,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 100, + funcId: 120, port: port_, ); }, @@ -4142,7 +4847,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 101, + funcId: 121, port: port_, ); }, @@ -4174,7 +4879,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 102, + funcId: 122, port: port_, ); }, @@ -4209,7 +4914,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 103, + funcId: 123, port: port_, ); }, @@ -4248,7 +4953,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 104, + funcId: 124, port: port_, ); }, @@ -4285,7 +4990,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 105, + funcId: 125, port: port_, ); }, @@ -4315,7 +5020,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 106, + funcId: 126, port: port_, ); }, @@ -4347,7 +5052,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 107, + funcId: 127, port: port_, ); }, @@ -4382,7 +5087,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 108, + funcId: 128, port: port_, ); }, @@ -4414,7 +5119,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 109, + funcId: 129, port: port_, ); }, @@ -4452,7 +5157,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 110, + funcId: 130, port: port_, ); }, @@ -4491,7 +5196,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 111, + funcId: 131, port: port_, ); }, @@ -4526,7 +5231,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 112, + funcId: 132, port: port_, ); }, @@ -4557,7 +5262,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 113, + funcId: 133, port: port_, ); }, @@ -4592,7 +5297,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 114, + funcId: 134, port: port_, ); }, @@ -4627,7 +5332,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 115, + funcId: 135, port: port_, ); }, @@ -4660,7 +5365,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 116, + funcId: 136, )!; }, codec: SseCodec( @@ -4700,7 +5405,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 117, + funcId: 137, port: port_, ); }, @@ -4745,7 +5450,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 118, + funcId: 138, port: port_, ); }, @@ -4775,7 +5480,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 119, + funcId: 139, port: port_, ); }, @@ -4808,7 +5513,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 120, + funcId: 140, port: port_, ); }, @@ -4843,7 +5548,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 121, + funcId: 141, port: port_, ); }, @@ -4864,80 +5569,6 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: ["base", "config"], ); - Future Function(int, dynamic, dynamic, dynamic, dynamic) - encode_DartFn_Inputs_String_String_i_64_String_Output_unit_AnyhowException( - FutureOr Function(String, String, PlatformInt64, String) raw, - ) { - return (callId, rawArg0, rawArg1, rawArg2, rawArg3) async { - final arg0 = dco_decode_String(rawArg0); - final arg1 = dco_decode_String(rawArg1); - final arg2 = dco_decode_i_64(rawArg2); - final arg3 = dco_decode_String(rawArg3); - - Box? rawOutput; - Box? rawError; - try { - rawOutput = Box(await raw(arg0, arg1, arg2, arg3)); - } catch (e, s) { - rawError = Box(AnyhowException("$e\n\n$s")); - } - - final serializer = SseSerializer(generalizedFrbRustBinding); - assert((rawOutput != null) ^ (rawError != null)); - if (rawOutput != null) { - serializer.buffer.putUint8(0); - sse_encode_unit(rawOutput.value, serializer); - } else { - serializer.buffer.putUint8(1); - sse_encode_AnyhowException(rawError!.value, serializer); - } - final output = serializer.intoRaw(); - - generalizedFrbRustBinding.dartFnDeliverOutput( - callId: callId, - ptr: output.ptr, - rustVecLen: output.rustVecLen, - dataLen: output.dataLen, - ); - }; - } - - Future Function(int, dynamic, dynamic) - encode_DartFn_Inputs_String_i_64_Output_unit_AnyhowException( - FutureOr Function(String, PlatformInt64) raw, - ) { - return (callId, rawArg0, rawArg1) async { - final arg0 = dco_decode_String(rawArg0); - final arg1 = dco_decode_i_64(rawArg1); - - Box? rawOutput; - Box? rawError; - try { - rawOutput = Box(await raw(arg0, arg1)); - } catch (e, s) { - rawError = Box(AnyhowException("$e\n\n$s")); - } - - final serializer = SseSerializer(generalizedFrbRustBinding); - assert((rawOutput != null) ^ (rawError != null)); - if (rawOutput != null) { - serializer.buffer.putUint8(0); - sse_encode_unit(rawOutput.value, serializer); - } else { - serializer.buffer.putUint8(1); - sse_encode_AnyhowException(rawError!.value, serializer); - } - final output = serializer.intoRaw(); - - generalizedFrbRustBinding.dartFnDeliverOutput( - callId: callId, - ptr: output.ptr, - rustVecLen: output.rustVecLen, - dataLen: output.dataLen, - ); - }; - } - Future Function( int, ) @@ -5010,42 +5641,6 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { }; } - Future Function(int, dynamic, dynamic) - encode_DartFn_Inputs_i_64_list_prim_u_8_strict_Output_unit_AnyhowException( - FutureOr Function(PlatformInt64, Uint8List) raw, - ) { - return (callId, rawArg0, rawArg1) async { - final arg0 = dco_decode_i_64(rawArg0); - final arg1 = dco_decode_list_prim_u_8_strict(rawArg1); - - Box? rawOutput; - Box? rawError; - try { - rawOutput = Box(await raw(arg0, arg1)); - } catch (e, s) { - rawError = Box(AnyhowException("$e\n\n$s")); - } - - final serializer = SseSerializer(generalizedFrbRustBinding); - assert((rawOutput != null) ^ (rawError != null)); - if (rawOutput != null) { - serializer.buffer.putUint8(0); - sse_encode_unit(rawOutput.value, serializer); - } else { - serializer.buffer.putUint8(1); - sse_encode_AnyhowException(rawError!.value, serializer); - } - final output = serializer.intoRaw(); - - generalizedFrbRustBinding.dartFnDeliverOutput( - callId: callId, - ptr: output.ptr, - rustVecLen: output.rustVecLen, - dataLen: output.dataLen, - ); - }; - } - Future Function(int, dynamic) encode_DartFn_Inputs_user_config_Output_unit_AnyhowException( FutureOr Function(UserConfig) raw, @@ -5093,24 +5688,6 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return dcoDecodeTimestamp(ts: dco_decode_i_64(raw).toInt(), isUtc: true); } - @protected - FutureOr Function(String, String, PlatformInt64, String) - dco_decode_DartFn_Inputs_String_String_i_64_String_Output_unit_AnyhowException( - dynamic raw, - ) { - // Codec=Dco (DartCObject based), see doc to use other codecs - throw UnimplementedError(''); - } - - @protected - FutureOr Function(String, PlatformInt64) - dco_decode_DartFn_Inputs_String_i_64_Output_unit_AnyhowException( - dynamic raw, - ) { - // Codec=Dco (DartCObject based), see doc to use other codecs - throw UnimplementedError(''); - } - @protected FutureOr> Function() dco_decode_DartFn_Inputs__Output_StreamSink_String_Sse_AnyhowException( @@ -5127,15 +5704,6 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { throw UnimplementedError(''); } - @protected - FutureOr Function(PlatformInt64, Uint8List) - dco_decode_DartFn_Inputs_i_64_list_prim_u_8_strict_Output_unit_AnyhowException( - dynamic raw, - ) { - // Codec=Dco (DartCObject based), see doc to use other codecs - throw UnimplementedError(''); - } - @protected FutureOr Function(UserConfig) dco_decode_DartFn_Inputs_user_config_Output_unit_AnyhowException( @@ -5151,6 +5719,16 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return decodeDartOpaque(raw, generalizedFrbRustBinding); } + @protected + Map dco_decode_Map_String_String_None(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return Map.fromEntries( + dco_decode_list_record_string_string( + raw, + ).map((e) => MapEntry(e.$1, e.$2)), + ); + } + @protected Map> dco_decode_Map_String_list_String_None( dynamic raw, @@ -5628,6 +6206,18 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return (raw as List).map(dco_decode_sql_value).toList(); } + @protected + MediaSizeReport dco_decode_media_size_report(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + final arr = raw as List; + if (arr.length != 2) + throw Exception('unexpected arr length: expect 2 but see ${arr.length}'); + return MediaSizeReport( + mediaBytes: dco_decode_opt_box_autoadd_i_64(arr[0]), + limitBytes: dco_decode_opt_box_autoadd_i_64(arr[1]), + ); + } + @protected Map>? dco_decode_opt_Map_String_list_String_None( dynamic raw, @@ -6077,6 +6667,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return decodeDartOpaque(inner, generalizedFrbRustBinding); } + @protected + Map sse_decode_Map_String_String_None( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + var inner = sse_decode_list_record_string_string(deserializer); + return Map.fromEntries(inner.map((e) => MapEntry(e.$1, e.$2))); + } + @protected Map> sse_decode_Map_String_list_String_None( SseDeserializer deserializer, @@ -6660,6 +7259,17 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return ans_; } + @protected + MediaSizeReport sse_decode_media_size_report(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var var_mediaBytes = sse_decode_opt_box_autoadd_i_64(deserializer); + var var_limitBytes = sse_decode_opt_box_autoadd_i_64(deserializer); + return MediaSizeReport( + mediaBytes: var_mediaBytes, + limitBytes: var_limitBytes, + ); + } + @protected Map>? sse_decode_opt_Map_String_list_String_None( SseDeserializer deserializer, @@ -7238,33 +7848,6 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ); } - @protected - void - sse_encode_DartFn_Inputs_String_String_i_64_String_Output_unit_AnyhowException( - FutureOr Function(String, String, PlatformInt64, String) self, - SseSerializer serializer, - ) { - // Codec=Sse (Serialization based), see doc to use other codecs - sse_encode_DartOpaque( - encode_DartFn_Inputs_String_String_i_64_String_Output_unit_AnyhowException( - self, - ), - serializer, - ); - } - - @protected - void sse_encode_DartFn_Inputs_String_i_64_Output_unit_AnyhowException( - FutureOr Function(String, PlatformInt64) self, - SseSerializer serializer, - ) { - // Codec=Sse (Serialization based), see doc to use other codecs - sse_encode_DartOpaque( - encode_DartFn_Inputs_String_i_64_Output_unit_AnyhowException(self), - serializer, - ); - } - @protected void sse_encode_DartFn_Inputs__Output_StreamSink_String_Sse_AnyhowException( FutureOr> Function() self, @@ -7289,21 +7872,6 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ); } - @protected - void - sse_encode_DartFn_Inputs_i_64_list_prim_u_8_strict_Output_unit_AnyhowException( - FutureOr Function(PlatformInt64, Uint8List) self, - SseSerializer serializer, - ) { - // Codec=Sse (Serialization based), see doc to use other codecs - sse_encode_DartOpaque( - encode_DartFn_Inputs_i_64_list_prim_u_8_strict_Output_unit_AnyhowException( - self, - ), - serializer, - ); - } - @protected void sse_encode_DartFn_Inputs_user_config_Output_unit_AnyhowException( FutureOr Function(UserConfig) self, @@ -7331,6 +7899,18 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ); } + @protected + void sse_encode_Map_String_String_None( + Map self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_list_record_string_string( + self.entries.map((e) => (e.key, e.value)).toList(), + serializer, + ); + } + @protected void sse_encode_Map_String_list_String_None( Map> self, @@ -7893,6 +8473,16 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { } } + @protected + void sse_encode_media_size_report( + MediaSizeReport self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_opt_box_autoadd_i_64(self.mediaBytes, serializer); + sse_encode_opt_box_autoadd_i_64(self.limitBytes, serializer); + } + @protected void sse_encode_opt_Map_String_list_String_None( Map>? self, diff --git a/lib/core/frb_generated.io.dart b/lib/core/frb_generated.io.dart index d1e685b0..5d64dd44 100644 --- a/lib/core/frb_generated.io.dart +++ b/lib/core/frb_generated.io.dart @@ -24,6 +24,7 @@ import 'frb_generated.dart'; import 'keys/backup_password_keys.dart'; import 'lib.dart'; import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated_io.dart'; +import 'services/media_upload.dart'; import 'signal/engine.dart'; import 'user_config.dart'; @@ -41,16 +42,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected DateTime dco_decode_Chrono_Utc(dynamic raw); - @protected - FutureOr Function(String, String, PlatformInt64, String) - dco_decode_DartFn_Inputs_String_String_i_64_String_Output_unit_AnyhowException( - dynamic raw, - ); - - @protected - FutureOr Function(String, PlatformInt64) - dco_decode_DartFn_Inputs_String_i_64_Output_unit_AnyhowException(dynamic raw); - @protected FutureOr> Function() dco_decode_DartFn_Inputs__Output_StreamSink_String_Sse_AnyhowException( @@ -61,12 +52,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { FutureOr Function(PlatformInt64) dco_decode_DartFn_Inputs_i_64_Output_unit_AnyhowException(dynamic raw); - @protected - FutureOr Function(PlatformInt64, Uint8List) - dco_decode_DartFn_Inputs_i_64_list_prim_u_8_strict_Output_unit_AnyhowException( - dynamic raw, - ); - @protected FutureOr Function(UserConfig) dco_decode_DartFn_Inputs_user_config_Output_unit_AnyhowException(dynamic raw); @@ -74,6 +59,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected Object dco_decode_DartOpaque(dynamic raw); + @protected + Map dco_decode_Map_String_String_None(dynamic raw); + @protected Map> dco_decode_Map_String_list_String_None(dynamic raw); @@ -255,6 +243,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected List dco_decode_list_sql_value(dynamic raw); + @protected + MediaSizeReport dco_decode_media_size_report(dynamic raw); + @protected Map>? dco_decode_opt_Map_String_list_String_None( dynamic raw, @@ -396,6 +387,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected Object sse_decode_DartOpaque(SseDeserializer deserializer); + @protected + Map sse_decode_Map_String_String_None( + SseDeserializer deserializer, + ); + @protected Map> sse_decode_Map_String_list_String_None( SseDeserializer deserializer, @@ -621,6 +617,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected List sse_decode_list_sql_value(SseDeserializer deserializer); + @protected + MediaSizeReport sse_decode_media_size_report(SseDeserializer deserializer); + @protected Map>? sse_decode_opt_Map_String_list_String_None( SseDeserializer deserializer, @@ -780,19 +779,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected void sse_encode_Chrono_Utc(DateTime self, SseSerializer serializer); - @protected - void - sse_encode_DartFn_Inputs_String_String_i_64_String_Output_unit_AnyhowException( - FutureOr Function(String, String, PlatformInt64, String) self, - SseSerializer serializer, - ); - - @protected - void sse_encode_DartFn_Inputs_String_i_64_Output_unit_AnyhowException( - FutureOr Function(String, PlatformInt64) self, - SseSerializer serializer, - ); - @protected void sse_encode_DartFn_Inputs__Output_StreamSink_String_Sse_AnyhowException( FutureOr> Function() self, @@ -805,13 +791,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseSerializer serializer, ); - @protected - void - sse_encode_DartFn_Inputs_i_64_list_prim_u_8_strict_Output_unit_AnyhowException( - FutureOr Function(PlatformInt64, Uint8List) self, - SseSerializer serializer, - ); - @protected void sse_encode_DartFn_Inputs_user_config_Output_unit_AnyhowException( FutureOr Function(UserConfig) self, @@ -821,6 +800,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected void sse_encode_DartOpaque(Object self, SseSerializer serializer); + @protected + void sse_encode_Map_String_String_None( + Map self, + SseSerializer serializer, + ); + @protected void sse_encode_Map_String_list_String_None( Map> self, @@ -1103,6 +1088,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected void sse_encode_list_sql_value(List self, SseSerializer serializer); + @protected + void sse_encode_media_size_report( + MediaSizeReport self, + SseSerializer serializer, + ); + @protected void sse_encode_opt_Map_String_list_String_None( Map>? self, diff --git a/lib/core/frb_generated.web.dart b/lib/core/frb_generated.web.dart index 5efa4165..7b54429d 100644 --- a/lib/core/frb_generated.web.dart +++ b/lib/core/frb_generated.web.dart @@ -26,6 +26,7 @@ import 'frb_generated.dart'; import 'keys/backup_password_keys.dart'; import 'lib.dart'; import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated_web.dart'; +import 'services/media_upload.dart'; import 'signal/engine.dart'; import 'user_config.dart'; @@ -43,16 +44,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected DateTime dco_decode_Chrono_Utc(dynamic raw); - @protected - FutureOr Function(String, String, PlatformInt64, String) - dco_decode_DartFn_Inputs_String_String_i_64_String_Output_unit_AnyhowException( - dynamic raw, - ); - - @protected - FutureOr Function(String, PlatformInt64) - dco_decode_DartFn_Inputs_String_i_64_Output_unit_AnyhowException(dynamic raw); - @protected FutureOr> Function() dco_decode_DartFn_Inputs__Output_StreamSink_String_Sse_AnyhowException( @@ -63,12 +54,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { FutureOr Function(PlatformInt64) dco_decode_DartFn_Inputs_i_64_Output_unit_AnyhowException(dynamic raw); - @protected - FutureOr Function(PlatformInt64, Uint8List) - dco_decode_DartFn_Inputs_i_64_list_prim_u_8_strict_Output_unit_AnyhowException( - dynamic raw, - ); - @protected FutureOr Function(UserConfig) dco_decode_DartFn_Inputs_user_config_Output_unit_AnyhowException(dynamic raw); @@ -76,6 +61,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected Object dco_decode_DartOpaque(dynamic raw); + @protected + Map dco_decode_Map_String_String_None(dynamic raw); + @protected Map> dco_decode_Map_String_list_String_None(dynamic raw); @@ -257,6 +245,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected List dco_decode_list_sql_value(dynamic raw); + @protected + MediaSizeReport dco_decode_media_size_report(dynamic raw); + @protected Map>? dco_decode_opt_Map_String_list_String_None( dynamic raw, @@ -398,6 +389,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected Object sse_decode_DartOpaque(SseDeserializer deserializer); + @protected + Map sse_decode_Map_String_String_None( + SseDeserializer deserializer, + ); + @protected Map> sse_decode_Map_String_list_String_None( SseDeserializer deserializer, @@ -623,6 +619,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected List sse_decode_list_sql_value(SseDeserializer deserializer); + @protected + MediaSizeReport sse_decode_media_size_report(SseDeserializer deserializer); + @protected Map>? sse_decode_opt_Map_String_list_String_None( SseDeserializer deserializer, @@ -782,19 +781,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected void sse_encode_Chrono_Utc(DateTime self, SseSerializer serializer); - @protected - void - sse_encode_DartFn_Inputs_String_String_i_64_String_Output_unit_AnyhowException( - FutureOr Function(String, String, PlatformInt64, String) self, - SseSerializer serializer, - ); - - @protected - void sse_encode_DartFn_Inputs_String_i_64_Output_unit_AnyhowException( - FutureOr Function(String, PlatformInt64) self, - SseSerializer serializer, - ); - @protected void sse_encode_DartFn_Inputs__Output_StreamSink_String_Sse_AnyhowException( FutureOr> Function() self, @@ -807,13 +793,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseSerializer serializer, ); - @protected - void - sse_encode_DartFn_Inputs_i_64_list_prim_u_8_strict_Output_unit_AnyhowException( - FutureOr Function(PlatformInt64, Uint8List) self, - SseSerializer serializer, - ); - @protected void sse_encode_DartFn_Inputs_user_config_Output_unit_AnyhowException( FutureOr Function(UserConfig) self, @@ -823,6 +802,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected void sse_encode_DartOpaque(Object self, SseSerializer serializer); + @protected + void sse_encode_Map_String_String_None( + Map self, + SseSerializer serializer, + ); + @protected void sse_encode_Map_String_list_String_None( Map> self, @@ -1105,6 +1090,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected void sse_encode_list_sql_value(List self, SseSerializer serializer); + @protected + void sse_encode_media_size_report( + MediaSizeReport self, + SseSerializer serializer, + ); + @protected void sse_encode_opt_Map_String_list_String_None( Map>? self, diff --git a/lib/core/services/media_upload.dart b/lib/core/services/media_upload.dart new file mode 100644 index 00000000..3ff2db41 --- /dev/null +++ b/lib/core/services/media_upload.dart @@ -0,0 +1,32 @@ +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.12.0. + +// ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import + +import '../frb_generated.dart'; +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; + +/// Why a media send stopped at `fileLimitReached`, for the chat entry to show. +class MediaSizeReport { + /// The encoded media as it sits on disk, while it is still there. + final PlatformInt64? mediaBytes; + + /// The largest single object the user's plan accepts. + final PlatformInt64? limitBytes; + + const MediaSizeReport({ + this.mediaBytes, + this.limitBytes, + }); + + @override + int get hashCode => mediaBytes.hashCode ^ limitBytes.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is MediaSizeReport && + runtimeType == other.runtimeType && + mediaBytes == other.mediaBytes && + limitBytes == other.limitBytes; +} diff --git a/lib/main.dart b/lib/main.dart index c24a9988..8f0fc4ec 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -20,9 +20,6 @@ import 'package:twonly/src/providers/connection.provider.dart'; import 'package:twonly/src/providers/image_editor.provider.dart'; import 'package:twonly/src/providers/purchases.provider.dart'; import 'package:twonly/src/providers/settings.provider.dart'; -import 'package:twonly/src/services/api/mediafiles/media_background.api.dart'; -import 'package:twonly/src/services/api/mediafiles/upload.api.dart'; -import 'package:twonly/src/services/background/callback_dispatcher.background.dart'; import 'package:twonly/src/services/backup.service.dart'; import 'package:twonly/src/services/mediafiles/mediafile.service.dart'; import 'package:twonly/src/services/memories/memories.service.dart'; @@ -30,7 +27,6 @@ import 'package:twonly/src/services/migrations.service.dart'; import 'package:twonly/src/services/notifications/fcm.notifications.dart'; import 'package:twonly/src/services/notifications/native.notifications.dart'; import 'package:twonly/src/services/notifications/setup.notifications.dart'; -import 'package:twonly/src/utils/avatars.dart'; import 'package:twonly/src/utils/exclusive_access.utils.dart'; import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/startup_guard.dart'; @@ -133,7 +129,7 @@ void main() async { final settingsController = SettingsChangeProvider()..loadSettings(); await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); - unawaited(initFileDownloader()); + unawaited(BackupService.initFileDownloader()); if (userExists) { unawaited(FcmNotificationService.initAfterUserLoaded()); @@ -192,8 +188,7 @@ Future postStartupTasks() async { unawaited(MediaFileService.purgeTempFolder()); // 2. Service initializations - unawaited(finishStartedPreprocessing()); - unawaited(createPushAvatars()); + unawaited(RustApi.finishStartedMediaUploads()); unawaited( newsService.init().then((_) { final lastDownload = newsService.lastDownloadedAt; @@ -204,8 +199,6 @@ Future postStartupTasks() async { }), ); - await Future.delayed(const Duration(seconds: 10)); - unawaited(initializeBackgroundTaskManager()); // 3. Delayed tasks (Wait for app to settle) await Future.delayed(const Duration(minutes: 2)); unawaited(BackupService.makeBackup()); diff --git a/lib/src/callbacks/callbacks.dart b/lib/src/callbacks/callbacks.dart index a831f7c4..59f25b32 100644 --- a/lib/src/callbacks/callbacks.dart +++ b/lib/src/callbacks/callbacks.dart @@ -1,43 +1,15 @@ import 'package:twonly/core/bridge/callbacks.dart'; import 'package:twonly/globals.dart'; -import 'package:twonly/locator.dart'; import 'package:twonly/src/callbacks/logging.callbacks.dart'; -import 'package:twonly/src/services/api/mediafiles/upload.api.dart'; -import 'package:twonly/src/services/flame.service.dart'; import 'package:twonly/src/services/key_verification.service.dart'; -import 'package:twonly/src/services/mediafiles/mediafile.service.dart'; import 'package:twonly/src/services/user.service.dart'; -import 'package:twonly/src/utils/avatars.dart'; - -Future _apiMediaAction( - String kind, - String mediaId, - int contactId, - String messageId, -) async { - final media = await twonlyDB.mediaFilesDao.getMediaFileById(mediaId); - if (media == null) return; - switch (kind) { - case 'stored': - await MediaFileService(media).storeMediaFile(); - case 'reupload': - await reuploadMediaFile(contactId, media, messageId); - } -} Future initFlutterCallbacksForRust() async { await initFlutterCallbacks( callbackId: isolateCallbackId, loggingGetStreamSink: LoggingCallbacks.getStreamSink, - apiMediaAction: _apiMediaAction, - apiVerificationProof: KeyVerificationService.handleVerificationProof, - apiCreatePushAvatars: (contactId) => - createPushAvatars(forceForUserId: contactId), - apiMediaReceived: (groupId, timestamp) => incFlameCounter( - groupId, - true, - DateTime.fromMillisecondsSinceEpoch(timestamp), - ), + apiVerificationSucceeded: + KeyVerificationService.handleVerificationSucceeded, apiUserConfigChanged: UserService.handleRustUserConfigChanged, ); } diff --git a/lib/src/channels/video_compression.channel.dart b/lib/src/channels/video_compression.channel.dart deleted file mode 100644 index c3dcbb35..00000000 --- a/lib/src/channels/video_compression.channel.dart +++ /dev/null @@ -1,46 +0,0 @@ -import 'package:flutter/services.dart'; -import 'package:twonly/src/utils/log.dart'; - -abstract class VideoCompressionChannel { - static const MethodChannel _channel = MethodChannel( - 'eu.twonly/videoCompression', - ); - - static void Function(double)? _currentProgressCallback; - static bool _handlerSetup = false; - - static void _setupProgressHandler() { - if (_handlerSetup) return; - - _channel.setMethodCallHandler((call) async { - if (call.method == 'onProgress') { - // ignore: avoid_dynamic_calls - final progress = call.arguments['progress'] as int; - _currentProgressCallback?.call(progress / 100.0); - } - }); - - _handlerSetup = true; - } - - static Future compressVideo({ - required String inputPath, - required String outputPath, - void Function(double progress)? onProgress, - }) async { - try { - _setupProgressHandler(); - _currentProgressCallback = onProgress; - await _channel.invokeMethod('compressVideo', { - 'input': inputPath, - 'output': outputPath, - }); - return outputPath; - } on PlatformException catch (e) { - Log.warn('Failed to compress video: $e'); - return null; - } finally { - _currentProgressCallback = null; - } - } -} diff --git a/lib/src/constants/keyvalue.keys.dart b/lib/src/constants/keyvalue.keys.dart index 9737cb9d..9f3bee56 100644 --- a/lib/src/constants/keyvalue.keys.dart +++ b/lib/src/constants/keyvalue.keys.dart @@ -1,6 +1,4 @@ class KeyValueKeys { - static const String lastPeriodicTaskExecution = - 'last_periodic_task_execution'; static const String currentBackupState = 'current_backup_state'; static const String backupRecoveryState = 'backup_recovery_state'; static const String onboardingState = 'onboarding_state'; diff --git a/lib/src/database/daos/mediafiles.dao.dart b/lib/src/database/daos/mediafiles.dao.dart index 2b139ae7..551ef8be 100644 --- a/lib/src/database/daos/mediafiles.dao.dart +++ b/lib/src/database/daos/mediafiles.dao.dart @@ -188,19 +188,6 @@ Stream> watchMediaFilesByIds(Set mediaIds) { ); } - Future updateAllRetransmissionUploadingState() async { - await (update(mediaFiles)..where( - (t) => - t.uploadState.equals(UploadState.uploading.name) & - t.reuploadRequestedBy.isNotNull(), - )) - .write( - const MediaFilesCompanion( - uploadState: Value(UploadState.preprocessing), - ), - ); - } - Future> getMessageIdsByMediaHash( Uint8List hash, int senderId, diff --git a/lib/src/database/daos/messages.dao.dart b/lib/src/database/daos/messages.dao.dart index a26931ec..fabbdcc7 100644 --- a/lib/src/database/daos/messages.dao.dart +++ b/lib/src/database/daos/messages.dao.dart @@ -350,7 +350,7 @@ Future purgeMessageTable() async { final mediaService = await MediaFileService.fromMediaId(msg.mediaId!); if (mediaService != null) { - mediaService.fullMediaRemoval(); + await mediaService.fullMediaRemoval(); } } else { Log.info( diff --git a/lib/src/localization/generated/app_localizations.dart b/lib/src/localization/generated/app_localizations.dart index 845cf786..14031316 100644 --- a/lib/src/localization/generated/app_localizations.dart +++ b/lib/src/localization/generated/app_localizations.dart @@ -4561,6 +4561,42 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Exporting memories...'** String get memoriesExportingProgress; + + /// No description provided for @fileLimitReachedTitle. + /// + /// In en, this message translates to: + /// **'File too large to send'** + String get fileLimitReachedTitle; + + /// No description provided for @fileLimitReachedDetail. + /// + /// In en, this message translates to: + /// **'This file is {size}, but your plan allows at most {limit} per send.'** + String fileLimitReachedDetail(String size, String limit); + + /// No description provided for @fileLimitReachedDetailNoSize. + /// + /// In en, this message translates to: + /// **'Your plan allows at most {limit} per send.'** + String fileLimitReachedDetailNoSize(String limit); + + /// No description provided for @fileLimitReachedHint. + /// + /// In en, this message translates to: + /// **'Record a shorter video and send it again.'** + String get fileLimitReachedHint; + + /// No description provided for @fileLimitReachedHintFree. + /// + /// In en, this message translates to: + /// **'Record a shorter video, or upgrade your plan to send larger files.'** + String get fileLimitReachedHintFree; + + /// No description provided for @fileLimitReachedUpgrade. + /// + /// In en, this message translates to: + /// **'Upgrade plan'** + String get fileLimitReachedUpgrade; } class _AppLocalizationsDelegate diff --git a/lib/src/localization/generated/app_localizations_de.dart b/lib/src/localization/generated/app_localizations_de.dart index f2802c67..9bf46143 100644 --- a/lib/src/localization/generated/app_localizations_de.dart +++ b/lib/src/localization/generated/app_localizations_de.dart @@ -2635,4 +2635,28 @@ class AppLocalizationsDe extends AppLocalizations { @override String get memoriesExportingProgress => 'Erinnerungen werden exportiert...'; + + @override + String get fileLimitReachedTitle => 'Datei zu groß zum Senden'; + + @override + String fileLimitReachedDetail(String size, String limit) { + return 'Diese Datei ist $size groß, dein Tarif erlaubt aber höchstens $limit pro Sendung.'; + } + + @override + String fileLimitReachedDetailNoSize(String limit) { + return 'Dein Tarif erlaubt höchstens $limit pro Sendung.'; + } + + @override + String get fileLimitReachedHint => + 'Nimm ein kürzeres Video auf und sende es erneut.'; + + @override + String get fileLimitReachedHintFree => + 'Nimm ein kürzeres Video auf oder wechsle den Tarif, um größere Dateien zu senden.'; + + @override + String get fileLimitReachedUpgrade => 'Tarif wechseln'; } diff --git a/lib/src/localization/generated/app_localizations_en.dart b/lib/src/localization/generated/app_localizations_en.dart index b9a9d31f..7f106c60 100644 --- a/lib/src/localization/generated/app_localizations_en.dart +++ b/lib/src/localization/generated/app_localizations_en.dart @@ -2610,4 +2610,28 @@ class AppLocalizationsEn extends AppLocalizations { @override String get memoriesExportingProgress => 'Exporting memories...'; + + @override + String get fileLimitReachedTitle => 'File too large to send'; + + @override + String fileLimitReachedDetail(String size, String limit) { + return 'This file is $size, but your plan allows at most $limit per send.'; + } + + @override + String fileLimitReachedDetailNoSize(String limit) { + return 'Your plan allows at most $limit per send.'; + } + + @override + String get fileLimitReachedHint => + 'Record a shorter video and send it again.'; + + @override + String get fileLimitReachedHintFree => + 'Record a shorter video, or upgrade your plan to send larger files.'; + + @override + String get fileLimitReachedUpgrade => 'Upgrade plan'; } diff --git a/lib/src/services/api/api.service.dart b/lib/src/services/api/api.service.dart index 5b76f39a..01ef09ff 100644 --- a/lib/src/services/api/api.service.dart +++ b/lib/src/services/api/api.service.dart @@ -4,7 +4,6 @@ import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:twonly/core/bridge/api.dart' as rust_api; import 'package:twonly/globals.dart'; import 'package:twonly/locator.dart'; -import 'package:twonly/src/services/api/mediafiles/upload.api.dart'; import 'package:twonly/src/services/memories/memories_cloud.service.dart'; import 'package:twonly/src/services/notifications/fcm.notifications.dart'; import 'package:twonly/src/utils/log.dart'; @@ -41,9 +40,9 @@ class ApiService { await FcmNotificationService.initFCMAfterAuthenticated(); if (AppState.isInBackgroundTask) { - await reuploadMediaFiles(); + await rust_api.RustApi.reuploadPendingMedia(); } else if (!AppState.isAppInBackground) { - unawaited(reuploadMediaFiles()); + unawaited(rust_api.RustApi.reuploadPendingMedia()); twonlyDB.markUpdated(); // resetUserDiscoveryRequestUpdates(); diff --git a/lib/src/services/api/mediafiles/media_background.api.dart b/lib/src/services/api/mediafiles/media_background.api.dart deleted file mode 100644 index eedd83c2..00000000 --- a/lib/src/services/api/mediafiles/media_background.api.dart +++ /dev/null @@ -1,137 +0,0 @@ -import 'dart:async'; - -import 'package:background_downloader/background_downloader.dart'; -import 'package:drift/drift.dart' show Value; -import 'package:flutter/foundation.dart'; -import 'package:twonly/locator.dart'; -import 'package:twonly/src/database/tables/mediafiles.table.dart'; -import 'package:twonly/src/database/twonly.db.dart'; -import 'package:twonly/src/services/api/mediafiles/upload.api.dart'; -import 'package:twonly/src/services/backup.service.dart'; -import 'package:twonly/src/services/mediafiles/mediafile.service.dart'; -import 'package:twonly/src/utils/log.dart'; - -Future initFileDownloader() async { - FileDownloader().updates.listen((update) async { - switch (update) { - case TaskStatusUpdate(): - if (update.task.taskId.contains('upload_')) { - await handleUploadStatusUpdate(update); - } - if (update.task.taskId.contains('backup_')) { - await BackupService.handleBackupStatusUpdate( - update.task.taskId, - update, - ); - } - case TaskProgressUpdate(): - Log.info( - 'Progress update for ${update.task} with progress ${update.progress}', - ); - } - }); - - await FileDownloader().start(); - - try { - var androidConfig = []; - if (!kReleaseMode) { - androidConfig = [(Config.bypassTLSCertificateValidation, true)]; - } - await FileDownloader().configure(androidConfig: androidConfig); - } catch (e) { - Log.error(e); - } - - if (!kReleaseMode) { - FileDownloader().configureNotification( - running: const TaskNotification( - 'Uploading/Downloading', - '{filename} ({progress}).', - ), - progressBar: true, - ); - } -} - -Future handleUploadStatusUpdate(TaskStatusUpdate update) async { - final mediaId = update.task.taskId.replaceAll('upload_', ''); - final media = await twonlyDB.mediaFilesDao.getMediaFileById(mediaId); - - if (update.status == TaskStatus.running) { - // Ignore these updates - return; - } - - if (media == null) { - Log.error( - 'Got an upload task but no upload media in the media upload database', - ); - return; - } - - if (update.status == TaskStatus.complete) { - if (update.responseStatusCode == 200) { - Log.info('Upload of ${media.mediaId} success!'); - await markUploadAsSuccessful(media); - return; - } - Log.warn( - 'Got HTTP error ${update.responseStatusCode} for $mediaId', - ); - Log.error( - 'Got HTTP error ${update.responseStatusCode} for media.', - onlyIfSentryEnabled: true, - ); - } - - if (update.status == TaskStatus.notFound) { - await twonlyDB.mediaFilesDao.updateMedia( - mediaId, - const MediaFilesCompanion( - uploadState: Value(UploadState.uploadLimitReached), - ), - ); - Log.info( - 'Background upload failed for $mediaId with status ${update.responseStatusCode}. Not trying again.', - ); - return; - } - - Log.info( - 'Background status $mediaId with status ${update.status} and ${update.responseStatusCode}. ', - ); - - if (update.status == TaskStatus.waitingToRetry) { - if (update.responseStatusCode == 401) { - // auth token is not valid, so either create a new task with a new token, or cancel task - final mediaService = MediaFileService(media); - await FileDownloader().cancelTaskWithId(update.task.taskId); - Log.info('Cancel task, already uploaded or will be reuploaded'); - if (mediaService.mediaFile.uploadState != UploadState.uploaded) { - await mediaService.setUploadState(UploadState.uploading); - // In all other cases just try the upload again... - await startBackgroundMediaUpload(mediaService); - } - } - } - - if (update.status == TaskStatus.failed || - update.status == TaskStatus.canceled) { - Log.warn( - 'Background upload failed for $mediaId with status ${update.status} and ${update.responseStatusCode}. ', - ); - Log.error( - 'Background upload failed with status ${update.status} and ${update.responseStatusCode}.', - onlyIfSentryEnabled: true, - ); - final mediaService = MediaFileService(media); - - // in case the media file is already uploaded to not reqtry - if (mediaService.mediaFile.uploadState != UploadState.uploaded) { - await mediaService.setUploadState(UploadState.uploading); - // In all other cases just try the upload again... - await startBackgroundMediaUpload(mediaService); - } - } -} diff --git a/lib/src/services/api/mediafiles/upload.api.dart b/lib/src/services/api/mediafiles/upload.api.dart deleted file mode 100644 index bf67019c..00000000 --- a/lib/src/services/api/mediafiles/upload.api.dart +++ /dev/null @@ -1,884 +0,0 @@ -import 'dart:async'; - -import 'package:background_downloader/background_downloader.dart'; -import 'package:clock/clock.dart'; -import 'package:connectivity_plus/connectivity_plus.dart'; -import 'package:cryptography_flutter_plus/cryptography_flutter_plus.dart'; -import 'package:cryptography_plus/cryptography_plus.dart'; -import 'package:drift/drift.dart'; -import 'package:fixnum/fixnum.dart'; -import 'package:http/http.dart' as http; -import 'package:mutex/mutex.dart'; -import 'package:twonly/globals.dart'; -import 'package:twonly/locator.dart'; -import 'package:twonly/src/database/tables/mediafiles.table.dart'; -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/data.pb.dart'; -import 'package:twonly/src/model/protobuf/client/generated/http_requests.pb.dart'; -import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart'; -import 'package:twonly/src/services/api/mediafiles/media_background.api.dart'; -import 'package:twonly/src/services/api/messages.api.dart'; -import 'package:twonly/src/services/api/utils.api.dart'; -import 'package:twonly/src/services/flame.service.dart'; -import 'package:twonly/src/services/mediafiles/mediafile.service.dart'; -import 'package:twonly/src/utils/exclusive_access.utils.dart'; -import 'package:twonly/src/utils/log.dart'; -import 'package:twonly/src/utils/misc.dart'; -import 'package:workmanager/workmanager.dart' hide TaskStatus; - -final lockRetransmission = Mutex(); -final Map _uploadMutexes = {}; - -Future _protectMediaUpload( - String mediaId, - Future Function() action, -) async { - final mutex = _uploadMutexes.putIfAbsent(mediaId, Mutex.new); - await mutex.protect(action); -} - -Future reuploadMediaFiles() async { - return exclusiveAccess( - lockName: 'reupload_maintenance', - mutex: lockRetransmission, - action: () async { - final receipts = await twonlyDB.receiptsDao - .getReceiptsForMediaRetransmissions(); - - if (receipts.isEmpty) return; - - Log.info('Reuploading ${receipts.length} media files to the server.'); - - final contacts = {}; - - for (final receipt in receipts) { - if (receipt.retryCount > 1 && receipt.lastRetry != null) { - final twentyFourHoursAgo = DateTime.now().subtract( - const Duration(hours: 6), - ); - if (receipt.lastRetry!.isAfter(twentyFourHoursAgo)) { - Log.info( - 'Ignoring ${receipt.receiptId} as it was retried in the last 6h', - ); - continue; - } - } - - var messageId = receipt.messageId; - if (receipt.messageId == null) { - Log.info('Message not in receipt. Loading it from the content.'); - try { - final content = EncryptedContent.fromBuffer(receipt.message); - if (content.hasMedia()) { - messageId = content.media.senderMessageId; - final messageExists = await twonlyDB.messagesDao - .getMessageById(messageId) - .getSingleOrNull(); - if (messageExists != null) { - await twonlyDB.receiptsDao.updateReceipt( - receipt.receiptId, - ReceiptsCompanion( - messageId: Value(messageId), - ), - ); - } else { - Log.info( - 'Message $messageId not found in DB for receipt recovery. Deleting stale receipt.', - ); - await twonlyDB.receiptsDao.deleteReceipt(receipt.receiptId); - continue; - } - } - } catch (e) { - Log.error(e); - } - } - if (messageId == null) { - Log.error('MessageId is empty for media file receipts'); - continue; - } - if (receipt.markForRetryAfterAccepted != null) { - if (!contacts.containsKey(receipt.contactId)) { - final contact = await twonlyDB.contactsDao - .getContactByUserId(receipt.contactId) - .getSingleOrNull(); - if (contact == null) { - Log.error( - 'Contact does not exists, but has a record in receipts, this should not be possible, because of the DELETE CASCADE relation.', - ); - continue; - } - contacts[receipt.contactId] = contact; - } - if (!(contacts[receipt.contactId]?.accepted ?? true)) { - Log.warn( - 'Could not send message as contact has still not yet accepted.', - ); - continue; - } - } - - if (receipt.ackByServerAt == null) { - // media file must be reuploaded again in case the media files - // was deleted by the server, the receiver will request a new media reupload - - final message = await twonlyDB.messagesDao - .getMessageById(messageId) - .getSingleOrNull(); - if (message == null || message.mediaId == null) { - // The message or media file does not exists any more, so delete the receipt... - if (message != null) { - // The media file of the message does not exist anymore. Removing it... - await twonlyDB.messagesDao.deleteMessagesById(messageId); - } - await twonlyDB.receiptsDao.deleteReceipt(receipt.receiptId); - Log.warn( - 'Message not found for reupload of the receipt, likely deleted from sender (${message == null} - ${message?.mediaId}).', - ); - continue; - } - - final mediaFile = await twonlyDB.mediaFilesDao.getMediaFileById( - message.mediaId!, - ); - if (mediaFile == null) { - Log.error( - 'Mediafile not found for reupload of the receipt (${message.messageId} - ${message.mediaId}).', - ); - continue; - } - await reuploadMediaFile( - receipt.contactId, - mediaFile, - message.messageId, - ); - } else { - Log.info('Reuploading media file $messageId'); - // the media file should be still on the server, so it should be enough - // to just resend the message containing the download token. - await tryToSendCompleteMessage(receiptId: receipt.receiptId); - } - } - }, - ); -} - -Future reuploadMediaFile( - int contactId, - MediaFile mediaFile, - String messageId, -) async { - return _protectMediaUpload(mediaFile.mediaId, () async { - Log.info('Reuploading media file: ${mediaFile.mediaId}'); - - await twonlyDB.receiptsDao.updateReceiptByContactAndMessageId( - contactId, - messageId, - const ReceiptsCompanion( - markForRetry: Value(null), - markForRetryAfterAccepted: Value(null), - ), - ); - - // Refresh media file to get latest reuploadRequestedBy - final currentMedia = await twonlyDB.mediaFilesDao.getMediaFileById( - mediaFile.mediaId, - ); - - final reuploadRequestedBy = (currentMedia?.reuploadRequestedBy ?? []) - ..add(contactId); - - await twonlyDB.mediaFilesDao.updateMedia( - mediaFile.mediaId, - MediaFilesCompanion( - uploadState: const Value(UploadState.preprocessing), - reuploadRequestedBy: Value(reuploadRequestedBy), - ), - ); - final mediaFileUpdated = await MediaFileService.fromMediaId( - mediaFile.mediaId, - ); - if (mediaFileUpdated != null) { - if (mediaFileUpdated.uploadRequestPath.existsSync()) { - mediaFileUpdated.uploadRequestPath.deleteSync(); - } - await _startBackgroundMediaUploadInternal(mediaFileUpdated); - } - }); -} - -final Mutex _lockPreprocessing = Mutex(); - -Future finishStartedPreprocessing() async { - return exclusiveAccess( - lockName: 'preprocessing_maintenance', - mutex: _lockPreprocessing, - action: () async { - final mediaFiles = await twonlyDB.mediaFilesDao - .getAllMediaFilesPendingUpload(); - - for (final mediaFile in mediaFiles) { - if (mediaFile.isDraftMedia) { - Log.info('Ignoring media files as it is a draft'); - continue; - } - - final messages = await twonlyDB.messagesDao.getMessagesByMediaId( - mediaFile.mediaId, - ); - if (messages.isEmpty) { - if (mediaFile.createdAt.isBefore( - clock.now().subtract(const Duration(hours: 1)), - )) { - Log.info( - 'Deleted orphaned media file ${mediaFile.mediaId} as no messages reference it.', - ); - MediaFileService(mediaFile).fullMediaRemoval(); - await twonlyDB.mediaFilesDao.deleteMediaFile(mediaFile.mediaId); - } else { - Log.info( - 'Media file ${mediaFile.mediaId} has no messages, but is too new to be deleted by finishStartedPreprocessing. Skipping.', - ); - } - continue; - } - - try { - final service = MediaFileService(mediaFile); - if (!service.originalPath.existsSync() && - !service.uploadRequestPath.existsSync()) { - if (service.storedPath.existsSync()) { - // media file was stored, we can recover tempPath from storedPath and upload it. - try { - if (!service.tempPath.existsSync()) { - service.storedPath.copySync(service.tempPath.path); - } - } catch (e) { - Log.error('Error recovering tempPath from storedPath: $e'); - continue; - } - } else { - if (mediaFile.reuploadRequestedBy != null) { - Log.warn( - 'Reupload requested for ${mediaFile.mediaId} but files are missing. Cancelling reupload but keeping record.', - ); - await twonlyDB.mediaFilesDao.updateMedia( - mediaFile.mediaId, - const MediaFilesCompanion( - uploadState: Value(UploadState.uploaded), - reuploadRequestedBy: Value(null), - ), - ); - continue; - } - - final messages = await twonlyDB.messagesDao.getMessagesByMediaId( - mediaFile.mediaId, - ); - - if (messages.isEmpty) { - Log.info( - 'Deleted media files ${mediaFile.mediaId} as originalPath and uploadRequestPath both do not exists and no messages reference it.', - ); - // the file does not exists anymore and no messages reference it. - await twonlyDB.mediaFilesDao.deleteMediaFile(mediaFile.mediaId); - } else { - Log.warn( - 'Media files ${mediaFile.mediaId} missing but messages still reference it. Keeping record to avoid broken chat history.', - ); - // Just mark as uploaded to stop preprocessing attempts - await twonlyDB.mediaFilesDao.updateMedia( - mediaFile.mediaId, - const MediaFilesCompanion( - uploadState: Value(UploadState.uploaded), - ), - ); - } - continue; - } - } - Log.info( - 'Finishing started preprocessing of ${mediaFile.mediaId} in state ${mediaFile.uploadState}.', - ); - await startBackgroundMediaUpload(service); - } catch (e) { - Log.warn(e); - } - } - }, - ); -} - -/// It can happen, that a media files is uploaded but not yet marked for been uploaded. -/// For example because the background_downloader plugin has not yet reported the finished upload. -/// In case the message receipts or a reaction was received, mark the media file as been uploaded. -Future handleMediaRelatedResponseFromReceiver(String messageId) async { - final message = await twonlyDB.messagesDao - .getMessageById(messageId) - .getSingleOrNull(); - if (message == null || message.mediaId == null) return; - final media = await twonlyDB.mediaFilesDao.getMediaFileById(message.mediaId!); - if (media == null) return; - - if (media.uploadState != UploadState.uploaded) { - Log.info('Media was not yet marked as uploaded. Doing it now.'); - await markUploadAsSuccessful(media); - } -} - -Future markUploadAsSuccessful(MediaFile media) async { - await twonlyDB.mediaFilesDao.updateMedia( - media.mediaId, - const MediaFilesCompanion( - uploadState: Value(UploadState.uploaded), - ), - ); - - /// As the messages where send in a bulk acknowledge all messages. - - final messages = await twonlyDB.messagesDao.getMessagesByMediaId( - media.mediaId, - ); - for (final message in messages) { - final contacts = await twonlyDB.groupsDao.getGroupNonLeftMembers( - message.groupId, - ); - for (final contact in contacts) { - await twonlyDB.messagesDao.handleMessageAckByServer( - contact.contactId, - message.messageId, - clock.now(), - ); - await twonlyDB.receiptsDao.updateReceiptByContactAndMessageId( - contact.contactId, - message.messageId, - ReceiptsCompanion( - ackByServerAt: Value(clock.now()), - retryCount: const Value(1), - lastRetry: Value(clock.now()), - markForRetry: const Value(null), - ), - ); - } - } -} - -Future initializeMediaUpload( - MediaType type, - int? displayLimitInMilliseconds, { - bool isDraftMedia = false, -}) async { - if (displayLimitInMilliseconds != null && displayLimitInMilliseconds < 1000) { - // in case the time was set in seconds... - // ignore: parameter_assignments - displayLimitInMilliseconds = displayLimitInMilliseconds * 1000; - } - final chacha20 = FlutterChacha20.poly1305Aead(); - final encryptionKey = await (await chacha20.newSecretKey()).extract(); - final encryptionNonce = chacha20.newNonce(); - - await twonlyDB.mediaFilesDao.updateAllMediaFiles( - const MediaFilesCompanion(isDraftMedia: Value(false)), - ); - - final mediaFile = await twonlyDB.mediaFilesDao.insertOrUpdateMedia( - MediaFilesCompanion( - uploadState: const Value(UploadState.initialized), - displayLimitInMilliseconds: Value(displayLimitInMilliseconds), - encryptionKey: Value(Uint8List.fromList(encryptionKey.bytes)), - encryptionNonce: Value(Uint8List.fromList(encryptionNonce)), - isDraftMedia: Value(isDraftMedia), - type: Value(type), - ), - ); - if (mediaFile == null) return null; - return MediaFileService(mediaFile); -} - -Future insertMediaFileInMessagesTable( - MediaFileService mediaService, - List groupIds, { - AdditionalMessageData? additionalData, -}) async { - await twonlyDB.transaction(() async { - await twonlyDB.mediaFilesDao.updateAllMediaFiles( - const MediaFilesCompanion( - isDraftMedia: Value(false), - ), - ); - for (final groupId in groupIds) { - final groupMembers = await twonlyDB.groupsDao.getGroupContact(groupId); - if (groupMembers.length == 1) { - if (groupMembers.first.accountDeleted) { - Log.warn( - 'Did not send media file to $groupId because the only account has deleted his account.', - ); - continue; - } - } - - final message = await twonlyDB.messagesDao.insertMessage( - MessagesCompanion( - groupId: Value(groupId), - mediaId: Value(mediaService.mediaFile.mediaId), - type: Value(MessageType.media.name), - additionalMessageData: Value.absentIfNull( - additionalData?.writeToBuffer(), - ), - ), - ); - await twonlyDB.groupsDao.increaseLastMessageExchange( - groupId, - clock.now(), - ); - if (message != null) { - Log.info( - 'Created message ${message.messageId} for media ${message.mediaId}', - ); - // de-archive contact when sending a new message - await twonlyDB.groupsDao.updateGroup( - message.groupId, - const GroupsCompanion( - archived: Value(false), - deletedContent: Value(false), - ), - ); - } else { - Log.error('Error inserting media upload message in database.'); - } - } - }); - - unawaited(startBackgroundMediaUpload(mediaService)); -} - -Future startBackgroundMediaUpload(MediaFileService mediaService) async { - return _protectMediaUpload( - mediaService.mediaFile.mediaId, - () => _startBackgroundMediaUploadInternal(mediaService), - ); -} - -Future _startBackgroundMediaUploadInternal( - MediaFileService mediaService, -) async { - // Refresh the media file state inside the mutex - await mediaService.updateFromDB(); - - if (mediaService.mediaFile.uploadState == UploadState.uploading) { - await _checkAndRecoverMissingUploadRequest(mediaService); - } - - if (mediaService.mediaFile.uploadState == UploadState.initialized || - mediaService.mediaFile.uploadState == UploadState.preprocessing) { - Log.info( - 'Handling media file ${mediaService.mediaFile.mediaId} in ${mediaService.mediaFile.uploadState}', - ); - - await mediaService.setUploadState(UploadState.preprocessing); - - if (!mediaService.tempPath.existsSync()) { - if (mediaService.storedPath.existsSync()) { - try { - mediaService.storedPath.copySync(mediaService.tempPath.path); - } catch (e) { - Log.error('Error copying storedPath to tempPath: $e'); - } - } else { - await mediaService.compressMedia(); - } - if (!mediaService.tempPath.existsSync()) { - final messages = await twonlyDB.messagesDao.getMessagesByMediaId( - mediaService.mediaFile.mediaId, - ); - if (messages.isEmpty) { - if (mediaService.mediaFile.createdAt.isBefore( - clock.now().subtract(const Duration(hours: 1)), - )) { - Log.warn( - 'Media files ${mediaService.mediaFile.mediaId} has no original, temp, or stored path. Removing it from DB as files are not existent.', - ); - await twonlyDB.mediaFilesDao.deleteMediaFile( - mediaService.mediaFile.mediaId, - ); - } else { - Log.warn( - 'Media files ${mediaService.mediaFile.mediaId} has no paths, but is too new to be deleted. Skipping deletion.', - ); - } - } else { - Log.warn( - 'Media files ${mediaService.mediaFile.mediaId} has no original, temp, or stored path, but messages still reference it. Marking as uploaded to stop retries.', - ); - await mediaService.setUploadState(UploadState.uploaded); - } - return; - } - } - - // if the user has enabled auto storing and the file - // was send with unlimited counter not in twonly-Mode then store the file - if (userService.currentUser.autoStoreAllSendUnlimitedMediaFiles && - !mediaService.mediaFile.requiresAuthentication && - !mediaService.storedPath.existsSync() && - mediaService.mediaFile.displayLimitInMilliseconds == null) { - await mediaService.storeMediaFile(); - } - - if (!mediaService.encryptedPath.existsSync()) { - Log.info( - 'Media ${mediaService.mediaFile.mediaId}: Encrypted file not found. Starting encryption.', - ); - await _encryptMediaFiles(mediaService); - if (!mediaService.encryptedPath.existsSync()) { - Log.warn( - 'Media ${mediaService.mediaFile.mediaId}: Encryption failed. Encrypted file still missing.', - ); - return; - } - Log.info( - 'Media ${mediaService.mediaFile.mediaId}: Encryption completed successfully.', - ); - } else { - Log.info( - 'Media ${mediaService.mediaFile.mediaId}: Encrypted file already exists.', - ); - } - - if (!mediaService.uploadRequestPath.existsSync()) { - Log.info( - 'Media ${mediaService.mediaFile.mediaId}: Upload request file not found. Creating it.', - ); - await _createUploadRequest(mediaService); - if (!mediaService.uploadRequestPath.existsSync()) { - Log.warn( - 'Media ${mediaService.mediaFile.mediaId}: Upload request file creation returned empty (e.g. no messages).', - ); - } - } else { - Log.info( - 'Media ${mediaService.mediaFile.mediaId}: Upload request file already exists.', - ); - } - - if (mediaService.uploadRequestPath.existsSync()) { - Log.info( - 'Media ${mediaService.mediaFile.mediaId}: Transitioning uploadState from ${mediaService.mediaFile.uploadState} to uploading.', - ); - await mediaService.setUploadState(UploadState.uploading); - // at this point the original file is not used any more, so it can be deleted - if (mediaService.originalPath.existsSync()) { - Log.info( - 'Media ${mediaService.mediaFile.mediaId}: Deleting original file as it is now encrypted.', - ); - mediaService.originalPath.deleteSync(); - } - } - } - - if (mediaService.mediaFile.uploadState == UploadState.uploading || - mediaService.mediaFile.uploadState == UploadState.uploadLimitReached) { - await _uploadUploadRequest(mediaService); - } -} - -Future _encryptMediaFiles(MediaFileService mediaService) async { - /// if there is a video wait until it is finished with compression - - if (!mediaService.tempPath.existsSync()) { - Log.error('Could not encrypted image as it does not exists'); - return; - } - - final dataToEncrypt = await mediaService.tempPath.readAsBytes(); - - final chacha20 = FlutterChacha20.poly1305Aead(); - - final secretBox = await chacha20.encrypt( - dataToEncrypt, - secretKey: SecretKey(mediaService.mediaFile.encryptionKey!), - nonce: mediaService.mediaFile.encryptionNonce, - ); - - await mediaService.setEncryptedMac(Uint8List.fromList(secretBox.mac.bytes)); - - mediaService.encryptedPath.writeAsBytesSync( - Uint8List.fromList(secretBox.cipherText), - ); -} - -Future _createUploadRequest(MediaFileService media) async { - final downloadTokens = []; - - final messagesOnSuccess = []; - - final messages = await twonlyDB.messagesDao.getMessagesByMediaId( - media.mediaFile.mediaId, - ); - - if (messages.isEmpty) { - // There where no user selected who should receive the image, so waiting with this step... - Log.info( - 'Media ${media.mediaFile.mediaId}: No recipient messages found, waiting to create upload request', - ); - return; - } - - for (final message in messages) { - final groupMembers = await twonlyDB.groupsDao.getGroupNonLeftMembers( - message.groupId, - ); - - if (media.mediaFile.reuploadRequestedBy == null) { - await incFlameCounter(message.groupId, false, message.createdAt); - } - - for (final groupMember in groupMembers) { - /// only send the upload to the users - if (media.mediaFile.reuploadRequestedBy != null) { - if (!media.mediaFile.reuploadRequestedBy!.contains( - groupMember.contactId, - )) { - continue; - } - } - - final contact = await twonlyDB.contactsDao.getContactById( - groupMember.contactId, - ); - - if (contact == null || contact.accountDeleted) { - continue; - } - - final downloadToken = getRandomUint8List(32); - - late EncryptedContent_Media_Type type; - switch (media.mediaFile.type) { - case MediaType.audio: - type = EncryptedContent_Media_Type.AUDIO; - case MediaType.image: - type = EncryptedContent_Media_Type.IMAGE; - case MediaType.gif: - type = EncryptedContent_Media_Type.GIF; - case MediaType.video: - type = EncryptedContent_Media_Type.VIDEO; - } - - if (media.mediaFile.reuploadRequestedBy != null) { - // not used any more... Receiver detects automatically if it is an reupload... - // type = EncryptedContent_Media_Type.REUPLOAD; - } - - final notEncryptedContent = EncryptedContent( - groupId: message.groupId, - media: EncryptedContent_Media( - senderMessageId: message.messageId, - type: type, - requiresAuthentication: media.mediaFile.requiresAuthentication, - timestamp: Int64(message.createdAt.millisecondsSinceEpoch), - downloadToken: downloadToken.toList(), - encryptionKey: media.mediaFile.encryptionKey, - encryptionNonce: media.mediaFile.encryptionNonce, - encryptionMac: media.mediaFile.encryptionMac, - additionalMessageData: message.additionalMessageData, - ), - ); - - if (media.mediaFile.displayLimitInMilliseconds != null) { - notEncryptedContent.media.displayLimitInMilliseconds = Int64( - media.mediaFile.displayLimitInMilliseconds!, - ); - } - - final cipherText = await RustApi.sendEncryptedContent( - contactId: groupMember.contactId, - content: notEncryptedContent.writeToBuffer(), - messageId: message.messageId, - onlySendIfNoReceiptsAreOpen: false, - onlyReturnEncryptedData: true, - blocking: true, - ); - - if (cipherText == null) { - Log.error( - 'Could not generate ciphertext message for ${groupMember.contactId}', - ); - continue; - } - - final messageOnSuccess = TextMessage() - ..body = cipherText - ..userId = Int64(groupMember.contactId) - // A media message is user visible, so the server may send the opaque - // FCM wake-up once the upload completes. - ..wakeReceiver = true; - - messagesOnSuccess.add(messageOnSuccess); - downloadTokens.add(downloadToken); - } - } - - final bytesToUpload = await media.encryptedPath.readAsBytes(); - - final uploadRequest = UploadRequest( - messagesOnSuccess: messagesOnSuccess, - downloadTokens: downloadTokens, - encryptedData: bytesToUpload, - ); - - final uploadRequestBytes = uploadRequest.writeToBuffer(); - - if (uploadRequestBytes.length > 49_000_000) { - await media.setUploadState(UploadState.fileLimitReached); - - await twonlyDB.messagesDao.updateMessagesByMediaId( - media.mediaFile.mediaId, - MessagesCompanion( - openedAt: Value(DateTime.now()), - ackByServer: Value(DateTime.now()), - ), - ); - return; - } - - await media.uploadRequestPath.writeAsBytes(uploadRequestBytes); -} - -Future _checkAndRecoverMissingUploadRequest( - MediaFileService media, { - bool triggerBackgroundUpload = false, -}) async { - if (!media.uploadRequestPath.existsSync()) { - Log.warn( - 'UploadRequestPath for media ${media.mediaFile.mediaId} does not exist. Reverting to preprocessing.', - ); - await media.setUploadState(UploadState.preprocessing); - if (triggerBackgroundUpload) { - unawaited(startBackgroundMediaUpload(media)); - } - return true; - } - return false; -} - -Future _uploadUploadRequest(MediaFileService media) async { - if (await _checkAndRecoverMissingUploadRequest( - media, - triggerBackgroundUpload: true, - )) { - return; - } - - final currentMedia = await twonlyDB.mediaFilesDao.getMediaFileById( - media.mediaFile.mediaId, - ); - - if (currentMedia == null || - currentMedia.uploadState == UploadState.backgroundUploadTaskStarted) { - Log.info('Download for ${media.mediaFile.mediaId} already started.'); - return; - } - - final apiUrl = '${RustApi.apiBaseUrl(protocol: 'https')}upload'; - - Log.info('Starting upload from ${media.mediaFile.mediaId}'); - - final headers = await getAuthenticationHeader(); - if (headers == null) { - Log.error('Auth headers are empty. Returning'); - return; - } - - final task = UploadTask.fromFile( - taskId: 'upload_${media.mediaFile.mediaId}', - displayName: media.mediaFile.type.name, - file: media.uploadRequestPath, - url: apiUrl, - priority: 0, - retries: 10, - headers: headers, - ); - - final connectivityResult = await Connectivity().checkConnectivity(); - - if (AppState.isInBackgroundTask || - !connectivityResult.contains(ConnectivityResult.mobile) && - !connectivityResult.contains(ConnectivityResult.wifi)) { - // no internet, directly put it into the background... - await FileDownloader().enqueue(task); - await media.setUploadState(UploadState.backgroundUploadTaskStarted); - Log.info('Enqueue upload task: ${task.taskId}'); - } else { - unawaited(uploadFileFastOrEnqueue(task, media)); - } -} - -Future uploadFileFastOrEnqueue( - UploadTask task, - MediaFileService media, -) async { - if (await _checkAndRecoverMissingUploadRequest( - media, - triggerBackgroundUpload: true, - )) { - return; - } - - final requestMultipart = http.MultipartRequest( - 'POST', - Uri.parse(task.url), - ); - - requestMultipart.headers.addAll(task.headers); - - requestMultipart.files.add( - await http.MultipartFile.fromPath( - 'file', - await task.filePath(), - filename: 'upload', - ), - ); - - try { - final workmanagerUniqueName = - 'progressing_finish_uploads_${media.mediaFile.mediaId}'; - - await Workmanager().registerOneOffTask( - workmanagerUniqueName, - 'eu.twonly.processing_task', - initialDelay: const Duration(minutes: 15), - constraints: Constraints( - networkType: NetworkType.connected, - ), - ); - - Log.info('Uploading fast: ${task.taskId}'); - - final response = await requestMultipart.send(); - var status = TaskStatus.failed; - if (response.statusCode == 200) { - status = TaskStatus.complete; - } else if (response.statusCode == 404) { - status = TaskStatus.notFound; - } - - await Workmanager().cancelByUniqueName(workmanagerUniqueName); - - await handleUploadStatusUpdate( - TaskStatusUpdate( - task, - status, - null, - null, - null, - response.statusCode, - ), - ); - } catch (e) { - Log.info('Upload failed enqueuing task...'); - await FileDownloader().enqueue(task); - await media.setUploadState(UploadState.backgroundUploadTaskStarted); - } -} diff --git a/lib/src/services/api/messages.api.dart b/lib/src/services/api/messages.api.dart deleted file mode 100644 index 481524e0..00000000 --- a/lib/src/services/api/messages.api.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'dart:typed_data'; - -import 'package:twonly/core/bridge/api.dart' as rust_api; -import 'package:twonly/src/database/twonly.db.dart' show Receipt; - -Future tryToSendCompleteMessage({ - String? receiptId, - Receipt? receipt, - bool onlyReturnEncryptedData = false, - bool blocking = true, -}) async { - final id = receiptId ?? receipt?.receiptId; - if (id == null) return null; - if (!onlyReturnEncryptedData) { - await rust_api.RustApi.sendQueuedMessage(receiptId: id); - return null; - } - return rust_api.RustApi.prepareQueuedMessage(receiptId: id); -} diff --git a/lib/src/services/api/rust_api_result.dart b/lib/src/services/api/rust_api_result.dart index 67e2d673..05787e28 100644 --- a/lib/src/services/api/rust_api_result.dart +++ b/lib/src/services/api/rust_api_result.dart @@ -1,9 +1,20 @@ import 'package:twonly/src/model/error_code.dart'; -import 'package:twonly/src/services/api/utils.api.dart'; import 'package:twonly/src/utils/log.dart'; export 'package:twonly/src/model/error_code.dart'; +class Result { + Result.error(this.error) : value = null, _isSuccess = false; + Result.success(this.value) : error = null, _isSuccess = true; + + final T? value; + final E? error; + final bool _isSuccess; + + bool get isSuccess => _isSuccess; + bool get isError => !_isSuccess; +} + Future> rustApiResult(Future request) async { try { return Result.success(await request); diff --git a/lib/src/services/api/utils.api.dart b/lib/src/services/api/utils.api.dart deleted file mode 100644 index 68b7c257..00000000 --- a/lib/src/services/api/utils.api.dart +++ /dev/null @@ -1,116 +0,0 @@ -import 'dart:convert'; - -import 'package:drift/drift.dart'; -import 'package:fixnum/fixnum.dart'; -import 'package:twonly/core/bridge/wrapper/key_manager.dart'; -import 'package:twonly/locator.dart'; -import 'package:twonly/src/database/tables/mediafiles.table.dart'; -import 'package:twonly/src/database/twonly.db.dart'; -import 'package:twonly/src/model/protobuf/client/generated/messages.pbserver.dart'; -import 'package:twonly/src/utils/log.dart'; -import 'package:twonly/src/utils/misc.dart'; -import 'package:twonly/src/utils/secure_storage.dart'; - -class Result { - Result.error(this.error) : value = null, _isSuccess = false; - Result.success(this.value) : error = null, _isSuccess = true; - - final T? value; - final E? error; - final bool _isSuccess; - - bool get isSuccess => _isSuccess; - bool get isError => !_isSuccess; -} - -DateTime fromTimestamp(Int64 timeStamp) { - final date = DateTime.fromMillisecondsSinceEpoch(timeStamp.toInt()); - final now = DateTime.now(); - if (date.isAfter(now)) { - return now; - } - return date; -} - -Future handleMediaError(MediaFile media) async { - await twonlyDB.mediaFilesDao.updateMedia( - media.mediaId, - const MediaFilesCompanion( - downloadState: Value(DownloadState.reuploadRequested), - ), - ); - final messages = await twonlyDB.messagesDao.getMessagesByMediaId( - media.mediaId, - ); - if (messages.length != 1) return; - final message = messages.first; - if (message.senderId == null) return; - await RustApi.sendEncryptedContent( - contactId: message.senderId!, - content: EncryptedContent( - mediaUpdate: EncryptedContent_MediaUpdate( - type: EncryptedContent_MediaUpdate_Type.DECRYPTION_ERROR, - targetMessageId: message.messageId, - ), - ).writeToBuffer(), - ); -} - -Future importSignalContactAndCreateRequest( - FrbUserData userdata, -) async { - try { - await RustApi.establishSignalSession( - contactId: userdata.userId, - expectedPublicKey: Uint8List.fromList(userdata.publicIdentityKey), - ); - - // 2. Then send user request - await RustApi.sendEncryptedContent( - contactId: userdata.userId, - content: EncryptedContent( - contactRequest: EncryptedContent_ContactRequest( - type: EncryptedContent_ContactRequest_Type.REQUEST, - ), - ).writeToBuffer(), - ); - - return true; - } catch (e) { - Log.error('Failed to establish session and send contact request: $e'); - return false; - } -} - -Future?> getAuthenticationHeader() async { - var headers = {}; - - if (userService.currentUser.canUseLoginTokenForAuth) { - final loginToken = await RustKeyManager.getLoginToken(); - - headers = { - 'x-twonly-user-id': userService.currentUser.userId - .toRadixString(16) - .padLeft(16, '0') - .toUpperCase(), - 'x-twonly-login-token': uint8ListToHex(loginToken), - }; - } else { - final apiAuthTokenRaw = await SecureStorage.instance.read( - key: 'api_auth_token', - ); - - if (apiAuthTokenRaw == null) { - Log.error('api auth token not defined.'); - return null; - } - - final apiAuthToken = uint8ListToHex(base64Decode(apiAuthTokenRaw)); - - headers = { - 'x-twonly-auth-token': apiAuthToken, - }; - } - - return headers; -} diff --git a/lib/src/services/background/callback_dispatcher.background.dart b/lib/src/services/background/callback_dispatcher.background.dart deleted file mode 100644 index ec48649e..00000000 --- a/lib/src/services/background/callback_dispatcher.background.dart +++ /dev/null @@ -1,183 +0,0 @@ -import 'dart:async'; - -import 'package:mutex/mutex.dart'; -import 'package:sentry_flutter/sentry_flutter.dart'; -import 'package:twonly/globals.dart'; -import 'package:twonly/locator.dart'; -import 'package:twonly/main.dart'; -import 'package:twonly/src/constants/keyvalue.keys.dart'; -import 'package:twonly/src/services/api/mediafiles/upload.api.dart'; -import 'package:twonly/src/utils/exclusive_access.utils.dart'; -import 'package:twonly/src/utils/keyvalue.dart'; -import 'package:twonly/src/utils/log.dart'; -import 'package:twonly/src/utils/startup_guard.dart'; -import 'package:workmanager/workmanager.dart'; - -// ignore: unreachable_from_main -Future initializeBackgroundTaskManager() async { - await Workmanager().initialize(callbackDispatcher); - await Workmanager().cancelByUniqueName('fetch_data_from_server'); - - // await Workmanager().registerPeriodicTask( - // 'fetch_data_from_server', - // 'eu.twonly.periodic_task', - // frequency: const Duration(minutes: 20), - // initialDelay: const Duration(minutes: 5), - // existingWorkPolicy: ExistingPeriodicWorkPolicy.update, - // constraints: Constraints( - // networkType: NetworkType.connected, - // ), - // ); -} - -@pragma('vm:entry-point') -void callbackDispatcher() { - Workmanager().executeTask((task, inputData) async { - SentryWidgetsFlutterBinding.ensureInitialized(); - await AppEnvironment.init(); - switch (task) { - case 'eu.twonly.periodic_task': - // if (await initBackgroundExecution()) { - // await handlePeriodicTask(); - // } - break; - case 'eu.twonly.processing_task': - case _ when task.startsWith('progressing_finish_uploads_'): - if (await initBackgroundExecution()) { - await handleProcessingTask(); - } - default: - Log.error('Unknown task was executed: $task'); - } - return Future.value(true); - }); -} - -bool _isInitialized = false; - -Future initBackgroundExecution() async { - // 1. Check startup guard IMMEDIATELY before doing ANYTHING else. - if (await StartupGuard.isAppStarting()) { - return false; - } - - AppState.isInBackgroundTask = true; - - if (await StartupGuard.isAppStarting()) { - Log.error('App is starting. Returning early.'); - return false; - } - if (_isInitialized) { - // Reload the users, as on Android the background isolate can - // stay alive for multiple hours between task executions - return userService.tryInit(); - } - - await twonlyMinimumInitialization(); - - if (!await userService.tryInit()) { - Log.info('Early return as user is not registered yet.'); - return false; - } - - Log.info('Background task is initialized'); - - _isInitialized = true; - return true; -} - -final Mutex _keyValueMutex = Mutex(); - -// ignore: unreachable_from_main -Future backgroundFetch({ - int? lastExecutionInSecondsLimit = 120, -}) async { - if (lastExecutionInSecondsLimit != null) { - final shouldBeExecuted = await exclusiveAccess( - lockName: 'periodic_task', - mutex: _keyValueMutex, - action: () async { - final lastExecution = await KeyValueStore.get( - KeyValueKeys.lastPeriodicTaskExecution, - ); - if (lastExecution != null && lastExecution.containsKey('timestamp')) { - final lastExecutionTime = lastExecution['timestamp'] as int?; - if (lastExecutionTime != null) { - final lastExecutionDate = DateTime.fromMillisecondsSinceEpoch( - lastExecutionTime, - ); - if (DateTime.now().difference(lastExecutionDate).inSeconds < - lastExecutionInSecondsLimit) { - return false; - } - } - } - await KeyValueStore.put(KeyValueKeys.lastPeriodicTaskExecution, { - 'timestamp': DateTime.now().millisecondsSinceEpoch, - }); - return true; - }, - ); - - if (!shouldBeExecuted) return false; - } - - Log.info('Periodic task was called.'); - AppState.gotMessageFromServer = false; - - final stopwatch = Stopwatch()..start(); - - var authenticated = false; - for (var attempt = 0; attempt < 100; attempt++) { - final state = await RustApi.connectionState(); - if (state == ApiConnectionState.authenticated) { - authenticated = true; - break; - } - if (state == ApiConnectionState.permanentlyRejected || - state == ApiConnectionState.suspended) { - break; - } - await Future.delayed(const Duration(milliseconds: 100)); - } - if (!authenticated) { - Log.info('Api is not authenticated. Returning early.'); - return false; - } - - var receiveMessage = false; - - try { - while (!AppState.gotMessageFromServer) { - if (stopwatch.elapsed.inSeconds >= 15) { - Log.info('No new message from the server after 15 seconds.'); - break; - } - await Future.delayed(const Duration(milliseconds: 500)); - } - - if (AppState.gotMessageFromServer) { - receiveMessage = true; - Log.info('Received a server message from the server.'); - } - - await finishStartedPreprocessing(); - - if (lastExecutionInSecondsLimit != null) { - await Future.delayed(const Duration(milliseconds: 2000)); - } - } finally { - await RustApi.close(); - stopwatch.stop(); - } - - Log.info('Periodic task finished after ${stopwatch.elapsed}.'); - return receiveMessage; -} - -Future handleProcessingTask() async { - Log.info('eu.twonly.processing_task was called.'); - final stopwatch = Stopwatch()..start(); - await finishStartedPreprocessing(); - Log.info('eu.twonly.processing_task finished after ${stopwatch.elapsed}.'); -} diff --git a/lib/src/services/backup.service.dart b/lib/src/services/backup.service.dart index 91d4288a..9e87bea7 100644 --- a/lib/src/services/backup.service.dart +++ b/lib/src/services/backup.service.dart @@ -1,9 +1,9 @@ import 'dart:async'; import 'dart:io'; -import 'dart:typed_data'; import 'package:background_downloader/background_downloader.dart'; import 'package:clock/clock.dart' as clock; +import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:mutex/mutex.dart'; import 'package:twonly/core/bridge/wrapper/backup.dart'; @@ -12,7 +12,6 @@ import 'package:twonly/globals.dart'; import 'package:twonly/locator.dart'; import 'package:twonly/src/constants/keyvalue.keys.dart'; import 'package:twonly/src/model/json/backup.model.dart'; -import 'package:twonly/src/services/api/utils.api.dart'; import 'package:twonly/src/services/user.service.dart'; import 'package:twonly/src/utils/keyvalue.dart'; import 'package:twonly/src/utils/log.dart'; @@ -31,6 +30,42 @@ class BackupService { static final _backupUpdateController = StreamController.broadcast(); static Stream get onBackupUpdated => _backupUpdateController.stream; + static Future initFileDownloader() async { + FileDownloader().updates.listen((update) async { + switch (update) { + case TaskStatusUpdate(): + if (update.task.taskId.contains('backup_')) { + await handleBackupStatusUpdate(update.task.taskId, update); + } + case TaskProgressUpdate(): + Log.info( + 'Progress update for ${update.task} with progress ${update.progress}', + ); + } + }); + + await FileDownloader().start(); + try { + var androidConfig = []; + if (!kReleaseMode) { + androidConfig = [(Config.bypassTLSCertificateValidation, true)]; + } + await FileDownloader().configure(androidConfig: androidConfig); + } catch (error) { + Log.error(error); + } + + if (!kReleaseMode) { + FileDownloader().configureNotification( + running: const TaskNotification( + 'Uploading/Downloading', + '{filename} ({progress}).', + ), + progressBar: true, + ); + } + } + static Future getData() async { return CurrentBackupStatus.fromJson( (await KeyValueStore.get(KeyValueKeys.currentBackupState)) ?? @@ -170,9 +205,11 @@ class BackupService { 'Archive backup has a size of ${File(backupArchive).statSync().size}.', ); - final headers = await getAuthenticationHeader(); - if (headers == null) { - Log.error('Auth headers are empty. Returning'); + late final Map headers; + try { + headers = await RustApi.authenticationHeaders(); + } catch (error) { + Log.error('Could not load authentication headers', error: error); return; } diff --git a/lib/src/services/key_verification.service.dart b/lib/src/services/key_verification.service.dart index 00db9335..e81fe281 100644 --- a/lib/src/services/key_verification.service.dart +++ b/lib/src/services/key_verification.service.dart @@ -64,54 +64,20 @@ class KeyVerificationService { ); } - static Future handleVerificationProof( - int fromUserId, - List receivedMac, - ) async { - Log.info('Received a verification proof. Verifying the calculated mac...'); - - final contactPubKey = await RustSignal.getContactPublicKey( - contactId: fromUserId, - ); - if (contactPubKey == null) { - Log.error('No public key stored..'); - return; - } - - final secretTokens = await twonlyDB.keyVerificationDao - .getRecentVerificationTokens(); - for (final secretToken in secretTokens) { - final recalculatedMac = await _createVerificationBytes( - fromUserId, - contactPubKey, - secretToken.token, - true, + static Future handleVerificationSucceeded(int fromUserId) async { + final contact = await twonlyDB.contactsDao.getContactById(fromUserId); + final context = rootNavigatorKey.currentContext; + if (context != null && context.mounted && contact != null) { + unawaited( + VerificationSuccessDialog.show( + context, + contact, + message: context.lang.secretQrTokenVerifiedSnackbar( + getContactDisplayName(contact), + ), + ), ); - if (recalculatedMac.equals(receivedMac)) { - await twonlyDB.keyVerificationDao.addKeyVerification( - fromUserId, - VerificationType.secretQrToken, - ); - Log.info('Contact was verified via secretQrToken'); - - final contact = await twonlyDB.contactsDao.getContactById(fromUserId); - final context = rootNavigatorKey.currentContext; - if (context != null && context.mounted && contact != null) { - unawaited( - VerificationSuccessDialog.show( - context, - contact, - message: context.lang.secretQrTokenVerifiedSnackbar( - getContactDisplayName(contact), - ), - ), - ); - } - return; - } } - - Log.error('No valid secret token could be found...'); } static Future verifySharedContact({ @@ -119,6 +85,10 @@ class KeyVerificationService { required List sharedPublicIdentityKey, required int senderId, }) async { + if (sharedPublicIdentityKey.isEmpty) { + Log.info('Shared contact $contactId carries no public key'); + return; + } final publicIdentityKey = await RustSignal.getContactPublicKey( contactId: contactId, ); diff --git a/lib/src/services/mediafiles/compression.service.dart b/lib/src/services/mediafiles/compression.service.dart deleted file mode 100644 index f6398425..00000000 --- a/lib/src/services/mediafiles/compression.service.dart +++ /dev/null @@ -1,147 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:drift/drift.dart' show Value; -import 'package:flutter/foundation.dart'; -import 'package:flutter_image_compress/flutter_image_compress.dart'; -import 'package:pro_video_editor/pro_video_editor.dart'; -import 'package:twonly/locator.dart'; -import 'package:twonly/src/channels/video_compression.channel.dart'; -import 'package:twonly/src/database/tables/mediafiles.table.dart'; -import 'package:twonly/src/database/twonly.db.dart'; -import 'package:twonly/src/services/mediafiles/mediafile.service.dart'; -import 'package:twonly/src/utils/log.dart'; - -Future compressImage( - File sourceFile, - File destinationFile, -) async { - final stopwatch = Stopwatch()..start(); - - try { - var compressedBytes = await FlutterImageCompress.compressWithFile( - sourceFile.path, - format: CompressFormat.webp, - quality: 90, - ); - - if (compressedBytes == null) { - throw Exception( - 'Could not compress media file: Sending original file.', - ); - } - - Log.info('Compressed images size in bytes: ${compressedBytes.length}'); - - if (compressedBytes.length >= 2 * 1000 * 1000) { - // if the media file is over 1MB compress it with 60% - final tmpCompressedBytes = await FlutterImageCompress.compressWithFile( - sourceFile.path, - format: CompressFormat.webp, - quality: 60, - ); - if (tmpCompressedBytes == null) { - Log.error( - 'Could not compress media file with 60%: $sourceFile. Sending original 90% compressed file.', - ); - } else { - compressedBytes = tmpCompressedBytes; - } - } - - await destinationFile.writeAsBytes(compressedBytes); - } catch (e) { - Log.warn('$e'); - sourceFile.copySync(destinationFile.path); - } - - stopwatch.stop(); - - Log.info( - 'Compression of the image took: ${stopwatch.elapsedMilliseconds} milliseconds.', - ); -} - -Future compressAndOverlayVideo(MediaFileService media) async { - if (media.tempPath.existsSync()) { - media.tempPath.deleteSync(); - } - if (media.ffmpegOutputPath.existsSync()) { - media.ffmpegOutputPath.deleteSync(); - } - - final stopwatch = Stopwatch()..start(); - - try { - final task = VideoRenderData( - videoSegments: [ - VideoSegment(video: EditorVideo.file(media.originalPath)), - ], - imageLayers: [ - if (media.overlayImagePath.existsSync()) - ImageLayer(image: EditorLayerImage.file(media.overlayImagePath)), - ], - enableAudio: !media.removeAudio, - ); - - await ProVideoEditor.instance.renderVideoToFile( - media.ffmpegOutputPath.path, - task, - ); - - if (Platform.isIOS || - media.ffmpegOutputPath.statSync().size >= 10_000_000 || - !kReleaseMode) { - String? compressedPath; - try { - compressedPath = await VideoCompressionChannel.compressVideo( - inputPath: media.ffmpegOutputPath.path, - outputPath: media.tempPath.path, - onProgress: (progress) async { - await twonlyDB.mediaFilesDao.updateMedia( - media.mediaFile.mediaId, - MediaFilesCompanion( - preProgressingProcess: Value((progress * 100).toInt()), - ), - ); - }, - ); - } catch (e) { - Log.warn('during video compression: $e'); - } - - if (compressedPath == null) { - Log.warn('Could not compress video using original video.'); - // as a fall back use the non compressed version - media.ffmpegOutputPath.copySync(media.tempPath.path); - } - } else { - // In case the video is smaller than 10MB do not compress it... - media.ffmpegOutputPath.copySync(media.tempPath.path); - } - - stopwatch.stop(); - - final sizeFrom = (media.ffmpegOutputPath.statSync().size / 1024 / 1024) - .toStringAsFixed(2); - final sizeTo = (media.tempPath.statSync().size / 1024 / 1024) - .toStringAsFixed(2); - - Log.info( - 'It took ${stopwatch.elapsedMilliseconds}ms to compress the video. Reduced from $sizeFrom to $sizeTo bytes.', - ); - } catch (e) { - Log.error(e); - // Log.error('Compression failed for the video with exit code $returnCode.'); - // Log.error(await session.getAllLogsAsString()); - // This should not happen, but in case "notify" the user that the video was not send... This is absolutely bad, but - // better this way then sending an uncompressed media file which potentially is 100MB big :/ - // Hopefully the user will report the strange behavior <3 - await twonlyDB.messagesDao.updateMessagesByMediaId( - media.mediaFile.mediaId, - const MessagesCompanion(isDeletedFromSender: Value(true)), - ); - media.fullMediaRemoval(); - await media.setUploadState(UploadState.uploaded); - } -} diff --git a/lib/src/services/api/mediafiles/download.api.dart b/lib/src/services/mediafiles/media_download_policy.dart similarity index 85% rename from lib/src/services/api/mediafiles/download.api.dart rename to lib/src/services/mediafiles/media_download_policy.dart index eade098d..35a3ceed 100644 --- a/lib/src/services/api/mediafiles/download.api.dart +++ b/lib/src/services/mediafiles/media_download_policy.dart @@ -57,14 +57,3 @@ Future startDownloadMedia(MediaFile media, bool force) async { await RustApi.downloadMedia(mediaId: media.mediaId); } } - -Future requestMediaReupload(String mediaId) => - RustApi.requestMediaReupload(mediaId: mediaId); - -Future makeMigrationToVersion91() async { - final mediaFiles = await twonlyDB.mediaFilesDao - .getAllMediaFilesReuploadRequested(); - for (final media in mediaFiles) { - await RustApi.requestMediaReupload(mediaId: media.mediaId); - } -} diff --git a/lib/src/services/mediafiles/mediafile.service.dart b/lib/src/services/mediafiles/mediafile.service.dart index 27ed7ec8..2f37a15a 100644 --- a/lib/src/services/mediafiles/mediafile.service.dart +++ b/lib/src/services/mediafiles/mediafile.service.dart @@ -1,26 +1,32 @@ import 'dart:async'; import 'dart:io'; -import 'package:clock/clock.dart'; -import 'package:drift/drift.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter_image_compress/flutter_image_compress.dart'; -import 'package:image/image.dart' as img; import 'package:path/path.dart'; import 'package:twonly/globals.dart'; import 'package:twonly/locator.dart'; import 'package:twonly/src/database/tables/mediafiles.table.dart'; import 'package:twonly/src/database/twonly.db.dart'; -import 'package:twonly/src/services/mediafiles/compression.service.dart'; -import 'package:twonly/src/services/mediafiles/thumbnail.service.dart'; import 'package:twonly/src/services/memories/memories_cloud.service.dart' show MemoriesCloudService; import 'package:twonly/src/utils/log.dart'; -import 'package:twonly/src/utils/misc.dart'; class MediaFileService { - MediaFileService(this.mediaFile); - MediaFile mediaFile; + MediaFileService(this._mediaFile); + MediaFile _mediaFile; + + /// Built file paths, keyed by the `_buildFilePath` arguments. Resolving a + /// path is pure string work plus a directory check, but the getters below are + /// read from `build()` methods, so the result is memoised per instance. + final Map _pathCache = {}; + + MediaFile get mediaFile => _mediaFile; + + set mediaFile(MediaFile value) { + if (value.mediaId != _mediaFile.mediaId || value.type != _mediaFile.type) { + _pathCache.clear(); + } + _mediaFile = value; + } static Future fromMediaId(String mediaId) async { final mediaFile = await twonlyDB.mediaFilesDao.getMediaFileById(mediaId); @@ -30,121 +36,9 @@ class MediaFileService { ); } - static Future purgeTempFolder() async { - try { - final tempDirectory = MediaFileService.buildDirectoryPath( - 'tmp', - AppEnvironment.supportDir, - ); - - final files = tempDirectory.listSync(); - if (files.isEmpty) return; - - final mediaIdToFile = >{}; - for (final file in files) { - final mediaId = basename(file.path).split('.').first; - mediaIdToFile.putIfAbsent(mediaId, () => []).add(file); - } - - final mediaIds = mediaIdToFile.keys.toList(); - - // Bulk fetch media files and messages - final allMediaFiles = await twonlyDB.mediaFilesDao.getMediaFilesByIds( - mediaIds, - ); - final allMessages = await twonlyDB.messagesDao.getMessagesByMediaIds( - mediaIds, - ); - - final mediaFileMap = {for (final m in allMediaFiles) m.mediaId: m}; - final messageMap = >{}; - for (final msg in allMessages) { - if (msg.mediaId != null) { - messageMap.putIfAbsent(msg.mediaId!, () => []).add(msg); - } - } - - for (final mediaId in mediaIds) { - // in case the mediaID is unknown the file will be deleted - var delete = true; - - final mediaFile = mediaFileMap[mediaId]; - - if (mediaFile != null) { - if (mediaFile.isDraftMedia) { - delete = false; - } - - // Never purge temp files while an upload is still in progress. - // The temp file is actively needed for encryption/upload. - if (mediaFile.uploadState != UploadState.uploaded && - mediaFile.uploadState != UploadState.fileLimitReached) { - delete = false; - } - - final messages = messageMap[mediaId] ?? []; - - // in case messages in empty the file will be deleted, as delete is true by default - - for (final message in messages) { - if (mediaFile.type == MediaType.audio) { - delete = false; // do not delete voice messages - } - - if (message.openedAt == null) { - // Message was not yet opened from all persons, so wait... - delete = false; - } else if (message.openedAt!.isAfter( - clock.now().subtract(const Duration(minutes: 3)), - )) { - // When the message was opened in the last two minutes, do not purge. - // Bug: When the user opens an image immediately after starting the app, there is a race condition: - // The message is marked as opened, but then purgeTempFolder is run - // (it is unawaited) and deletes the file. Thi gives a grace period: - // The image must have been opened within the last two minutes, otherwise do not delete it. - delete = false; - } else if (mediaFile.requiresAuthentication || - mediaFile.displayLimitInMilliseconds != null) { - // Message was opened by all persons, and they can not reopen the image. - } else if (message.openedAt!.isAfter( - clock.now().subtract(const Duration(days: 2)), - )) { - // In case the image was opened, but send with unlimited time or no authentication. - if (message.senderId == null) { - delete = false; - } else { - // Check weather the image was send in a group. Then the images is preserved for two days in case another person stores the image. - // This also allows to reopen this image for two days. - final group = await twonlyDB.groupsDao.getGroup( - message.groupId, - ); - if (group != null && !group.isDirectChat) { - delete = false; - } - } - // In case the app was send in a direct chat, then it can be deleted. - } - } - } - - if (delete) { - Log.info('Purging media file $mediaId'); - final filesToPurge = mediaIdToFile[mediaId] ?? []; - for (final file in filesToPurge) { - try { - if (file.existsSync()) { - file.deleteSync(); - } - } catch (e) { - Log.error('Error deleting file ${file.path}: $e'); - } - } - } - } - } catch (e) { - Log.error('Error in purgeTempFolder: $e'); - } - } + /// Rust decides which temporary media a message is finished with; this only + /// triggers the sweep. + static Future purgeTempFolder() => RustApi.purgeMediaTempFolder(); Future updateFromDB() async { final updated = await twonlyDB.mediaFilesDao.getMediaFileById( @@ -156,11 +50,9 @@ class MediaFileService { } Future setDisplayLimit(int? displayLimitInMilliseconds) async { - await twonlyDB.mediaFilesDao.updateMedia( - mediaFile.mediaId, - MediaFilesCompanion( - displayLimitInMilliseconds: Value(displayLimitInMilliseconds), - ), + await RustApi.setMediaDisplayLimit( + mediaId: mediaFile.mediaId, + displayLimitInMilliseconds: displayLimitInMilliseconds, ); await updateFromDB(); } @@ -168,124 +60,32 @@ class MediaFileService { bool get removeAudio => mediaFile.removeAudio ?? false; Future toggleRemoveAudio() async { - // var removeAudio = false; - // if (mediaFile.removeAudio != null) { - // removeAudio = !mediaFile.removeAudio!; - // } - await twonlyDB.mediaFilesDao.updateMedia( - mediaFile.mediaId, - MediaFilesCompanion( - removeAudio: Value(!removeAudio), - ), - ); - await updateFromDB(); - } - - Future setUploadState(UploadState uploadState) async { - await twonlyDB.mediaFilesDao.updateMedia( - mediaFile.mediaId, - MediaFilesCompanion( - uploadState: Value(uploadState), - ), - ); - await updateFromDB(); - } - - Future setEncryptedMac(Uint8List encryptionMac) async { - await twonlyDB.mediaFilesDao.updateMedia( - mediaFile.mediaId, - MediaFilesCompanion( - encryptionMac: Value(encryptionMac), - ), - ); + await RustApi.toggleMediaRemoveAudio(mediaId: mediaFile.mediaId); await updateFromDB(); } Future setRequiresAuth(bool requiresAuthentication) async { - await twonlyDB.mediaFilesDao.updateMedia( - mediaFile.mediaId, - MediaFilesCompanion( - requiresAuthentication: Value(requiresAuthentication), - displayLimitInMilliseconds: requiresAuthentication - ? const Value(12000) - : const Value.absent(), - ), + await RustApi.setMediaRequiresAuthentication( + mediaId: mediaFile.mediaId, + requiresAuthentication: requiresAuthentication, ); await updateFromDB(); } + /// Rust renders thumbnails, including the video frame grab, so this only + /// asks it to refresh the one for this media file. Future createThumbnail() async { - if (!storedPath.existsSync() || storedPath.lengthSync() == 0) { - if (storedPath.existsSync() && storedPath.lengthSync() == 0) { - try { - storedPath.deleteSync(); - } catch (_) {} - } - if (mediaFile.stored && - mediaFile.cloudState == CloudState.none && - mediaFile.createdAt.isBefore( - clock.now().subtract(const Duration(days: 30)), - )) { - // media files does not exists any more so also delete the database entry - await twonlyDB.mediaFilesDao.deleteMediaFile(mediaFile.mediaId); - fullMediaRemoval(); - } - return; - } - var success = false; - switch (mediaFile.type) { - case MediaType.gif: - success = await createThumbnailsForGif(storedPath, thumbnailPath); - case MediaType.image: - success = await createThumbnailsForImage(storedPath, thumbnailPath); - case MediaType.video: - success = await createThumbnailsForVideo(storedPath, thumbnailPath); - case MediaType.audio: - break; - } - - if (success) { - await twonlyDB.mediaFilesDao.updateMedia( - mediaFile.mediaId, - const MediaFilesCompanion(hasThumbnail: Value(true)), - ); - await updateFromDB(); - } + await RustApi.mediaStepFinished( + mediaId: mediaFile.mediaId, + kind: 'thumbnail', + ); + await updateFromDB(); } - Future compressMedia() async { - if (!originalPath.existsSync()) { - Log.warn('Could not compress as original media does not exists.'); - return; - } - - switch (mediaFile.type) { - case MediaType.image: - await compressImage(originalPath, tempPath); - case MediaType.video: - await compressAndOverlayVideo(this); - case MediaType.audio: - case MediaType.gif: - originalPath.copySync(tempPath.path); - } - } - - void fullMediaRemoval() { - final pathsToRemove = [ - tempPath, - encryptedPath, - originalPath, - storedPath, - thumbnailPath, - uploadRequestPath, - ]; - - for (final path in pathsToRemove) { - if (path.existsSync()) { - path.deleteSync(); - } - } - } + /// Removes every file of this media item. The row is kept so a message that + /// still references it keeps rendering. + Future fullMediaRemoval() => + RustApi.removeMediaFiles(mediaId: mediaFile.mediaId); // Media was send with unlimited display limit time and without auth required // and the temp media file still exists, then the media file can be reopened again... @@ -300,79 +100,38 @@ class MediaFileService { ((mediaFile.type == MediaType.image || mediaFile.type == MediaType.gif) && mediaFile.stored); + /// Rust keeps the local copy, exports it to the gallery when the user asked + /// for that, and recomputes size, hash and thumbnail state from the file it + /// wrote. Future storeMediaFile() async { Log.info('Storing media file ${mediaFile.mediaId}'); - await twonlyDB.mediaFilesDao.updateMedia( - mediaFile.mediaId, - const MediaFilesCompanion( - stored: Value(true), - ), - ); - - if (originalPath.existsSync() && !tempPath.existsSync()) { - await compressMedia(); - } - if (tempPath.existsSync()) { - await tempPath.copy(storedPath.path); - if (userService.currentUser.storeMediaFilesInGallery) { - if (mediaFile.type == MediaType.video) { - await saveVideoToGallery( - storedPath.path, - name: mediaFile.mediaId, - ); - } else { - unawaited( - saveImageToGallery( - await storedPath.readAsBytes(), - createdAt: mediaFile.createdAt, - name: mediaFile.mediaId, - ), - ); - } - } - } else { - Log.warn( - 'Could not store image locally as ${tempPath.path} does not exist.', - ); - } - unawaited(createThumbnail()); - await calculateAndSaveSize(); - await hashMediaFile(); + await RustApi.storeMedia(mediaId: mediaFile.mediaId); + await updateFromDB(); await MemoriesCloudService().checkUploads(); - // updateFromDb is done in hashStoredMedia() } - Future calculateAndSaveSize() async { - if (storedPath.existsSync()) { - final size = storedPath.lengthSync(); - await twonlyDB.mediaFilesDao.updateMedia( - mediaFile.mediaId, - MediaFilesCompanion( - sizeInBytes: Value(size), - ), - ); - await updateFromDB(); - } - } - - Future hashMediaFile() async { - late final List checksum; - if (storedPath.existsSync()) { - checksum = await sha256File(storedPath); - } else if (tempPath.existsSync()) { - checksum = await sha256File(tempPath); - } else { - return; - } - await twonlyDB.mediaFilesDao.updateMedia( - mediaFile.mediaId, - MediaFilesCompanion( - storedFileHash: Value(Uint8List.fromList(checksum)), - ), - ); + /// Recomputes the size and content hash Rust keeps for the stored file. + Future refreshStoredMetadata() async { + await RustApi.mediaStepFinished(mediaId: mediaFile.mediaId, kind: 'stored'); await updateFromDB(); } + /// Exports this media file to the user's photo library. + Future saveToGallery() => + RustApi.saveMediaToGallery(mediaId: mediaFile.mediaId); + + /// Trimming transparent borders, and the size, hash and preview derived from + /// the result, are all Rust-owned. + Future cropTransparentBorders() async { + await RustApi.cropMediaTransparentBorders(mediaId: mediaFile.mediaId); + await updateFromDB(); + } + + /// Directories this isolate has already created. The media folders are only + /// ever created, never removed behind our back, so the `existsSync` probe + /// only has to run once per directory instead of on every path lookup. + static final Set _ensuredDirectories = {}; + static Directory buildDirectoryPath( String directory, String applicationSupportDirectory, @@ -384,8 +143,10 @@ class MediaFileService { directory, ), ); - if (!mediaBaseDir.existsSync()) { - mediaBaseDir.createSync(recursive: true); + if (_ensuredDirectories.add(mediaBaseDir.path)) { + if (!mediaBaseDir.existsSync()) { + mediaBaseDir.createSync(recursive: true); + } } return mediaBaseDir; } @@ -395,6 +156,9 @@ class MediaFileService { String namePrefix = '', String extensionParam = '', }) { + final cacheKey = '$directory|$namePrefix|$extensionParam'; + final cached = _pathCache[cacheKey]; + if (cached != null) return cached; var extension = extensionParam; if (extension == '') { switch (mediaFile.type) { @@ -412,7 +176,7 @@ class MediaFileService { directory, AppEnvironment.supportDir, ); - return File( + return _pathCache[cacheKey] = File( join(mediaBaseDir.path, '${mediaFile.mediaId}$namePrefix.$extension'), ); } @@ -436,172 +200,9 @@ class MediaFileService { 'tmp', namePrefix: '.original', ); - File get ffmpegOutputPath => _buildFilePath( - 'tmp', - namePrefix: '.ffmpeg', - ); File get overlayImagePath => _buildFilePath( 'tmp', namePrefix: '.overlay', extensionParam: 'png', ); - - Future cropTransparentBorders() async { - if (mediaFile.type != MediaType.image) { - await twonlyDB.mediaFilesDao.updateMedia( - mediaFile.mediaId, - const MediaFilesCompanion(hasCropAnalyzed: Value(true)), - ); - return; - } - - if (!storedPath.existsSync() || storedPath.lengthSync() == 0) { - await twonlyDB.mediaFilesDao.updateMedia( - mediaFile.mediaId, - const MediaFilesCompanion(hasCropAnalyzed: Value(true)), - ); - return; - } - - try { - final bytes = await storedPath.readAsBytes(); - final result = await compute(_processImageCrop, bytes); - - if (result.isCropped && result.pngBytes != null) { - try { - final webpBytes = await FlutterImageCompress.compressWithList( - result.pngBytes!, - format: CompressFormat.webp, - quality: 90, - ); - - if (webpBytes.isNotEmpty) { - await storedPath.writeAsBytes(webpBytes); - } else { - Log.warn('WebP compression returned empty, falling back to PNG'); - await storedPath.writeAsBytes(result.pngBytes!); - } - } catch (e) { - Log.error('Error compressing to WebP, falling back to PNG: $e'); - await storedPath.writeAsBytes(result.pngBytes!); - } - - if (thumbnailPath.existsSync()) { - await thumbnailPath.delete(); - } - await createThumbnail(); - final checksum = await sha256File(storedPath); - await twonlyDB.mediaFilesDao.updateMedia( - mediaFile.mediaId, - MediaFilesCompanion( - hasCropAnalyzed: const Value(true), - storedFileHash: Value(Uint8List.fromList(checksum)), - ), - ); - await updateFromDB(); - return; - } - - await twonlyDB.mediaFilesDao.updateMedia( - mediaFile.mediaId, - const MediaFilesCompanion(hasCropAnalyzed: Value(true)), - ); - await updateFromDB(); - } catch (e) { - Log.warn( - 'Error auto-cropping transparent borders for mediaId ${mediaFile.mediaId}: $e', - ); - await twonlyDB.mediaFilesDao.updateMedia( - mediaFile.mediaId, - const MediaFilesCompanion(hasCropAnalyzed: Value(true)), - ); - await updateFromDB(); - } - } -} - -class _CropResult { - const _CropResult(this.pngBytes, this.isCropped); - final Uint8List? pngBytes; - final bool isCropped; -} - -_CropResult _processImageCrop(Uint8List bytes) { - final image = img.decodeImage(bytes); - if (image == null) return const _CropResult(null, false); - - var minY = 0; - var maxY = image.height - 1; - var minX = 0; - var maxX = image.width - 1; - - var found = false; - for (var y = 0; y < image.height; y++) { - for (var x = 0; x < image.width; x++) { - if (image.getPixel(x, y).a > 10) { - minY = y; - found = true; - break; - } - } - if (found) break; - } - - found = false; - for (var y = image.height - 1; y >= minY; y--) { - for (var x = 0; x < image.width; x++) { - if (image.getPixel(x, y).a > 10) { - maxY = y; - found = true; - break; - } - } - if (found) break; - } - - found = false; - for (var x = 0; x < image.width; x++) { - for (var y = minY; y <= maxY; y++) { - if (image.getPixel(x, y).a > 10) { - minX = x; - found = true; - break; - } - } - if (found) break; - } - - found = false; - for (var x = image.width - 1; x >= minX; x--) { - for (var y = minY; y <= maxY; y++) { - if (image.getPixel(x, y).a > 10) { - maxX = x; - found = true; - break; - } - } - if (found) break; - } - - final newWidth = maxX - minX + 1; - final newHeight = maxY - minY + 1; - - if (minY > 0 || - maxY < image.height - 1 || - minX > 0 || - maxX < image.width - 1) { - if (newWidth > 10 && newHeight > 10) { - final cropped = img.copyCrop( - image, - x: minX, - y: minY, - width: newWidth, - height: newHeight, - ); - final pngBytes = img.encodePng(cropped); - return _CropResult(pngBytes, true); - } - } - - return const _CropResult(null, false); } diff --git a/lib/src/services/mediafiles/thumbnail.service.dart b/lib/src/services/mediafiles/thumbnail.service.dart deleted file mode 100644 index a27f5f5d..00000000 --- a/lib/src/services/mediafiles/thumbnail.service.dart +++ /dev/null @@ -1,217 +0,0 @@ -import 'dart:io'; -import 'dart:ui'; -import 'package:flutter/foundation.dart'; -import 'package:flutter_image_compress/flutter_image_compress.dart'; -import 'package:image/image.dart' as img; -import 'package:pro_video_editor/pro_video_editor.dart'; -import 'package:twonly/src/utils/log.dart'; - -Future createThumbnailsForVideo( - File sourceFile, - File destinationFile, -) async { - final stopwatch = Stopwatch()..start(); - - if (!sourceFile.existsSync() || sourceFile.lengthSync() == 0) { - Log.warn('Source video file does not exist or is empty.'); - try { - if (destinationFile.existsSync()) { - destinationFile.deleteSync(); - } - } catch (_) {} - return false; - } - - if (destinationFile.existsSync()) { - if (destinationFile.lengthSync() > 0) { - return true; - } else { - try { - destinationFile.deleteSync(); - } catch (_) {} - } - } - - try { - final images = await ProVideoEditor.instance.getThumbnails( - ThumbnailConfigs( - video: EditorVideo.file(sourceFile), - outputFormat: ThumbnailFormat.webp, - timestamps: const [ - Duration.zero, - ], - outputSize: const Size(272, 153), - ), - ); - - if (images.isNotEmpty && images.first.isNotEmpty) { - stopwatch.stop(); - await destinationFile.writeAsBytes(images.first); - if (destinationFile.existsSync() && destinationFile.lengthSync() > 0) { - Log.info( - 'It took ${stopwatch.elapsedMilliseconds}ms to create the video thumbnail.', - ); - return true; - } - } - } catch (e) { - Log.error('Error creating video thumbnail: $e'); - } - - Log.warn( - 'Thumbnail creation failed for the video.', - ); - try { - if (destinationFile.existsSync()) { - destinationFile.deleteSync(); - } - } catch (_) {} - return false; -} - -Future createThumbnailsForImage( - File sourceFile, - File destinationFile, -) async { - final stopwatch = Stopwatch()..start(); - - if (!sourceFile.existsSync() || sourceFile.lengthSync() == 0) { - Log.warn('Source image file does not exist or is empty.'); - try { - if (destinationFile.existsSync()) { - destinationFile.deleteSync(); - } - } catch (_) {} - return false; - } - - if (destinationFile.existsSync()) { - if (destinationFile.lengthSync() > 0) { - return true; - } else { - try { - destinationFile.deleteSync(); - } catch (_) {} - } - } - - try { - await FlutterImageCompress.compressAndGetFile( - sourceFile.absolute.path, - destinationFile.absolute.path, - minWidth: 300, - minHeight: 300, - quality: 50, - format: CompressFormat.webp, - ); - stopwatch.stop(); - - if (destinationFile.existsSync() && destinationFile.lengthSync() > 0) { - Log.info( - 'It took ${stopwatch.elapsedMilliseconds}ms to create the image thumbnail.', - ); - return true; - } else { - Log.warn('Compressed image thumbnail is empty or missing.'); - try { - if (destinationFile.existsSync()) { - destinationFile.deleteSync(); - } - } catch (_) {} - return false; - } - } catch (e) { - Log.error('Error creating image thumbnail: $e'); - try { - if (destinationFile.existsSync()) { - destinationFile.deleteSync(); - } - } catch (_) {} - return false; - } -} - -Future createThumbnailsForGif( - File sourceFile, - File destinationFile, -) async { - final stopwatch = Stopwatch()..start(); - - if (!sourceFile.existsSync() || sourceFile.lengthSync() == 0) { - Log.warn('Source GIF file does not exist or is empty.'); - try { - if (destinationFile.existsSync()) { - destinationFile.deleteSync(); - } - } catch (_) {} - return false; - } - - if (destinationFile.existsSync()) { - if (destinationFile.lengthSync() > 0) { - return true; - } else { - try { - destinationFile.deleteSync(); - } catch (_) {} - } - } - - try { - // For GIFs, we decode the first frame and save it as WebP - final bytes = await sourceFile.readAsBytes(); - final pngBytes = await compute(_processGifThumbnail, bytes); - if (pngBytes == null || pngBytes.isEmpty) { - Log.error('Could not decode GIF for thumbnail.'); - return false; - } - - final webp = await FlutterImageCompress.compressWithList( - pngBytes, - format: CompressFormat.webp, - quality: 85, - ); - if (webp.isEmpty) { - Log.error('GIF thumbnail compression returned empty.'); - return false; - } - - await destinationFile.writeAsBytes(webp); - - stopwatch.stop(); - if (destinationFile.existsSync() && destinationFile.lengthSync() > 0) { - Log.info( - 'It took ${stopwatch.elapsedMilliseconds}ms to create the GIF thumbnail.', - ); - return true; - } else { - try { - if (destinationFile.existsSync()) { - destinationFile.deleteSync(); - } - } catch (_) {} - return false; - } - } catch (e) { - Log.error('Error creating GIF thumbnail: $e'); - try { - if (destinationFile.existsSync()) { - destinationFile.deleteSync(); - } - } catch (_) {} - return false; - } -} - -Uint8List? _processGifThumbnail(Uint8List bytes) { - final image = img.decodeGif(bytes); - if (image == null) return null; - - final thumbnail = img.copyResize( - image, - width: image.width > image.height ? 400 : null, - height: image.height >= image.width ? 400 : null, - ); - - return img.encodePng(thumbnail); -} diff --git a/lib/src/services/memories/memories.service.dart b/lib/src/services/memories/memories.service.dart index 035d49e8..47791c7b 100644 --- a/lib/src/services/memories/memories.service.dart +++ b/lib/src/services/memories/memories.service.dart @@ -279,7 +279,7 @@ class MemoriesService { final mediaService = MediaFileService(mediaFile); if (mediaService.mediaFile.storedFileHash == null) { - await mediaService.hashMediaFile(); + await mediaService.refreshStoredMetadata(); } if (!mediaService.mediaFile.hasCropAnalyzed) { @@ -287,7 +287,7 @@ class MemoriesService { } if (mediaService.mediaFile.sizeInBytes == null) { - await mediaService.calculateAndSaveSize(); + await mediaService.refreshStoredMetadata(); } if (mediaService.mediaFile.blurhash == null) { diff --git a/lib/src/services/migrations.service.dart b/lib/src/services/migrations.service.dart index 7377efbc..7779bae4 100644 --- a/lib/src/services/migrations.service.dart +++ b/lib/src/services/migrations.service.dart @@ -6,7 +6,6 @@ import 'package:twonly/globals.dart'; import 'package:twonly/locator.dart'; import 'package:twonly/src/database/tables/contacts.table.dart'; import 'package:twonly/src/database/twonly.db.dart'; -import 'package:twonly/src/services/api/mediafiles/download.api.dart'; import 'package:twonly/src/services/passwordless_recovery.service.dart'; import 'package:twonly/src/services/user.service.dart'; import 'package:twonly/src/services/user_discovery.service.dart'; @@ -14,14 +13,15 @@ import 'package:twonly/src/visual/views/onboarding/setup.view.dart'; Future runMigrations() async { if (userService.currentUser.appVersion < 90) { - // BUG: Requested media files for reupload where not reuploaded because the wrong state... - await twonlyDB.mediaFilesDao.updateAllRetransmissionUploadingState(); + // BUG: Requested media files for reupload where not reuploaded because the + // wrong state. The Rust upload loop now treats `uploading` as resumable and + // recovers these on its own, so this migration only records the version. await UserService.update((u) => u.appVersion = 90); } if (userService.currentUser.appVersion < 91) { // BUG: Requested media files for reupload where not reuploaded because the wrong state... - await makeMigrationToVersion91(); + await RustApi.retryPendingMediaReuploads(); await UserService.update((u) => u.appVersion = 91); } diff --git a/lib/src/utils/avatars.dart b/lib/src/utils/avatars.dart deleted file mode 100644 index ec65ff6f..00000000 --- a/lib/src/utils/avatars.dart +++ /dev/null @@ -1,147 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'dart:ui' as ui; - -import 'package:flutter/services.dart'; -import 'package:flutter_svg/svg.dart'; -import 'package:mutex/mutex.dart'; -import 'package:twonly/globals.dart'; -import 'package:twonly/locator.dart'; -import 'package:twonly/src/utils/log.dart'; - -const defaultAvatarSvg = ''' - - - - - - - - - - - - - - - - - - - - - - - -'''; - -String getAvatarSvg(Uint8List avatarSvgCompressed) { - try { - // Check for GZIP magic bytes (0x1F 0x8B) - final isGzip = - avatarSvgCompressed.length >= 2 && - avatarSvgCompressed[0] == 0x1F && - avatarSvgCompressed[1] == 0x8B; - - final decodedBytes = isGzip - ? gzip.decode(avatarSvgCompressed) - : avatarSvgCompressed; - return utf8.decode(decodedBytes); - } catch (e) { - Log.error('Failed to decode avatar SVG: $e'); - return defaultAvatarSvg; - } -} - -Future createPushAvatars({int? forceForUserId}) async { - final contacts = await twonlyDB.contactsDao.getAllContacts(); - - for (final contact in contacts) { - try { - if (contact.avatarSvgCompressed == null) continue; - - if (forceForUserId == null) { - if (avatarPNGFile(contact.userId).existsSync()) { - continue; // only create the avatar in case no avatar exists yet fot this user - } - } else if (contact.userId != forceForUserId) { - // only update the avatar for this specified contact - continue; - } - - final avatarSvg = getAvatarSvg(contact.avatarSvgCompressed!); - - final pictureInfo = await vg.loadPicture( - SvgStringLoader(avatarSvg), - null, - ); - - final image = await pictureInfo.picture.toImage(270, 300); - - final byteData = await image.toByteData(format: ui.ImageByteFormat.png); - final pngBytes = byteData!.buffer.asUint8List(); - - await avatarPNGFile(contact.userId).writeAsBytes(pngBytes); - pictureInfo.picture.dispose(); - } catch (e) { - Log.error(e); - } - } -} - -File avatarPNGFile(int contactId) { - final avatarsDirectory = Directory( - '${AppEnvironment.cacheDir}/avatars', - ); - - if (!avatarsDirectory.existsSync()) { - avatarsDirectory.createSync(recursive: true); - } - return File('${avatarsDirectory.path}/$contactId.png'); -} - -File currentUserAvatarFile(int avatarCounter) { - final avatarsDirectory = Directory( - '${AppEnvironment.cacheDir}/avatars', - ); - - if (!avatarsDirectory.existsSync()) { - avatarsDirectory.createSync(recursive: true); - } - return File('${avatarsDirectory.path}/user_$avatarCounter.png'); -} - -final _avatarMutex = Mutex(); - -Future getUserAvatar() async { - if (userService.currentUser.avatarSvg == null) { - return null; - } - - return _avatarMutex.protect(() async { - final avatarCounter = userService.currentUser.avatarCounter; - final file = currentUserAvatarFile(avatarCounter); - if (file.existsSync()) { - return file.path; - } - - final pictureInfo = await vg.loadPicture( - SvgStringLoader(userService.currentUser.avatarSvg!), - null, - ); - - final image = await pictureInfo.picture.toImage(270, 300); - - final byteData = await image.toByteData(format: ui.ImageByteFormat.png); - final pngBytes = byteData!.buffer.asUint8List(); - - await file.writeAsBytes(pngBytes, flush: true); - pictureInfo.picture.dispose(); - - return file.path; - }); -} diff --git a/lib/src/utils/misc.dart b/lib/src/utils/misc.dart index 1be9c41d..ab0f0c79 100644 --- a/lib/src/utils/misc.dart +++ b/lib/src/utils/misc.dart @@ -1,5 +1,4 @@ import 'dart:io'; -import 'dart:isolate'; import 'dart:math'; import 'package:clock/clock.dart'; @@ -7,11 +6,8 @@ import 'package:convert/convert.dart'; import 'package:crypto/crypto.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:gal/gal.dart'; -import 'package:image/image.dart' as img; import 'package:intl/intl.dart'; import 'package:local_auth/local_auth.dart'; -import 'package:path/path.dart'; import 'package:provider/provider.dart'; import 'package:twonly/src/localization/generated/app_localizations.dart'; import 'package:twonly/src/model/error_code.dart'; @@ -35,99 +31,6 @@ extension ShortCutsExtension on BuildContext { } } -Future saveImageToGallery( - Uint8List imageBytes, { - DateTime? createdAt, - String? name, -}) async { - var bytesToProcess = imageBytes; - - if (createdAt != null) { - try { - bytesToProcess = await Isolate.run(() { - final image = img.decodeImage(imageBytes); - if (image != null) { - final formattedDate = DateFormat( - 'yyyy:MM:dd HH:mm:ss', - ).format(createdAt); - image.exif.imageIfd[0x0132] = img.IfdValueAscii( - formattedDate, - ); // DateTime - image.exif.exifIfd[0x9003] = img.IfdValueAscii( - formattedDate, - ); // DateTimeOriginal - image.exif.exifIfd[0x9004] = img.IfdValueAscii( - formattedDate, - ); // DateTimeDigitized - - return img.encodeJpg(image); - } - return imageBytes; - }); - } catch (e) { - Log.error(e); - } - } - - final hasAccess = await Gal.hasAccess(toAlbum: true); - if (!hasAccess) { - await Gal.requestAccess(toAlbum: true); - } - try { - await Gal.putImageBytes( - bytesToProcess, - album: 'twonly', - name: name ?? 'image', - ); - return null; - } on GalException catch (e) { - Log.error(e); - return e.type.message; - } -} - -Future saveVideoToGallery( - String videoPath, { - String? name, -}) async { - final hasAccess = await Gal.hasAccess(toAlbum: true); - if (!hasAccess) { - await Gal.requestAccess(toAlbum: true); - } - - var pathToSave = videoPath; - File? tempFile; - - try { - if (name != null) { - final file = File(videoPath); - final extension = file.path.split('.').last; - final tempDir = Directory.systemTemp; - tempFile = File(join(tempDir.path, '$name.$extension')); - if (tempFile.existsSync()) { - try { - tempFile.deleteSync(); - } catch (_) {} - } - file.copySync(tempFile.path); - pathToSave = tempFile.path; - } - await Gal.putVideo(pathToSave, album: 'twonly'); - return null; - } on GalException catch (e) { - Log.error(e); - return e.type.message; - } finally { - if (tempFile != null && tempFile.existsSync()) { - try { - tempFile.deleteSync(); - } catch (e) { - Log.error('Failed to delete temp video file: $e'); - } - } - } -} - Uint8List getRandomUint8List(int length) { final random = Random.secure(); final randomBytes = Uint8List(length); diff --git a/lib/src/utils/qr.utils.dart b/lib/src/utils/qr.utils.dart index d64f9c4a..04e5738b 100644 --- a/lib/src/utils/qr.utils.dart +++ b/lib/src/utils/qr.utils.dart @@ -10,7 +10,6 @@ import 'package:twonly/core/bridge/wrapper/signal.dart'; import 'package:twonly/locator.dart'; import 'package:twonly/src/database/tables/contacts.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/model/protobuf/client/generated/qr.pb.dart'; import 'package:twonly/src/services/key_verification.service.dart'; import 'package:twonly/src/utils/log.dart'; @@ -116,19 +115,11 @@ class QrCodeUtils { Future addNewContactFromPublicProfile(PublicProfile profile) async { try { - await RustApi.establishSignalSession( - contactId: profile.userId.toInt(), - expectedPublicKey: Uint8List.fromList(profile.publicIdentityKey), - ); - await RustApi.sendEncryptedContent( - contactId: profile.userId.toInt(), - content: EncryptedContent( - contactRequest: EncryptedContent_ContactRequest( - type: EncryptedContent_ContactRequest_Type.REQUEST, - ), - ).writeToBuffer(), - ); - + // The contact row has to exist before the session is established: Rust + // marks the contact as `v2` while processing the prekey bundle, and the + // contact request is queued against this row. Adding the contact + // afterwards would leave it on the `v1` default, which makes every + // message to it refetch a prekey bundle first. final added = await twonlyDB.contactsDao.insertOnConflictUpdate( ContactsCompanion( username: Value(profile.username), @@ -139,6 +130,13 @@ Future addNewContactFromPublicProfile(PublicProfile profile) async { ), ); + if (!await RustApi.tryRequestContactById( + contactId: profile.userId.toInt(), + expectedPublicKey: Uint8List.fromList(profile.publicIdentityKey), + )) { + return false; + } + if (added > 0) { // The user was added via the profile scanned from the QR code so the scanned public key was used. await twonlyDB.keyVerificationDao.addKeyVerification( diff --git a/lib/src/visual/components/animate_icon.comp.dart b/lib/src/visual/components/animate_icon.comp.dart index afe42cb6..f18caef9 100644 --- a/lib/src/visual/components/animate_icon.comp.dart +++ b/lib/src/visual/components/animate_icon.comp.dart @@ -8,16 +8,32 @@ final RegExp _emojiRegex = RegExp( r'[#*0-9]\uFE0F?\u20E3|[\xA9\xAE\u203C\u2049\u2122\u2139\u2194-\u2199\u21A9\u21AA\u231A\u231B\u2328\u23CF\u23ED-\u23EF\u23F1\u23F2\u23F8-\u23FA\u24C2\u25AA\u25AB\u25B6\u25C0\u25FB\u25FC\u25FE\u2600-\u2604\u260E\u2611\u2614\u2615\u2618\u2620\u2622\u2623\u2626\u262A\u262E\u262F\u2638-\u263A\u2640\u2642\u2648-\u2653\u265F\u2660\u2663\u2665\u2666\u2668\u267B\u267E\u267F\u2692\u2694-\u2697\u2699\u269B\u269C\u26A0\u26A7\u26AA\u26B0\u26B1\u26BD\u26BE\u26C4\u26C8\u26CF\u26D1\u26D3\u26E9\u26F0-\u26F5\u26F7\u26F8\u26FA\u2702\u2708\u2709\u270F\u2712\u2714\u2716\u271D\u2721\u2733\u2734\u2744\u2747\u2757\u2763\u27A1\u2934\u2935\u2B05-\u2B07\u2B1B\u2B1C\u2B55\u3030\u303D\u3297\u3299]\uFE0F?|[\u261D\u270C\u270D](?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?|[\u270A\u270B](?:\uD83C[\uDFFB-\uDFFF])?|[\u23E9-\u23EC\u23F0\u23F3\u25FD\u2693\u26A1\u26AB\u26C5\u26CE\u26D4\u26EA\u26FD\u2705\u2728\u274C\u274E\u2753-\u2755\u2795-\u2797\u27B0\u27BF\u2B50]|\u26F9(?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|\u2764\uFE0F?(?:\u200D(?:\uD83D\uDD25|\uD83E\uDE79))?|\uD83C(?:[\uDC04\uDD70\uDD71\uDD7E\uDD7F\uDE02\uDE37\uDF21\uDF24-\uDF2C\uDF36\uDF7D\uDF96\uDF97\uDF99-\uDF9B\uDF9E\uDF9F\uDFCD\uDFCE\uDFD4-\uDFDF\uDFF5\uDFF7]\uFE0F?|[\uDF85\uDFC2\uDFC7](?:\uD83C[\uDFFB-\uDFFF])?|[\uDFC3\uDFC4\uDFCA](?:\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDFCB\uDFCC](?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDCCF\uDD8E\uDD91-\uDD9A\uDE01\uDE1A\uDE2F\uDE32-\uDE36\uDE38-\uDE3A\uDE50\uDE51\uDF00-\uDF20\uDF2D-\uDF35\uDF37-\uDF7C\uDF7E-\uDF84\uDF86-\uDF93\uDFA0-\uDFC1\uDFC5\uDFC6\uDFC8\uDFC9\uDFCF-\uDFD3\uDFE0-\uDFF0\uDFF8-\uDFFF]|\uDDE6\uD83C[\uDDE8-\uDDEC\uDDEE\uDDF1\uDDF2\uDDF4\uDDF6-\uDDFA\uDDFC\uDDFD\uDDFF]|\uDDE7\uD83C[\uDDE6\uDDE7\uDDE9-\uDDEF\uDDF1-\uDDF4\uDDF6-\uDDF9\uDDFB\uDDFC\uDDFE\uDDFF]|\uDDE8\uD83C[\uDDE6\uDDE8\uDDE9\uDDEB-\uDDEE\uDDF0-\uDDF5\uDDF7\uDDFA-\uDDFF]|\uDDE9\uD83C[\uDDEA\uDDEC\uDDEF\uDDF0\uDDF2\uDDF4\uDDFF]|\uDDEA\uD83C[\uDDE6\uDDE8\uDDEA\uDDEC\uDDED\uDDF7-\uDDFA]|\uDDEB\uD83C[\uDDEE-\uDDF0\uDDF2\uDDF4\uDDF7]|\uDDEC\uD83C[\uDDE6\uDDE7\uDDE9-\uDDEE\uDDF1-\uDDF3\uDDF5-\uDDFA\uDDFC\uDDFE]|\uDDED\uD83C[\uDDF0\uDDF2\uDDF3\uDDF7\uDDF9\uDDFA]|\uDDEE\uD83C[\uDDE8-\uDDEA\uDDF1-\uDDF4\uDDF6-\uDDF9]|\uDDEF\uD83C[\uDDEA\uDDF2\uDDF4\uDDF5]|\uDDF0\uD83C[\uDDEA\uDDEC-\uDDEE\uDDF2\uDDF3\uDDF5\uDDF7\uDDFC\uDDFE\uDDFF]|\uDDF1\uD83C[\uDDE6-\uDDE8\uDDEE\uDDF0\uDDF7-\uDDFB\uDDFE]|\uDDF2\uD83C[\uDDE6\uDDE8-\uDDED\uDDF0-\uDDFF]|\uDDF3\uD83C[\uDDE6\uDDE8\uDDEA-\uDDEC\uDDEE\uDDF1\uDDF4\uDDF5\uDDF7\uDDFA\uDDFF]|\uDDF4\uD83C\uDDF2|\uDDF5\uD83C[\uDDE6\uDDEA-\uDDED\uDDF0-\uDDF3\uDDF7-\uDDF9\uDDFC\uDDFE]|\uDDF6\uD83C\uDDE6|\uDDF7\uD83C[\uDDEA\uDDF4\uDDF8\uDDFA\uDDFC]|\uDDF8\uD83C[\uDDE6-\uDDEA\uDDEC-\uDDF4\uDDF7-\uDDF9\uDDFB\uDDFD-\uDDFF]|\uDDF9\uD83C[\uDDE6\uDDE8\uDDE9\uDDEB-\uDDED\uDDEF-\uDDF4\uDDF7\uDDF9\uDDFB\uDDFC\uDDFF]|\uDDFA\uD83C[\uDDE6\uDDEC\uDDF2\uDDF3\uDDF8\uDDFE\uDDFF]|\uDDFB\uD83C[\uDDE6\uDDE8\uDDEA\uDDEC\uDDEE\uDDF3\uDDFA]|\uDDFC\uD83C[\uDDEB\uDDF8]|\uDDFD\uD83C\uDDF0|\uDDFE\uD83C[\uDDEA\uDDF9]|\uDDFF\uD83C[\uDDE6\uDDF2\uDDFC]|\uDFF3\uFE0F?(?:\u200D(?:\u26A7\uFE0F?|\uD83C\uDF08))?|\uDFF4(?:\u200D\u2620\uFE0F?|\uDB40\uDC67\uDB40\uDC62\uDB40(?:\uDC65\uDB40\uDC6E\uDB40\uDC67|\uDC73\uDB40\uDC63\uDB40\uDC74|\uDC77\uDB40\uDC6C\uDB40\uDC73)\uDB40\uDC7F)?)|\uD83D(?:[\uDC08\uDC26](?:\u200D\u2B1B)?|[\uDC3F\uDCFD\uDD49\uDD4A\uDD6F\uDD70\uDD73\uDD76-\uDD79\uDD87\uDD8A-\uDD8D\uDDA5\uDDA8\uDDB1\uDDB2\uDDBC\uDDC2-\uDDC4\uDDD1-\uDDD3\uDDDC-\uDDDE\uDDE1\uDDE3\uDDE8\uDDEF\uDDF3\uDDFA\uDECB\uDECD-\uDECF\uDEE0-\uDEE5\uDEE9\uDEF0\uDEF3]\uFE0F?|[\uDC42\uDC43\uDC46-\uDC50\uDC66\uDC67\uDC6B-\uDC6D\uDC72\uDC74-\uDC76\uDC78\uDC7C\uDC83\uDC85\uDC8F\uDC91\uDCAA\uDD7A\uDD95\uDD96\uDE4C\uDE4F\uDEC0\uDECC](?:\uD83C[\uDFFB-\uDFFF])?|[\uDC6E\uDC70\uDC71\uDC73\uDC77\uDC81\uDC82\uDC86\uDC87\uDE45-\uDE47\uDE4B\uDE4D\uDE4E\uDEA3\uDEB4-\uDEB6](?:\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDD74\uDD90](?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?|[\uDC00-\uDC07\uDC09-\uDC14\uDC16-\uDC25\uDC27-\uDC3A\uDC3C-\uDC3E\uDC40\uDC44\uDC45\uDC51-\uDC65\uDC6A\uDC79-\uDC7B\uDC7D-\uDC80\uDC84\uDC88-\uDC8E\uDC90\uDC92-\uDCA9\uDCAB-\uDCFC\uDCFF-\uDD3D\uDD4B-\uDD4E\uDD50-\uDD67\uDDA4\uDDFB-\uDE2D\uDE2F-\uDE34\uDE37-\uDE44\uDE48-\uDE4A\uDE80-\uDEA2\uDEA4-\uDEB3\uDEB7-\uDEBF\uDEC1-\uDEC5\uDED0-\uDED2\uDED5-\uDED7\uDEDC-\uDEDF\uDEEB\uDEEC\uDEF4-\uDEFC\uDFE0-\uDFEB\uDFF0]|\uDC15(?:\u200D\uD83E\uDDBA)?|\uDC3B(?:\u200D\u2744\uFE0F?)?|\uDC41\uFE0F?(?:\u200D\uD83D\uDDE8\uFE0F?)?|\uDC68(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D(?:[\uDC68\uDC69]\u200D\uD83D(?:\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?)|[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?)|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C(?:\uDFFB(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFC-\uDFFF])))?|\uDFFC(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFB\uDFFD-\uDFFF])))?|\uDFFD(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF])))?|\uDFFE(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFB-\uDFFD\uDFFF])))?|\uDFFF(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFB-\uDFFE])))?))?|\uDC69(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?[\uDC68\uDC69]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D(?:[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?|\uDC69\u200D\uD83D(?:\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?))|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C(?:\uDFFB(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFC-\uDFFF])))?|\uDFFC(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFB\uDFFD-\uDFFF])))?|\uDFFD(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF])))?|\uDFFE(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFB-\uDFFD\uDFFF])))?|\uDFFF(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFB-\uDFFE])))?))?|\uDC6F(?:\u200D[\u2640\u2642]\uFE0F?)?|\uDD75(?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|\uDE2E(?:\u200D\uD83D\uDCA8)?|\uDE35(?:\u200D\uD83D\uDCAB)?|\uDE36(?:\u200D\uD83C\uDF2B\uFE0F?)?)|\uD83E(?:[\uDD0C\uDD0F\uDD18-\uDD1F\uDD30-\uDD34\uDD36\uDD77\uDDB5\uDDB6\uDDBB\uDDD2\uDDD3\uDDD5\uDEC3-\uDEC5\uDEF0\uDEF2-\uDEF8](?:\uD83C[\uDFFB-\uDFFF])?|[\uDD26\uDD35\uDD37-\uDD39\uDD3D\uDD3E\uDDB8\uDDB9\uDDCD-\uDDCF\uDDD4\uDDD6-\uDDDD](?:\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDDDE\uDDDF](?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDD0D\uDD0E\uDD10-\uDD17\uDD20-\uDD25\uDD27-\uDD2F\uDD3A\uDD3F-\uDD45\uDD47-\uDD76\uDD78-\uDDB4\uDDB7\uDDBA\uDDBC-\uDDCC\uDDD0\uDDE0-\uDDFF\uDE70-\uDE7C\uDE80-\uDE88\uDE90-\uDEBD\uDEBF-\uDEC2\uDECE-\uDEDB\uDEE0-\uDEE8]|\uDD3C(?:\u200D[\u2640\u2642]\uFE0F?|\uD83C[\uDFFB-\uDFFF])?|\uDDD1(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1))|\uD83C(?:\uDFFB(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFC-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?|\uDFFC(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFB\uDFFD-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?|\uDFFD(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?|\uDFFE(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFB-\uDFFD\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?|\uDFFF(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFB-\uDFFE]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?))?|\uDEF1(?:\uD83C(?:\uDFFB(?:\u200D\uD83E\uDEF2\uD83C[\uDFFC-\uDFFF])?|\uDFFC(?:\u200D\uD83E\uDEF2\uD83C[\uDFFB\uDFFD-\uDFFF])?|\uDFFD(?:\u200D\uD83E\uDEF2\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF])?|\uDFFE(?:\u200D\uD83E\uDEF2\uD83C[\uDFFB-\uDFFD\uDFFF])?|\uDFFF(?:\u200D\uD83E\uDEF2\uD83C[\uDFFB-\uDFFE])?))?)', ); +/// Message layout asks whether a body is a single emoji several times per +/// bubble per rebuild, and the regex above is large. Results are memoised, with +/// a bounded map so a long conversation cannot grow it without limit. +const _isOneEmojiCacheLimit = 500; +final Map _isOneEmojiCache = {}; + bool isOneEmoji(String character) { if (EmojiAnimationComp.animatedIcons.containsKey(character)) { return true; } + + final cached = _isOneEmojiCache[character]; + if (cached != null) return cached; + + var result = false; final matches = _emojiRegex.allMatches(character); if (matches.length == 1) { final match = matches.first; - return match.start == 0 && match.end == character.length; + result = match.start == 0 && match.end == character.length; } - return false; + + if (_isOneEmojiCache.length >= _isOneEmojiCacheLimit) { + _isOneEmojiCache.remove(_isOneEmojiCache.keys.first); + } + _isOneEmojiCache[character] = result; + return result; } class EmojiAnimationComp extends StatelessWidget { diff --git a/lib/src/visual/components/avatar_icon.comp.dart b/lib/src/visual/components/avatar_icon.comp.dart index 4ed70896..ca810948 100644 --- a/lib/src/visual/components/avatar_icon.comp.dart +++ b/lib/src/visual/components/avatar_icon.comp.dart @@ -1,11 +1,12 @@ import 'dart:async'; import 'dart:io'; +import 'package:flutter/foundation.dart' show listEquals, setEquals; import 'package:flutter/material.dart'; import 'package:flutter_svg/svg.dart'; import 'package:twonly/locator.dart'; import 'package:twonly/src/database/twonly.db.dart'; -import 'package:twonly/src/utils/avatars.dart'; +import 'package:twonly/src/utils/log.dart'; import 'package:vector_graphics/vector_graphics.dart'; class AvatarIcon extends StatefulWidget { @@ -31,6 +32,51 @@ class AvatarIcon extends StatefulWidget { State createState() => _AvatarIconState(); } +/// Avatars are only ever drawn from PNG. Rasterising the stored SVG on the UI +/// thread is what made long chat lists stutter, so a missing PNG is rendered +/// once by Rust instead of being drawn as vector graphics every frame. +/// Keyed by contact plus profile counter, so a changed avatar naturally lands +/// on a fresh key. +final Map _avatarPngPathCache = {}; + +/// Avatar paths already known to exist on disk. Only positive results are +/// cached: a PNG can still appear after a miss (it is written when the avatar +/// finishes downloading, or by the render below), so a miss stays re-checkable. +final Set _existingAvatarPngPaths = {}; + +/// Contacts whose PNG render is already in flight, so a list that shows the +/// same contact in several rows asks Rust to render it only once. +final Map> _pendingAvatarRenders = {}; + +/// Avatars Rust could not turn into a PNG, keyed by contact plus profile +/// counter so a broken SVG is not re-rendered on every stream tick while a +/// later avatar still gets its own attempt. +final Set _unrenderableAvatars = {}; + +const _avatarCacheLimit = 200; + +void _putBounded(Map cache, String key, T value) { + if (cache.length >= _avatarCacheLimit) { + cache.remove(cache.keys.first); + } + cache[key] = value; +} + +String _avatarCacheKey(Contact contact) => + '${contact.userId}:${contact.senderProfileCounter}'; + +String _avatarPngPathFor(Contact contact) { + final key = _avatarCacheKey(contact); + final cached = _avatarPngPathCache[key]; + if (cached != null) return cached; + final path = RustApi.avatarPngPath( + contactId: contact.userId, + profileCounter: contact.senderProfileCounter, + ); + _putBounded(_avatarPngPathCache, key, path); + return path; +} + class _AvatarIconState extends State { List _avatarContacts = []; Set _contactsWithPngAvatar = {}; @@ -50,7 +96,10 @@ class _AvatarIconState extends State { @override void didUpdateWidget(AvatarIcon oldWidget) { super.didUpdateWidget(oldWidget); - if (widget.contacts != null && widget.contacts != oldWidget.contacts) { + // Compare by value: callers commonly build a fresh list on every rebuild, + // and an identity check would restart the avatar probe every frame. + if (widget.contacts != null && + !listEquals(widget.contacts, oldWidget.contacts)) { _setAvatarContacts(widget.contacts!); } } @@ -66,17 +115,62 @@ class _AvatarIconState extends State { Future _refreshAvatarFiles() async { final available = {}; for (final contact in _avatarContacts) { - final file = avatarPNGFile(contact.userId); - // Async file access keeps avatar discovery off the UI thread. - // ignore: avoid_slow_async_io - if (await file.exists()) { + final path = _avatarPngPathFor(contact); + var exists = _existingAvatarPngPaths.contains(path); + if (!exists) { + // Async file access keeps avatar discovery off the UI thread. + // ignore: avoid_slow_async_io + exists = await File(path).exists(); + if (exists) _existingAvatarPngPaths.add(path); + } + if (exists) { available.add(contact.userId); + } else if (!_unrenderableAvatars.contains(_avatarCacheKey(contact))) { + // No PNG yet: render one rather than falling back to the SVG. The + // default avatar is shown until it lands. + unawaited(_renderAvatarPng(contact)); } } if (!mounted) return; + if (setEquals(_contactsWithPngAvatar, available)) return; setState(() => _contactsWithPngAvatar = available); } + Future _renderAvatarPng(Contact contact) async { + final contactId = contact.userId; + // Every widget showing this contact awaits the same render, so each one + // still learns the path while Rust rasterises it only once. + var render = _pendingAvatarRenders[contactId]; + if (render == null) { + // Block body on purpose: returning `remove`'s value would hand + // `whenComplete` the future it is completing and hang it. + render = RustApi.ensureAvatarPng(contactId: contactId).whenComplete(() { + _pendingAvatarRenders.remove(contactId); + }); + _pendingAvatarRenders[contactId] = render; + } + + String? path; + try { + path = await render; + } catch (e) { + Log.error('Failed to render avatar for $contactId: $e'); + _unrenderableAvatars.add(_avatarCacheKey(contact)); + return; + } + if (path == null) { + _unrenderableAvatars.add(_avatarCacheKey(contact)); + return; + } + _existingAvatarPngPaths.add(path); + if (!mounted) return; + if (_contactsWithPngAvatar.contains(contactId)) return; + if (!_avatarContacts.any((entry) => entry.userId == contactId)) return; + setState(() { + _contactsWithPngAvatar = {..._contactsWithPngAvatar, contactId}; + }); + } + @override void dispose() { groupStream?.cancel(); @@ -93,20 +187,14 @@ class _AvatarIconState extends State { } Widget getAvatarForContact(Contact contact) { - final avatarFile = avatarPNGFile(contact.userId); if (_contactsWithPngAvatar.contains(contact.userId)) { return Image.file( - avatarFile, - errorBuilder: errorBuilder, - ); - } - - if (contact.avatarSvgCompressed != null) { - return SvgPicture.string( - getAvatarSvg(contact.avatarSvgCompressed!), + File(_avatarPngPathFor(contact)), errorBuilder: errorBuilder, ); } + // Deliberately no SVG fallback: the render kicked off by + // `_refreshAvatarFiles` swaps this placeholder for the PNG when it is done. return errorBuilder(null, null, null); } @@ -163,7 +251,7 @@ class _AvatarIconState extends State { return; } - final path = await getUserAvatar(); + final path = await RustApi.currentUserAvatarPath(); if (mounted) { setState(() { @@ -178,12 +266,7 @@ class _AvatarIconState extends State { Widget avatars = Container(); - if (widget.svg != null) { - avatars = SvgPicture.string( - widget.svg!, - errorBuilder: errorBuilder, - ); - } else if (widget.myAvatar) { + if (widget.myAvatar) { if (_myAvatarPath != null) { avatars = Image.file( File(_myAvatarPath!), @@ -234,6 +317,14 @@ class _AvatarIconState extends State { ], ); } + } else if (widget.svg != null) { + // Last resort for callers with no contact behind the avatar (the + // passwordless recovery flow renders friends straight from a payload). + // Anything backed by a contact has already been served as PNG above. + avatars = SvgPicture.string( + widget.svg!, + errorBuilder: errorBuilder, + ); } else { avatars = const SvgPicture( AssetBytesLoader('assets/images/default_avatar.svg.vec'), diff --git a/lib/src/visual/components/profile_qr_code.comp.dart b/lib/src/visual/components/profile_qr_code.comp.dart index 970c3475..e8dc3af6 100644 --- a/lib/src/visual/components/profile_qr_code.comp.dart +++ b/lib/src/visual/components/profile_qr_code.comp.dart @@ -3,10 +3,10 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:qr_flutter/qr_flutter.dart'; +import 'package:twonly/locator.dart'; import 'package:twonly/src/database/daos/contacts.dao.dart'; import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/services/key_verification.service.dart'; -import 'package:twonly/src/utils/avatars.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/qr.utils.dart'; @@ -114,7 +114,7 @@ class _ProfileQrCodeCompState extends State { final qr = await QrCodeUtils.publicProfileLink(); Uint8List? avatarBytes; if (widget.showAvatar) { - final avatarPath = await getUserAvatar(); + final avatarPath = await RustApi.currentUserAvatarPath(); if (avatarPath != null) { avatarBytes = await File(avatarPath).readAsBytes(); } else { diff --git a/lib/src/visual/context_menu/context_menu.helper.dart b/lib/src/visual/context_menu/context_menu.helper.dart index 9ba912bc..07b3c7e0 100644 --- a/lib/src/visual/context_menu/context_menu.helper.dart +++ b/lib/src/visual/context_menu/context_menu.helper.dart @@ -13,7 +13,10 @@ class ContextMenu extends StatefulWidget { super.key, }); - final List items; + /// Built on long press rather than eagerly: a context menu exists for every + /// row in a list, and assembling the items can be costly (localized titles, + /// closures, and in the message menu a filesystem probe). + final List Function() items; final Widget child; final double? minWidth; @@ -29,15 +32,12 @@ class _ContextMenuState extends State @override void initState() { super.initState(); - _controller = - AnimationController( - vsync: this, - lowerBound: double.negativeInfinity, - upperBound: double.infinity, - value: 0, - )..addListener(() { - setState(() {}); - }); + _controller = AnimationController( + vsync: this, + lowerBound: double.negativeInfinity, + upperBound: double.infinity, + value: 0, + ); } @override @@ -117,7 +117,7 @@ class _ContextMenuState extends State curve: Curves.fastOutSlowIn, ), items: >[ - ...widget.items.map( + ...widget.items().map( (item) { Widget child = ListTile( title: Text(item.title), @@ -149,15 +149,20 @@ class _ContextMenuState extends State @override Widget build(BuildContext context) { - final scale = 1.0 - (_controller.value * 0.02); return GestureDetector( onLongPress: _showCustomMenu, onTapDown: _onTapDown, onTapUp: _onTapUp, onTapCancel: _onTapCancel, - child: Transform.scale( - scale: scale, + // AnimatedBuilder keeps the press animation from rebuilding the wrapped + // row; only the Transform is re-evaluated per tick. + child: AnimatedBuilder( + animation: _controller, child: widget.child, + builder: (context, child) => Transform.scale( + scale: 1.0 - (_controller.value * 0.02), + child: child, + ), ), ); } diff --git a/lib/src/visual/context_menu/group.context_menu.dart b/lib/src/visual/context_menu/group.context_menu.dart index 8c6094b6..0aa69a29 100644 --- a/lib/src/visual/context_menu/group.context_menu.dart +++ b/lib/src/visual/context_menu/group.context_menu.dart @@ -20,9 +20,11 @@ class GroupContextMenu extends StatelessWidget { @override Widget build(BuildContext context) { - final navigator = Navigator.of(context); + // `late` so the ancestor lookup only runs if the menu is actually + // opened, rather than once per row on every rebuild. + late final navigator = Navigator.of(context); return ContextMenu( - items: [ + items: () => [ if (!group.archived) ContextMenuItem( title: context.lang.contextMenuArchiveUser, diff --git a/lib/src/visual/context_menu/user.context_menu.dart b/lib/src/visual/context_menu/user.context_menu.dart index 453c0306..82ae43cb 100644 --- a/lib/src/visual/context_menu/user.context_menu.dart +++ b/lib/src/visual/context_menu/user.context_menu.dart @@ -17,10 +17,12 @@ class UserContextMenu extends StatelessWidget { @override Widget build(BuildContext context) { - final navigator = Navigator.of(context); + // `late` so the ancestor lookup only runs if the menu is actually + // opened, rather than once per row on every rebuild. + late final navigator = Navigator.of(context); return ContextMenu( minWidth: 150, - items: [ + items: () => [ ContextMenuItem( title: context.lang.contextMenuUserProfile, onTap: () => diff --git a/lib/src/visual/elements/better_text.element.dart b/lib/src/visual/elements/better_text.element.dart index 3a1647ac..80287ba4 100644 --- a/lib/src/visual/elements/better_text.element.dart +++ b/lib/src/visual/elements/better_text.element.dart @@ -4,21 +4,70 @@ import 'package:twonly/src/utils/log.dart'; import 'package:url_launcher/url_launcher.dart'; -class BetterText extends StatelessWidget { +// Regular expression to find URLs and domains. +final _urlRegExp = RegExp( + r'''(?:(?:https?://|www\.)(?:[^\s()<>]+|\([^\s()<>]+\))+(?:\([^\s()<>]+\)|[^\s`!()\[\]{};:'".,<>?«»“”‘’]))|(?:(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,})''', + caseSensitive: false, +); + +Future _openUrl(String url) async { + final lUrl = Uri.parse(url.startsWith('http') ? url : 'http://$url'); + try { + await launchUrl(lUrl, mode: LaunchMode.externalApplication); + } catch (e) { + Log.error('Could not launch $e'); + } +} + +class BetterText extends StatefulWidget { const BetterText({required this.text, required this.textColor, super.key}); final String text; final Color textColor; @override - Widget build(BuildContext context) { - // Regular expression to find URLs and domains - final urlRegExp = RegExp( - r'''(?:(?:https?://|www\.)(?:[^\s()<>]+|\([^\s()<>]+\))+(?:\([^\s()<>]+\)|[^\s`!()\[\]{};:'".,<>?«»“”‘’]))|(?:(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,})''', - caseSensitive: false, - ); + State createState() => _BetterTextState(); +} +class _BetterTextState extends State { + /// Link detection only depends on the message text, so the spans and their + /// gesture recognizers are built once instead of on every rebuild. The + /// recognizers also need disposing, which the previous per-build version + /// never did. + late List _spans; + final List _recognizers = []; + + @override + void initState() { + super.initState(); + _buildSpans(); + } + + @override + void didUpdateWidget(BetterText oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.text != widget.text) { + _disposeRecognizers(); + _buildSpans(); + } + } + + @override + void dispose() { + _disposeRecognizers(); + super.dispose(); + } + + void _disposeRecognizers() { + for (final recognizer in _recognizers) { + recognizer.dispose(); + } + _recognizers.clear(); + } + + void _buildSpans() { + final text = widget.text; final spans = []; - final matches = urlRegExp.allMatches(text); + final matches = _urlRegExp.allMatches(text); var lastMatchEnd = 0; @@ -27,7 +76,12 @@ class BetterText extends StatelessWidget { spans.add(TextSpan(text: text.substring(lastMatchEnd, match.start))); } - final url = match.group(0); + final url = match.group(0)!; + final recognizer = TapGestureRecognizer() + ..onTap = () async { + await _openUrl(url); + }; + _recognizers.add(recognizer); spans.add( TextSpan( text: url, @@ -35,17 +89,7 @@ class BetterText extends StatelessWidget { decoration: TextDecoration.underline, decorationColor: Colors.white, ), - recognizer: TapGestureRecognizer() - ..onTap = () async { - final lUrl = Uri.parse( - url!.startsWith('http') ? url : 'http://$url', - ); - try { - await launchUrl(lUrl, mode: LaunchMode.externalApplication); - } catch (e) { - Log.error('Could not launch $e'); - } - }, + recognizer: recognizer, ), ); @@ -60,15 +104,20 @@ class BetterText extends StatelessWidget { ); } + _spans = spans; + } + + @override + Widget build(BuildContext context) { return Text.rich( TextSpan( - children: spans, + children: _spans, ), softWrap: true, textAlign: TextAlign.start, overflow: TextOverflow.visible, style: TextStyle( - color: textColor, + color: widget.textColor, fontSize: 17, decoration: TextDecoration.none, fontWeight: FontWeight.normal, diff --git a/lib/src/visual/elements/contact_chip.element.dart b/lib/src/visual/elements/contact_chip.element.dart index e4974c02..650199ed 100644 --- a/lib/src/visual/elements/contact_chip.element.dart +++ b/lib/src/visual/elements/contact_chip.element.dart @@ -1,10 +1,8 @@ -import 'dart:typed_data'; - import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:twonly/locator.dart'; import 'package:twonly/src/database/daos/contacts.dao.dart'; import 'package:twonly/src/database/twonly.db.dart'; -import 'package:twonly/src/utils/avatars.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/visual/components/avatar_icon.comp.dart'; import 'package:twonly/src/visual/components/verification_badge.comp.dart'; @@ -49,7 +47,7 @@ class ContactChip extends StatelessWidget { contactId: contact?.userId, fontSize: 10, svg: avatarSvg != null - ? getAvatarSvg(Uint8List.fromList(avatarSvg!)) + ? RustApi.decodeAvatarSvg(avatarSvgCompressed: avatarSvg!) : null, ), label: Row( diff --git a/lib/src/visual/views/camera/camera_preview_components/camera_preview_controller_view.dart b/lib/src/visual/views/camera/camera_preview_components/camera_preview_controller_view.dart index 28b97b10..6c00a352 100644 --- a/lib/src/visual/views/camera/camera_preview_components/camera_preview_controller_view.dart +++ b/lib/src/visual/views/camera/camera_preview_components/camera_preview_controller_view.dart @@ -15,7 +15,7 @@ import 'package:twonly/globals.dart'; import 'package:twonly/locator.dart'; import 'package:twonly/src/database/tables/mediafiles.table.dart'; import 'package:twonly/src/database/twonly.db.dart'; -import 'package:twonly/src/services/api/mediafiles/upload.api.dart'; +import 'package:twonly/src/services/mediafiles/mediafile.service.dart'; import 'package:twonly/src/services/user.service.dart'; import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/misc.dart'; @@ -393,11 +393,12 @@ class _CameraPreviewViewState extends State { final type = mediaType ?? ((videoFilePath != null) ? MediaType.video : MediaType.image); - final mediaFileService = await initializeMediaUpload( - type, - userService.currentUser.defaultShowTime, + final mediaId = await RustApi.initializeMediaUpload( + mediaType: type.name, + displayLimitInMilliseconds: userService.currentUser.defaultShowTime, isDraftMedia: true, ); + final mediaFileService = await MediaFileService.fromMediaId(mediaId); if (!mounted) return true; if (mediaFileService == null) { diff --git a/lib/src/visual/views/camera/camera_preview_components/main_camera_controller.dart b/lib/src/visual/views/camera/camera_preview_components/main_camera_controller.dart index 4eba2188..0f61e7be 100644 --- a/lib/src/visual/views/camera/camera_preview_components/main_camera_controller.dart +++ b/lib/src/visual/views/camera/camera_preview_components/main_camera_controller.dart @@ -567,8 +567,8 @@ class MainCameraController { } else { await showAlertDialog( context, - context.lang.groupNetworkIssue, - context.lang.recoverErrorNoInternet, + context.lang.addFriendTitle, + context.lang.additionalUserAddError(profile.username), customCancel: '', ); } diff --git a/lib/src/visual/views/camera/share_image_contact_selection.view.dart b/lib/src/visual/views/camera/share_image_contact_selection.view.dart index 1cd2088f..f030a353 100644 --- a/lib/src/visual/views/camera/share_image_contact_selection.view.dart +++ b/lib/src/visual/views/camera/share_image_contact_selection.view.dart @@ -8,7 +8,6 @@ import 'package:twonly/src/database/daos/contacts.dao.dart'; import 'package:twonly/src/database/tables/mediafiles.table.dart'; import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/model/protobuf/client/generated/data.pb.dart'; -import 'package:twonly/src/services/api/mediafiles/upload.api.dart'; import 'package:twonly/src/services/flame.service.dart'; import 'package:twonly/src/services/mediafiles/mediafile.service.dart'; import 'package:twonly/src/utils/misc.dart'; @@ -311,10 +310,12 @@ class _ShareImageView extends State { // in case mediaStoreFutureReady is ready, the image is stored in the originalPath unawaited( - insertMediaFileInMessagesTable( - widget.mediaFileService, - widget.selectedGroupIds.toList(), - additionalData: widget.additionalData, + RustApi.sendMediaToGroups( + mediaId: + widget.mediaFileService.mediaFile.mediaId, + groupIds: widget.selectedGroupIds.toList(), + additionalMessageData: widget.additionalData + ?.writeToBuffer(), ), ); diff --git a/lib/src/visual/views/camera/share_image_editor.view.dart b/lib/src/visual/views/camera/share_image_editor.view.dart index 91d85187..18c9e8f2 100644 --- a/lib/src/visual/views/camera/share_image_editor.view.dart +++ b/lib/src/visual/views/camera/share_image_editor.view.dart @@ -12,7 +12,6 @@ import 'package:twonly/src/database/daos/contacts.dao.dart'; import 'package:twonly/src/database/tables/mediafiles.table.dart'; import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/model/protobuf/client/generated/data.pb.dart'; -import 'package:twonly/src/services/api/mediafiles/upload.api.dart'; import 'package:twonly/src/services/mediafiles/mediafile.service.dart'; import 'package:twonly/src/services/user.service.dart'; import 'package:twonly/src/utils/log.dart'; @@ -655,10 +654,10 @@ class _ShareImageEditorView extends State { await storeImageAsOriginal(); // Insert media file into the messages database and start uploading process in the background - await insertMediaFileInMessagesTable( - mediaService, - [widget.sendToGroup!.groupId], - additionalData: getAdditionalData(), + await RustApi.sendMediaToGroups( + mediaId: mediaService.mediaFile.mediaId, + groupIds: [widget.sendToGroup!.groupId], + additionalMessageData: getAdditionalData()?.writeToBuffer(), ); if (mounted) { diff --git a/lib/src/visual/views/chats/chat_list_components/group_list_item.comp.dart b/lib/src/visual/views/chats/chat_list_components/group_list_item.comp.dart index 136ec067..31fea6e8 100644 --- a/lib/src/visual/views/chats/chat_list_components/group_list_item.comp.dart +++ b/lib/src/visual/views/chats/chat_list_components/group_list_item.comp.dart @@ -10,7 +10,7 @@ import 'package:twonly/src/database/daos/key_verification.dao.dart'; import 'package:twonly/src/database/tables/mediafiles.table.dart'; import 'package:twonly/src/database/tables/messages.table.dart'; import 'package:twonly/src/database/twonly.db.dart'; -import 'package:twonly/src/services/api/mediafiles/download.api.dart'; +import 'package:twonly/src/services/mediafiles/media_download_policy.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/visual/components/avatar_icon.comp.dart'; import 'package:twonly/src/visual/components/contact_labels.comp.dart'; diff --git a/lib/src/visual/views/chats/chat_messages.view.dart b/lib/src/visual/views/chats/chat_messages.view.dart index 7050d979..d7338361 100644 --- a/lib/src/visual/views/chats/chat_messages.view.dart +++ b/lib/src/visual/views/chats/chat_messages.view.dart @@ -112,6 +112,9 @@ class _ChatMessagesViewState extends State Timer? _nextTypingIndicator; + /// Set by the composer while it is announcing that the user is typing. + final ValueNotifier _composing = ValueNotifier(false); + @override void initState() { super.initState(); @@ -141,6 +144,7 @@ class _ChatMessagesViewState extends State _messageDataVersion.dispose(); itemPositionsListener.itemPositions.removeListener(_loadOlderWhenNeeded); _nextTypingIndicator?.cancel(); + _composing.dispose(); try { textFieldFocus?.dispose(); // ignore: empty_catches @@ -193,10 +197,11 @@ class _ChatMessagesViewState extends State if (userService.currentUser.typingIndicators) { unawaited(RustApi.sendTyping(groupId: widget.groupId, isTyping: false)); - _nextTypingIndicator = Timer.periodic(const Duration(seconds: 2), ( - _, - ) async { - if (_isViewActive()) { + _nextTypingIndicator = Timer.periodic(chatOpenPingInterval, (_) async { + // A typing announcement refreshes the contact's chat-open state as + // well, so pinging while the composer is active would spend a second + // message only to clear the typing flag that composer just set. + if (_isViewActive() && !_composing.value) { await RustApi.sendTyping(groupId: widget.groupId, isTyping: false); } }); @@ -746,6 +751,7 @@ class _ChatMessagesViewState extends State group: group, quotesMessage: quotesMessage, textFieldFocus: textFieldFocus!, + composing: _composing, onMessageSend: () { setState(() { quotesMessage = null; diff --git a/lib/src/visual/views/chats/chat_messages_components/chat_list_entry.dart b/lib/src/visual/views/chats/chat_messages_components/chat_list_entry.dart index 0e57796b..ccf4d66b 100644 --- a/lib/src/visual/views/chats/chat_messages_components/chat_list_entry.dart +++ b/lib/src/visual/views/chats/chat_messages_components/chat_list_entry.dart @@ -87,9 +87,18 @@ class _ChatListEntryState extends State { void _applySharedData() { if (!widget.useSharedData) return; reactions = widget.reactions ?? const []; - mediaService = widget.mediaFile == null - ? null - : MediaFileService(widget.mediaFile!); + final mediaFile = widget.mediaFile; + if (mediaFile == null) { + mediaService = null; + return; + } + // Reuse the existing service when the row is unchanged: it memoises the + // media paths, and a fresh instance would also force the thumbnail below to + // re-resolve its image. + final current = mediaService; + if (current == null || current.mediaFile != mediaFile) { + mediaService = MediaFileService(mediaFile); + } } @override diff --git a/lib/src/visual/views/chats/chat_messages_components/entries/chat_ask_a_friend.entry.dart b/lib/src/visual/views/chats/chat_messages_components/entries/chat_ask_a_friend.entry.dart index ca842ba8..452865e6 100644 --- a/lib/src/visual/views/chats/chat_messages_components/entries/chat_ask_a_friend.entry.dart +++ b/lib/src/visual/views/chats/chat_messages_components/entries/chat_ask_a_friend.entry.dart @@ -8,7 +8,6 @@ import 'package:twonly/locator.dart'; import 'package:twonly/src/constants/routes.keys.dart'; import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/model/protobuf/client/generated/data.pb.dart'; -import 'package:twonly/src/services/api/utils.api.dart'; import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/visual/components/avatar_icon.comp.dart'; @@ -139,7 +138,10 @@ class _ChatAskAFriendEntryState extends State { deletedByUser: const Value(false), ), ); - await importSignalContactAndCreateRequest(userdata); + await RustApi.tryRequestContactById( + contactId: userdata.userId, + expectedPublicKey: userdata.publicIdentityKey, + ); } } catch (e) { Log.error(e); @@ -162,7 +164,7 @@ class _ChatAskAFriendEntryState extends State { return Container( constraints: BoxConstraints( - maxWidth: MediaQuery.of(context).size.width * 0.8, + maxWidth: MediaQuery.sizeOf(context).width * 0.8, ), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: BoxDecoration( diff --git a/lib/src/visual/views/chats/chat_messages_components/entries/chat_audio_entry.dart b/lib/src/visual/views/chats/chat_messages_components/entries/chat_audio_entry.dart index 0488cb49..1924fc5f 100644 --- a/lib/src/visual/views/chats/chat_messages_components/entries/chat_audio_entry.dart +++ b/lib/src/visual/views/chats/chat_messages_components/entries/chat_audio_entry.dart @@ -79,7 +79,7 @@ class _ChatAudioEntryState extends State { return IntrinsicWidth( child: Container( constraints: BoxConstraints( - maxWidth: MediaQuery.of(context).size.width * 0.8, + maxWidth: MediaQuery.sizeOf(context).width * 0.8, minWidth: 280, ), padding: info.padding, diff --git a/lib/src/visual/views/chats/chat_messages_components/entries/chat_contacts.entry.dart b/lib/src/visual/views/chats/chat_messages_components/entries/chat_contacts.entry.dart index 7134264b..7686359d 100644 --- a/lib/src/visual/views/chats/chat_messages_components/entries/chat_contacts.entry.dart +++ b/lib/src/visual/views/chats/chat_messages_components/entries/chat_contacts.entry.dart @@ -8,11 +8,11 @@ import 'package:twonly/locator.dart'; import 'package:twonly/src/constants/routes.keys.dart'; import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/model/protobuf/client/generated/data.pb.dart'; -import 'package:twonly/src/services/api/utils.api.dart'; import 'package:twonly/src/services/key_verification.service.dart'; import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/visual/components/add_contact_dialog.comp.dart'; import 'package:twonly/src/visual/elements/better_text.element.dart'; +import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/chat_unknown.entry.dart'; import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/common.dart'; class ChatContactsEntry extends StatefulWidget { @@ -34,27 +34,52 @@ class ChatContactsEntry extends StatefulWidget { } class _ChatContactsEntryState extends State { + /// Decoded once per message rather than on every rebuild. + AdditionalMessageData? _data; + + @override + void initState() { + super.initState(); + _decode(); + } + + @override + void didUpdateWidget(ChatContactsEntry oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.message.additionalMessageData != + widget.message.additionalMessageData) { + _decode(); + } + } + + void _decode() { + if (widget.message.additionalMessageData == null) { + _data = null; + return; + } + try { + _data = AdditionalMessageData.fromBuffer( + widget.message.additionalMessageData!, + ); + } catch (e) { + _data = null; + } + } + @override Widget build(BuildContext context) { - AdditionalMessageData? data; - - if (widget.message.additionalMessageData != null) { - try { - data = AdditionalMessageData.fromBuffer( - widget.message.additionalMessageData!, - ); - } catch (e) { - data = null; - } - } + final data = _data; + // Never collapse to nothing: a message row exists either way, so an + // unreadable payload has to stay visible instead of leaving a phantom + // bubble in the chat. if (data == null || data.contacts.isEmpty) { - return const SizedBox.shrink(); + return const ChatUnknownEntry(); } return Container( constraints: BoxConstraints( - maxWidth: MediaQuery.of(context).size.width * 0.8, + maxWidth: MediaQuery.sizeOf(context).width * 0.8, ), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: BoxDecoration( @@ -145,7 +170,12 @@ class _ContactRowState extends State<_ContactRow> { ), ); - if (added > 0) await importSignalContactAndCreateRequest(userdata); + if (added > 0) { + await RustApi.tryRequestContactById( + contactId: userdata.userId, + expectedPublicKey: userdata.publicIdentityKey, + ); + } await KeyVerificationService.verifySharedContact( contactId: userdata.userId, diff --git a/lib/src/visual/views/chats/chat_messages_components/entries/chat_flame_restored.entry.dart b/lib/src/visual/views/chats/chat_messages_components/entries/chat_flame_restored.entry.dart index 0b28e1a0..dac7b1e9 100644 --- a/lib/src/visual/views/chats/chat_messages_components/entries/chat_flame_restored.entry.dart +++ b/lib/src/visual/views/chats/chat_messages_components/entries/chat_flame_restored.entry.dart @@ -39,7 +39,7 @@ class ChatFlameRestoredEntry extends StatelessWidget { return Container( constraints: BoxConstraints( - maxWidth: MediaQuery.of(context).size.width * 0.8, + maxWidth: MediaQuery.sizeOf(context).width * 0.8, ), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: BoxDecoration( diff --git a/lib/src/visual/views/chats/chat_messages_components/entries/chat_media_entry.dart b/lib/src/visual/views/chats/chat_messages_components/entries/chat_media_entry.dart index dbbcc201..e22498e4 100644 --- a/lib/src/visual/views/chats/chat_messages_components/entries/chat_media_entry.dart +++ b/lib/src/visual/views/chats/chat_messages_components/entries/chat_media_entry.dart @@ -9,7 +9,7 @@ import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/model/memory_item.model.dart'; import 'package:twonly/src/model/protobuf/client/generated/data.pb.dart'; import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart'; -import 'package:twonly/src/services/api/mediafiles/download.api.dart' +import 'package:twonly/src/services/mediafiles/media_download_policy.dart' as received; import 'package:twonly/src/services/mediafiles/mediafile.service.dart'; import 'package:twonly/src/utils/misc.dart'; @@ -47,12 +47,36 @@ class _ChatMediaEntryState extends State { GlobalKey reopenMediaFile = GlobalKey(); bool _canBeReopened = false; + /// Decoded once per message instead of on every rebuild; the buffer only + /// changes when the message itself does. + String? _link; + @override void initState() { super.initState(); + _decodeAdditionalData(); unawaited(initAsync()); } + @override + void didUpdateWidget(ChatMediaEntry oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.message.additionalMessageData != + widget.message.additionalMessageData) { + _decodeAdditionalData(); + } + } + + void _decodeAdditionalData() { + final addData = widget.message.additionalMessageData; + if (addData == null) { + _link = null; + return; + } + final data = AdditionalMessageData.fromBuffer(addData); + _link = data.hasLink() ? data.link : null; + } + Future initAsync() async { if (widget.message.senderId == null || widget.message.mediaStored) { return; @@ -120,34 +144,31 @@ class _ChatMediaEntryState extends State { Widget additionalMessageData = Container(); - final addData = widget.message.additionalMessageData; - if (addData != null) { - final data = AdditionalMessageData.fromBuffer(addData); - if (data.hasLink() && widget.message.mediaStored) { - imageBorderRadius = widget.borderRadius.copyWith( - bottomLeft: const Radius.circular(5), - bottomRight: const Radius.circular(5), - ); + final link = _link; + if (link != null && widget.message.mediaStored) { + imageBorderRadius = widget.borderRadius.copyWith( + bottomLeft: const Radius.circular(5), + bottomRight: const Radius.circular(5), + ); - additionalMessageData = Container( - constraints: BoxConstraints( - maxWidth: MediaQuery.of(context).size.width * 0.8, + additionalMessageData = Container( + constraints: BoxConstraints( + maxWidth: MediaQuery.sizeOf(context).width * 0.8, + ), + padding: widget.info.padding, + decoration: BoxDecoration( + color: widget.info.color, + borderRadius: widget.borderRadius.copyWith( + topLeft: const Radius.circular(5), ), - padding: widget.info.padding, - decoration: BoxDecoration( - color: widget.info.color, - borderRadius: widget.borderRadius.copyWith( - topLeft: const Radius.circular(5), - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - BetterText(text: data.link, textColor: widget.info.textColor), - ], - ), - ); - } + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + BetterText(text: link, textColor: widget.info.textColor), + ], + ), + ); } return Column( diff --git a/lib/src/visual/views/chats/chat_messages_components/entries/chat_text_entry.dart b/lib/src/visual/views/chats/chat_messages_components/entries/chat_text_entry.dart index 35d46979..5815acbe 100644 --- a/lib/src/visual/views/chats/chat_messages_components/entries/chat_text_entry.dart +++ b/lib/src/visual/views/chats/chat_messages_components/entries/chat_text_entry.dart @@ -39,7 +39,7 @@ class ChatTextEntry extends StatelessWidget { return IntrinsicWidth( child: Container( constraints: BoxConstraints( - maxWidth: MediaQuery.of(context).size.width * 0.8, + maxWidth: MediaQuery.sizeOf(context).width * 0.8, minWidth: info.minWidth, ), padding: info.padding, diff --git a/lib/src/visual/views/chats/chat_messages_components/entries/chat_unknown.entry.dart b/lib/src/visual/views/chats/chat_messages_components/entries/chat_unknown.entry.dart index 12fc0c4d..93a1f5da 100644 --- a/lib/src/visual/views/chats/chat_messages_components/entries/chat_unknown.entry.dart +++ b/lib/src/visual/views/chats/chat_messages_components/entries/chat_unknown.entry.dart @@ -11,7 +11,7 @@ class ChatUnknownEntry extends StatelessWidget { Widget build(BuildContext context) { return Container( constraints: BoxConstraints( - maxWidth: MediaQuery.of(context).size.width * 0.8, + maxWidth: MediaQuery.sizeOf(context).width * 0.8, ), padding: const EdgeInsets.only(left: 10, top: 6, bottom: 6, right: 10), decoration: BoxDecoration( diff --git a/lib/src/visual/views/chats/chat_messages_components/entries/common.dart b/lib/src/visual/views/chats/chat_messages_components/entries/common.dart index 84eb3e99..099cc548 100644 --- a/lib/src/visual/views/chats/chat_messages_components/entries/common.dart +++ b/lib/src/visual/views/chats/chat_messages_components/entries/common.dart @@ -53,7 +53,8 @@ BubbleInfo getBubbleInfo( } } - info.spacerWidth = minWidth - measureTextWidth(info.text) - 53; + final textWidth = measureTextWidth(info.text); + info.spacerWidth = minWidth - textWidth - 53; if (info.spacerWidth < 0) info.spacerWidth = 0; info @@ -65,7 +66,7 @@ BubbleInfo getBubbleInfo( info ..color = context.color.surfaceBright ..displayTime = false; - } else if (measureTextWidth(info.text) > 270) { + } else if (textWidth > 270) { info.expanded = true; } @@ -80,15 +81,32 @@ BubbleInfo getBubbleInfo( return info; } +/// Laying text out is expensive and `getBubbleInfo` runs for every visible +/// bubble on every rebuild, while the same message content is measured over and +/// over. Keep the last few hundred results around, in insertion order, so the +/// cache stays bounded as the user scrolls through a long conversation. +const _measuredTextCacheLimit = 500; +final Map _measuredTextCache = {}; + double measureTextWidth( String text, ) { + final cached = _measuredTextCache[text]; + if (cached != null) return cached; + final tp = TextPainter( text: TextSpan(text: text, style: const TextStyle(fontSize: 17)), textDirection: TextDirection.ltr, maxLines: 1, )..layout(); - return tp.size.width; + final width = tp.size.width; + tp.dispose(); + + if (_measuredTextCache.length >= _measuredTextCacheLimit) { + _measuredTextCache.remove(_measuredTextCache.keys.first); + } + _measuredTextCache[text] = width; + return width; } bool combineTextMessageWithNext(Message message, Message? nextMessage) { diff --git a/lib/src/visual/views/chats/chat_messages_components/entries/friendly_message_time.comp.dart b/lib/src/visual/views/chats/chat_messages_components/entries/friendly_message_time.comp.dart index bb1f5510..8fd866f8 100644 --- a/lib/src/visual/views/chats/chat_messages_components/entries/friendly_message_time.comp.dart +++ b/lib/src/visual/views/chats/chat_messages_components/entries/friendly_message_time.comp.dart @@ -143,15 +143,16 @@ String friendlyTime(BuildContext context, DateTime dt) { } // Determine 24h vs 12h from system/local settings - final use24Hour = MediaQuery.of(context).alwaysUse24HourFormat; + final use24Hour = MediaQuery.alwaysUse24HourFormatOf(context); - if (!use24Hour) { - // 12-hour format with locale-aware AM/PM - final format = DateFormat.jm(Localizations.localeOf(context).toString()); - return format.format(dt); - } else { - // 24-hour HH:mm, locale-aware - final format = DateFormat.Hm(Localizations.localeOf(context).toString()); - return format.format(dt); - } + final locale = Localizations.localeOf(context).toString(); + // Building a DateFormat parses a pattern and looks up locale data, which is + // wasted work when every visible message asks for the same two formats. + final format = _timeFormats.putIfAbsent( + '$locale|$use24Hour', + () => use24Hour ? DateFormat.Hm(locale) : DateFormat.jm(locale), + ); + return format.format(dt); } + +final Map _timeFormats = {}; diff --git a/lib/src/visual/views/chats/chat_messages_components/file_limit_reached.dialog.dart b/lib/src/visual/views/chats/chat_messages_components/file_limit_reached.dialog.dart new file mode 100644 index 00000000..b5820503 --- /dev/null +++ b/lib/src/visual/views/chats/chat_messages_components/file_limit_reached.dialog.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; +import 'package:twonly/core/bridge/api.dart'; +import 'package:twonly/core/services/media_upload.dart'; +import 'package:twonly/src/constants/routes.keys.dart'; +import 'package:twonly/src/providers/purchases.provider.dart'; +import 'package:twonly/src/services/subscription.service.dart'; +import 'package:twonly/src/utils/misc.dart'; + +/// Explains a send that stopped because the media exceeded the largest single +/// object the user's plan accepts. +/// +/// Only the free plan is offered an upgrade: every paid plan shares the same +/// per-file limit, so changing plan would not make this file sendable. +Future showFileLimitReachedDialog( + BuildContext context, + String mediaId, +) async { + MediaSizeReport? report; + try { + report = await RustApi.mediaSizeLimitReport(mediaId: mediaId); + } catch (_) { + // The numbers are what make the warning specific, but the warning is + // still worth showing without them. + } + if (!context.mounted) return; + + final isFreePlan = + context.read().plan == SubscriptionPlan.Free; + final limit = report?.limitBytes; + final size = report?.mediaBytes; + + String? detail; + if (limit != null) { + detail = size != null + ? context.lang.fileLimitReachedDetail( + formatBytes(size), + formatBytes(limit), + ) + : context.lang.fileLimitReachedDetailNoSize(formatBytes(limit)); + } + + await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(dialogContext.lang.fileLimitReachedTitle), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (detail != null) ...[ + Text(detail), + const SizedBox(height: 12), + ], + Text( + isFreePlan + ? dialogContext.lang.fileLimitReachedHintFree + : dialogContext.lang.fileLimitReachedHint, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text(dialogContext.lang.close), + ), + if (isFreePlan) + FilledButton( + onPressed: () { + Navigator.of(dialogContext).pop(); + dialogContext.push(Routes.settingsSubscription); + }, + child: Text(dialogContext.lang.fileLimitReachedUpgrade), + ), + ], + ), + ); +} diff --git a/lib/src/visual/views/chats/chat_messages_components/message_context_menu.dart b/lib/src/visual/views/chats/chat_messages_components/message_context_menu.dart index c1ae5eac..3f925248 100644 --- a/lib/src/visual/views/chats/chat_messages_components/message_context_menu.dart +++ b/lib/src/visual/views/chats/chat_messages_components/message_context_menu.dart @@ -103,9 +103,11 @@ class MessageContextMenu extends StatelessWidget { @override Widget build(BuildContext context) { - final navigator = Navigator.of(context); + // `late` so the ancestor lookup only runs if the menu is actually + // opened, rather than once per row on every rebuild. + late final navigator = Navigator.of(context); return ContextMenu( - items: [ + items: () => [ if (!message.isDeletedFromSender) ContextMenuItem( title: context.lang.react, diff --git a/lib/src/visual/views/chats/chat_messages_components/message_input.dart b/lib/src/visual/views/chats/chat_messages_components/message_input.dart index b532bf56..5144dca8 100644 --- a/lib/src/visual/views/chats/chat_messages_components/message_input.dart +++ b/lib/src/visual/views/chats/chat_messages_components/message_input.dart @@ -13,7 +13,7 @@ import 'package:twonly/globals.dart'; import 'package:twonly/locator.dart'; import 'package:twonly/src/database/tables/mediafiles.table.dart'; import 'package:twonly/src/database/twonly.db.dart'; -import 'package:twonly/src/services/api/mediafiles/upload.api.dart'; +import 'package:twonly/src/services/mediafiles/mediafile.service.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/visual/views/camera/camera_send_to.view.dart'; import 'package:twonly/src/visual/views/chats/chat_messages_components/bottom_sheets/share_additional.bottom_sheet.dart'; @@ -21,6 +21,7 @@ import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/c import 'package:twonly/src/visual/views/chats/chat_messages_components/message_input_components/ask_for_friend_promotions.comp.dart'; import 'package:twonly/src/visual/views/chats/chat_messages_components/message_input_components/sparks.comp.dart'; import 'package:twonly/src/visual/views/chats/chat_messages_components/message_input_components/user_discovery_manual_approval.comp.dart'; +import 'package:twonly/src/visual/views/chats/chat_messages_components/typing_indicator.dart'; import 'package:twonly/src/visual/views/contact/contact_components/restore_flame.comp.dart'; class MessageInput extends StatefulWidget { @@ -29,6 +30,7 @@ class MessageInput extends StatefulWidget { required this.quotesMessage, required this.textFieldFocus, required this.onMessageSend, + required this.composing, super.key, }); @@ -37,10 +39,19 @@ class MessageInput extends StatefulWidget { final Message? quotesMessage; final VoidCallback onMessageSend; + /// Published so the chat-open heartbeat can stay quiet while the typing + /// announcement is already covering this conversation. + final ValueNotifier composing; + @override State createState() => _MessageInputState(); } +/// How long after the last keystroke the composer still counts as being typed +/// in. Longer than [typingIndicatorInterval] so the indicator survives the gap +/// between two announcements. +const _composingIdleTimeout = Duration(seconds: 6); + enum RecordingState { none, recording, finished } class _MessageInputState extends State { @@ -85,14 +96,14 @@ class _MessageInputState extends State { } widget.textFieldFocus.addListener(_handleTextFocusChange); if (userService.currentUser.typingIndicators) { - _nextTypingIndicator = Timer.periodic(const Duration(seconds: 1), ( - _, - ) async { - if (widget.textFieldFocus.hasFocus && - _lastTextChangeTime != null && - DateTime.now().difference(_lastTextChangeTime!) <= - const Duration(seconds: 6)) { - await RustApi.sendTyping(groupId: widget.group.groupId, isTyping: true); + _nextTypingIndicator = Timer.periodic(typingIndicatorInterval, (_) async { + final composing = _isComposing; + widget.composing.value = composing; + if (composing) { + await RustApi.sendTyping( + groupId: widget.group.groupId, + isTyping: true, + ); } }); } @@ -108,6 +119,7 @@ class _MessageInputState extends State { _recordingTimer?.cancel(); recorderController.dispose(); _nextTypingIndicator?.cancel(); + widget.composing.value = false; // Persist draft message on close final draftText = _textFieldController.text; @@ -128,8 +140,31 @@ class _MessageInputState extends State { recorderController = RecorderController(); } + /// Whether the composer counts as being typed in right now. + /// + /// The idle window outlives one announcement interval, so a pause to think + /// mid-sentence does not drop the indicator on the other side. + bool get _isComposing => + widget.textFieldFocus.hasFocus && + _lastTextChangeTime != null && + clock.now().difference(_lastTextChangeTime!) <= _composingIdleTimeout; + void _handleTextChange() { - _lastTextChangeTime = clock.now(); + final now = clock.now(); + // The periodic announcement is what keeps the indicator alive, but at its + // cadence the first keystroke would take seconds to reach the other side. + // That one is announced directly and the timer carries it from there. + final wasIdle = _lastTextChangeTime == null || + now.difference(_lastTextChangeTime!) > typingIndicatorInterval; + _lastTextChangeTime = now; + if (wasIdle && + userService.currentUser.typingIndicators && + widget.textFieldFocus.hasFocus) { + widget.composing.value = true; + unawaited( + RustApi.sendTyping(groupId: widget.group.groupId, isTyping: true), + ); + } } void _handleTextFocusChange() { @@ -195,10 +230,11 @@ class _MessageInputState extends State { if (audioTmpPath == null) return; - final mediaFileService = await initializeMediaUpload( - MediaType.audio, - null, + final mediaId = await RustApi.initializeMediaUpload( + mediaType: MediaType.audio.name, + isDraftMedia: false, ); + final mediaFileService = await MediaFileService.fromMediaId(mediaId); if (mediaFileService == null) return; @@ -206,9 +242,9 @@ class _MessageInputState extends State { ..copySync(mediaFileService.originalPath.path) ..deleteSync(); - await insertMediaFileInMessagesTable( - mediaFileService, - [widget.group.groupId], + await RustApi.sendMediaToGroups( + mediaId: mediaFileService.mediaFile.mediaId, + groupIds: [widget.group.groupId], ); } diff --git a/lib/src/visual/views/chats/chat_messages_components/message_send_state_icon.dart b/lib/src/visual/views/chats/chat_messages_components/message_send_state_icon.dart index 9df84bbb..6560a095 100644 --- a/lib/src/visual/views/chats/chat_messages_components/message_send_state_icon.dart +++ b/lib/src/visual/views/chats/chat_messages_components/message_send_state_icon.dart @@ -12,6 +12,7 @@ import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/visual/components/animate_icon.comp.dart'; import 'package:twonly/src/visual/themes/colors.dart'; +import 'package:twonly/src/visual/views/chats/chat_messages_components/file_limit_reached.dialog.dart'; enum MessageSendState { received, @@ -225,6 +226,10 @@ class _MessageSendStateIconState extends State { context.lang.fileLimitReached, style: const TextStyle(fontSize: 9), ); + // The warning has no room for the sizes that explain it, so they are + // one tap away instead. + final mediaId = mediaFile.mediaId; + onTap = () => showFileLimitReachedDialog(context, mediaId); } } diff --git a/lib/src/visual/views/chats/chat_messages_components/response_container.dart b/lib/src/visual/views/chats/chat_messages_components/response_container.dart index b871aa4f..6db21344 100644 --- a/lib/src/visual/views/chats/chat_messages_components/response_container.dart +++ b/lib/src/visual/views/chats/chat_messages_components/response_container.dart @@ -49,7 +49,7 @@ class ResponseContainer extends StatelessWidget { : () => scrollToMessage!(msg.quotesMessageId!), child: Container( constraints: BoxConstraints( - maxWidth: MediaQuery.of(context).size.width * 0.8, + maxWidth: MediaQuery.sizeOf(context).width * 0.8, ), decoration: BoxDecoration( color: getMessageColor(msg.senderId != null), @@ -258,7 +258,7 @@ class _ResponsePreviewState extends State { ), constraints: BoxConstraints( minWidth: 60, - maxWidth: MediaQuery.of(context).size.width * 0.7, + maxWidth: MediaQuery.sizeOf(context).width * 0.7, ), decoration: widget.showLeftBorder ? BoxDecoration( diff --git a/lib/src/visual/views/chats/chat_messages_components/typing_indicator.dart b/lib/src/visual/views/chats/chat_messages_components/typing_indicator.dart index 2a34cb3a..86178ffe 100644 --- a/lib/src/visual/views/chats/chat_messages_components/typing_indicator.dart +++ b/lib/src/visual/views/chats/chat_messages_components/typing_indicator.dart @@ -9,26 +9,36 @@ import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/visual/components/avatar_icon.comp.dart'; import 'package:twonly/src/visual/views/chats/chat_messages.view.dart'; +/// How often a composing user re-announces that it is typing. +/// +/// Every announcement is a message, and a message sent sealed costs a Privacy +/// Pass token out of a daily quota. At the one-second cadence this used to run +/// at, a few minutes of typing spent a whole day's worth, after which every +/// later message — real ones included — fell back to the named transport. +const typingIndicatorInterval = Duration(seconds: 4); + +/// How long a received typing announcement counts for. Comfortably longer than +/// the send cadence so a single delayed message does not blink the indicator +/// off between announcements. +const typingIndicatorLifetime = Duration(seconds: 9); + +/// How often an open chat announces itself. This heartbeat runs for as long as +/// the conversation is on screen rather than only while someone types, so it is +/// the heavier of the two. +const chatOpenPingInterval = Duration(seconds: 6); + +/// How long a received chat-open announcement counts for. +const chatOpenLifetime = Duration(seconds: 14); + bool isTyping(GroupMember member) { return member.lastTypeIndicator != null && - clock - .now() - .difference( - member.lastTypeIndicator!, - ) - .inSeconds <= - 2; + clock.now().difference(member.lastTypeIndicator!) <= + typingIndicatorLifetime; } bool hasChatOpen(GroupMember member) { return member.lastChatOpened != null && - clock - .now() - .difference( - member.lastChatOpened!, - ) - .inSeconds <= - 3; + clock.now().difference(member.lastChatOpened!) <= chatOpenLifetime; } class TypingIndicator extends StatefulWidget { diff --git a/lib/src/visual/views/chats/media_viewer.view.dart b/lib/src/visual/views/chats/media_viewer.view.dart index 64463681..762a8a43 100644 --- a/lib/src/visual/views/chats/media_viewer.view.dart +++ b/lib/src/visual/views/chats/media_viewer.view.dart @@ -15,8 +15,7 @@ import 'package:twonly/src/database/tables/mediafiles.table.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/mediafiles/download.api.dart'; -import 'package:twonly/src/services/api/utils.api.dart'; +import 'package:twonly/src/services/mediafiles/media_download_policy.dart'; import 'package:twonly/src/services/mediafiles/mediafile.service.dart'; import 'package:twonly/src/services/notifications/native.notifications.dart'; import 'package:twonly/src/utils/log.dart'; @@ -194,7 +193,7 @@ class _MediaViewerViewState extends State { if (currentMedia != null) { if (!imageSaved && currentMedia!.mediaFile.displayLimitInMilliseconds != null) { - currentMedia!.fullMediaRemoval(); + await currentMedia!.fullMediaRemoval(); } } @@ -348,7 +347,9 @@ class _MediaViewerViewState extends State { Log.warn( 'Temp media file not found for media ID: ${currentMediaLocal.mediaFile.mediaId}', ); - await handleMediaError(currentMediaLocal.mediaFile); + await RustApi.requestMediaReupload( + mediaId: currentMediaLocal.mediaFile.mediaId, + ); return advanceToNextMediaOrExit(); } diff --git a/lib/src/visual/views/chats/message_info.view.dart b/lib/src/visual/views/chats/message_info.view.dart index a13a0076..4ddea6f7 100644 --- a/lib/src/visual/views/chats/message_info.view.dart +++ b/lib/src/visual/views/chats/message_info.view.dart @@ -163,20 +163,25 @@ class _MessageInfoViewState extends State { style: const TextStyle(fontSize: 12), ), Text(actionTypeText), - // The transport is decided per recipient, so it is only known - // once this member's copy has actually left the device. - if (ackByServer != null) - Text( - sealedSender != null - ? context.lang.sealedSenderTransportSealed - : context.lang.sealedSenderTransportStandard, - style: TextStyle( - fontSize: 12, - color: Theme.of(context).hintColor, - ), - ), ], ), + // The transport is decided per recipient, so it is only known + // once this member's copy has actually left the device. + if (ackByServer != null) ...[ + const SizedBox(width: 10), + Tooltip( + message: sealedSender != null + ? context.lang.sealedSenderTransportSealed + : context.lang.sealedSenderTransportStandard, + child: FaIcon( + sealedSender != null + ? FontAwesomeIcons.solidEnvelope + : FontAwesomeIcons.envelope, + size: 13, + color: Theme.of(context).hintColor, + ), + ), + ], ], ), ), diff --git a/lib/src/visual/views/contact/add_contact_via_qr_link.view.dart b/lib/src/visual/views/contact/add_contact_via_qr_link.view.dart index 3437528a..e333735e 100644 --- a/lib/src/visual/views/contact/add_contact_via_qr_link.view.dart +++ b/lib/src/visual/views/contact/add_contact_via_qr_link.view.dart @@ -7,7 +7,6 @@ import 'package:go_router/go_router.dart'; import 'package:twonly/locator.dart'; import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/model/protobuf/client/generated/qr.pb.dart'; -import 'package:twonly/src/services/api/utils.api.dart'; import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/qr.utils.dart'; @@ -54,7 +53,10 @@ class _AddContactViaQrLinkViewState extends State { ); if (added > 0) { - await importSignalContactAndCreateRequest(userData); + await RustApi.tryRequestContactById( + contactId: userData.userId, + expectedPublicKey: userData.publicIdentityKey, + ); if (widget.qrCodeLink != null) { // As the user does now exist he can now be marked as verified await QrCodeUtils.handleQrCodeLink(widget.qrCodeLink!); diff --git a/lib/src/visual/views/contact/add_new_contact.view.dart b/lib/src/visual/views/contact/add_new_contact.view.dart index 1b85c17e..332535bd 100644 --- a/lib/src/visual/views/contact/add_new_contact.view.dart +++ b/lib/src/visual/views/contact/add_new_contact.view.dart @@ -14,7 +14,6 @@ import 'package:twonly/src/constants/routes.keys.dart'; import 'package:twonly/src/database/daos/user_discovery.dao.dart'; import 'package:twonly/src/database/tables/contacts.table.dart'; import 'package:twonly/src/database/twonly.db.dart'; -import 'package:twonly/src/services/api/utils.api.dart'; import 'package:twonly/src/services/notifications/native.notifications.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/visual/components/alert.dialog.dart'; @@ -179,7 +178,12 @@ class _SearchUsernameView extends State { } } - if (added > 0) await importSignalContactAndCreateRequest(userdata); + if (added > 0) { + await RustApi.tryRequestContactById( + contactId: userdata.userId, + expectedPublicKey: userdata.publicIdentityKey, + ); + } } @override diff --git a/lib/src/visual/views/contact/add_new_contact_components/friend_suggestions.comp.dart b/lib/src/visual/views/contact/add_new_contact_components/friend_suggestions.comp.dart index e877cbc0..f2a565b9 100644 --- a/lib/src/visual/views/contact/add_new_contact_components/friend_suggestions.comp.dart +++ b/lib/src/visual/views/contact/add_new_contact_components/friend_suggestions.comp.dart @@ -5,7 +5,6 @@ import 'package:twonly/locator.dart'; import 'package:twonly/src/database/daos/contacts.dao.dart'; import 'package:twonly/src/database/daos/user_discovery.dao.dart'; import 'package:twonly/src/database/twonly.db.dart'; -import 'package:twonly/src/services/api/utils.api.dart'; import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/visual/components/avatar_icon.comp.dart'; @@ -72,7 +71,12 @@ class FriendSuggestionsComp extends StatelessWidget { ), ); - if (added > 0) await importSignalContactAndCreateRequest(userdata); + if (added > 0) { + await RustApi.tryRequestContactById( + contactId: userdata.userId, + expectedPublicKey: userdata.publicIdentityKey, + ); + } } Future _hideAnnouncedUser(int userId) async { diff --git a/lib/src/visual/views/groups/group_member.context.dart b/lib/src/visual/views/groups/group_member.context.dart index f6b4f24e..579d4451 100644 --- a/lib/src/visual/views/groups/group_member.context.dart +++ b/lib/src/visual/views/groups/group_member.context.dart @@ -117,9 +117,11 @@ class GroupMemberContextMenu extends StatelessWidget { @override Widget build(BuildContext context) { - final navigator = Navigator.of(context); + // `late` so the ancestor lookup only runs if the menu is actually + // opened, rather than once per row on every rebuild. + late final navigator = Navigator.of(context); return ContextMenu( - items: [ + items: () => [ if (contact.accepted) ContextMenuItem( title: context.lang.contextMenuOpenChat, diff --git a/lib/src/visual/views/home.view.dart b/lib/src/visual/views/home.view.dart index e43ac157..5ad95aef 100644 --- a/lib/src/visual/views/home.view.dart +++ b/lib/src/visual/views/home.view.dart @@ -12,7 +12,6 @@ import 'package:twonly/locator.dart'; import 'package:twonly/src/constants/routes.keys.dart'; import 'package:twonly/src/database/tables/mediafiles.table.dart'; import 'package:twonly/src/providers/routing.provider.dart'; -import 'package:twonly/src/services/api/mediafiles/upload.api.dart'; import 'package:twonly/src/services/mediafiles/mediafile.service.dart'; import 'package:twonly/src/services/notifications/native.notifications.dart'; import 'package:twonly/src/services/notifications/setup.notifications.dart'; @@ -134,10 +133,12 @@ class HomeViewState extends State with WidgetsBindingObserver { final type = media.$2; final filePath = media.$1; - final newMediaService = await initializeMediaUpload( - type, - userService.currentUser.defaultShowTime, + final mediaId = await RustApi.initializeMediaUpload( + mediaType: type.name, + displayLimitInMilliseconds: userService.currentUser.defaultShowTime, + isDraftMedia: false, ); + final newMediaService = await MediaFileService.fromMediaId(mediaId); if (newMediaService == null) { Log.error('Could not create new media file for intent shared file'); return; @@ -207,7 +208,8 @@ class HomeViewState extends State with WidgetsBindingObserver { } Future _initAsync() async { - final initialNativeTap = await NativeNotificationService.consumeInitialTap(); + final initialNativeTap = + await NativeNotificationService.consumeInitialTap(); if (initialNativeTap != null) { _openNativeNotification(initialNativeTap.conversationId); } diff --git a/lib/src/visual/views/memories/components/memory_thumbnail.comp.dart b/lib/src/visual/views/memories/components/memory_thumbnail.comp.dart index c62f80d9..84f22c47 100644 --- a/lib/src/visual/views/memories/components/memory_thumbnail.comp.dart +++ b/lib/src/visual/views/memories/components/memory_thumbnail.comp.dart @@ -41,6 +41,7 @@ class _MemoriesThumbnailCompState extends State { ImageStream? _imageStream; ImageInfo? _imageInfo; int _retries = 0; + bool _hasStoredFile = false; late final ImageStreamListener _listener; @override @@ -76,6 +77,7 @@ class _MemoriesThumbnailCompState extends State { media.thumbnailPath.lengthSync() > 0; final hasStored = media.storedPath.existsSync() && media.storedPath.lengthSync() > 0; + _hasStoredFile = hasStored; final isImageOrGif = media.mediaFile.type == MediaType.image || media.mediaFile.type == MediaType.gif; @@ -263,9 +265,7 @@ class _MemoriesThumbnailCompState extends State { ), Builder( builder: (context) { - final hasStored = - media.storedPath.existsSync() && - media.storedPath.lengthSync() > 0; + final hasStored = _hasStoredFile; final IconData iconData; final Color color; diff --git a/lib/src/visual/views/memories/memories.view.dart b/lib/src/visual/views/memories/memories.view.dart index c6e8e570..657af338 100644 --- a/lib/src/visual/views/memories/memories.view.dart +++ b/lib/src/visual/views/memories/memories.view.dart @@ -322,7 +322,7 @@ class MemoriesViewState extends State .firstOrNull; if (item != null) { if (isCompletely) { - item.mediaService.fullMediaRemoval(); + await item.mediaService.fullMediaRemoval(); await RustApi.deleteMemory(mediaId: mediaId); await twonlyDB.mediaFilesDao.deleteMediaFile(mediaId); } else { @@ -361,21 +361,7 @@ class MemoriesViewState extends State .where((e) => e.mediaService.mediaFile.mediaId == mediaId) .firstOrNull; if (item != null) { - final media = item.mediaService; - if (media.mediaFile.type == MediaType.video) { - await saveVideoToGallery( - media.storedPath.path, - name: media.mediaFile.mediaId, - ); - } else if (media.mediaFile.type == MediaType.image || - media.mediaFile.type == MediaType.gif) { - final imageBytes = await media.storedPath.readAsBytes(); - await saveImageToGallery( - imageBytes, - createdAt: media.mediaFile.createdAt, - name: media.mediaFile.mediaId, - ); - } + await item.mediaService.saveToGallery(); } setProgress((i + 1) / selectedList.length); } diff --git a/lib/src/visual/views/memories/synchronized_viewer.view.dart b/lib/src/visual/views/memories/synchronized_viewer.view.dart index 69555d39..473dde2c 100644 --- a/lib/src/visual/views/memories/synchronized_viewer.view.dart +++ b/lib/src/visual/views/memories/synchronized_viewer.view.dart @@ -6,7 +6,7 @@ import 'package:twonly/locator.dart'; import 'package:twonly/src/database/tables/mediafiles.table.dart'; import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/model/memory_item.model.dart'; -import 'package:twonly/src/services/api/mediafiles/upload.api.dart'; +import 'package:twonly/src/services/mediafiles/mediafile.service.dart'; import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/visual/components/delete_memories_dialog.comp.dart'; @@ -176,7 +176,7 @@ class _SynchronizedImageViewerScreenState if (deleteCompletely == null) return; if (deleteCompletely) { - item.mediaService.fullMediaRemoval(); + await item.mediaService.fullMediaRemoval(); await RustApi.deleteMemory(mediaId: mediaId); await twonlyDB.mediaFilesDao.deleteMediaFile(mediaId); @@ -221,20 +221,7 @@ class _SynchronizedImageViewerScreenState } try { - if (item.mediaFile.type == MediaType.video) { - await saveVideoToGallery( - item.storedPath.path, - name: item.mediaFile.mediaId, - ); - } else if (item.mediaFile.type == MediaType.image || - item.mediaFile.type == MediaType.gif) { - final imageBytes = await item.storedPath.readAsBytes(); - await saveImageToGallery( - imageBytes, - createdAt: item.mediaFile.createdAt, - name: item.mediaFile.mediaId, - ); - } + await item.saveToGallery(); if (!mounted) return; showSnackbar( context, @@ -254,10 +241,12 @@ class _SynchronizedImageViewerScreenState Future _shareMediaFile() async { final orgMediaService = widget.galleryItems[_currentIndex].mediaService; - final newMediaService = await initializeMediaUpload( - orgMediaService.mediaFile.type, - userService.currentUser.defaultShowTime, + final mediaId = await RustApi.initializeMediaUpload( + mediaType: orgMediaService.mediaFile.type.name, + displayLimitInMilliseconds: userService.currentUser.defaultShowTime, + isDraftMedia: false, ); + final newMediaService = await MediaFileService.fromMediaId(mediaId); if (newMediaService == null) { Log.error('Could not create new mediaFile'); return; diff --git a/lib/src/visual/views/onboarding/recover_passwordless.view.dart b/lib/src/visual/views/onboarding/recover_passwordless.view.dart index 94c88213..884fef4b 100644 --- a/lib/src/visual/views/onboarding/recover_passwordless.view.dart +++ b/lib/src/visual/views/onboarding/recover_passwordless.view.dart @@ -18,7 +18,6 @@ import 'package:twonly/src/model/json/onboarding_state.model.dart'; import 'package:twonly/src/model/protobuf/client/generated/passwordless_recovery.pb.dart'; import 'package:twonly/src/services/backup.service.dart'; import 'package:twonly/src/services/passwordless_recovery.service.dart'; -import 'package:twonly/src/utils/avatars.dart'; import 'package:twonly/src/utils/keyvalue.dart'; import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/misc.dart'; @@ -496,7 +495,9 @@ class _RecoverPasswordlessState extends State { children: [ AvatarIcon( svg: first.myAvatarSvg != null - ? getAvatarSvg(Uint8List.fromList(first.myAvatarSvg!)) + ? RustApi.decodeAvatarSvg( + avatarSvgCompressed: first.myAvatarSvg!, + ) : null, fontSize: 60, ), diff --git a/lib/src/visual/views/settings/backup/backup_utils.dart b/lib/src/visual/views/settings/backup/backup_utils.dart index cb1c314c..86cc0de3 100644 --- a/lib/src/visual/views/settings/backup/backup_utils.dart +++ b/lib/src/visual/views/settings/backup/backup_utils.dart @@ -89,7 +89,7 @@ Future promptAndDisableMemoriesBackup(BuildContext context) async { for (final media in allMedias) { final ms = MediaFileService(media); if (!ms.storedPath.existsSync()) { - ms.fullMediaRemoval(); + await ms.fullMediaRemoval(); await twonlyDB.mediaFilesDao.deleteMediaFile(media.mediaId); } } diff --git a/lib/src/visual/views/settings/data_and_storage.view.dart b/lib/src/visual/views/settings/data_and_storage.view.dart index 9b3a38e4..5259e59c 100644 --- a/lib/src/visual/views/settings/data_and_storage.view.dart +++ b/lib/src/visual/views/settings/data_and_storage.view.dart @@ -5,7 +5,7 @@ import 'package:go_router/go_router.dart'; import 'package:twonly/locator.dart'; import 'package:twonly/src/constants/routes.keys.dart'; import 'package:twonly/src/database/tables/mediafiles.table.dart'; -import 'package:twonly/src/services/api/mediafiles/download.api.dart'; +import 'package:twonly/src/services/mediafiles/media_download_policy.dart'; import 'package:twonly/src/services/user.service.dart'; import 'package:twonly/src/utils/misc.dart'; diff --git a/lib/src/visual/views/settings/data_and_storage/import_from_gallery.view.dart b/lib/src/visual/views/settings/data_and_storage/import_from_gallery.view.dart index 4a257d32..69d38c6f 100644 --- a/lib/src/visual/views/settings/data_and_storage/import_from_gallery.view.dart +++ b/lib/src/visual/views/settings/data_and_storage/import_from_gallery.view.dart @@ -114,7 +114,7 @@ class _ImportFromGalleryViewState extends State { await mediaService.storedPath.parent.create(recursive: true); await File(mediaService.storedPath.path).writeAsBytes(bytes); - await mediaService.calculateAndSaveSize(); + await mediaService.refreshStoredMetadata(); await mediaService.createThumbnail(); unawaited(mediaService.cropTransparentBorders()); @@ -425,7 +425,7 @@ class _ImportFromGalleryViewState extends State { await mediaService.storedPath.parent.create(recursive: true); await file.copy(mediaService.storedPath.path); - await mediaService.calculateAndSaveSize(); + await mediaService.refreshStoredMetadata(); await mediaService.createThumbnail(); unawaited(mediaService.cropTransparentBorders()); diff --git a/lib/src/visual/views/settings/data_and_storage/storage_contents.view.dart b/lib/src/visual/views/settings/data_and_storage/storage_contents.view.dart index 80be0b3d..85cf881f 100644 --- a/lib/src/visual/views/settings/data_and_storage/storage_contents.view.dart +++ b/lib/src/visual/views/settings/data_and_storage/storage_contents.view.dart @@ -336,7 +336,7 @@ class _StorageContentsViewState extends State { if (deleteCompletely) { await twonlyDB.mediaFilesDao.deleteMediaFile(file.mediaId); unawaited(RustApi.deleteMemory(mediaId: file.mediaId)); - MediaFileService(file).fullMediaRemoval(); + await MediaFileService(file).fullMediaRemoval(); } else { MediaFileService(file).storedPath.deleteSync(); } @@ -385,7 +385,7 @@ class _StorageContentsViewState extends State { if (deleteCompletely) { await twonlyDB.mediaFilesDao.deleteMediaFile(file.mediaId); unawaited(RustApi.deleteMemory(mediaId: file.mediaId)); - MediaFileService(file).fullMediaRemoval(); + await MediaFileService(file).fullMediaRemoval(); } else { MediaFileService(file).storedPath.deleteSync(); } diff --git a/lib/src/visual/views/settings/developer/retransmission_data.view.dart b/lib/src/visual/views/settings/developer/retransmission_data.view.dart index 93ad2f8a..bc6eaed5 100644 --- a/lib/src/visual/views/settings/developer/retransmission_data.view.dart +++ b/lib/src/visual/views/settings/developer/retransmission_data.view.dart @@ -7,7 +7,6 @@ import 'package:hashlib/random.dart'; import 'package:twonly/locator.dart'; import 'package:twonly/src/database/daos/contacts.dao.dart'; import 'package:twonly/src/database/twonly.db.dart'; -import 'package:twonly/src/services/api/messages.api.dart'; import 'package:twonly/src/visual/components/alert.dialog.dart'; import 'package:twonly/src/visual/components/avatar_icon.comp.dart'; @@ -199,9 +198,7 @@ class _RetransmissionDataViewState extends State { ackByServerAt: const Value(null), ), ); - await tryToSendCompleteMessage( - receiptId: newReceiptId, - ); + await RustApi.sendQueuedMessage(receiptId: newReceiptId); }, label: const FaIcon(FontAwesomeIcons.arrowRotateRight), ), diff --git a/lib/src/visual/views/settings/help/contact_us.view.dart b/lib/src/visual/views/settings/help/contact_us.view.dart index be9b6bf7..c673c82e 100644 --- a/lib/src/visual/views/settings/help/contact_us.view.dart +++ b/lib/src/visual/views/settings/help/contact_us.view.dart @@ -11,7 +11,6 @@ import 'package:package_info_plus/package_info_plus.dart'; import 'package:twonly/locator.dart'; import 'package:twonly/src/constants/routes.keys.dart'; import 'package:twonly/src/model/protobuf/client/generated/http_requests.pb.dart'; -import 'package:twonly/src/services/api/utils.api.dart'; import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/visual/components/snackbar.dart'; @@ -72,9 +71,11 @@ class _ContactUsState extends State { final requestMultipart = http.MultipartRequest('POST', Uri.parse(apiUrl)); - final headers = await getAuthenticationHeader(); - if (headers == null) { - Log.error('Auth headers are empty. Returning'); + late final Map headers; + try { + headers = await RustApi.authenticationHeaders(); + } catch (error) { + Log.error('Could not load authentication headers', error: error); return null; } diff --git a/pubspec.lock b/pubspec.lock index 4912c19c..f0db06dc 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -590,54 +590,6 @@ packages: description: flutter source: sdk version: "0.0.0" - flutter_image_compress: - dependency: "direct main" - description: - name: flutter_image_compress - sha256: "51d23be39efc2185e72e290042a0da41aed70b14ef97db362a6b5368d0523b27" - url: "https://pub.dev" - source: hosted - version: "2.4.0" - flutter_image_compress_common: - dependency: transitive - description: - name: flutter_image_compress_common - sha256: c5c5d50c15e97dd7dc72ff96bd7077b9f791932f2076c5c5b6c43f2c88607bfb - url: "https://pub.dev" - source: hosted - version: "1.0.6" - flutter_image_compress_macos: - dependency: transitive - description: - name: flutter_image_compress_macos - sha256: "20019719b71b743aba0ef874ed29c50747461e5e8438980dfa5c2031898f7337" - url: "https://pub.dev" - source: hosted - version: "1.0.3" - flutter_image_compress_ohos: - dependency: transitive - description: - name: flutter_image_compress_ohos - sha256: e76b92bbc830ee08f5b05962fc78a532011fcd2041f620b5400a593e96da3f51 - url: "https://pub.dev" - source: hosted - version: "0.0.3" - flutter_image_compress_platform_interface: - dependency: transitive - description: - name: flutter_image_compress_platform_interface - sha256: "579cb3947fd4309103afe6442a01ca01e1e6f93dc53bb4cbd090e8ce34a41889" - url: "https://pub.dev" - source: hosted - version: "1.0.5" - flutter_image_compress_web: - dependency: transitive - description: - name: flutter_image_compress_web - sha256: b9b141ac7c686a2ce7bb9a98176321e1182c9074650e47bb140741a44b6f5a96 - url: "https://pub.dev" - source: hosted - version: "0.1.5" flutter_keyboard_visibility_linux: dependency: transitive description: @@ -864,14 +816,6 @@ packages: description: flutter source: sdk version: "0.0.0" - gal: - dependency: "direct main" - description: - name: gal - sha256: "969598f986789127fd407a750413249e1352116d4c2be66e81837ffeeaafdfee" - url: "https://pub.dev" - source: hosted - version: "2.3.2" get_it: dependency: "direct main" description: @@ -1517,13 +1461,6 @@ packages: url: "https://pub.dev" source: hosted version: "6.5.0" - pro_video_editor: - dependency: "direct main" - description: - path: "dependencies/pro_video_editor" - relative: true - source: path - version: "2.11.3" process: dependency: transitive description: @@ -2072,38 +2009,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" - workmanager: - dependency: "direct main" - description: - name: workmanager - sha256: "065673b2a465865183093806925419d311a9a5e0995aa74ccf8920fd695e2d10" - url: "https://pub.dev" - source: hosted - version: "0.9.0+3" - workmanager_android: - dependency: transitive - description: - name: workmanager_android - sha256: "9ae744db4ef891f5fcd2fb8671fccc712f4f96489a487a1411e0c8675e5e8cb7" - url: "https://pub.dev" - source: hosted - version: "0.9.0+2" - workmanager_apple: - dependency: transitive - description: - name: workmanager_apple - sha256: "1cc12ae3cbf5535e72f7ba4fde0c12dd11b757caf493a28e22d684052701f2ca" - url: "https://pub.dev" - source: hosted - version: "0.9.1+2" - workmanager_platform_interface: - dependency: "direct dev" - description: - name: workmanager_platform_interface - sha256: f40422f10b970c67abb84230b44da22b075147637532ac501729256fcea10a47 - url: "https://pub.dev" - source: hosted - version: "0.9.1+1" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index b8abeaa3..329f102f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -47,7 +47,6 @@ dependencies: font_awesome_flutter: ^11.0.0 share_plus: ^13.1.0 package_info_plus: ^10.1.0 - workmanager: ^0.9.0+3 # Trustworthy publishers @@ -81,9 +80,7 @@ dependencies: cryptography_flutter_plus: ^3.0.0 cryptography_plus: ^3.0.0 flutter_android_volume_keydown: ^1.0.1 - flutter_image_compress: ^2.4.0 flutter_volume_controller: ^1.3.4 - gal: ^2.3.1 photo_manager: ^3.9.0 google_mlkit_barcode_scanning: ^0.14.1 google_mlkit_face_detection: ^0.13.1 @@ -113,7 +110,6 @@ dependencies: lottie: ^3.5.1 mutex: ^3.1.0 photo_view: ^0.15.0 - pro_video_editor: ^2.11.3 qr_flutter: ^4.1.0 qr: ^3.1.0-wip restart_app: ^1.7.3 @@ -176,8 +172,6 @@ dependency_overrides: path: ./dependencies/mutex photo_view: path: ./dependencies/photo_view - pro_video_editor: - path: ./dependencies/pro_video_editor qr_flutter: path: ./dependencies/qr_flutter qr: @@ -202,7 +196,6 @@ dev_dependencies: in_app_purchase_platform_interface: ^1.4.0 integration_test: sdk: flutter - workmanager_platform_interface: any freezed: ^4.0.0 flutter_launcher_icons: @@ -239,4 +232,3 @@ flutter: - family: NotoColorEmoji fonts: - asset: assets/fonts/NotoColorEmoji.ttf - diff --git a/rust/.sqlx/query-0c67ef7dacbe15ba15e0251896fda6b06f826ad9500012cc539b345acf541e31.json b/rust/.sqlx/query-0c67ef7dacbe15ba15e0251896fda6b06f826ad9500012cc539b345acf541e31.json new file mode 100644 index 00000000..0138457a --- /dev/null +++ b/rust/.sqlx/query-0c67ef7dacbe15ba15e0251896fda6b06f826ad9500012cc539b345acf541e31.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "SELECT reupload_requested_by FROM media_files WHERE media_id = ?", + "describe": { + "columns": [ + { + "name": "reupload_requested_by", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "media_files", + "name": "reupload_requested_by" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true + ] + }, + "hash": "0c67ef7dacbe15ba15e0251896fda6b06f826ad9500012cc539b345acf541e31" +} diff --git a/rust/.sqlx/query-11d039e3120bb525a51abef30edca4a0dd66b7a8a14fe654e3996484e2da7b55.json b/rust/.sqlx/query-11d039e3120bb525a51abef30edca4a0dd66b7a8a14fe654e3996484e2da7b55.json new file mode 100644 index 00000000..051050ba --- /dev/null +++ b/rust/.sqlx/query-11d039e3120bb525a51abef30edca4a0dd66b7a8a14fe654e3996484e2da7b55.json @@ -0,0 +1,21 @@ +{ + "db_name": "SQLite", + "query": "SELECT COUNT(*) FROM media_files WHERE is_draft_media = 1", + "describe": { + "columns": [ + { + "name": "COUNT(*)", + "ordinal": 0, + "type_info": "Integer", + "origin": "Expression" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false + ] + }, + "hash": "11d039e3120bb525a51abef30edca4a0dd66b7a8a14fe654e3996484e2da7b55" +} diff --git a/rust/.sqlx/query-171f02051bfe0b07af544652524c7d56665033c64c180972ebd3bf44cbc34a54.json b/rust/.sqlx/query-171f02051bfe0b07af544652524c7d56665033c64c180972ebd3bf44cbc34a54.json new file mode 100644 index 00000000..182bac73 --- /dev/null +++ b/rust/.sqlx/query-171f02051bfe0b07af544652524c7d56665033c64c180972ebd3bf44cbc34a54.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "SELECT media_send_counter, media_received_counter FROM contacts WHERE user_id = 7", + "describe": { + "columns": [ + { + "name": "media_send_counter", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "contacts", + "name": "media_send_counter" + } + } + }, + { + "name": "media_received_counter", + "ordinal": 1, + "type_info": "Integer", + "origin": { + "Table": { + "table": "contacts", + "name": "media_received_counter" + } + } + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false + ] + }, + "hash": "171f02051bfe0b07af544652524c7d56665033c64c180972ebd3bf44cbc34a54" +} diff --git a/rust/.sqlx/query-1838231f7a50c805bea5bea556ddc7f58bc3147ddfa96a0e1067a5302694f1e0.json b/rust/.sqlx/query-1838231f7a50c805bea5bea556ddc7f58bc3147ddfa96a0e1067a5302694f1e0.json new file mode 100644 index 00000000..31298030 --- /dev/null +++ b/rust/.sqlx/query-1838231f7a50c805bea5bea556ddc7f58bc3147ddfa96a0e1067a5302694f1e0.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n UPDATE receipts\n SET deferred_until_session = CAST(strftime('%s', 'now') AS INTEGER)\n WHERE receipt_id = ?\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "1838231f7a50c805bea5bea556ddc7f58bc3147ddfa96a0e1067a5302694f1e0" +} diff --git a/rust/.sqlx/query-19645cdadf2646e2c3afb1f09dd509a62222baebc54743f3485368fdf27c167a.json b/rust/.sqlx/query-19645cdadf2646e2c3afb1f09dd509a62222baebc54743f3485368fdf27c167a.json deleted file mode 100644 index 02b99fe6..00000000 --- a/rust/.sqlx/query-19645cdadf2646e2c3afb1f09dd509a62222baebc54743f3485368fdf27c167a.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO media_files(media_id, type, download_state, upload_state) VALUES (?, 'image', 'ready', 'uploaded')", - "describe": { - "columns": [], - "parameters": { - "Right": 1 - }, - "nullable": [] - }, - "hash": "19645cdadf2646e2c3afb1f09dd509a62222baebc54743f3485368fdf27c167a" -} diff --git a/rust/.sqlx/query-2cd83f566f77c7f91ad0ceccf7bcf6c33cfb6885d2add6be4438b1e2e639ea49.json b/rust/.sqlx/query-2cd83f566f77c7f91ad0ceccf7bcf6c33cfb6885d2add6be4438b1e2e639ea49.json new file mode 100644 index 00000000..6822b498 --- /dev/null +++ b/rust/.sqlx/query-2cd83f566f77c7f91ad0ceccf7bcf6c33cfb6885d2add6be4438b1e2e639ea49.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT receipt_id FROM receipts\n WHERE will_be_retried_by_media_upload = 0\n AND deferred_until_session IS NULL\n AND (ack_by_server_at IS NULL OR mark_for_retry IS NOT NULL)\n AND (mark_for_retry_after_accepted IS NULL OR EXISTS(\n SELECT 1 FROM contacts\n WHERE contacts.user_id = receipts.contact_id AND contacts.accepted = 1\n ))\n ORDER BY created_at\n ", + "describe": { + "columns": [ + { + "name": "receipt_id", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "receipts", + "name": "receipt_id" + } + } + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false + ] + }, + "hash": "2cd83f566f77c7f91ad0ceccf7bcf6c33cfb6885d2add6be4438b1e2e639ea49" +} diff --git a/rust/.sqlx/query-3fb06e1bcd7f022aefe5f96121bce55c7d81f48f24b3209139a74ddb9a65a46a.json b/rust/.sqlx/query-3fb06e1bcd7f022aefe5f96121bce55c7d81f48f24b3209139a74ddb9a65a46a.json new file mode 100644 index 00000000..2e793f41 --- /dev/null +++ b/rust/.sqlx/query-3fb06e1bcd7f022aefe5f96121bce55c7d81f48f24b3209139a74ddb9a65a46a.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO media_files(media_id, type, upload_state) VALUES (?, 'image', 'backgroundUploadTaskStarted')", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "3fb06e1bcd7f022aefe5f96121bce55c7d81f48f24b3209139a74ddb9a65a46a" +} diff --git a/rust/.sqlx/query-4697f56357a2f8f6b68eeb3150f770c9ce478f28f89a46be6f1f875a099bb61c.json b/rust/.sqlx/query-4697f56357a2f8f6b68eeb3150f770c9ce478f28f89a46be6f1f875a099bb61c.json new file mode 100644 index 00000000..333ff75e --- /dev/null +++ b/rust/.sqlx/query-4697f56357a2f8f6b68eeb3150f770c9ce478f28f89a46be6f1f875a099bb61c.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "SELECT is_draft_media FROM media_files WHERE media_id = ?", + "describe": { + "columns": [ + { + "name": "is_draft_media", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "media_files", + "name": "is_draft_media" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "4697f56357a2f8f6b68eeb3150f770c9ce478f28f89a46be6f1f875a099bb61c" +} diff --git a/rust/.sqlx/query-4b435d279b535dd175cda868bc0c6f5e54ef1be1b13433d3226bf5b3459f2119.json b/rust/.sqlx/query-4b435d279b535dd175cda868bc0c6f5e54ef1be1b13433d3226bf5b3459f2119.json new file mode 100644 index 00000000..663902e6 --- /dev/null +++ b/rust/.sqlx/query-4b435d279b535dd175cda868bc0c6f5e54ef1be1b13433d3226bf5b3459f2119.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO media_files\n (media_id, type, download_state, upload_state, encryption_key, encryption_nonce)\n VALUES (?, 'image', 'ready', 'uploaded', ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "4b435d279b535dd175cda868bc0c6f5e54ef1be1b13433d3226bf5b3459f2119" +} diff --git a/rust/.sqlx/query-640d4ca86b7c1d4b4836216e0bf11b9a8a67801326b8286cef264fb6d0aa758a.json b/rust/.sqlx/query-640d4ca86b7c1d4b4836216e0bf11b9a8a67801326b8286cef264fb6d0aa758a.json new file mode 100644 index 00000000..0841688b --- /dev/null +++ b/rust/.sqlx/query-640d4ca86b7c1d4b4836216e0bf11b9a8a67801326b8286cef264fb6d0aa758a.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "SELECT token FROM verification_tokens WHERE created_at >= ?", + "describe": { + "columns": [ + { + "name": "token", + "ordinal": 0, + "type_info": "Blob", + "origin": { + "Table": { + "table": "verification_tokens", + "name": "token" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "640d4ca86b7c1d4b4836216e0bf11b9a8a67801326b8286cef264fb6d0aa758a" +} diff --git a/rust/.sqlx/query-691fd5c99b857c046398f68bb59884c9dedb86737e0e9e57100d00ae0fcc9df9.json b/rust/.sqlx/query-691fd5c99b857c046398f68bb59884c9dedb86737e0e9e57100d00ae0fcc9df9.json new file mode 100644 index 00000000..7e01b330 --- /dev/null +++ b/rust/.sqlx/query-691fd5c99b857c046398f68bb59884c9dedb86737e0e9e57100d00ae0fcc9df9.json @@ -0,0 +1,74 @@ +{ + "db_name": "SQLite", + "query": "SELECT upload_state, display_limit_in_milliseconds, encryption_key,\n encryption_nonce, is_draft_media\n FROM media_files WHERE media_id = ?", + "describe": { + "columns": [ + { + "name": "upload_state", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "media_files", + "name": "upload_state" + } + } + }, + { + "name": "display_limit_in_milliseconds", + "ordinal": 1, + "type_info": "Integer", + "origin": { + "Table": { + "table": "media_files", + "name": "display_limit_in_milliseconds" + } + } + }, + { + "name": "encryption_key", + "ordinal": 2, + "type_info": "Blob", + "origin": { + "Table": { + "table": "media_files", + "name": "encryption_key" + } + } + }, + { + "name": "encryption_nonce", + "ordinal": 3, + "type_info": "Blob", + "origin": { + "Table": { + "table": "media_files", + "name": "encryption_nonce" + } + } + }, + { + "name": "is_draft_media", + "ordinal": 4, + "type_info": "Integer", + "origin": { + "Table": { + "table": "media_files", + "name": "is_draft_media" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + true, + true, + true, + false + ] + }, + "hash": "691fd5c99b857c046398f68bb59884c9dedb86737e0e9e57100d00ae0fcc9df9" +} diff --git a/rust/.sqlx/query-6ccbb2b713538eff9f1f875d34db485dea137371d504e1d3edf724174dff0264.json b/rust/.sqlx/query-6ccbb2b713538eff9f1f875d34db485dea137371d504e1d3edf724174dff0264.json deleted file mode 100644 index 9c88dd8d..00000000 --- a/rust/.sqlx/query-6ccbb2b713538eff9f1f875d34db485dea137371d504e1d3edf724174dff0264.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n UPDATE media_files\n SET upload_state = 'reuploadRequested',\n reupload_requested_by = ?\n WHERE media_id = ?\n ", - "describe": { - "columns": [], - "parameters": { - "Right": 2 - }, - "nullable": [] - }, - "hash": "6ccbb2b713538eff9f1f875d34db485dea137371d504e1d3edf724174dff0264" -} diff --git a/rust/.sqlx/query-7b99510fb5ac60158de57314bba961e1f332c51556ea52543937224fd5f96426.json b/rust/.sqlx/query-7b99510fb5ac60158de57314bba961e1f332c51556ea52543937224fd5f96426.json new file mode 100644 index 00000000..d87959b0 --- /dev/null +++ b/rust/.sqlx/query-7b99510fb5ac60158de57314bba961e1f332c51556ea52543937224fd5f96426.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE contacts SET signal_version = 'v2' WHERE user_id = ? AND signal_version != 'v2'", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "7b99510fb5ac60158de57314bba961e1f332c51556ea52543937224fd5f96426" +} diff --git a/rust/.sqlx/query-849126eacdf201dfda1c306bfa469fe7f1063f6d29b3d371af4e3a73fec69892.json b/rust/.sqlx/query-849126eacdf201dfda1c306bfa469fe7f1063f6d29b3d371af4e3a73fec69892.json new file mode 100644 index 00000000..e2be64a6 --- /dev/null +++ b/rust/.sqlx/query-849126eacdf201dfda1c306bfa469fe7f1063f6d29b3d371af4e3a73fec69892.json @@ -0,0 +1,21 @@ +{ + "db_name": "SQLite", + "query": "SELECT COUNT(*) FROM direct_media_upload_jobs", + "describe": { + "columns": [ + { + "name": "COUNT(*)", + "ordinal": 0, + "type_info": "Integer", + "origin": "Expression" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false + ] + }, + "hash": "849126eacdf201dfda1c306bfa469fe7f1063f6d29b3d371af4e3a73fec69892" +} diff --git a/rust/.sqlx/query-89e33f7bbef216fbb377471cf8d05c5f030a15a0ac5d4177ae2385a8f4ad4c2d.json b/rust/.sqlx/query-89e33f7bbef216fbb377471cf8d05c5f030a15a0ac5d4177ae2385a8f4ad4c2d.json new file mode 100644 index 00000000..46abcbb7 --- /dev/null +++ b/rust/.sqlx/query-89e33f7bbef216fbb377471cf8d05c5f030a15a0ac5d4177ae2385a8f4ad4c2d.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "SELECT download_token FROM media_files WHERE media_id = ?", + "describe": { + "columns": [ + { + "name": "download_token", + "ordinal": 0, + "type_info": "Blob", + "origin": { + "Table": { + "table": "media_files", + "name": "download_token" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true + ] + }, + "hash": "89e33f7bbef216fbb377471cf8d05c5f030a15a0ac5d4177ae2385a8f4ad4c2d" +} diff --git a/rust/.sqlx/query-8b9b84ef9e2621d83b53aec4798ce90d7c817188bf715c183a9fd291912ee351.json b/rust/.sqlx/query-8b9b84ef9e2621d83b53aec4798ce90d7c817188bf715c183a9fd291912ee351.json new file mode 100644 index 00000000..726bdd10 --- /dev/null +++ b/rust/.sqlx/query-8b9b84ef9e2621d83b53aec4798ce90d7c817188bf715c183a9fd291912ee351.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE groups SET\n total_media_counter = total_media_counter + 1,\n last_flame_counter_change = ?,\n last_message_received = ?,\n last_message_send = ?,\n flame_counter = ?,\n max_flame_counter = ?,\n max_flame_counter_from = ?\n WHERE group_id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 7 + }, + "nullable": [] + }, + "hash": "8b9b84ef9e2621d83b53aec4798ce90d7c817188bf715c183a9fd291912ee351" +} diff --git a/rust/.sqlx/query-8e15426be4c8d85e4c8cd6380c0884ad777e768b2275ce5995767912d8376e81.json b/rust/.sqlx/query-8e15426be4c8d85e4c8cd6380c0884ad777e768b2275ce5995767912d8376e81.json new file mode 100644 index 00000000..4d31ec1a --- /dev/null +++ b/rust/.sqlx/query-8e15426be4c8d85e4c8cd6380c0884ad777e768b2275ce5995767912d8376e81.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE contacts\n SET media_received_counter = media_received_counter + 1\n WHERE user_id IN (\n SELECT contact_id FROM group_members WHERE group_id = ?\n )", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "8e15426be4c8d85e4c8cd6380c0884ad777e768b2275ce5995767912d8376e81" +} diff --git a/rust/.sqlx/query-9827adc146370a1683144faef554d374c03a156bb55ff5368f47d0c046191d74.json b/rust/.sqlx/query-9827adc146370a1683144faef554d374c03a156bb55ff5368f47d0c046191d74.json new file mode 100644 index 00000000..4f5baa43 --- /dev/null +++ b/rust/.sqlx/query-9827adc146370a1683144faef554d374c03a156bb55ff5368f47d0c046191d74.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "SELECT media_id FROM media_files WHERE download_state = 'reuploadRequested'", + "describe": { + "columns": [ + { + "name": "media_id", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "media_files", + "name": "media_id" + } + } + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false + ] + }, + "hash": "9827adc146370a1683144faef554d374c03a156bb55ff5368f47d0c046191d74" +} diff --git a/rust/.sqlx/query-995548e67942e10d3d6334f66aa2cf6e4aa80baa39a8dce7c7ae029c94a62341.json b/rust/.sqlx/query-995548e67942e10d3d6334f66aa2cf6e4aa80baa39a8dce7c7ae029c94a62341.json new file mode 100644 index 00000000..4209e6a1 --- /dev/null +++ b/rust/.sqlx/query-995548e67942e10d3d6334f66aa2cf6e4aa80baa39a8dce7c7ae029c94a62341.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO direct_media_upload_jobs\n (attachment_id, media_id, multipart_path, manifest_path,\n complete_body_path, native_descriptor_json, state, expires_at)\n SELECT ?, ?, ?, ?, ?, '{}', 'scheduled', expires_at\n FROM direct_media_upload_slots WHERE attachment_id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 6 + }, + "nullable": [] + }, + "hash": "995548e67942e10d3d6334f66aa2cf6e4aa80baa39a8dce7c7ae029c94a62341" +} diff --git a/rust/.sqlx/query-9d453175e0cbbc67cce408e838fa04b78752136eb500a404a8b81e1cc8e7034b.json b/rust/.sqlx/query-9d453175e0cbbc67cce408e838fa04b78752136eb500a404a8b81e1cc8e7034b.json new file mode 100644 index 00000000..107459b4 --- /dev/null +++ b/rust/.sqlx/query-9d453175e0cbbc67cce408e838fa04b78752136eb500a404a8b81e1cc8e7034b.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE contacts\n SET media_send_counter = media_send_counter + 1\n WHERE user_id IN (\n SELECT contact_id FROM group_members WHERE group_id = ?\n )", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "9d453175e0cbbc67cce408e838fa04b78752136eb500a404a8b81e1cc8e7034b" +} diff --git a/rust/.sqlx/query-9dd7161496c2e88db0b7851661ba76a603da3bab19543ba9c7a36b33c171122a.json b/rust/.sqlx/query-9dd7161496c2e88db0b7851661ba76a603da3bab19543ba9c7a36b33c171122a.json new file mode 100644 index 00000000..160fa14e --- /dev/null +++ b/rust/.sqlx/query-9dd7161496c2e88db0b7851661ba76a603da3bab19543ba9c7a36b33c171122a.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE receipts SET deferred_until_session = NULL WHERE contact_id = ? AND deferred_until_session IS NOT NULL", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "9dd7161496c2e88db0b7851661ba76a603da3bab19543ba9c7a36b33c171122a" +} diff --git a/rust/.sqlx/query-aeb01be35d655b1c6f0675dcd3b9cade39a69b4b7882fe6949f90029d15993ee.json b/rust/.sqlx/query-aeb01be35d655b1c6f0675dcd3b9cade39a69b4b7882fe6949f90029d15993ee.json new file mode 100644 index 00000000..50f5b82e --- /dev/null +++ b/rust/.sqlx/query-aeb01be35d655b1c6f0675dcd3b9cade39a69b4b7882fe6949f90029d15993ee.json @@ -0,0 +1,86 @@ +{ + "db_name": "SQLite", + "query": "SELECT total_media_counter, flame_counter, max_flame_counter,\n last_message_send, last_message_received,\n last_flame_counter_change\n FROM groups WHERE group_id = 'group'", + "describe": { + "columns": [ + { + "name": "total_media_counter", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "groups", + "name": "total_media_counter" + } + } + }, + { + "name": "flame_counter", + "ordinal": 1, + "type_info": "Integer", + "origin": { + "Table": { + "table": "groups", + "name": "flame_counter" + } + } + }, + { + "name": "max_flame_counter", + "ordinal": 2, + "type_info": "Integer", + "origin": { + "Table": { + "table": "groups", + "name": "max_flame_counter" + } + } + }, + { + "name": "last_message_send", + "ordinal": 3, + "type_info": "Integer", + "origin": { + "Table": { + "table": "groups", + "name": "last_message_send" + } + } + }, + { + "name": "last_message_received", + "ordinal": 4, + "type_info": "Integer", + "origin": { + "Table": { + "table": "groups", + "name": "last_message_received" + } + } + }, + { + "name": "last_flame_counter_change", + "ordinal": 5, + "type_info": "Integer", + "origin": { + "Table": { + "table": "groups", + "name": "last_flame_counter_change" + } + } + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + false, + true, + true, + true + ] + }, + "hash": "aeb01be35d655b1c6f0675dcd3b9cade39a69b4b7882fe6949f90029d15993ee" +} diff --git a/rust/.sqlx/query-bdf69a9fec2ffc402c655567c8b29eb77ab35a1dcaf3c1f129b83c331da33329.json b/rust/.sqlx/query-bdf69a9fec2ffc402c655567c8b29eb77ab35a1dcaf3c1f129b83c331da33329.json deleted file mode 100644 index dd59eec8..00000000 --- a/rust/.sqlx/query-bdf69a9fec2ffc402c655567c8b29eb77ab35a1dcaf3c1f129b83c331da33329.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n SELECT receipt_id FROM receipts\n WHERE will_be_retried_by_media_upload = 0\n AND (ack_by_server_at IS NULL OR mark_for_retry IS NOT NULL)\n AND (mark_for_retry_after_accepted IS NULL OR EXISTS(\n SELECT 1 FROM contacts\n WHERE contacts.user_id = receipts.contact_id AND contacts.accepted = 1\n ))\n ORDER BY created_at\n ", - "describe": { - "columns": [ - { - "name": "receipt_id", - "ordinal": 0, - "type_info": "Text", - "origin": { - "Table": { - "table": "receipts", - "name": "receipt_id" - } - } - } - ], - "parameters": { - "Right": 0 - }, - "nullable": [ - false - ] - }, - "hash": "bdf69a9fec2ffc402c655567c8b29eb77ab35a1dcaf3c1f129b83c331da33329" -} diff --git a/rust/.sqlx/query-d124b935c2f00e351d97f4413093df70c43890d26eb6d4fd593d5a59ce3cc261.json b/rust/.sqlx/query-d124b935c2f00e351d97f4413093df70c43890d26eb6d4fd593d5a59ce3cc261.json new file mode 100644 index 00000000..156483e0 --- /dev/null +++ b/rust/.sqlx/query-d124b935c2f00e351d97f4413093df70c43890d26eb6d4fd593d5a59ce3cc261.json @@ -0,0 +1,21 @@ +{ + "db_name": "SQLite", + "query": "SELECT COUNT(*) FROM direct_media_upload_jobs WHERE attachment_id = ?", + "describe": { + "columns": [ + { + "name": "COUNT(*)", + "ordinal": 0, + "type_info": "Integer", + "origin": "Expression" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "d124b935c2f00e351d97f4413093df70c43890d26eb6d4fd593d5a59ce3cc261" +} diff --git a/rust/.sqlx/query-e4de0df8339d2993da31717738cb55789f8f7388b1b8d66677f62ac3ea8d973d.json b/rust/.sqlx/query-e4de0df8339d2993da31717738cb55789f8f7388b1b8d66677f62ac3ea8d973d.json new file mode 100644 index 00000000..4ec99373 --- /dev/null +++ b/rust/.sqlx/query-e4de0df8339d2993da31717738cb55789f8f7388b1b8d66677f62ac3ea8d973d.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "SELECT state FROM direct_media_upload_slots WHERE attachment_id = ?", + "describe": { + "columns": [ + { + "name": "state", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "direct_media_upload_slots", + "name": "state" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "e4de0df8339d2993da31717738cb55789f8f7388b1b8d66677f62ac3ea8d973d" +} diff --git a/rust/.sqlx/query-e585b96b1b6bf08be34cbd2363279b75d76e8e5d64cfba5a120c03e9cf60b924.json b/rust/.sqlx/query-e585b96b1b6bf08be34cbd2363279b75d76e8e5d64cfba5a120c03e9cf60b924.json new file mode 100644 index 00000000..3ce03cb3 --- /dev/null +++ b/rust/.sqlx/query-e585b96b1b6bf08be34cbd2363279b75d76e8e5d64cfba5a120c03e9cf60b924.json @@ -0,0 +1,86 @@ +{ + "db_name": "SQLite", + "query": "SELECT last_message_send, last_message_received,\n last_flame_counter_change, flame_counter,\n max_flame_counter, max_flame_counter_from\n FROM groups WHERE group_id = ?", + "describe": { + "columns": [ + { + "name": "last_message_send", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "groups", + "name": "last_message_send" + } + } + }, + { + "name": "last_message_received", + "ordinal": 1, + "type_info": "Integer", + "origin": { + "Table": { + "table": "groups", + "name": "last_message_received" + } + } + }, + { + "name": "last_flame_counter_change", + "ordinal": 2, + "type_info": "Integer", + "origin": { + "Table": { + "table": "groups", + "name": "last_flame_counter_change" + } + } + }, + { + "name": "flame_counter", + "ordinal": 3, + "type_info": "Integer", + "origin": { + "Table": { + "table": "groups", + "name": "flame_counter" + } + } + }, + { + "name": "max_flame_counter", + "ordinal": 4, + "type_info": "Integer", + "origin": { + "Table": { + "table": "groups", + "name": "max_flame_counter" + } + } + }, + { + "name": "max_flame_counter_from", + "ordinal": 5, + "type_info": "Integer", + "origin": { + "Table": { + "table": "groups", + "name": "max_flame_counter_from" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + true, + true, + false, + false, + true + ] + }, + "hash": "e585b96b1b6bf08be34cbd2363279b75d76e8e5d64cfba5a120c03e9cf60b924" +} diff --git a/rust/.sqlx/query-f99a73efe31db041db33f6bffac4709af3aa142fb9516c8058a01fdc6e04dc0a.json b/rust/.sqlx/query-f99a73efe31db041db33f6bffac4709af3aa142fb9516c8058a01fdc6e04dc0a.json new file mode 100644 index 00000000..2c3b37c0 --- /dev/null +++ b/rust/.sqlx/query-f99a73efe31db041db33f6bffac4709af3aa142fb9516c8058a01fdc6e04dc0a.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE direct_media_upload_slots SET state = 'reserved' WHERE attachment_id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "f99a73efe31db041db33f6bffac4709af3aa142fb9516c8058a01fdc6e04dc0a" +} diff --git a/rust/.sqlx/query-fbc1e1631eb0d9c0f6ee05c97ed181138d9a68fe0f49acde06d3d2539c59822e.json b/rust/.sqlx/query-fbc1e1631eb0d9c0f6ee05c97ed181138d9a68fe0f49acde06d3d2539c59822e.json new file mode 100644 index 00000000..57733898 --- /dev/null +++ b/rust/.sqlx/query-fbc1e1631eb0d9c0f6ee05c97ed181138d9a68fe0f49acde06d3d2539c59822e.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "SELECT encryption_mac FROM media_files WHERE media_id = ?", + "describe": { + "columns": [ + { + "name": "encryption_mac", + "ordinal": 0, + "type_info": "Blob", + "origin": { + "Table": { + "table": "media_files", + "name": "encryption_mac" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true + ] + }, + "hash": "fbc1e1631eb0d9c0f6ee05c97ed181138d9a68fe0f49acde06d3d2539c59822e" +} diff --git a/rust/.sqlx/query-fe8a8fe73432311204a95160b282b3534599a7e4ea290120083b058532a305d9.json b/rust/.sqlx/query-fe8a8fe73432311204a95160b282b3534599a7e4ea290120083b058532a305d9.json new file mode 100644 index 00000000..83f8f6d2 --- /dev/null +++ b/rust/.sqlx/query-fe8a8fe73432311204a95160b282b3534599a7e4ea290120083b058532a305d9.json @@ -0,0 +1,21 @@ +{ + "db_name": "SQLite", + "query": "SELECT COUNT(*) FROM direct_media_upload_slots WHERE state = 'reserved'", + "describe": { + "columns": [ + { + "name": "COUNT(*)", + "ordinal": 0, + "type_info": "Integer", + "origin": "Expression" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false + ] + }, + "hash": "fe8a8fe73432311204a95160b282b3534599a7e4ea290120083b058532a305d9" +} diff --git a/rust/Cargo.lock b/rust/Cargo.lock index d1e3f665..0e3cd50f 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -421,6 +421,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.12.1" @@ -443,6 +449,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex 2.0.1", ] @@ -571,6 +579,12 @@ dependencies = [ "thiserror 2.0.19", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "combine" version = "4.6.7" @@ -1591,6 +1605,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gimli" version = "0.32.3" @@ -2059,6 +2083,34 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png 0.18.1", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + [[package]] name = "imagesize" version = "0.13.0" @@ -2194,6 +2246,16 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + [[package]] name = "js-sys" version = "0.3.103" @@ -2438,6 +2500,16 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "libwebp-sys" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54cd30df7c7165ce74a456e4ca9732c603e8dc5e60784558c1c6dc047f876733" +dependencies = [ + "cc", + "glob", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -2533,6 +2605,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "multimap" version = "0.10.1" @@ -2880,6 +2962,19 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "poly1305" version = "0.8.0" @@ -3113,6 +3208,18 @@ dependencies = [ "prost", ] +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quinn" version = "0.11.11" @@ -3453,9 +3560,11 @@ dependencies = [ "futures-util", "hex", "hkdf 0.12.4", - "hmac 0.13.0", + "hmac 0.12.1", + "image", "jni", "keyring-core", + "libc", "libsignal-protocol", "libsqlite3-sys", "p384", @@ -3486,6 +3595,7 @@ dependencies = [ "tracing-subscriber", "uuid", "walkdir", + "webp", "zeroize", "zip", ] @@ -4383,7 +4493,7 @@ dependencies = [ "bytemuck", "cfg-if", "log", - "png", + "png 0.17.16", "tiny-skia-path", ] @@ -5034,6 +5144,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webp" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c071456adef4aca59bf6a583c46b90ff5eb0b4f758fc347cea81290288f37ce1" +dependencies = [ + "image", + "libwebp-sys", +] + [[package]] name = "webpki-roots" version = "1.0.9" @@ -5043,6 +5163,12 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "whoami" version = "2.1.2" @@ -5454,3 +5580,18 @@ dependencies = [ "log", "simd-adler32", ] + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 45d42a7e..c52d52dd 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -30,7 +30,7 @@ prost = "0.14.1" blahaj = "0.6.0" serde_json = "1.0" base64 = "0.22.1" -hmac = "0.13.0" +hmac = "0.12.1" hkdf = "0.12.4" sha2 = "0.10.8" aes-gcm = "0.10.3" @@ -50,7 +50,7 @@ walkdir = "2.5.0" libsignal-protocol = { git = "https://github.com/signalapp/libsignal", version = "0.1.0", rev = "44a6dd8fc9f9d6903b32842b80b3894a75678418" } rand08 = { version = "0.8.5", package = "rand" } rand = "0.9.4" -uuid = { version = "1", features = ["v4"] } +uuid = { version = "1", features = ["v4", "v7"] } flate2 = "1" resvg = { version = "0.45", default-features = false, features = ["text"] } reqwest = { version = "0.12", default-features = false, features = [ @@ -74,9 +74,12 @@ p384 = { version = "0.13.0", default-features = false, features = [ "voprf", ] } stream-tungstenite = "0.6.1" +image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "gif", "bmp", "webp"] } +webp = "0.3.1" [target.'cfg(target_os = "ios")'.dependencies] # iOS backend: Requires the 'protected' feature for Data Protection Keychain apple-native-keyring-store = { version = "1", features = ["protected"] } +libc = "0.2" [target.'cfg(target_os = "android")'.dependencies] # Android backend: Interfaces with the Android Keystore android-native-keyring-store = "1" diff --git a/rust/build.rs b/rust/build.rs index 778cbba7..a90c9925 100644 --- a/rust/build.rs +++ b/rust/build.rs @@ -39,6 +39,7 @@ fn main() -> Result<()> { prost_build::Config::new() .include_file("client_messages.rs") .compile_protos(&client_protos, &[client_proto_root])?; + println!("cargo:rerun-if-changed=src/api/proto/api/http/http_requests.proto"); prost_build::compile_protos( &["src/api/proto/api/http/http_requests.proto"], &["src/api/proto/"], diff --git a/rust/src/api/messages/incoming.rs b/rust/src/api/messages/incoming.rs index f8ddf6c1..8c1e2bf5 100644 --- a/rust/src/api/messages/incoming.rs +++ b/rust/src/api/messages/incoming.rs @@ -218,16 +218,9 @@ async fn upgrade_legacy_session_to_v2( ) -> Result<()> { tracing::info!("Received legacy signal message; rejecting and upgrading session to v2"); - let has_v2_session = { - let rust_database = ctx.rust_db.read().await.clone(); - sqlx::query_scalar!( - "SELECT EXISTS(SELECT 1 FROM signal_sessions WHERE name = ? AND device_id = 1)", - from_user_id.to_string(), - ) - .fetch_one(&rust_database.pool) - .await? - != 0 - }; + let has_v2_session = ContactService::new(ctx) + .has_v2_session(from_user_id) + .await?; let is_v2_contact = sqlx::query_scalar!( "SELECT signal_version FROM contacts WHERE user_id = ?", @@ -360,6 +353,26 @@ pub(crate) async fn handle_decoded_server_message( }; match decrypted { Ok(plaintext) => { + // Decrypting proves the peer speaks v2, so drop any 'v1' + // marking left by a contact lookup that ran before the peer + // published a prekey bundle. Otherwise every receipt back to + // them would keep fetching a bundle the server does not have. + sqlx::query!( + "UPDATE contacts SET signal_version = 'v2' WHERE user_id = ? AND signal_version != 'v2'", + from_user_id, + ) + .execute(&mut *t) + .await?; + + // Decrypting means a session now exists, so anything parked + // for want of one can go out again. + sqlx::query!( + "UPDATE receipts SET deferred_until_session = NULL WHERE contact_id = ? AND deferred_until_session IS NOT NULL", + from_user_id, + ) + .execute(&mut *t) + .await?; + let content = proto::EncryptedContent::decode(plaintext.as_slice()).map_err(|error| { TwonlyError::Generic(format!( @@ -494,7 +507,7 @@ async fn handle_encrypted_inner( } if let Some(update) = content.media_update { - return media::handle_media_update(t, from_user_id, update).await; + return media::handle_media_update(ctx, t, from_user_id, update).await; } if let Some(error) = content.error_messages { @@ -517,7 +530,7 @@ async fn handle_encrypted_inner( } if let Some(proof) = content.key_verification_proof { - return verification::handle_key_verification_proof(t, from_user_id, proof).await; + return verification::handle_key_verification_proof(ctx, t, from_user_id, proof).await; } if let Some(recovery) = content.passwordless_recovery { @@ -614,7 +627,7 @@ async fn handle_encrypted_inner( } if let Some(media) = content.media { - return media::handle_media(t, from_user_id, &group_id, media).await; + return media::handle_media(ctx, t, from_user_id, &group_id, media).await; } if let Some(reaction) = content.reaction { diff --git a/rust/src/api/messages/incoming/additional_data.rs b/rust/src/api/messages/incoming/additional_data.rs index ef7f3fe5..650e8f18 100644 --- a/rust/src/api/messages/incoming/additional_data.rs +++ b/rust/src/api/messages/incoming/additional_data.rs @@ -65,6 +65,11 @@ async fn verify_shared_contacts( let signal_database = ctx.rust_db.read().await.clone(); for contact in data.contacts { + if contact.public_identity_key.is_empty() { + tracing::info!("shared contact carries no public key, skipping verification"); + continue; + } + let stored_identity = sqlx::query_scalar!( "SELECT identity_key FROM signal_identities WHERE name = ?", contact.user_id.to_string(), diff --git a/rust/src/api/messages/incoming/contact.rs b/rust/src/api/messages/incoming/contact.rs index 613b078d..b6093798 100644 --- a/rust/src/api/messages/incoming/contact.rs +++ b/rust/src/api/messages/incoming/contact.rs @@ -8,7 +8,6 @@ use crate::api::proto::client::encrypted_content; use crate::api::proto::client::EncryptedContent; use crate::api::Server; use crate::bridge::api::ServerResult; -use crate::bridge::callbacks::get_callbacks; use crate::context::Context; use crate::database::app::tables::{ Contact, Group, GroupHistoryType, InsertGroupHistories, UpdateContact, @@ -227,6 +226,8 @@ pub(crate) async fn handle_contact_update( return Err(TwonlyError::Generic("invalid contact update".into())); } + let avatar_svg_compressed = update.avatar_svg_compressed.clone(); + let previous = sqlx::query!( "SELECT username, display_name FROM contacts WHERE user_id = ?", from_user_id, @@ -275,10 +276,18 @@ pub(crate) async fn handle_contact_update( .update(tr) .await?; - if let Ok(callbacks) = get_callbacks() { - tokio::spawn(async move { - (callbacks.api.create_push_avatars)(from_user_id).await; - }); + if let (Some(profile_counter), Some(avatar)) = + (sender_profile_counter, avatar_svg_compressed.as_deref()) + { + if let Err(error) = crate::services::notifications::notification_avatar_path( + ctx, + from_user_id, + profile_counter, + Some(avatar), + ) { + tracing::warn!(from_user_id, %error, "failed to render updated contact avatar"); + } } + Ok(()) } diff --git a/rust/src/api/messages/incoming/media.rs b/rust/src/api/messages/incoming/media.rs index 983f0987..81c0c9de 100644 --- a/rust/src/api/messages/incoming/media.rs +++ b/rust/src/api/messages/incoming/media.rs @@ -4,21 +4,18 @@ */ use crate::api::proto::client::encrypted_content; -use crate::bridge::callbacks::get_callbacks; use crate::context::Context; use crate::database::app::tables::Group; use crate::error::{Result, TwonlyError}; +use crate::services::media_upload::MediaUploadService; use crate::services::mediafiles::MediaFileService; use crate::utils::{milliseconds_to_seconds, new_uuid_v4}; use encrypted_content::media::Type as MediaType; use encrypted_content::media_update::Type as MediaUpdateType; use sqlx::{Sqlite, Transaction}; +use std::sync::Arc; -fn spawn_media_download(media_id: String) { - let Ok(ctx) = Context::get_static() else { - tracing::warn!(media_id, "could not start media download without context"); - return; - }; +fn spawn_media_download(ctx: &Arc, media_id: String) { let ctx = ctx.clone(); tokio::spawn(async move { if let Err(error) = MediaFileService::new(&ctx) @@ -30,15 +27,32 @@ fn spawn_media_download(media_id: String) { }); } -fn spawn_media_action(kind: &'static str, media_id: String, contact_id: i64, message_id: String) { - if let Ok(callbacks) = get_callbacks() { - tokio::spawn(async move { - (callbacks.api.media_action)(kind.to_owned(), media_id, contact_id, message_id).await; - }); - } +/// The receiver kept the media, so the sender keeps its own copy too. +fn spawn_media_store(ctx: &Arc, media_id: String) { + let ctx = ctx.clone(); + tokio::spawn(async move { + if let Err(error) = MediaUploadService::new(&ctx).store(&media_id).await { + tracing::warn!(media_id, %error, "storing media after a receiver response failed"); + } + }); +} + +/// The receiver could not decrypt the media, so it has to be re-encrypted and +/// uploaded again for that contact. +fn spawn_media_reupload(ctx: &Arc, media_id: String, contact_id: i64, message_id: String) { + let ctx = ctx.clone(); + tokio::spawn(async move { + if let Err(error) = MediaUploadService::new(&ctx) + .reupload(contact_id, &media_id, &message_id) + .await + { + tracing::warn!(media_id, %error, "media reupload failed"); + } + }); } pub(crate) async fn handle_media( + ctx: &Arc, t: &mut Transaction<'_, Sqlite>, from_user_id: i64, group_id: &str, @@ -83,7 +97,7 @@ pub(crate) async fn handle_media( .execute(&mut **t) .await?; - spawn_media_download(media_id); + spawn_media_download(ctx, media_id); return Ok(()); } @@ -140,7 +154,7 @@ pub(crate) async fn handle_media( .execute(&mut **t) .await?; - spawn_media_download(media_id); + spawn_media_download(ctx, media_id); return Ok(()); } @@ -198,21 +212,15 @@ pub(crate) async fn handle_media( .await?; Group::increase_last_message_exchange(t, group_id, timestamp).await?; + Group::record_media_exchange(t, group_id, true, timestamp).await?; - if let Ok(callbacks) = get_callbacks() { - let group_id = group_id.to_owned(); - let timestamp = media.timestamp; - tokio::spawn(async move { - (callbacks.api.media_received)(group_id, timestamp).await; - }); - } - - spawn_media_download(media_id); + spawn_media_download(ctx, media_id); Ok(()) } pub(crate) async fn handle_media_update( + ctx: &Arc, t: &mut Transaction<'_, Sqlite>, from_user_id: i64, update: encrypted_content::MediaUpdate, @@ -257,29 +265,13 @@ pub(crate) async fn handle_media_update( .execute(&mut **t) .await?; - spawn_media_action( - "stored", - media_id.clone(), - from_user_id, - update.target_message_id.clone(), - ); + spawn_media_store(ctx, media_id.clone()); } MediaUpdateType::DecryptionError => { - let requested_by = from_user_id.to_string(); - sqlx::query!( - r#" - UPDATE media_files - SET upload_state = 'reuploadRequested', - reupload_requested_by = ? - WHERE media_id = ? - "#, - requested_by, - media_id, - ) - .execute(&mut **t) - .await?; - - spawn_media_action("reupload", media_id, from_user_id, update.target_message_id); + // The upload state and the requester list are owned by the reupload + // path itself, which appends this contact instead of replacing + // whoever else is still waiting for the same media. + spawn_media_reupload(ctx, media_id, from_user_id, update.target_message_id); } } Ok(()) diff --git a/rust/src/api/messages/incoming/messages.rs b/rust/src/api/messages/incoming/messages.rs index 5081e3a5..e11cd459 100644 --- a/rust/src/api/messages/incoming/messages.rs +++ b/rust/src/api/messages/incoming/messages.rs @@ -380,9 +380,23 @@ async fn prepare_queued_receipt_from_row( if is_encrypted { if row.signal_version != "v2" { - ContactService::new(ctx) + let contacts = ContactService::new(ctx); + if let Err(error) = contacts .establish_signal_session(row.contact_id, None) - .await?; + .await + { + // The peer may have published no prekey bundle yet, for example + // when their account predates the PQC keys. An existing session + // still encrypts for them, so only a peer we cannot talk to at + // all keeps the payload queued. + if !contacts.has_v2_session(row.contact_id).await? { + return Err(error); + } + tracing::warn!( + contact_id = row.contact_id, + "prekey bundle unavailable; encrypting with the existing v2 session: {error}" + ); + } } let plaintext = message.encrypted_content.take().ok_or_else(|| { @@ -443,7 +457,22 @@ pub(crate) async fn send_queued_receipt(ctx: &Arc, receipt_id: &str) -> return Ok(()); } - let receipt = prepare_queued_receipt_from_row(ctx, receipt_id, row).await?; + let receipt = match prepare_queued_receipt_from_row(ctx, receipt_id, row).await { + Ok(receipt) => receipt, + // `prepare_queued_receipt_from_row` only lets this through when there + // is no usable session either, so there is nothing to retry against + // until the peer reappears. + Err(TwonlyError::PeerHasNoPrekeyBundle(contact_id)) => { + tracing::info!( + receipt_id, + contact_id, + "peer has no prekey bundle and no session; deferring the receipt until one exists" + ); + defer_receipt_until_session(&app_db, receipt_id).await?; + return Ok(()); + } + Err(error) => return Err(error), + }; let sent_sealed = SealedSenderService::try_send(ctx, &receipt).await?; @@ -539,12 +568,54 @@ pub(crate) async fn send_queued_receipt(ctx: &Arc, receipt_id: &str) -> Ok(()) } +/// Parks a receipt that cannot be encrypted until the peer becomes reachable. +/// +/// The peer has published no prekey bundle and we hold no session with them, so +/// every sweep would repeat the same failing server round-trip. The receipt is +/// kept, not dropped: `release_deferred_receipts` puts it back in the queue as +/// soon as a session exists. +async fn defer_receipt_until_session( + database: &Arc, + receipt_id: &str, +) -> Result<()> { + sqlx::query!( + r#" + UPDATE receipts + SET deferred_until_session = CAST(strftime('%s', 'now') AS INTEGER) + WHERE receipt_id = ? + "#, + receipt_id, + ) + .execute(&database.pool) + .await?; + database.notify_committed(["receipts"]); + Ok(()) +} + +/// Requeues everything parked for a peer we can now encrypt for. +/// +/// Called wherever a v2 session appears: a prekey bundle fetch that succeeded, +/// or an inbound message from the peer that opened a session on our side. +pub(crate) async fn release_deferred_receipts( + database: &Arc, + contact_id: i64, +) -> Result<()> { + sqlx::query!( + "UPDATE receipts SET deferred_until_session = NULL WHERE contact_id = ? AND deferred_until_session IS NOT NULL", + contact_id, + ) + .execute(&database.pool) + .await?; + Ok(()) +} + pub async fn retransmit_queued_receipts(ctx: &Arc) -> Result<()> { let database = ctx.app_db.read().await.clone(); let receipt_ids = sqlx::query_scalar!( r#" SELECT receipt_id FROM receipts WHERE will_be_retried_by_media_upload = 0 + AND deferred_until_session IS NULL AND (ack_by_server_at IS NULL OR mark_for_retry IS NOT NULL) AND (mark_for_retry_after_accepted IS NULL OR EXISTS( SELECT 1 FROM contacts diff --git a/rust/src/api/messages/incoming/verification.rs b/rust/src/api/messages/incoming/verification.rs index f7ffd7a7..89816bab 100644 --- a/rust/src/api/messages/incoming/verification.rs +++ b/rust/src/api/messages/incoming/verification.rs @@ -5,17 +5,91 @@ use crate::api::proto::client::encrypted_content; use crate::bridge::callbacks::get_callbacks; +use crate::context::Context; +use crate::database::app::tables::{KeyVerificationType, NewKeyVerification}; use crate::error::Result; +use crate::user_config::UserConfig; +use hmac::{Hmac, Mac}; +use sha2::Sha256; use sqlx::{Sqlite, Transaction}; +use std::sync::Arc; pub(crate) async fn handle_key_verification_proof( - _transaction: &mut Transaction<'_, Sqlite>, + ctx: &Arc, + transaction: &mut Transaction<'_, Sqlite>, from_user_id: i64, proof: encrypted_content::KeyVerificationProof, ) -> Result<()> { + let signal_engine = ctx.signal_engine.lock().await; + let Some(engine) = signal_engine.as_ref() else { + tracing::warn!( + from_user_id, + "verification proof arrived before Signal initialized" + ); + return Ok(()); + }; + let Some(contact_public_key) = engine + .get_contact_identity_key(&from_user_id.to_string()) + .await? + else { + tracing::warn!( + from_user_id, + "verification proof has no stored contact identity" + ); + return Ok(()); + }; + let own_public_key = engine.get_identity_key().await?; + drop(signal_engine); + + let own_user_id = ctx.user_id().await?; + let mut signed_data = Vec::with_capacity(16 + own_public_key.len() + contact_public_key.len()); + signed_data.extend_from_slice(&own_user_id.to_le_bytes()); + signed_data.extend_from_slice(&own_public_key); + signed_data.extend_from_slice(&from_user_id.to_le_bytes()); + signed_data.extend_from_slice(&contact_public_key); + + let cutoff = chrono::Utc::now().timestamp() - 60 * 60; + let tokens = sqlx::query_scalar!( + "SELECT token FROM verification_tokens WHERE created_at >= ?", + cutoff, + ) + .fetch_all(&mut **transaction) + .await?; + + let verified = tokens.into_iter().any(|token| { + Hmac::::new_from_slice(&token).is_ok_and(|mut mac| { + mac.update(&signed_data); + mac.verify_slice(&proof.calculated_mac).is_ok() + }) + }); + if !verified { + tracing::warn!( + from_user_id, + "verification proof did not match a recent token" + ); + return Ok(()); + } + + NewKeyVerification::new(from_user_id, KeyVerificationType::SecretQrToken) + .insert(transaction) + .await?; + + if UserConfig::load_required_from(ctx)?.is_user_discovery_enabled { + ctx.user_discovery + .get() + .await + .update_verification_state_for_user( + from_user_id, + Some(chrono::Utc::now().timestamp_millis()), + transaction, + ) + .await?; + } + + tracing::info!(from_user_id, "contact verified via secret QR token"); if let Ok(callbacks) = get_callbacks() { tokio::spawn(async move { - (callbacks.api.verification_proof)(from_user_id, proof.calculated_mac).await; + (callbacks.api.verification_succeeded)(from_user_id).await; }); } Ok(()) diff --git a/rust/src/api/proto/api/http/http_requests.proto b/rust/src/api/proto/api/http/http_requests.proto index de96b982..b764c902 100644 --- a/rust/src/api/proto/api/http/http_requests.proto +++ b/rust/src/api/proto/api/http/http_requests.proto @@ -13,6 +13,56 @@ message SealedSenderMessageResponse { string message_id = 1; } +message RequestUploadSlots { + repeated string locally_known_slot_ids = 1; +} + +message PresignedPostV2 { + string url = 1; + map fields = 2; +} + +message UploadSlot { + string attachment_id = 1; + int64 expires_at_unix_seconds = 2; + int64 maximum_object_bytes = 3; + PresignedPostV2 media_upload = 4; + bytes background_capability = 5; +} + +message UploadSlots { + repeated UploadSlot slots = 1; + int32 maximum_active_slots = 2; + int32 refill_threshold = 3; +} + +message AttachmentDispatch { + int64 recipient_user_id = 1; + bytes encrypted_body = 2; + optional bytes push_data = 3; + bytes download_token = 4; +} + +message AttachmentManifest { + int64 encrypted_object_size = 1; + repeated AttachmentDispatch dispatches = 2; +} + +enum AttachmentState { + ATTACHMENT_STATE_UNKNOWN = 0; + ATTACHMENT_STATE_RESERVED = 1; + ATTACHMENT_STATE_UPLOADING = 2; + ATTACHMENT_STATE_READY = 3; + ATTACHMENT_STATE_REJECTED = 4; + ATTACHMENT_STATE_EXPIRED = 5; + ATTACHMENT_STATE_ABANDONED = 6; +} + +message AttachmentStatus { + AttachmentState state = 1; + string rejection_reason = 2; +} + message UpdateGroupState { message UpdateTBS { uint64 version_id = 1; diff --git a/rust/src/api/proto/api/websocket/error.proto b/rust/src/api/proto/api/websocket/error.proto index edc2256d..3fb85ae6 100644 --- a/rust/src/api/proto/api/websocket/error.proto +++ b/rust/src/api/proto/api/websocket/error.proto @@ -45,4 +45,5 @@ enum ErrorCode { ForegroundSessionConnected = 1036; NoRecoveryData = 1037; InvalidRecoveryData = 1038; + PrivacyPassQuotaExhausted = 1039; } diff --git a/rust/src/api/runtime/helpers.rs b/rust/src/api/runtime/helpers.rs index f6db9770..ea15e9a8 100644 --- a/rust/src/api/runtime/helpers.rs +++ b/rust/src/api/runtime/helpers.rs @@ -11,6 +11,7 @@ use crate::api::Server; use crate::bridge::api::ServerResult; use crate::context::{Context, RuntimeMode}; use crate::error::{Result, TwonlyError}; +use crate::services::direct_media_upload::DirectMediaUploadService; use crate::services::groups::GroupService; use crate::services::mediafiles::MediaFileService; use crate::services::privacy_pass::PrivacyPassTokens; @@ -67,6 +68,19 @@ pub(crate) fn schedule_post_authentication(ctx: &Arc, in_background: bo tracing::warn!("failed to refill the Privacy Pass token pool: {error}"); } + // Runs before the receipt sweep below: without a bundle on the server + // no peer can open a session with this account, so anything they have + // queued for us stays stuck. + if let Err(error) = Server::ensure_pqc_bundle_published(&ctx).await { + tracing::warn!("failed to verify this account's PQC prekey bundle: {error}"); + } + + // Direct-media uploads consume Rust-owned slots, so keep their cache + // warm whenever an authenticated connection becomes available. + if let Err(error) = DirectMediaUploadService::new(&ctx).preload_slots().await { + tracing::warn!("failed to preload direct-media upload slots: {error}"); + } + if let Err(error) = messages::retransmit_queued_receipts(&ctx).await { tracing::warn!("failed to retransmit queued receipts: {error}"); } diff --git a/rust/src/api/server/prekeys.rs b/rust/src/api/server/prekeys.rs index ec7d8b14..9ff221a5 100644 --- a/rust/src/api/server/prekeys.rs +++ b/rust/src/api/server/prekeys.rs @@ -4,9 +4,12 @@ */ use super::Server; -use crate::api::proto::client_to_server; +use crate::api::proto::{client_to_server, server_to_client}; +use crate::api::runtime::helpers::decode_ok_value; +use crate::bridge::api::ServerResult; use crate::context::Context; -use crate::error::Result; +use crate::error::{Result, TwonlyError}; +use std::sync::atomic::Ordering; use std::sync::Arc; pub struct PqcPreKeyInput { @@ -18,6 +21,48 @@ pub struct PqcPreKeyInput { } impl Server { + /// Publishes a signed PQC prekey when the server holds none for this + /// account. + /// + /// The bundle is otherwise only published by the registration handshake, so + /// an account created before that existed keeps no `pqc_bundle` and no code + /// path ever creates one. Peers then cannot open a session with it at all, + /// and every message they queue for us fails forever. Checking once per + /// connection lets such an account heal itself. + pub async fn ensure_pqc_bundle_published(ctx: &Arc) -> Result<()> { + if ctx.pqc_bundle_verified.load(Ordering::Acquire) { + return Ok(()); + } + + let user_id = ctx.user_id().await?; + let bytes = Self::application( + ctx, + client_to_server::application_data::ApplicationData::GetUserById( + client_to_server::application_data::GetUserById { user_id }, + ), + ) + .await?; + let user = match decode_ok_value(bytes, |value| match value { + server_to_client::response::ok::Ok::Userdata(user) => Some(user), + _ => None, + })? { + ServerResult::Ok(user) => user, + ServerResult::ErrorCode(code) => { + return Err(TwonlyError::Generic(format!( + "could not read back this account to check its prekey bundle: server error {code}" + ))); + } + }; + + if user.pqc_bundle.is_none() { + tracing::info!("server holds no PQC prekey bundle for this account; publishing one"); + Self::generate_and_upload_pqc_pre_keys(ctx).await?; + } + + ctx.pqc_bundle_verified.store(true, Ordering::Release); + Ok(()) + } + #[doc(hidden)] pub async fn generate_and_upload_pqc_pre_keys(ctx: &Arc) -> Result> { let engine = ctx.signal_engine.lock().await; diff --git a/rust/src/bridge/api.rs b/rust/src/bridge/api.rs index 08db0534..c015adc4 100644 --- a/rust/src/bridge/api.rs +++ b/rust/src/bridge/api.rs @@ -3,7 +3,6 @@ * */ -use crate::api::messages::incoming::messages; use crate::api::ApiRuntime; pub use crate::api::PqcPreKeyInput; use crate::api::Server; @@ -11,10 +10,13 @@ use crate::context::Context; use crate::context::RuntimeMode; use crate::error::{Result, TwonlyError}; use crate::frb_generated::StreamSink; +use crate::services::avatars; use crate::services::contacts::ContactService; +use crate::services::media_upload::{MediaSizeReport, MediaUploadService}; use crate::services::messages::MessageService; use crate::user_config::UserConfig; use flutter_rust_bridge::frb; +use std::collections::HashMap; #[frb(ignore)] pub enum ServerResult { @@ -150,6 +152,163 @@ pub struct FrbMemoriesUploadUrls { pub struct RustApi {} impl RustApi { + #[frb(sync)] + pub fn decode_avatar_svg(avatar_svg_compressed: Vec) -> String { + avatars::decode_avatar_svg(avatar_svg_compressed) + } + + #[frb(sync)] + pub fn avatar_png_path(contact_id: i64, profile_counter: i64) -> Result { + let ctx = Context::get_static()?; + Ok( + avatars::contact_avatar_path(ctx, contact_id, profile_counter) + .display() + .to_string(), + ) + } + + /// Path to a contact's avatar PNG, rendered from the stored SVG if it is + /// missing. Returns `None` when the contact has no avatar at all. + pub async fn ensure_avatar_png(contact_id: i64) -> Result> { + let ctx = Context::get_static()?; + let path = avatars::ensure_contact_avatar_png(ctx, contact_id).await?; + Ok(path.map(|path| path.display().to_string())) + } + + pub async fn current_user_avatar_path() -> Result> { + let ctx = Context::get_static()?.clone(); + let path = tokio::task::spawn_blocking(move || avatars::current_user_avatar_path(&ctx)) + .await + .map_err(|error| { + TwonlyError::Generic(format!("avatar render task failed: {error}")) + })??; + Ok(path.map(|path| path.display().to_string())) + } + + /// Settles every background transfer this device believes is still in + /// flight and resumes any upload a terminated process left behind. + pub async fn finish_started_media_uploads() -> Result<()> { + let ctx = Context::get_static()?; + MediaUploadService::new(ctx).finish_started_uploads().await + } + + /// Creates the media row and its content-encryption material and returns + /// the media id the UI addresses every later step by. + pub async fn initialize_media_upload( + media_type: String, + display_limit_in_milliseconds: Option, + is_draft_media: bool, + ) -> Result { + let ctx = Context::get_static()?; + MediaUploadService::new(ctx) + .initialize(media_type, display_limit_in_milliseconds, is_draft_media) + .await + } + + /// Creates one outgoing message per selected group and starts the upload. + pub async fn send_media_to_groups( + media_id: String, + group_ids: Vec, + additional_message_data: Option>, + ) -> Result<()> { + let ctx = Context::get_static()?; + MediaUploadService::new(ctx) + .insert_into_messages(media_id, group_ids, additional_message_data) + .await + } + + /// Retries the media sends whose receipts are still marked for retry. + pub async fn reupload_pending_media() -> Result<()> { + let ctx = Context::get_static()?; + MediaUploadService::new(ctx).reupload_pending().await + } + + pub async fn store_media(media_id: String) -> Result<()> { + let ctx = Context::get_static()?; + MediaUploadService::new(ctx).store(&media_id).await + } + + /// Reports that a Flutter plugin step finished so Rust can record the + /// derived state (thumbnail present, crop analyzed, new size and hash). + pub async fn media_step_finished(media_id: String, kind: String) -> Result<()> { + let ctx = Context::get_static()?; + MediaUploadService::new(ctx) + .media_step_finished(&media_id, &kind) + .await + } + + /// Exports a stored media file to the user's photo library. + pub async fn save_media_to_gallery(media_id: String) -> Result<()> { + let ctx = Context::get_static()?; + MediaUploadService::new(ctx) + .save_to_gallery(&media_id) + .await + } + + /// Trims fully transparent borders an editor left around a stored image and + /// refreshes the preview and content hash derived from it. + pub async fn crop_media_transparent_borders(media_id: String) -> Result<()> { + let ctx = Context::get_static()?; + MediaUploadService::new(ctx) + .crop_transparent_borders(&media_id) + .await + } + + /// Deletes every file of a media item while keeping its row. + pub async fn remove_media_files(media_id: String) -> Result<()> { + let ctx = Context::get_static()?; + MediaUploadService::new(ctx).remove_files(&media_id).await + } + + /// Gives up on a media file whose source could not be produced, marking + /// its messages as deleted by the sender instead of retrying forever. + pub async fn abandon_media(media_id: String) -> Result<()> { + let ctx = Context::get_static()?; + MediaUploadService::new(ctx).abandon(&media_id).await + } + + /// Explains a send that stopped because the media was too large: the size + /// it reached and the largest single object the plan accepts. + pub async fn media_size_limit_report(media_id: String) -> Result { + let ctx = Context::get_static()?; + MediaUploadService::new(ctx) + .size_limit_report(&media_id) + .await + } + + /// Deletes temporary media whose messages are finished with it. + pub async fn purge_media_temp_folder() -> Result<()> { + let ctx = Context::get_static()?; + MediaUploadService::new(ctx).purge_temp_folder().await + } + + pub async fn set_media_display_limit( + media_id: String, + display_limit_in_milliseconds: Option, + ) -> Result<()> { + let ctx = Context::get_static()?; + MediaUploadService::new(ctx) + .set_display_limit(&media_id, display_limit_in_milliseconds) + .await + } + + pub async fn set_media_requires_authentication( + media_id: String, + requires_authentication: bool, + ) -> Result<()> { + let ctx = Context::get_static()?; + MediaUploadService::new(ctx) + .set_requires_authentication(&media_id, requires_authentication) + .await + } + + pub async fn toggle_media_remove_audio(media_id: String) -> Result<()> { + let ctx = Context::get_static()?; + MediaUploadService::new(ctx) + .toggle_remove_audio(&media_id) + .await + } + pub async fn request_contact_by_username(username: String) -> Result<()> { let ctx = Context::get_static()?; ContactService::new(ctx) @@ -157,6 +316,47 @@ impl RustApi { .await } + pub async fn try_request_contact_by_id(contact_id: i64, expected_public_key: Vec) -> bool { + let result = match Context::get_static() { + Ok(ctx) => { + ContactService::new(ctx) + .request_by_id(contact_id, expected_public_key, false) + .await + } + Err(error) => Err(error), + }; + if let Err(error) = result { + tracing::error!(contact_id, %error, "failed to establish session and send contact request"); + return false; + } + true + } + + pub async fn authentication_headers() -> Result> { + use base64::Engine as _; + + let ctx = Context::get_static()?; + let user = UserConfig::load_required_from(ctx)?; + let mut headers = HashMap::new(); + if user.can_use_login_token_for_auth { + let login_token = ctx.key_manager.lock().await.main_key.get_login_token(); + headers.insert("x-twonly-user-id".into(), format!("{:016X}", user.user_id)); + headers.insert("x-twonly-login-token".into(), hex::encode(login_token)); + } else { + let encoded = ctx + .secure_storage + .read("api_auth_token")? + .ok_or_else(|| TwonlyError::Generic("API auth token is not defined".into()))?; + let auth_token = base64::engine::general_purpose::STANDARD + .decode(encoded) + .map_err(|error| { + TwonlyError::Generic(format!("invalid stored API auth token: {error}")) + })?; + headers.insert("x-twonly-auth-token".into(), hex::encode(auth_token)); + } + Ok(headers) + } + #[frb(sync)] pub fn api_base_url(protocol: String) -> String { if cfg!(debug_assertions) { @@ -190,6 +390,11 @@ impl RustApi { pub async fn set_background(in_background: bool) -> Result<()> { let ctx = Context::get_static()?; + if !in_background { + // Coming back to the foreground is the first chance to notice that + // the OS finished a media transfer while this process was idle. + crate::services::direct_media_upload::watch_pending_uploads(ctx); + } ApiRuntime::set_background(ctx, in_background).await } @@ -297,6 +502,13 @@ impl RustApi { .request_reupload(&media_id) .await } + + pub async fn retry_pending_media_reuploads() -> Result<()> { + let ctx = Context::get_static()?; + crate::services::mediafiles::MediaFileService::new(ctx) + .retry_pending_reuploads() + .await + } pub async fn set_login_token(token: Vec) -> Result<()> { let ctx = Context::get_static()?; Server::set_login_token(ctx, token) @@ -654,12 +866,6 @@ impl RustApi { .await } - pub async fn prepare_queued_message(receipt_id: String) -> Result>> { - messages::prepare_queued_receipt(Context::get_static()?, &receipt_id) - .await - .map_err(Into::into) - } - pub async fn notify_messages_opened(contact_id: i64, message_ids: Vec) -> Result<()> { let ctx = Context::get_static()?; MessageService::new(ctx) diff --git a/rust/src/bridge/callbacks.rs b/rust/src/bridge/callbacks.rs index ddc6911c..fb14734d 100644 --- a/rust/src/bridge/callbacks.rs +++ b/rust/src/bridge/callbacks.rs @@ -27,10 +27,7 @@ callback_generator! { get_stream_sink: () => StreamSink }, Api api { - media_action: (String, String, i64, String) => (), - verification_proof: (i64, Vec) => (), - create_push_avatars: (i64) => (), - media_received: (String, i64) => (), + verification_succeeded: (i64) => (), user_config_changed: (crate::user_config::UserConfig) => () } } diff --git a/rust/src/bridge/wrapper/key_manager.rs b/rust/src/bridge/wrapper/key_manager.rs index d250a9d0..2ad94021 100644 --- a/rust/src/bridge/wrapper/key_manager.rs +++ b/rust/src/bridge/wrapper/key_manager.rs @@ -11,11 +11,6 @@ use crate::signal::engine::RustSignalEngine; pub struct RustKeyManager {} impl RustKeyManager { - pub async fn get_login_token() -> Result> { - let key_manager = get_twonly_flutter()?.key_manager.lock().await; - Ok(key_manager.main_key.get_login_token().to_vec()) - } - pub async fn get_user_id() -> Result> { let key_manager = get_twonly_flutter()?.key_manager.lock().await; Ok(key_manager.user_id) diff --git a/rust/src/context.rs b/rust/src/context.rs index bc013730..a982d358 100644 --- a/rust/src/context.rs +++ b/rust/src/context.rs @@ -17,7 +17,7 @@ use crate::signal::engine::RustSignalEngine; use crate::user_discovery::UserDiscovery; use crate::utils::Shared; use libsignal_protocol::IdentityKey; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::{path::PathBuf, sync::Arc}; use tokio::sync::{Mutex, Notify, OnceCell, RwLock}; use zeroize::Zeroize; @@ -50,6 +50,10 @@ pub struct Context { /// contexts in one process have separate quotas and must not block or back /// each other off. pub(crate) privacy_pass_issuance: Mutex>, + /// Set once this connection has confirmed the account has a PQC prekey + /// bundle on the server (or has just published one). Per context rather + /// than process-wide so two accounts in one process check independently. + pub(crate) pqc_bundle_verified: AtomicBool, } impl Context { @@ -127,6 +131,7 @@ impl Context { incoming_generation: AtomicU64::new(0), incoming_committed: Notify::new(), privacy_pass_issuance: Mutex::new(None), + pqc_bundle_verified: AtomicBool::new(false), }); ApiRuntime::initialize(&ctx).await?; ApiRuntime::connect(&ctx).await?; @@ -287,6 +292,7 @@ impl Context { incoming_generation: AtomicU64::new(0), incoming_committed: Notify::new(), privacy_pass_issuance: Mutex::new(None), + pqc_bundle_verified: AtomicBool::new(false), }); if let Err(error) = ctx.initialize_user_discovery_from_config().await { tracing::warn!("failed to initialize user discovery: {error}"); @@ -328,6 +334,7 @@ impl Context { incoming_generation: AtomicU64::new(0), incoming_committed: Notify::new(), privacy_pass_issuance: Mutex::new(None), + pqc_bundle_verified: AtomicBool::new(false), }); if let Err(error) = ctx.initialize_user_discovery_from_config().await { tracing::warn!("failed to initialize user discovery: {error}"); diff --git a/rust/src/database/app/migrations/0005_direct_media_upload.sql b/rust/src/database/app/migrations/0005_direct_media_upload.sql new file mode 100644 index 00000000..b24b2a9c --- /dev/null +++ b/rust/src/database/app/migrations/0005_direct_media_upload.sql @@ -0,0 +1,28 @@ +CREATE TABLE direct_media_upload_slots ( + attachment_id TEXT NOT NULL PRIMARY KEY, + expires_at INTEGER NOT NULL, + maximum_object_bytes INTEGER NOT NULL, + upload_url TEXT NOT NULL, + upload_fields_json TEXT NOT NULL, + capability BLOB NOT NULL, + state TEXT NOT NULL DEFAULT 'cached' + CHECK (state IN ('cached', 'reserved', 'consumed', 'abandoned')), + created_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s', CURRENT_TIMESTAMP) AS INTEGER)) +); +CREATE INDEX direct_media_upload_slots_usable_idx + ON direct_media_upload_slots(state, expires_at); + +CREATE TABLE direct_media_upload_jobs ( + attachment_id TEXT NOT NULL PRIMARY KEY + REFERENCES direct_media_upload_slots(attachment_id) ON DELETE CASCADE, + media_id TEXT NOT NULL, + multipart_path TEXT NOT NULL, + manifest_path TEXT NOT NULL, + complete_body_path TEXT NOT NULL, + native_descriptor_json TEXT NOT NULL, + state TEXT NOT NULL DEFAULT 'prepared' + CHECK (state IN ('prepared', 'scheduled', 'waiting_for_server', 'ready', 'failed')), + expires_at INTEGER NOT NULL, + created_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s', CURRENT_TIMESTAMP) AS INTEGER)), + updated_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s', CURRENT_TIMESTAMP) AS INTEGER)) +); diff --git a/rust/src/database/app/migrations/0006_defer_receipts_missing_bundle.sql b/rust/src/database/app/migrations/0006_defer_receipts_missing_bundle.sql new file mode 100644 index 00000000..f105e7bb --- /dev/null +++ b/rust/src/database/app/migrations/0006_defer_receipts_missing_bundle.sql @@ -0,0 +1,7 @@ +-- A queued receipt for a peer with no published prekey bundle and no local +-- session cannot be encrypted at all. Retrying it on every sweep only repeats +-- the same server round-trip, so it is parked here and released again when the +-- peer becomes reachable (a bundle fetch succeeds, or an inbound message from +-- them opens a session). +ALTER TABLE receipts + ADD COLUMN deferred_until_session INTEGER; diff --git a/rust/src/database/app/mod.rs b/rust/src/database/app/mod.rs index 7a56ff1a..8b72a328 100644 --- a/rust/src/database/app/mod.rs +++ b/rust/src/database/app/mod.rs @@ -16,7 +16,7 @@ mod legacy_import; pub mod tables; pub const APP_DATABASE_FILE: &str = "app_db.sqlite"; -pub const APP_SCHEMA_VERSION: i64 = 3; +pub const APP_SCHEMA_VERSION: i64 = 5; /// Tables imported from the legacy Drift database. Every entry must exist in /// Drift schema 25, because a missing table aborts the whole import. Rust-only diff --git a/rust/src/database/app/tables/groups.rs b/rust/src/database/app/tables/groups.rs index 8a4f76ef..4ce77ee6 100644 --- a/rust/src/database/app/tables/groups.rs +++ b/rust/src/database/app/tables/groups.rs @@ -3,6 +3,7 @@ * */ +use chrono::{Local, TimeZone}; use sqlx::{Sqlite, Transaction}; use crate::context::Context; @@ -41,6 +42,146 @@ impl Group { Ok(()) } + /// Records one successfully created media message and updates the flame + /// state in the same transaction. This used to round-trip through Dart, + /// which made the counters non-atomic and unavailable to headless Rust + /// runtimes. + pub async fn record_media_exchange( + tr: &mut Transaction<'_, Sqlite>, + group_id: &str, + received: bool, + timestamp: i64, + ) -> Result<()> { + let Some(group) = sqlx::query!( + r#"SELECT last_message_send, last_message_received, + last_flame_counter_change, flame_counter, + max_flame_counter, max_flame_counter_from + FROM groups WHERE group_id = ?"#, + group_id, + ) + .fetch_optional(&mut **tr) + .await? + else { + return Ok(()); + }; + + if received { + sqlx::query!( + r#"UPDATE contacts + SET media_received_counter = media_received_counter + 1 + WHERE user_id IN ( + SELECT contact_id FROM group_members WHERE group_id = ? + )"#, + group_id, + ) + .execute(&mut **tr) + .await?; + } else { + sqlx::query!( + r#"UPDATE contacts + SET media_send_counter = media_send_counter + 1 + WHERE user_id IN ( + SELECT contact_id FROM group_members WHERE group_id = ? + )"#, + group_id, + ) + .execute(&mut **tr) + .await?; + } + + let now = Local::now(); + let start_of_today = Local + .from_local_datetime( + &now.date_naive() + .and_hms_opt(0, 0, 0) + .ok_or_else(|| TwonlyError::Generic("invalid local date".into()))?, + ) + .earliest() + .ok_or_else(|| TwonlyError::Generic("local day has no midnight".into()))? + .timestamp(); + let two_days_ago = start_of_today - 2 * 24 * 60 * 60; + + let mut flame_counter = group.flame_counter; + let mut max_flame_counter = group.max_flame_counter; + let mut max_flame_counter_from = group.max_flame_counter_from; + let mut last_flame_counter_change = group.last_flame_counter_change; + + if group + .last_message_send + .zip(group.last_message_received) + .is_some_and(|(sent, received)| sent < two_days_ago || received < two_days_ago) + { + flame_counter = 0; + } + + if last_flame_counter_change.is_none_or(|changed| changed < start_of_today) { + let completes_today = if received { + group + .last_message_send + .is_some_and(|sent| sent > start_of_today) + } else { + group + .last_message_received + .is_some_and(|received| received > start_of_today) + }; + if completes_today { + flame_counter += 1; + if last_flame_counter_change.is_none_or(|changed| changed < timestamp) { + last_flame_counter_change = Some(timestamp); + } + if flame_counter >= max_flame_counter + || max_flame_counter_from + .is_none_or(|from| from < now.timestamp() - 5 * 24 * 60 * 60) + { + max_flame_counter = flame_counter; + max_flame_counter_from = Some(now.timestamp()); + } + } + } + + let last_message_send = if !received + && group + .last_message_send + .is_none_or(|previous| previous < timestamp) + { + Some(timestamp) + } else { + group.last_message_send + }; + let last_message_received = if received + && group + .last_message_received + .is_none_or(|previous| previous < timestamp) + { + Some(timestamp) + } else { + group.last_message_received + }; + + sqlx::query!( + r#"UPDATE groups SET + total_media_counter = total_media_counter + 1, + last_flame_counter_change = ?, + last_message_received = ?, + last_message_send = ?, + flame_counter = ?, + max_flame_counter = ?, + max_flame_counter_from = ? + WHERE group_id = ?"#, + last_flame_counter_change, + last_message_received, + last_message_send, + flame_counter, + max_flame_counter, + max_flame_counter_from, + group_id, + ) + .execute(&mut **tr) + .await?; + + Ok(()) + } + pub async fn ensure_exists(tr: &mut Transaction<'_, Sqlite>, group_id: &str) -> Result<()> { let exists = sqlx::query_scalar!( "SELECT EXISTS(SELECT 1 FROM groups WHERE group_id = ?)", @@ -538,3 +679,72 @@ impl GetMissingGroupPublicKeys { fn current_unix_timestamp() -> Result { Ok(crate::utils::current_time().timestamp()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::database::app::AppDatabase; + + #[tokio::test] + async fn media_exchange_updates_contact_and_flame_counters_atomically() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("app.sqlite"); + let database = AppDatabase::new(path.to_str().unwrap(), None, false) + .await + .unwrap(); + database.run_migrations().await.unwrap(); + + sqlx::query("INSERT INTO contacts(user_id, username) VALUES (7, 'alice')") + .execute(&database.pool) + .await + .unwrap(); + sqlx::query("INSERT INTO groups(group_id, group_name) VALUES ('group', 'Group')") + .execute(&database.pool) + .await + .unwrap(); + sqlx::query("INSERT INTO group_members(group_id, contact_id) VALUES ('group', 7)") + .execute(&database.pool) + .await + .unwrap(); + + let now = Local::now(); + let start_of_today = Local + .from_local_datetime(&now.date_naive().and_hms_opt(0, 0, 0).unwrap()) + .earliest() + .unwrap() + .timestamp(); + let mut transaction = database.pool.begin().await.unwrap(); + Group::record_media_exchange(&mut transaction, "group", true, start_of_today + 60) + .await + .unwrap(); + Group::record_media_exchange(&mut transaction, "group", false, start_of_today + 120) + .await + .unwrap(); + transaction.commit().await.unwrap(); + + let contact = sqlx::query!( + "SELECT media_send_counter, media_received_counter FROM contacts WHERE user_id = 7" + ) + .fetch_one(&database.pool) + .await + .unwrap(); + assert_eq!(contact.media_send_counter, 1); + assert_eq!(contact.media_received_counter, 1); + + let group = sqlx::query!( + r#"SELECT total_media_counter, flame_counter, max_flame_counter, + last_message_send, last_message_received, + last_flame_counter_change + FROM groups WHERE group_id = 'group'"# + ) + .fetch_one(&database.pool) + .await + .unwrap(); + assert_eq!(group.total_media_counter, 2); + assert_eq!(group.flame_counter, 1); + assert_eq!(group.max_flame_counter, 1); + assert_eq!(group.last_message_send, Some(start_of_today + 120)); + assert_eq!(group.last_message_received, Some(start_of_today + 60)); + assert_eq!(group.last_flame_counter_change, Some(start_of_today + 120)); + } +} diff --git a/rust/src/database/app/tables/key_verification.rs b/rust/src/database/app/tables/key_verification.rs index 1f02f1e2..d6444f83 100644 --- a/rust/src/database/app/tables/key_verification.rs +++ b/rust/src/database/app/tables/key_verification.rs @@ -10,12 +10,14 @@ use crate::error::Result; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum KeyVerificationType { ContactSharedByVerified, + SecretQrToken, } impl KeyVerificationType { fn as_str(self) -> &'static str { match self { Self::ContactSharedByVerified => "contactSharedByVerified", + Self::SecretQrToken => "secretQrToken", } } } diff --git a/rust/src/database/app/tables/mediafiles.rs b/rust/src/database/app/tables/mediafiles.rs index 9bfd1a21..679b90ff 100644 --- a/rust/src/database/app/tables/mediafiles.rs +++ b/rust/src/database/app/tables/mediafiles.rs @@ -26,6 +26,16 @@ impl MediaFile { return Ok(()); }; + Self::mark_uploaded(transaction, &media_id).await + } + + /// Single owner of the "media reached the server" transition. The state + /// change, the per-recipient message actions, and the receipt bookkeeping + /// have to become visible together, so they share one transaction. + pub async fn mark_uploaded( + transaction: &mut Transaction<'_, Sqlite>, + media_id: &str, + ) -> Result<()> { let updated = sqlx::query!( "UPDATE media_files SET upload_state = 'uploaded' WHERE media_id = ? AND upload_state IS NOT 'uploaded'", media_id, diff --git a/rust/src/error.rs b/rust/src/error.rs index 8168f82b..c687296e 100644 --- a/rust/src/error.rs +++ b/rust/src/error.rs @@ -75,6 +75,9 @@ pub enum TwonlyError { #[error("{0}")] SqliteError(#[from] sqlx::Error), + #[error("encrypted media is {bytes} bytes but the plan allows {limit}")] + MediaTooLarge { bytes: i64, limit: i64 }, + #[error("{0}")] Generic(String), @@ -91,6 +94,14 @@ pub enum TwonlyError { #[error("API user response is missing required field: {0}")] ApiResponseMissingField(&'static str), + /// The server answered with the account but holds no PQC prekey bundle for + /// it, so no new Signal session can be opened with that peer. Distinct from + /// `ApiResponseMissingField` because callers defer work instead of failing: + /// the peer publishes a bundle only when their client next registers or + /// republishes, and an inbound message from them still opens a session. + #[error("peer {0} has published no prekey bundle")] + PeerHasNoPrekeyBundle(i64), + #[error("{0}")] IoError(#[from] std::io::Error), diff --git a/rust/src/frb_generated.rs b/rust/src/frb_generated.rs index 0726fe31..7f7d6f2f 100644 --- a/rust/src/frb_generated.rs +++ b/rust/src/frb_generated.rs @@ -38,7 +38,7 @@ flutter_rust_bridge::frb_generated_boilerplate!( default_rust_auto_opaque = RustAutoOpaqueMoi, ); pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0"; -pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 463166747; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 640399573; // Section: executor @@ -353,19 +353,8 @@ fn wire__crate__bridge__callbacks__init_flutter_callbacks_impl( decode_DartFn_Inputs__Output_StreamSink_String_Sse_AnyhowException( ::sse_decode(&mut deserializer), ); - let api_api_media_action = - decode_DartFn_Inputs_String_String_i_64_String_Output_unit_AnyhowException( - ::sse_decode(&mut deserializer), - ); - let api_api_verification_proof = - decode_DartFn_Inputs_i_64_list_prim_u_8_strict_Output_unit_AnyhowException( - ::sse_decode(&mut deserializer), - ); - let api_api_create_push_avatars = decode_DartFn_Inputs_i_64_Output_unit_AnyhowException( - ::sse_decode(&mut deserializer), - ); - let api_api_media_received = - decode_DartFn_Inputs_String_i_64_Output_unit_AnyhowException( + let api_api_verification_succeeded = + decode_DartFn_Inputs_i_64_Output_unit_AnyhowException( ::sse_decode(&mut deserializer), ); let api_api_user_config_changed = @@ -379,10 +368,7 @@ fn wire__crate__bridge__callbacks__init_flutter_callbacks_impl( crate::bridge::callbacks::init_flutter_callbacks( api_callback_id, api_logging_get_stream_sink, - api_api_media_action, - api_api_verification_proof, - api_api_create_push_avatars, - api_api_media_received, + api_api_verification_succeeded, api_api_user_config_changed, ); })?; @@ -586,6 +572,43 @@ fn wire__crate__bridge__groups__remove_member_from_group_impl( }, ) } +fn wire__crate__bridge__api__rust_api_abandon_media_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "rust_api_abandon_media", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_media_id = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = + crate::bridge::api::RustApi::abandon_media(api_media_id).await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__bridge__api__rust_api_add_additional_user_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -689,6 +712,78 @@ fn wire__crate__bridge__api__rust_api_api_base_url_impl( }, ) } +fn wire__crate__bridge__api__rust_api_authentication_headers_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "rust_api_authentication_headers", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = + crate::bridge::api::RustApi::authentication_headers().await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} +fn wire__crate__bridge__api__rust_api_avatar_png_path_impl( + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "rust_api_avatar_png_path", + port: None, + mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_contact_id = ::sse_decode(&mut deserializer); + let api_profile_counter = ::sse_decode(&mut deserializer); + deserializer.end(); + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || { + let output_ok = crate::bridge::api::RustApi::avatar_png_path( + api_contact_id, + api_profile_counter, + )?; + Ok(output_ok) + })(), + ) + }, + ) +} fn wire__crate__bridge__api__rust_api_change_username_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -1026,6 +1121,114 @@ fn wire__crate__bridge__api__rust_api_connection_state_impl( }, ) } +fn wire__crate__bridge__api__rust_api_crop_media_transparent_borders_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "rust_api_crop_media_transparent_borders", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_media_id = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = + crate::bridge::api::RustApi::crop_media_transparent_borders( + api_media_id, + ) + .await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} +fn wire__crate__bridge__api__rust_api_current_user_avatar_path_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "rust_api_current_user_avatar_path", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = + crate::bridge::api::RustApi::current_user_avatar_path().await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} +fn wire__crate__bridge__api__rust_api_decode_avatar_svg_impl( + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "rust_api_decode_avatar_svg", + port: None, + mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_avatar_svg_compressed = >::sse_decode(&mut deserializer); + deserializer.end(); + transform_result_sse::<_, ()>((move || { + let output_ok = Result::<_, ()>::Ok( + crate::bridge::api::RustApi::decode_avatar_svg(api_avatar_svg_compressed), + )?; + Ok(output_ok) + })()) + }, + ) +} fn wire__crate__bridge__api__rust_api_delete_account_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -1244,6 +1447,43 @@ fn wire__crate__bridge__api__rust_api_download_pending_media_impl( }, ) } +fn wire__crate__bridge__api__rust_api_ensure_avatar_png_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "rust_api_ensure_avatar_png", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_contact_id = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = + crate::bridge::api::RustApi::ensure_avatar_png(api_contact_id).await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__bridge__api__rust_api_establish_signal_session_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -1324,6 +1564,42 @@ fn wire__crate__bridge__api__rust_api_events_impl( }, ) } +fn wire__crate__bridge__api__rust_api_finish_started_media_uploads_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "rust_api_finish_started_media_uploads", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = + crate::bridge::api::RustApi::finish_started_media_uploads().await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__bridge__api__rust_api_force_ipa_check_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -1665,6 +1941,49 @@ fn wire__crate__bridge__api__rust_api_get_user_id_from_username_impl( }, ) } +fn wire__crate__bridge__api__rust_api_initialize_media_upload_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "rust_api_initialize_media_upload", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_media_type = ::sse_decode(&mut deserializer); + let api_display_limit_in_milliseconds = >::sse_decode(&mut deserializer); + let api_is_draft_media = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = crate::bridge::api::RustApi::initialize_media_upload( + api_media_type, + api_display_limit_in_milliseconds, + api_is_draft_media, + ) + .await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__bridge__api__rust_api_insert_and_send_additional_data_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -1913,6 +2232,85 @@ fn wire__crate__bridge__api__rust_api_load_plan_balance_impl( }, ) } +fn wire__crate__bridge__api__rust_api_media_size_limit_report_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "rust_api_media_size_limit_report", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_media_id = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = + crate::bridge::api::RustApi::media_size_limit_report(api_media_id) + .await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} +fn wire__crate__bridge__api__rust_api_media_step_finished_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "rust_api_media_step_finished", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_media_id = ::sse_decode(&mut deserializer); + let api_kind = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = crate::bridge::api::RustApi::media_step_finished( + api_media_id, + api_kind, + ) + .await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__bridge__api__rust_api_notification_badge_count_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -2027,7 +2425,7 @@ fn wire__crate__bridge__api__rust_api_perform_passwordless_recovery_heartbeat_im }, ) } -fn wire__crate__bridge__api__rust_api_prepare_queued_message_impl( +fn wire__crate__bridge__api__rust_api_purge_media_temp_folder_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, rust_vec_len_: i32, @@ -2035,7 +2433,7 @@ fn wire__crate__bridge__api__rust_api_prepare_queued_message_impl( ) { FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( flutter_rust_bridge::for_generated::TaskInfo { - debug_name: "rust_api_prepare_queued_message", + debug_name: "rust_api_purge_media_temp_folder", port: Some(port_), mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, }, @@ -2049,14 +2447,12 @@ fn wire__crate__bridge__api__rust_api_prepare_queued_message_impl( }; let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message); - let api_receipt_id = ::sse_decode(&mut deserializer); deserializer.end(); move |context| async move { transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( (move || async move { let output_ok = - crate::bridge::api::RustApi::prepare_queued_message(api_receipt_id) - .await?; + crate::bridge::api::RustApi::purge_media_temp_folder().await?; Ok(output_ok) })() .await, @@ -2271,6 +2667,43 @@ fn wire__crate__bridge__api__rust_api_remove_additional_user_impl( }, ) } +fn wire__crate__bridge__api__rust_api_remove_media_files_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "rust_api_remove_media_files", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_media_id = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = + crate::bridge::api::RustApi::remove_media_files(api_media_id).await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__bridge__api__rust_api_report_user_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -2502,6 +2935,116 @@ fn wire__crate__bridge__api__rust_api_retransmit_all_messages_impl( }, ) } +fn wire__crate__bridge__api__rust_api_retry_pending_media_reuploads_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "rust_api_retry_pending_media_reuploads", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = + crate::bridge::api::RustApi::retry_pending_media_reuploads().await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} +fn wire__crate__bridge__api__rust_api_reupload_pending_media_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "rust_api_reupload_pending_media", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = + crate::bridge::api::RustApi::reupload_pending_media().await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} +fn wire__crate__bridge__api__rust_api_save_media_to_gallery_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "rust_api_save_media_to_gallery", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_media_id = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = + crate::bridge::api::RustApi::save_media_to_gallery(api_media_id) + .await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__bridge__api__rust_api_send_binary_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -2673,6 +3216,49 @@ fn wire__crate__bridge__api__rust_api_send_encrypted_content_to_group_impl( }, ) } +fn wire__crate__bridge__api__rust_api_send_media_to_groups_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "rust_api_send_media_to_groups", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_media_id = ::sse_decode(&mut deserializer); + let api_group_ids = >::sse_decode(&mut deserializer); + let api_additional_message_data = >>::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = crate::bridge::api::RustApi::send_media_to_groups( + api_media_id, + api_group_ids, + api_additional_message_data, + ) + .await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__bridge__api__rust_api_send_queued_message_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -2863,6 +3449,89 @@ fn wire__crate__bridge__api__rust_api_set_login_token_impl( }, ) } +fn wire__crate__bridge__api__rust_api_set_media_display_limit_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "rust_api_set_media_display_limit", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_media_id = ::sse_decode(&mut deserializer); + let api_display_limit_in_milliseconds = >::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = crate::bridge::api::RustApi::set_media_display_limit( + api_media_id, + api_display_limit_in_milliseconds, + ) + .await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} +fn wire__crate__bridge__api__rust_api_set_media_requires_authentication_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "rust_api_set_media_requires_authentication", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_media_id = ::sse_decode(&mut deserializer); + let api_requires_authentication = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = + crate::bridge::api::RustApi::set_media_requires_authentication( + api_media_id, + api_requires_authentication, + ) + .await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__bridge__api__rust_api_set_network_available_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -2901,6 +3570,43 @@ fn wire__crate__bridge__api__rust_api_set_network_available_impl( }, ) } +fn wire__crate__bridge__api__rust_api_store_media_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "rust_api_store_media", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_media_id = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = + crate::bridge::api::RustApi::store_media(api_media_id).await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__bridge__api__rust_api_submit_recovery_share_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -2942,6 +3648,87 @@ fn wire__crate__bridge__api__rust_api_submit_recovery_share_impl( }, ) } +fn wire__crate__bridge__api__rust_api_toggle_media_remove_audio_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "rust_api_toggle_media_remove_audio", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_media_id = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = + crate::bridge::api::RustApi::toggle_media_remove_audio(api_media_id) + .await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} +fn wire__crate__bridge__api__rust_api_try_request_contact_by_id_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "rust_api_try_request_contact_by_id", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_contact_id = ::sse_decode(&mut deserializer); + let api_expected_public_key = >::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, ()>( + (move || async move { + let output_ok = Result::<_, ()>::Ok( + crate::bridge::api::RustApi::try_request_contact_by_id( + api_contact_id, + api_expected_public_key, + ) + .await, + )?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__bridge__api__rust_api_update_fcm_token_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -3431,43 +4218,6 @@ let api_addition = ::sse_decode(&mut deserializer);deserializer.end(); m })().await) } }) } -fn wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_login_token_impl( - port_: flutter_rust_bridge::for_generated::MessagePort, - ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, - rust_vec_len_: i32, - data_len_: i32, -) { - FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( - flutter_rust_bridge::for_generated::TaskInfo { - debug_name: "rust_key_manager_get_login_token", - port: Some(port_), - mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, - }, - move || { - let message = unsafe { - flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( - ptr_, - rust_vec_len_, - data_len_, - ) - }; - let mut deserializer = - flutter_rust_bridge::for_generated::SseDeserializer::new(message); - deserializer.end(); - move |context| async move { - transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( - (move || async move { - let output_ok = - crate::bridge::wrapper::key_manager::RustKeyManager::get_login_token() - .await?; - Ok(output_ok) - })() - .await, - ) - } - }, - ) -} fn wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_signal_identity_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -4374,88 +5124,6 @@ fn wire__crate__bridge__user_config__user_config_api_update_impl( // Section: related_funcs -fn decode_DartFn_Inputs_String_String_i_64_String_Output_unit_AnyhowException( - dart_opaque: flutter_rust_bridge::DartOpaque, -) -> impl Fn(String, String, i64, String) -> flutter_rust_bridge::DartFnFuture<()> { - use flutter_rust_bridge::IntoDart; - - async fn body( - dart_opaque: flutter_rust_bridge::DartOpaque, - arg0: String, - arg1: String, - arg2: i64, - arg3: String, - ) -> () { - let args = vec![ - arg0.into_into_dart().into_dart(), - arg1.into_into_dart().into_dart(), - arg2.into_into_dart().into_dart(), - arg3.into_into_dart().into_dart(), - ]; - let message = FLUTTER_RUST_BRIDGE_HANDLER - .dart_fn_invoke(dart_opaque, args) - .await; - - let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message); - let action = deserializer.cursor.read_u8().unwrap(); - let ans = match action { - 0 => std::result::Result::Ok(<()>::sse_decode(&mut deserializer)), - 1 => std::result::Result::Err( - ::sse_decode(&mut deserializer), - ), - _ => unreachable!(), - }; - deserializer.end(); - let ans = ans.expect("Dart throws exception but Rust side assume it is not failable"); - ans - } - - move |arg0: String, arg1: String, arg2: i64, arg3: String| { - flutter_rust_bridge::for_generated::convert_into_dart_fn_future(body( - dart_opaque.clone(), - arg0, - arg1, - arg2, - arg3, - )) - } -} -fn decode_DartFn_Inputs_String_i_64_Output_unit_AnyhowException( - dart_opaque: flutter_rust_bridge::DartOpaque, -) -> impl Fn(String, i64) -> flutter_rust_bridge::DartFnFuture<()> { - use flutter_rust_bridge::IntoDart; - - async fn body(dart_opaque: flutter_rust_bridge::DartOpaque, arg0: String, arg1: i64) -> () { - let args = vec![ - arg0.into_into_dart().into_dart(), - arg1.into_into_dart().into_dart(), - ]; - let message = FLUTTER_RUST_BRIDGE_HANDLER - .dart_fn_invoke(dart_opaque, args) - .await; - - let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message); - let action = deserializer.cursor.read_u8().unwrap(); - let ans = match action { - 0 => std::result::Result::Ok(<()>::sse_decode(&mut deserializer)), - 1 => std::result::Result::Err( - ::sse_decode(&mut deserializer), - ), - _ => unreachable!(), - }; - deserializer.end(); - let ans = ans.expect("Dart throws exception but Rust side assume it is not failable"); - ans - } - - move |arg0: String, arg1: i64| { - flutter_rust_bridge::for_generated::convert_into_dart_fn_future(body( - dart_opaque.clone(), - arg0, - arg1, - )) - } -} fn decode_DartFn_Inputs__Output_StreamSink_String_Sse_AnyhowException( dart_opaque: flutter_rust_bridge::DartOpaque, ) -> impl Fn() -> flutter_rust_bridge::DartFnFuture< @@ -4524,42 +5192,6 @@ fn decode_DartFn_Inputs_i_64_Output_unit_AnyhowException( )) } } -fn decode_DartFn_Inputs_i_64_list_prim_u_8_strict_Output_unit_AnyhowException( - dart_opaque: flutter_rust_bridge::DartOpaque, -) -> impl Fn(i64, Vec) -> flutter_rust_bridge::DartFnFuture<()> { - use flutter_rust_bridge::IntoDart; - - async fn body(dart_opaque: flutter_rust_bridge::DartOpaque, arg0: i64, arg1: Vec) -> () { - let args = vec![ - arg0.into_into_dart().into_dart(), - arg1.into_into_dart().into_dart(), - ]; - let message = FLUTTER_RUST_BRIDGE_HANDLER - .dart_fn_invoke(dart_opaque, args) - .await; - - let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message); - let action = deserializer.cursor.read_u8().unwrap(); - let ans = match action { - 0 => std::result::Result::Ok(<()>::sse_decode(&mut deserializer)), - 1 => std::result::Result::Err( - ::sse_decode(&mut deserializer), - ), - _ => unreachable!(), - }; - deserializer.end(); - let ans = ans.expect("Dart throws exception but Rust side assume it is not failable"); - ans - } - - move |arg0: i64, arg1: Vec| { - flutter_rust_bridge::for_generated::convert_into_dart_fn_future(body( - dart_opaque.clone(), - arg0, - arg1, - )) - } -} fn decode_DartFn_Inputs_user_config_Output_unit_AnyhowException( dart_opaque: flutter_rust_bridge::DartOpaque, ) -> impl Fn(crate::user_config::UserConfig) -> flutter_rust_bridge::DartFnFuture<()> { @@ -4627,6 +5259,14 @@ impl SseDecode for flutter_rust_bridge::DartOpaque { } } +impl SseDecode for std::collections::HashMap { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut inner = >::sse_decode(deserializer); + return inner.into_iter().collect(); + } +} + impl SseDecode for std::collections::HashMap> { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -5157,6 +5797,18 @@ impl SseDecode for Vec { } } +impl SseDecode for crate::services::media_upload::MediaSizeReport { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut var_mediaBytes = >::sse_decode(deserializer); + let mut var_limitBytes = >::sse_decode(deserializer); + return crate::services::media_upload::MediaSizeReport { + media_bytes: var_mediaBytes, + limit_bytes: var_limitBytes, + }; + } +} + impl SseDecode for Option>> { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -5741,110 +6393,128 @@ fn pde_ffi_dispatcher_primary_impl( 13 => wire__crate__bridge__groups__leave_group_impl(port, ptr, rust_vec_len, data_len), 14 => wire__crate__bridge__groups__manage_admin_state_impl(port, ptr, rust_vec_len, data_len), 15 => wire__crate__bridge__groups__remove_member_from_group_impl(port, ptr, rust_vec_len, data_len), -16 => wire__crate__bridge__api__rust_api_add_additional_user_impl(port, ptr, rust_vec_len, data_len), -17 => wire__crate__bridge__api__rust_api_allocate_sequence_impl(port, ptr, rust_vec_len, data_len), -19 => wire__crate__bridge__api__rust_api_change_username_impl(port, ptr, rust_vec_len, data_len), -20 => wire__crate__bridge__api__rust_api_check_for_deleted_usernames_impl(port, ptr, rust_vec_len, data_len), -21 => wire__crate__bridge__api__rust_api_check_for_passwordless_notification_impl(port, ptr, rust_vec_len, data_len), -22 => wire__crate__bridge__api__rust_api_clear_contact_request_notifications_impl(port, ptr, rust_vec_len, data_len), -23 => wire__crate__bridge__api__rust_api_clear_conversation_notifications_impl(port, ptr, rust_vec_len, data_len), -24 => wire__crate__bridge__api__rust_api_close_impl(port, ptr, rust_vec_len, data_len), -25 => wire__crate__bridge__api__rust_api_confirm_memories_upload_impl(port, ptr, rust_vec_len, data_len), -26 => wire__crate__bridge__api__rust_api_connect_impl(port, ptr, rust_vec_len, data_len), -27 => wire__crate__bridge__api__rust_api_connection_state_impl(port, ptr, rust_vec_len, data_len), -28 => wire__crate__bridge__api__rust_api_delete_account_impl(port, ptr, rust_vec_len, data_len), -29 => wire__crate__bridge__api__rust_api_delete_memory_impl(port, ptr, rust_vec_len, data_len), -30 => wire__crate__bridge__api__rust_api_disable_memories_backup_impl(port, ptr, rust_vec_len, data_len), -31 => wire__crate__bridge__api__rust_api_download_done_impl(port, ptr, rust_vec_len, data_len), -32 => wire__crate__bridge__api__rust_api_download_media_impl(port, ptr, rust_vec_len, data_len), -33 => wire__crate__bridge__api__rust_api_download_pending_media_impl(port, ptr, rust_vec_len, data_len), -34 => wire__crate__bridge__api__rust_api_establish_signal_session_impl(port, ptr, rust_vec_len, data_len), -35 => wire__crate__bridge__api__rust_api_events_impl(port, ptr, rust_vec_len, data_len), -36 => wire__crate__bridge__api__rust_api_force_ipa_check_impl(port, ptr, rust_vec_len, data_len), -37 => wire__crate__bridge__api__rust_api_get_memories_url_impl(port, ptr, rust_vec_len, data_len), -38 => wire__crate__bridge__api__rust_api_get_memories_usage_impl(port, ptr, rust_vec_len, data_len), -39 => wire__crate__bridge__api__rust_api_get_plan_balance_impl(port, ptr, rust_vec_len, data_len), -40 => wire__crate__bridge__api__rust_api_get_proof_of_work_impl(port, ptr, rust_vec_len, data_len), -41 => wire__crate__bridge__api__rust_api_get_server_key_for_passwordless_recovery_impl(port, ptr, rust_vec_len, data_len), -42 => wire__crate__bridge__api__rust_api_get_user_by_id_impl(port, ptr, rust_vec_len, data_len), -43 => wire__crate__bridge__api__rust_api_get_user_data_impl(port, ptr, rust_vec_len, data_len), -44 => wire__crate__bridge__api__rust_api_get_user_id_from_username_impl(port, ptr, rust_vec_len, data_len), -45 => wire__crate__bridge__api__rust_api_insert_and_send_additional_data_impl(port, ptr, rust_vec_len, data_len), -46 => wire__crate__bridge__api__rust_api_insert_and_send_ask_about_user_impl(port, ptr, rust_vec_len, data_len), -47 => wire__crate__bridge__api__rust_api_insert_and_send_contact_share_impl(port, ptr, rust_vec_len, data_len), -48 => wire__crate__bridge__api__rust_api_insert_and_send_text_impl(port, ptr, rust_vec_len, data_len), -49 => wire__crate__bridge__api__rust_api_ipa_purchase_impl(port, ptr, rust_vec_len, data_len), -50 => wire__crate__bridge__api__rust_api_load_plan_balance_impl(port, ptr, rust_vec_len, data_len), -51 => wire__crate__bridge__api__rust_api_notification_badge_count_impl(port, ptr, rust_vec_len, data_len), -52 => wire__crate__bridge__api__rust_api_notify_messages_opened_impl(port, ptr, rust_vec_len, data_len), -53 => wire__crate__bridge__api__rust_api_perform_passwordless_recovery_heartbeat_impl(port, ptr, rust_vec_len, data_len), -54 => wire__crate__bridge__api__rust_api_prepare_queued_message_impl(port, ptr, rust_vec_len, data_len), -55 => wire__crate__bridge__api__rust_api_register_impl(port, ptr, rust_vec_len, data_len), -56 => wire__crate__bridge__api__rust_api_register_passwordless_notification_impl(port, ptr, rust_vec_len, data_len), -57 => wire__crate__bridge__api__rust_api_register_passwordless_recovery_impl(port, ptr, rust_vec_len, data_len), -58 => wire__crate__bridge__api__rust_api_reload_configuration_impl(port, ptr, rust_vec_len, data_len), -59 => wire__crate__bridge__api__rust_api_remove_additional_user_impl(port, ptr, rust_vec_len, data_len), -60 => wire__crate__bridge__api__rust_api_report_user_impl(port, ptr, rust_vec_len, data_len), -61 => wire__crate__bridge__api__rust_api_request_binary_impl(port, ptr, rust_vec_len, data_len), -62 => wire__crate__bridge__api__rust_api_request_contact_by_username_impl(port, ptr, rust_vec_len, data_len), -63 => wire__crate__bridge__api__rust_api_request_media_reupload_impl(port, ptr, rust_vec_len, data_len), -64 => wire__crate__bridge__api__rust_api_request_memories_upload_impl(port, ptr, rust_vec_len, data_len), -65 => wire__crate__bridge__api__rust_api_retransmit_all_messages_impl(port, ptr, rust_vec_len, data_len), -66 => wire__crate__bridge__api__rust_api_send_binary_impl(port, ptr, rust_vec_len, data_len), -67 => wire__crate__bridge__api__rust_api_send_contact_profile_impl(port, ptr, rust_vec_len, data_len), -68 => wire__crate__bridge__api__rust_api_send_encrypted_content_impl(port, ptr, rust_vec_len, data_len), -69 => wire__crate__bridge__api__rust_api_send_encrypted_content_to_group_impl(port, ptr, rust_vec_len, data_len), -70 => wire__crate__bridge__api__rust_api_send_queued_message_impl(port, ptr, rust_vec_len, data_len), -71 => wire__crate__bridge__api__rust_api_send_text_message_impl(port, ptr, rust_vec_len, data_len), -72 => wire__crate__bridge__api__rust_api_send_typing_impl(port, ptr, rust_vec_len, data_len), -73 => wire__crate__bridge__api__rust_api_set_background_impl(port, ptr, rust_vec_len, data_len), -74 => wire__crate__bridge__api__rust_api_set_login_token_impl(port, ptr, rust_vec_len, data_len), -75 => wire__crate__bridge__api__rust_api_set_network_available_impl(port, ptr, rust_vec_len, data_len), -76 => wire__crate__bridge__api__rust_api_submit_recovery_share_impl(port, ptr, rust_vec_len, data_len), -77 => wire__crate__bridge__api__rust_api_update_fcm_token_impl(port, ptr, rust_vec_len, data_len), -78 => wire__crate__bridge__api__rust_api_update_signed_pre_key_impl(port, ptr, rust_vec_len, data_len), -79 => wire__crate__bridge__api__rust_api_upload_pqc_pre_keys_impl(port, ptr, rust_vec_len, data_len), -80 => wire__crate__bridge__wrapper__app_database__rust_app_database_changes_impl(port, ptr, rust_vec_len, data_len), -81 => wire__crate__bridge__wrapper__app_database__rust_app_database_execute_impl(port, ptr, rust_vec_len, data_len), -82 => wire__crate__bridge__wrapper__app_database__rust_app_database_legacy_import_complete_impl(port, ptr, rust_vec_len, data_len), -83 => wire__crate__bridge__wrapper__app_database__rust_app_database_migrate_legacy_database_impl(port, ptr, rust_vec_len, data_len), -84 => wire__crate__bridge__wrapper__app_database__rust_app_database_select_impl(port, ptr, rust_vec_len, data_len), -85 => wire__crate__bridge__wrapper__backup__rust_backup_archive_create_backup_archive_impl(port, ptr, rust_vec_len, data_len), -86 => wire__crate__bridge__wrapper__backup__rust_backup_archive_get_backup_download_token_impl(port, ptr, rust_vec_len, data_len), -87 => wire__crate__bridge__wrapper__backup__rust_backup_archive_restore_backup_archive_impl(port, ptr, rust_vec_len, data_len), -88 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_backup_id_impl(port, ptr, rust_vec_len, data_len), -89 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_backup_password_keys_impl(port, ptr, rust_vec_len, data_len), -90 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_identity_backup_bytes_impl(port, ptr, rust_vec_len, data_len), -91 => wire__crate__bridge__wrapper__backup__rust_backup_identity_import_backup_password_keys_impl(port, ptr, rust_vec_len, data_len), -92 => wire__crate__bridge__wrapper__backup__rust_backup_identity_restore_identity_backup_impl(port, ptr, rust_vec_len, data_len), -93 => wire__crate__bridge__wrapper__backup__rust_backup_identity_set_backup_password_keys_impl(port, ptr, rust_vec_len, data_len), -94 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_decrypt_cloud_media_key_impl(port, ptr, rust_vec_len, data_len), -95 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_encrypt_cloud_media_key_impl(port, ptr, rust_vec_len, data_len), -96 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_login_token_impl(port, ptr, rust_vec_len, data_len), -97 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_signal_identity_impl(port, ptr, rust_vec_len, data_len), -98 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_user_id_impl(port, ptr, rust_vec_len, data_len), -99 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_import_serialized_impl(port, ptr, rust_vec_len, data_len), -100 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_import_signal_identity_impl(port, ptr, rust_vec_len, data_len), -101 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_remove_key_manager_impl(port, ptr, rust_vec_len, data_len), -102 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_serialize_impl(port, ptr, rust_vec_len, data_len), -103 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_set_user_id_impl(port, ptr, rust_vec_len, data_len), -104 => wire__crate__bridge__wrapper__signal__rust_signal_decrypt_impl(port, ptr, rust_vec_len, data_len), -105 => wire__crate__bridge__wrapper__signal__rust_signal_encrypt_impl(port, ptr, rust_vec_len, data_len), -106 => wire__crate__bridge__wrapper__signal__rust_signal_generate_bundle_impl(port, ptr, rust_vec_len, data_len), -107 => wire__crate__bridge__wrapper__signal__rust_signal_generate_pqc_prekeys_impl(port, ptr, rust_vec_len, data_len), -108 => wire__crate__bridge__wrapper__signal__rust_signal_get_contact_public_key_impl(port, ptr, rust_vec_len, data_len), -109 => wire__crate__bridge__wrapper__signal__rust_signal_get_user_public_key_impl(port, ptr, rust_vec_len, data_len), -110 => wire__crate__bridge__wrapper__signal__rust_signal_process_prekey_bundle_impl(port, ptr, rust_vec_len, data_len), -111 => wire__crate__bridge__wrapper__rust_utils_generate_shares_impl(port, ptr, rust_vec_len, data_len), -112 => wire__crate__bridge__wrapper__rust_utils_recover_secret_impl(port, ptr, rust_vec_len, data_len), -113 => wire__crate__bridge__callbacks__log__strip_ansi_impl(port, ptr, rust_vec_len, data_len), -114 => wire__crate__bridge__groups__update_chat_deletion_time_impl(port, ptr, rust_vec_len, data_len), -115 => wire__crate__bridge__groups__update_group_name_impl(port, ptr, rust_vec_len, data_len), -117 => wire__crate__bridge__user_config__user_config_api_create_impl(port, ptr, rust_vec_len, data_len), -118 => wire__crate__bridge__user_config__user_config_api_import_json_impl(port, ptr, rust_vec_len, data_len), -119 => wire__crate__bridge__user_config__user_config_api_load_impl(port, ptr, rust_vec_len, data_len), -120 => wire__crate__bridge__user_config__user_config_api_save_impl(port, ptr, rust_vec_len, data_len), -121 => wire__crate__bridge__user_config__user_config_api_update_impl(port, ptr, rust_vec_len, data_len), +16 => wire__crate__bridge__api__rust_api_abandon_media_impl(port, ptr, rust_vec_len, data_len), +17 => wire__crate__bridge__api__rust_api_add_additional_user_impl(port, ptr, rust_vec_len, data_len), +18 => wire__crate__bridge__api__rust_api_allocate_sequence_impl(port, ptr, rust_vec_len, data_len), +20 => wire__crate__bridge__api__rust_api_authentication_headers_impl(port, ptr, rust_vec_len, data_len), +22 => wire__crate__bridge__api__rust_api_change_username_impl(port, ptr, rust_vec_len, data_len), +23 => wire__crate__bridge__api__rust_api_check_for_deleted_usernames_impl(port, ptr, rust_vec_len, data_len), +24 => wire__crate__bridge__api__rust_api_check_for_passwordless_notification_impl(port, ptr, rust_vec_len, data_len), +25 => wire__crate__bridge__api__rust_api_clear_contact_request_notifications_impl(port, ptr, rust_vec_len, data_len), +26 => wire__crate__bridge__api__rust_api_clear_conversation_notifications_impl(port, ptr, rust_vec_len, data_len), +27 => wire__crate__bridge__api__rust_api_close_impl(port, ptr, rust_vec_len, data_len), +28 => wire__crate__bridge__api__rust_api_confirm_memories_upload_impl(port, ptr, rust_vec_len, data_len), +29 => wire__crate__bridge__api__rust_api_connect_impl(port, ptr, rust_vec_len, data_len), +30 => wire__crate__bridge__api__rust_api_connection_state_impl(port, ptr, rust_vec_len, data_len), +31 => wire__crate__bridge__api__rust_api_crop_media_transparent_borders_impl(port, ptr, rust_vec_len, data_len), +32 => wire__crate__bridge__api__rust_api_current_user_avatar_path_impl(port, ptr, rust_vec_len, data_len), +34 => wire__crate__bridge__api__rust_api_delete_account_impl(port, ptr, rust_vec_len, data_len), +35 => wire__crate__bridge__api__rust_api_delete_memory_impl(port, ptr, rust_vec_len, data_len), +36 => wire__crate__bridge__api__rust_api_disable_memories_backup_impl(port, ptr, rust_vec_len, data_len), +37 => wire__crate__bridge__api__rust_api_download_done_impl(port, ptr, rust_vec_len, data_len), +38 => wire__crate__bridge__api__rust_api_download_media_impl(port, ptr, rust_vec_len, data_len), +39 => wire__crate__bridge__api__rust_api_download_pending_media_impl(port, ptr, rust_vec_len, data_len), +40 => wire__crate__bridge__api__rust_api_ensure_avatar_png_impl(port, ptr, rust_vec_len, data_len), +41 => wire__crate__bridge__api__rust_api_establish_signal_session_impl(port, ptr, rust_vec_len, data_len), +42 => wire__crate__bridge__api__rust_api_events_impl(port, ptr, rust_vec_len, data_len), +43 => wire__crate__bridge__api__rust_api_finish_started_media_uploads_impl(port, ptr, rust_vec_len, data_len), +44 => wire__crate__bridge__api__rust_api_force_ipa_check_impl(port, ptr, rust_vec_len, data_len), +45 => wire__crate__bridge__api__rust_api_get_memories_url_impl(port, ptr, rust_vec_len, data_len), +46 => wire__crate__bridge__api__rust_api_get_memories_usage_impl(port, ptr, rust_vec_len, data_len), +47 => wire__crate__bridge__api__rust_api_get_plan_balance_impl(port, ptr, rust_vec_len, data_len), +48 => wire__crate__bridge__api__rust_api_get_proof_of_work_impl(port, ptr, rust_vec_len, data_len), +49 => wire__crate__bridge__api__rust_api_get_server_key_for_passwordless_recovery_impl(port, ptr, rust_vec_len, data_len), +50 => wire__crate__bridge__api__rust_api_get_user_by_id_impl(port, ptr, rust_vec_len, data_len), +51 => wire__crate__bridge__api__rust_api_get_user_data_impl(port, ptr, rust_vec_len, data_len), +52 => wire__crate__bridge__api__rust_api_get_user_id_from_username_impl(port, ptr, rust_vec_len, data_len), +53 => wire__crate__bridge__api__rust_api_initialize_media_upload_impl(port, ptr, rust_vec_len, data_len), +54 => wire__crate__bridge__api__rust_api_insert_and_send_additional_data_impl(port, ptr, rust_vec_len, data_len), +55 => wire__crate__bridge__api__rust_api_insert_and_send_ask_about_user_impl(port, ptr, rust_vec_len, data_len), +56 => wire__crate__bridge__api__rust_api_insert_and_send_contact_share_impl(port, ptr, rust_vec_len, data_len), +57 => wire__crate__bridge__api__rust_api_insert_and_send_text_impl(port, ptr, rust_vec_len, data_len), +58 => wire__crate__bridge__api__rust_api_ipa_purchase_impl(port, ptr, rust_vec_len, data_len), +59 => wire__crate__bridge__api__rust_api_load_plan_balance_impl(port, ptr, rust_vec_len, data_len), +60 => wire__crate__bridge__api__rust_api_media_size_limit_report_impl(port, ptr, rust_vec_len, data_len), +61 => wire__crate__bridge__api__rust_api_media_step_finished_impl(port, ptr, rust_vec_len, data_len), +62 => wire__crate__bridge__api__rust_api_notification_badge_count_impl(port, ptr, rust_vec_len, data_len), +63 => wire__crate__bridge__api__rust_api_notify_messages_opened_impl(port, ptr, rust_vec_len, data_len), +64 => wire__crate__bridge__api__rust_api_perform_passwordless_recovery_heartbeat_impl(port, ptr, rust_vec_len, data_len), +65 => wire__crate__bridge__api__rust_api_purge_media_temp_folder_impl(port, ptr, rust_vec_len, data_len), +66 => wire__crate__bridge__api__rust_api_register_impl(port, ptr, rust_vec_len, data_len), +67 => wire__crate__bridge__api__rust_api_register_passwordless_notification_impl(port, ptr, rust_vec_len, data_len), +68 => wire__crate__bridge__api__rust_api_register_passwordless_recovery_impl(port, ptr, rust_vec_len, data_len), +69 => wire__crate__bridge__api__rust_api_reload_configuration_impl(port, ptr, rust_vec_len, data_len), +70 => wire__crate__bridge__api__rust_api_remove_additional_user_impl(port, ptr, rust_vec_len, data_len), +71 => wire__crate__bridge__api__rust_api_remove_media_files_impl(port, ptr, rust_vec_len, data_len), +72 => wire__crate__bridge__api__rust_api_report_user_impl(port, ptr, rust_vec_len, data_len), +73 => wire__crate__bridge__api__rust_api_request_binary_impl(port, ptr, rust_vec_len, data_len), +74 => wire__crate__bridge__api__rust_api_request_contact_by_username_impl(port, ptr, rust_vec_len, data_len), +75 => wire__crate__bridge__api__rust_api_request_media_reupload_impl(port, ptr, rust_vec_len, data_len), +76 => wire__crate__bridge__api__rust_api_request_memories_upload_impl(port, ptr, rust_vec_len, data_len), +77 => wire__crate__bridge__api__rust_api_retransmit_all_messages_impl(port, ptr, rust_vec_len, data_len), +78 => wire__crate__bridge__api__rust_api_retry_pending_media_reuploads_impl(port, ptr, rust_vec_len, data_len), +79 => wire__crate__bridge__api__rust_api_reupload_pending_media_impl(port, ptr, rust_vec_len, data_len), +80 => wire__crate__bridge__api__rust_api_save_media_to_gallery_impl(port, ptr, rust_vec_len, data_len), +81 => wire__crate__bridge__api__rust_api_send_binary_impl(port, ptr, rust_vec_len, data_len), +82 => wire__crate__bridge__api__rust_api_send_contact_profile_impl(port, ptr, rust_vec_len, data_len), +83 => wire__crate__bridge__api__rust_api_send_encrypted_content_impl(port, ptr, rust_vec_len, data_len), +84 => wire__crate__bridge__api__rust_api_send_encrypted_content_to_group_impl(port, ptr, rust_vec_len, data_len), +85 => wire__crate__bridge__api__rust_api_send_media_to_groups_impl(port, ptr, rust_vec_len, data_len), +86 => wire__crate__bridge__api__rust_api_send_queued_message_impl(port, ptr, rust_vec_len, data_len), +87 => wire__crate__bridge__api__rust_api_send_text_message_impl(port, ptr, rust_vec_len, data_len), +88 => wire__crate__bridge__api__rust_api_send_typing_impl(port, ptr, rust_vec_len, data_len), +89 => wire__crate__bridge__api__rust_api_set_background_impl(port, ptr, rust_vec_len, data_len), +90 => wire__crate__bridge__api__rust_api_set_login_token_impl(port, ptr, rust_vec_len, data_len), +91 => wire__crate__bridge__api__rust_api_set_media_display_limit_impl(port, ptr, rust_vec_len, data_len), +92 => wire__crate__bridge__api__rust_api_set_media_requires_authentication_impl(port, ptr, rust_vec_len, data_len), +93 => wire__crate__bridge__api__rust_api_set_network_available_impl(port, ptr, rust_vec_len, data_len), +94 => wire__crate__bridge__api__rust_api_store_media_impl(port, ptr, rust_vec_len, data_len), +95 => wire__crate__bridge__api__rust_api_submit_recovery_share_impl(port, ptr, rust_vec_len, data_len), +96 => wire__crate__bridge__api__rust_api_toggle_media_remove_audio_impl(port, ptr, rust_vec_len, data_len), +97 => wire__crate__bridge__api__rust_api_try_request_contact_by_id_impl(port, ptr, rust_vec_len, data_len), +98 => wire__crate__bridge__api__rust_api_update_fcm_token_impl(port, ptr, rust_vec_len, data_len), +99 => wire__crate__bridge__api__rust_api_update_signed_pre_key_impl(port, ptr, rust_vec_len, data_len), +100 => wire__crate__bridge__api__rust_api_upload_pqc_pre_keys_impl(port, ptr, rust_vec_len, data_len), +101 => wire__crate__bridge__wrapper__app_database__rust_app_database_changes_impl(port, ptr, rust_vec_len, data_len), +102 => wire__crate__bridge__wrapper__app_database__rust_app_database_execute_impl(port, ptr, rust_vec_len, data_len), +103 => wire__crate__bridge__wrapper__app_database__rust_app_database_legacy_import_complete_impl(port, ptr, rust_vec_len, data_len), +104 => wire__crate__bridge__wrapper__app_database__rust_app_database_migrate_legacy_database_impl(port, ptr, rust_vec_len, data_len), +105 => wire__crate__bridge__wrapper__app_database__rust_app_database_select_impl(port, ptr, rust_vec_len, data_len), +106 => wire__crate__bridge__wrapper__backup__rust_backup_archive_create_backup_archive_impl(port, ptr, rust_vec_len, data_len), +107 => wire__crate__bridge__wrapper__backup__rust_backup_archive_get_backup_download_token_impl(port, ptr, rust_vec_len, data_len), +108 => wire__crate__bridge__wrapper__backup__rust_backup_archive_restore_backup_archive_impl(port, ptr, rust_vec_len, data_len), +109 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_backup_id_impl(port, ptr, rust_vec_len, data_len), +110 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_backup_password_keys_impl(port, ptr, rust_vec_len, data_len), +111 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_identity_backup_bytes_impl(port, ptr, rust_vec_len, data_len), +112 => wire__crate__bridge__wrapper__backup__rust_backup_identity_import_backup_password_keys_impl(port, ptr, rust_vec_len, data_len), +113 => wire__crate__bridge__wrapper__backup__rust_backup_identity_restore_identity_backup_impl(port, ptr, rust_vec_len, data_len), +114 => wire__crate__bridge__wrapper__backup__rust_backup_identity_set_backup_password_keys_impl(port, ptr, rust_vec_len, data_len), +115 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_decrypt_cloud_media_key_impl(port, ptr, rust_vec_len, data_len), +116 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_encrypt_cloud_media_key_impl(port, ptr, rust_vec_len, data_len), +117 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_signal_identity_impl(port, ptr, rust_vec_len, data_len), +118 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_user_id_impl(port, ptr, rust_vec_len, data_len), +119 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_import_serialized_impl(port, ptr, rust_vec_len, data_len), +120 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_import_signal_identity_impl(port, ptr, rust_vec_len, data_len), +121 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_remove_key_manager_impl(port, ptr, rust_vec_len, data_len), +122 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_serialize_impl(port, ptr, rust_vec_len, data_len), +123 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_set_user_id_impl(port, ptr, rust_vec_len, data_len), +124 => wire__crate__bridge__wrapper__signal__rust_signal_decrypt_impl(port, ptr, rust_vec_len, data_len), +125 => wire__crate__bridge__wrapper__signal__rust_signal_encrypt_impl(port, ptr, rust_vec_len, data_len), +126 => wire__crate__bridge__wrapper__signal__rust_signal_generate_bundle_impl(port, ptr, rust_vec_len, data_len), +127 => wire__crate__bridge__wrapper__signal__rust_signal_generate_pqc_prekeys_impl(port, ptr, rust_vec_len, data_len), +128 => wire__crate__bridge__wrapper__signal__rust_signal_get_contact_public_key_impl(port, ptr, rust_vec_len, data_len), +129 => wire__crate__bridge__wrapper__signal__rust_signal_get_user_public_key_impl(port, ptr, rust_vec_len, data_len), +130 => wire__crate__bridge__wrapper__signal__rust_signal_process_prekey_bundle_impl(port, ptr, rust_vec_len, data_len), +131 => wire__crate__bridge__wrapper__rust_utils_generate_shares_impl(port, ptr, rust_vec_len, data_len), +132 => wire__crate__bridge__wrapper__rust_utils_recover_secret_impl(port, ptr, rust_vec_len, data_len), +133 => wire__crate__bridge__callbacks__log__strip_ansi_impl(port, ptr, rust_vec_len, data_len), +134 => wire__crate__bridge__groups__update_chat_deletion_time_impl(port, ptr, rust_vec_len, data_len), +135 => wire__crate__bridge__groups__update_group_name_impl(port, ptr, rust_vec_len, data_len), +137 => wire__crate__bridge__user_config__user_config_api_create_impl(port, ptr, rust_vec_len, data_len), +138 => wire__crate__bridge__user_config__user_config_api_import_json_impl(port, ptr, rust_vec_len, data_len), +139 => wire__crate__bridge__user_config__user_config_api_load_impl(port, ptr, rust_vec_len, data_len), +140 => wire__crate__bridge__user_config__user_config_api_save_impl(port, ptr, rust_vec_len, data_len), +141 => wire__crate__bridge__user_config__user_config_api_update_impl(port, ptr, rust_vec_len, data_len), _ => unreachable!(), } } @@ -5857,8 +6527,12 @@ fn pde_ffi_dispatcher_sync_impl( ) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { // Codec=Pde (Serialization + dispatch), see doc to use other codecs match func_id { - 18 => wire__crate__bridge__api__rust_api_api_base_url_impl(ptr, rust_vec_len, data_len), - 116 => wire__crate__bridge__user_config__user_config_api_clone_impl( + 19 => wire__crate__bridge__api__rust_api_api_base_url_impl(ptr, rust_vec_len, data_len), + 21 => wire__crate__bridge__api__rust_api_avatar_png_path_impl(ptr, rust_vec_len, data_len), + 33 => { + wire__crate__bridge__api__rust_api_decode_avatar_svg_impl(ptr, rust_vec_len, data_len) + } + 136 => wire__crate__bridge__user_config__user_config_api_clone_impl( ptr, rust_vec_len, data_len, @@ -6305,6 +6979,27 @@ impl } } // Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::services::media_upload::MediaSizeReport { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + [ + self.media_bytes.into_into_dart().into_dart(), + self.limit_bytes.into_into_dart().into_dart(), + ] + .into_dart() + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for crate::services::media_upload::MediaSizeReport +{ +} +impl flutter_rust_bridge::IntoIntoDart + for crate::services::media_upload::MediaSizeReport +{ + fn into_into_dart(self) -> crate::services::media_upload::MediaSizeReport { + self + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs impl flutter_rust_bridge::IntoDart for crate::user_config::PasswordlessRecoveryConfig { fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { [ @@ -6757,6 +7452,13 @@ impl SseEncode for flutter_rust_bridge::DartOpaque { } } +impl SseEncode for std::collections::HashMap { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + >::sse_encode(self.into_iter().collect(), serializer); + } +} + impl SseEncode for std::collections::HashMap> { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { @@ -7180,6 +7882,14 @@ impl SseEncode for Vec { } } +impl SseEncode for crate::services::media_upload::MediaSizeReport { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + >::sse_encode(self.media_bytes, serializer); + >::sse_encode(self.limit_bytes, serializer); + } +} + impl SseEncode for Option>> { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 8da09e0a..fec96ac8 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -13,7 +13,7 @@ pub use error::TwonlyError; mod frb_generated; mod keys; pub mod log; -mod native_notifications; +mod native; pub mod sealed_sender; mod secure_storage; pub mod services; diff --git a/rust/src/native/gallery.rs b/rust/src/native/gallery.rs new file mode 100644 index 00000000..d5fbe3a5 --- /dev/null +++ b/rust/src/native/gallery.rs @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2026, Tobias Müller git@tsmr.eu + * + */ + +//! Handing a finished media file to the user's photo library. +//! +//! Rust decides whether an export happens and writes the metadata into the file +//! first; the platform only performs the insert, because MediaStore and Photos +//! have no Rust equivalent. + +use crate::error::{Result, TwonlyError}; +use std::path::Path; + +#[cfg(target_os = "ios")] +pub(crate) fn save(path: &Path, is_video: bool, _name: &str, created_at_millis: i64) -> Result<()> { + use std::ffi::CString; + use std::os::raw::c_char; + + type SaveToGallery = unsafe extern "C" fn(*const c_char, bool, i64) -> bool; + + let path = CString::new(path.as_os_str().as_encoded_bytes()) + .map_err(|_| TwonlyError::Generic("gallery path contains a NUL byte".into()))?; + let symbol = c"twonly_save_to_gallery"; + let callback = unsafe { libc::dlsym(libc::RTLD_DEFAULT, symbol.as_ptr()) }; + if callback.is_null() { + return Err(TwonlyError::Generic( + "iOS gallery export is unavailable".into(), + )); + } + // SAFETY: the Swift @_cdecl declaration has this exact stable C ABI signature. + let callback: SaveToGallery = unsafe { std::mem::transmute(callback) }; + // SAFETY: Swift copies the string before this call returns. + if unsafe { callback(path.as_ptr(), is_video, created_at_millis) } { + Ok(()) + } else { + Err(TwonlyError::Generic( + "iOS could not save to the photo library".into(), + )) + } +} + +#[cfg(target_os = "android")] +pub(crate) fn save(path: &Path, is_video: bool, name: &str, created_at_millis: i64) -> Result<()> { + use crate::native::transfer::android::{gallery_class, jni_env}; + use jni::objects::{JObject, JValue}; + + let class = gallery_class()?; + let mut env = jni_env()?; + let string = |value: &str| { + env.new_string(value) + .map_err(|error| TwonlyError::Generic(error.to_string())) + }; + let path = JObject::from(string(&path.to_string_lossy())?); + let name = JObject::from(string(name)?); + let call = env + .call_static_method( + class, + "save", + "(Ljava/lang/String;ZLjava/lang/String;J)Z", + &[ + JValue::Object(&path), + JValue::Bool(u8::from(is_video)), + JValue::Object(&name), + JValue::Long(created_at_millis), + ], + ) + .and_then(|value| value.z()); + if env.exception_check().unwrap_or(false) { + let _ = env.exception_describe(); + let _ = env.exception_clear(); + } + if call.map_err(|error| TwonlyError::Generic(error.to_string()))? { + Ok(()) + } else { + Err(TwonlyError::Generic( + "Android could not save to the photo library".into(), + )) + } +} + +#[cfg(not(any(target_os = "android", target_os = "ios")))] +pub(crate) fn save( + _path: &Path, + _is_video: bool, + _name: &str, + _created_at_millis: i64, +) -> Result<()> { + Err(TwonlyError::Generic( + "platform gallery export is only available on Android and iOS".into(), + )) +} diff --git a/rust/src/native/image.rs b/rust/src/native/image.rs new file mode 100644 index 00000000..fc80fae6 --- /dev/null +++ b/rust/src/native/image.rs @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2026, Tobias Müller git@tsmr.eu + * + */ + +//! The one image job Rust cannot do alone: decoding container formats with no +//! practical pure-Rust decoder — HEIC/HEIF from an iPhone camera above all. +//! The platform decodes to PNG on disk and Rust takes it from there, so no +//! pixel buffer has to cross the FFI boundary. + +use crate::error::{Result, TwonlyError}; +use std::path::Path; + +#[cfg(target_os = "ios")] +pub(crate) fn decode_to_png(input: &Path, output: &Path) -> Result<()> { + use std::ffi::CString; + use std::os::raw::c_char; + + type DecodeImage = unsafe extern "C" fn(*const c_char, *const c_char) -> bool; + + let input = CString::new(input.as_os_str().as_encoded_bytes()) + .map_err(|_| TwonlyError::Generic("image path contains a NUL byte".into()))?; + let output = CString::new(output.as_os_str().as_encoded_bytes()) + .map_err(|_| TwonlyError::Generic("image path contains a NUL byte".into()))?; + // The implementation lives in the app executable, so it is resolved at + // runtime rather than required when the standalone cdylib is linked. + let symbol = c"twonly_decode_image_to_png"; + let callback = unsafe { libc::dlsym(libc::RTLD_DEFAULT, symbol.as_ptr()) }; + if callback.is_null() { + return Err(TwonlyError::Generic( + "iOS image decoding is unavailable".into(), + )); + } + // SAFETY: the Swift @_cdecl declaration has this exact stable C ABI signature. + let callback: DecodeImage = unsafe { std::mem::transmute(callback) }; + // SAFETY: Swift copies both strings before this call returns. + if unsafe { callback(input.as_ptr(), output.as_ptr()) } { + Ok(()) + } else { + Err(TwonlyError::Generic( + "iOS could not decode this image".into(), + )) + } +} + +#[cfg(target_os = "android")] +pub(crate) fn decode_to_png(input: &Path, output: &Path) -> Result<()> { + use crate::native::transfer::android::{jni_env, media_codec_class}; + use jni::objects::{JObject, JValue}; + + let class = media_codec_class()?; + let mut env = jni_env()?; + let input = env + .new_string(input.to_string_lossy().as_ref()) + .map_err(|error| TwonlyError::Generic(error.to_string()))?; + let output = env + .new_string(output.to_string_lossy().as_ref()) + .map_err(|error| TwonlyError::Generic(error.to_string()))?; + let input = JObject::from(input); + let output = JObject::from(output); + let call = env + .call_static_method( + class, + "decodeToPng", + "(Ljava/lang/String;Ljava/lang/String;)Z", + &[JValue::Object(&input), JValue::Object(&output)], + ) + .and_then(|value| value.z()); + // A pending Java exception would abort the process once this thread returns + // to the VM, so it is cleared and reported as a plain error instead. + if env.exception_check().unwrap_or(false) { + let _ = env.exception_describe(); + let _ = env.exception_clear(); + } + if call.map_err(|error| TwonlyError::Generic(error.to_string()))? { + Ok(()) + } else { + Err(TwonlyError::Generic( + "Android could not decode this image".into(), + )) + } +} + +#[cfg(not(any(target_os = "android", target_os = "ios")))] +pub(crate) fn decode_to_png(_input: &Path, _output: &Path) -> Result<()> { + Err(TwonlyError::Generic( + "platform image decoding is only available on Android and iOS".into(), + )) +} diff --git a/rust/src/native/mod.rs b/rust/src/native/mod.rs new file mode 100644 index 00000000..e8ef7467 --- /dev/null +++ b/rust/src/native/mod.rs @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2026, Tobias Müller git@tsmr.eu + * + */ + +//! Every boundary between Rust and the platform. +//! +//! Rust owns the decisions; these modules only hand work to the OS APIs that +//! have no Rust equivalent — hardware codecs, container formats without a Rust +//! decoder, durable background transfer, the photo library and notifications. +//! None of them require the Flutter engine to be running. + +pub(crate) mod gallery; +pub(crate) mod image; +pub(crate) mod notifications; +pub(crate) mod transfer; +pub(crate) mod video; diff --git a/rust/src/native_notifications.rs b/rust/src/native/notifications.rs similarity index 100% rename from rust/src/native_notifications.rs rename to rust/src/native/notifications.rs diff --git a/rust/src/native/transfer.rs b/rust/src/native/transfer.rs new file mode 100644 index 00000000..30773586 --- /dev/null +++ b/rust/src/native/transfer.rs @@ -0,0 +1,167 @@ +use crate::error::{Result, TwonlyError}; + +#[cfg(target_os = "ios")] +pub(crate) fn schedule(descriptor_json: &str) -> Result<()> { + use std::ffi::CString; + use std::os::raw::c_char; + + type ScheduleUpload = unsafe extern "C" fn(*const c_char) -> bool; + + let descriptor = CString::new(descriptor_json) + .map_err(|_| TwonlyError::Generic("native upload descriptor contains a NUL byte".into()))?; + let symbol = c"twonly_schedule_direct_media_uploads"; + // The callback lives in the containing iOS executable, so resolve it at runtime instead of + // requiring it while Cargo links the standalone Rust cdylib build product. + let callback = unsafe { libc::dlsym(libc::RTLD_DEFAULT, symbol.as_ptr()) }; + if callback.is_null() { + return Err(TwonlyError::Generic( + "iOS direct-media upload callback is unavailable".into(), + )); + } + // SAFETY: the Swift @_cdecl declaration has this exact stable C ABI signature. + let callback: ScheduleUpload = unsafe { std::mem::transmute(callback) }; + // SAFETY: Swift copies and decodes the string before this call returns. + if unsafe { callback(descriptor.as_ptr()) } { + Ok(()) + } else { + Err(TwonlyError::Generic( + "iOS rejected the direct-media upload schedule".into(), + )) + } +} + +#[cfg(target_os = "android")] +pub(crate) mod android { + use super::*; + use jni::objects::{GlobalRef, JObject, JValue}; + use jni::sys::{jint, JNI_VERSION_1_6}; + use jni::{AttachGuard, JavaVM}; + use std::ffi::c_void; + use std::sync::OnceLock; + + const TRANSFER_CLASS: &str = "eu/twonly/directmedia/DirectMediaTransfer"; + const MEDIA_CODEC_CLASS: &str = "eu/twonly/media/NativeImageCodec"; + const VIDEO_CODEC_CLASS: &str = "eu/twonly/media/NativeVideoCodec"; + const GALLERY_CLASS: &str = "eu/twonly/media/NativeGallery"; + + static JAVA_VM: OnceLock = OnceLock::new(); + static TRANSFER: OnceLock = OnceLock::new(); + static MEDIA_CODEC: OnceLock = OnceLock::new(); + static VIDEO_CODEC: OnceLock = OnceLock::new(); + static GALLERY: OnceLock = OnceLock::new(); + /// `JNI_OnLoad` runs before Rust logging exists, so failures there are + /// recorded rather than logged, and reported by the first call that needs + /// the class. + static CLASS_ERROR: OnceLock = OnceLock::new(); + + #[unsafe(no_mangle)] + pub extern "system" fn JNI_OnLoad(vm: JavaVM, _reserved: *mut c_void) -> jint { + // A thread Rust spawned itself and attaches later only sees the system + // class loader, which knows nothing about the APK. `JNI_OnLoad` runs on + // the Java thread that called `System.loadLibrary`, so it is the one + // place where the application's own classes can still be resolved by + // name. Hold on to them; every later call comes from a Rust thread. + match vm.get_env() { + Ok(mut env) => { + for (name, cache) in [ + (TRANSFER_CLASS, &TRANSFER), + (MEDIA_CODEC_CLASS, &MEDIA_CODEC), + (VIDEO_CODEC_CLASS, &VIDEO_CODEC), + (GALLERY_CLASS, &GALLERY), + ] { + match env + .find_class(name) + .and_then(|class| env.new_global_ref(class)) + { + Ok(global) => { + let _ = cache.set(global); + } + Err(error) => { + // Leaving a ClassNotFoundException pending would abort + // the process the next time this thread enters the VM. + let _ = env.exception_clear(); + let _ = CLASS_ERROR.set(format!("{name}: {error}")); + } + } + } + } + Err(error) => { + let _ = CLASS_ERROR.set(format!("no JNI env during JNI_OnLoad: {error}")); + } + } + let _ = JAVA_VM.set(vm); + JNI_VERSION_1_6 + } + + fn cached_class(cache: &'static OnceLock, name: &str) -> Result<&'static GlobalRef> { + cache.get().ok_or_else(|| { + TwonlyError::Generic(format!( + "Android class {name} is unavailable ({})", + CLASS_ERROR + .get() + .map_or("JNI_OnLoad never ran", String::as_str) + )) + }) + } + + pub(crate) fn media_codec_class() -> Result<&'static GlobalRef> { + cached_class(&MEDIA_CODEC, MEDIA_CODEC_CLASS) + } + + pub(crate) fn video_codec_class() -> Result<&'static GlobalRef> { + cached_class(&VIDEO_CODEC, VIDEO_CODEC_CLASS) + } + + pub(crate) fn gallery_class() -> Result<&'static GlobalRef> { + cached_class(&GALLERY, GALLERY_CLASS) + } + + pub(crate) fn jni_env() -> Result> { + JAVA_VM + .get() + .ok_or_else(|| TwonlyError::Generic("Android Java VM is unavailable".into()))? + .attach_current_thread() + .map_err(|error| TwonlyError::Generic(error.to_string())) + } + + pub(crate) fn schedule(descriptor_json: &str) -> Result<()> { + let transfer = cached_class(&TRANSFER, TRANSFER_CLASS)?; + let mut env = jni_env()?; + let descriptor = env + .new_string(descriptor_json) + .map_err(|error| TwonlyError::Generic(error.to_string()))?; + let descriptor_object = JObject::from(descriptor); + let call = env + .call_static_method( + transfer, + "schedule", + "(Ljava/lang/String;)Z", + &[JValue::Object(&descriptor_object)], + ) + .and_then(|value| value.z()); + // A pending Java exception left on this thread would be raised as a + // fatal error when the attachment is dropped, taking the process with + // it. The scheduling failure is reported through `Result` instead. + if env.exception_check().unwrap_or(false) { + let _ = env.exception_describe(); + let _ = env.exception_clear(); + } + if call.map_err(|error| TwonlyError::Generic(error.to_string()))? { + Ok(()) + } else { + Err(TwonlyError::Generic( + "Android rejected the direct-media upload schedule".into(), + )) + } + } +} + +#[cfg(target_os = "android")] +pub(crate) use android::schedule; + +#[cfg(not(any(target_os = "android", target_os = "ios")))] +pub(crate) fn schedule(_descriptor_json: &str) -> Result<()> { + Err(TwonlyError::Generic( + "native background transfer is only available on Android and iOS".into(), + )) +} diff --git a/rust/src/native/video.rs b/rust/src/native/video.rs new file mode 100644 index 00000000..17727aee --- /dev/null +++ b/rust/src/native/video.rs @@ -0,0 +1,291 @@ +/* + * Copyright (c) 2026, Tobias Müller git@tsmr.eu + * + */ + +//! Video composition and transcoding, in one hardware pass per platform. +//! +//! iOS uses AVFoundation (VideoToolbox underneath) and Android uses Media3 +//! `Transformer` (MediaCodec plus an OpenGL overlay stage). Neither involves +//! ffmpeg or the Flutter engine, so a send can finish while the app is in the +//! background or was relaunched without its UI. + +use crate::error::{Result, TwonlyError}; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +/// One render request. The overlay is a pre-rasterised PNG the size of the +/// video frame; the editor draws it once when the user hits send. Owned so the +/// whole request can move onto the blocking pool. +// Only the mobile implementations read these; the desktop stub cannot render. +#[cfg_attr(not(any(target_os = "android", target_os = "ios")), allow(dead_code))] +pub(crate) struct RenderRequest { + pub media_id: String, + pub input: PathBuf, + pub overlay: Option, + pub output: PathBuf, + pub remove_audio: bool, +} + +/// The platform reports progress from its own thread — Android's main looper, +/// a GCD timer on iOS — which is not inside the async runtime. Spawning from +/// there would panic, and a panic unwinding back through the C ABI aborts the +/// process, so the runtime is captured while still on a Rust thread. +#[cfg_attr(not(any(target_os = "android", target_os = "ios")), allow(dead_code))] +static RUNTIME: OnceLock = OnceLock::new(); + +#[cfg_attr(not(any(target_os = "android", target_os = "ios")), allow(dead_code))] +fn remember_runtime() { + if let Ok(handle) = tokio::runtime::Handle::try_current() { + let _ = RUNTIME.set(handle); + } +} + +/// Reports transcoding progress back into the media row so the send state in +/// chat can show it. Called by the platform while a render is running. +#[cfg_attr(not(any(target_os = "android", target_os = "ios")), allow(dead_code))] +fn report_progress(media_id: &str, percent: i64) { + let Ok(ctx) = crate::context::Context::get_static() else { + return; + }; + let Some(runtime) = RUNTIME.get() else { + return; + }; + let ctx = ctx.clone(); + let media_id = media_id.to_owned(); + runtime.spawn(async move { + let database = ctx.app_db.read().await.clone(); + let _ = + sqlx::query("UPDATE media_files SET pre_progressing_process = ? WHERE media_id = ?") + .bind(percent.clamp(0, 100)) + .bind(&media_id) + .execute(&database.pool) + .await; + database.notify_committed(["media_files"]); + }); +} + +#[cfg(target_os = "ios")] +pub(crate) fn render(request: &RenderRequest) -> Result<()> { + remember_runtime(); + use std::ffi::{CStr, CString}; + use std::os::raw::{c_char, c_int}; + + type ProgressCallback = unsafe extern "C" fn(*const c_char, c_int); + type RenderVideo = unsafe extern "C" fn( + *const c_char, + *const c_char, + *const c_char, + bool, + *const c_char, + ProgressCallback, + ) -> bool; + + unsafe extern "C" fn progress(media_id: *const c_char, percent: c_int) { + // Unwinding back into Swift would abort the process, so a panic here + // can only cost this one progress update. + let _ = std::panic::catch_unwind(|| { + if media_id.is_null() { + return; + } + // SAFETY: Swift passes the same NUL-terminated string it was handed. + let media_id = unsafe { CStr::from_ptr(media_id) }; + if let Ok(media_id) = media_id.to_str() { + report_progress(media_id, i64::from(percent)); + } + }); + } + + let to_c = |path: &PathBuf| { + CString::new(path.as_os_str().as_encoded_bytes()) + .map_err(|_| TwonlyError::Generic("video path contains a NUL byte".into())) + }; + let input = to_c(&request.input)?; + let output = to_c(&request.output)?; + let overlay = request.overlay.as_ref().map(to_c).transpose()?; + let media_id = CString::new(request.media_id.as_str()) + .map_err(|_| TwonlyError::Generic("media id contains a NUL byte".into()))?; + + let symbol = c"twonly_render_video"; + // The implementation lives in the app executable, so it is resolved at + // runtime instead of being required when the cdylib is linked. + let callback = unsafe { libc::dlsym(libc::RTLD_DEFAULT, symbol.as_ptr()) }; + if callback.is_null() { + return Err(TwonlyError::Generic( + "iOS video rendering is unavailable".into(), + )); + } + // SAFETY: the Swift @_cdecl declaration has this exact stable C ABI signature. + let callback: RenderVideo = unsafe { std::mem::transmute(callback) }; + // SAFETY: Swift copies every string before returning, and the progress + // function pointer is only called for the duration of this call. + let rendered = unsafe { + callback( + input.as_ptr(), + overlay + .as_ref() + .map_or(std::ptr::null(), |path| path.as_ptr()), + output.as_ptr(), + request.remove_audio, + media_id.as_ptr(), + progress, + ) + }; + if rendered { + Ok(()) + } else { + Err(TwonlyError::Generic( + "iOS could not render the video".into(), + )) + } +} + +#[cfg(target_os = "android")] +pub(crate) fn render(request: &RenderRequest) -> Result<()> { + remember_runtime(); + use crate::native::transfer::android::{jni_env, video_codec_class}; + use jni::objects::{JObject, JValue}; + + let class = video_codec_class()?; + let mut env = jni_env()?; + let string = |value: &str| { + env.new_string(value) + .map_err(|error| TwonlyError::Generic(error.to_string())) + }; + let input = JObject::from(string(&request.input.to_string_lossy())?); + let output = JObject::from(string(&request.output.to_string_lossy())?); + let media_id = JObject::from(string(&request.media_id)?); + let overlay = match request.overlay.as_ref() { + Some(path) => JObject::from(string(&path.to_string_lossy())?), + None => JObject::null(), + }; + + let call = env + .call_static_method( + class, + "render", + "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZLjava/lang/String;)Z", + &[ + JValue::Object(&input), + JValue::Object(&overlay), + JValue::Object(&output), + JValue::Bool(u8::from(request.remove_audio)), + JValue::Object(&media_id), + ], + ) + .and_then(|value| value.z()); + // A pending Java exception would abort the process once this thread returns + // to the VM, so it is cleared and reported as a plain error instead. + if env.exception_check().unwrap_or(false) { + let _ = env.exception_describe(); + let _ = env.exception_clear(); + } + if call.map_err(|error| TwonlyError::Generic(error.to_string()))? { + Ok(()) + } else { + Err(TwonlyError::Generic( + "Android could not render the video".into(), + )) + } +} + +/// Progress reported by the Android renderer while `render` is blocked. +#[cfg(target_os = "android")] +#[unsafe(no_mangle)] +pub extern "system" fn Java_eu_twonly_media_NativeVideoCodec_reportProgress( + mut env: jni::JNIEnv, + _class: jni::objects::JClass, + media_id: jni::objects::JString, + percent: jni::sys::jint, +) { + // This runs on Android's main looper. Unwinding back into the VM would + // abort the process, so a panic can only cost this one progress update. + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let Ok(media_id) = env.get_string(&media_id) else { + return; + }; + report_progress(&media_id.to_string_lossy(), i64::from(percent)); + })); +} + +#[cfg(not(any(target_os = "android", target_os = "ios")))] +pub(crate) fn render(_request: &RenderRequest) -> Result<()> { + Err(TwonlyError::Generic( + "platform video rendering is only available on Android and iOS".into(), + )) +} + +/// Extracts a frame as PNG so Rust can encode the thumbnail itself. The frame +/// grab needs the platform's video decoder; everything after it does not. +#[cfg(target_os = "ios")] +pub(crate) fn extract_frame(input: &Path, output: &Path) -> Result<()> { + use std::ffi::CString; + use std::os::raw::c_char; + + type ExtractFrame = unsafe extern "C" fn(*const c_char, *const c_char) -> bool; + + let to_c = |path: &Path| { + CString::new(path.as_os_str().as_encoded_bytes()) + .map_err(|_| TwonlyError::Generic("video path contains a NUL byte".into())) + }; + let input = to_c(input)?; + let output = to_c(output)?; + let symbol = c"twonly_extract_video_frame"; + let callback = unsafe { libc::dlsym(libc::RTLD_DEFAULT, symbol.as_ptr()) }; + if callback.is_null() { + return Err(TwonlyError::Generic( + "iOS frame extraction is unavailable".into(), + )); + } + // SAFETY: the Swift @_cdecl declaration has this exact stable C ABI signature. + let callback: ExtractFrame = unsafe { std::mem::transmute(callback) }; + // SAFETY: Swift copies both strings before this call returns. + if unsafe { callback(input.as_ptr(), output.as_ptr()) } { + Ok(()) + } else { + Err(TwonlyError::Generic( + "iOS could not extract a video frame".into(), + )) + } +} + +#[cfg(target_os = "android")] +pub(crate) fn extract_frame(input: &Path, output: &Path) -> Result<()> { + use crate::native::transfer::android::{jni_env, video_codec_class}; + use jni::objects::{JObject, JValue}; + + let class = video_codec_class()?; + let mut env = jni_env()?; + let string = |value: &str| { + env.new_string(value) + .map_err(|error| TwonlyError::Generic(error.to_string())) + }; + let input = JObject::from(string(&input.to_string_lossy())?); + let output = JObject::from(string(&output.to_string_lossy())?); + let call = env + .call_static_method( + class, + "extractFrame", + "(Ljava/lang/String;Ljava/lang/String;)Z", + &[JValue::Object(&input), JValue::Object(&output)], + ) + .and_then(|value| value.z()); + if env.exception_check().unwrap_or(false) { + let _ = env.exception_describe(); + let _ = env.exception_clear(); + } + if call.map_err(|error| TwonlyError::Generic(error.to_string()))? { + Ok(()) + } else { + Err(TwonlyError::Generic( + "Android could not extract a video frame".into(), + )) + } +} + +#[cfg(not(any(target_os = "android", target_os = "ios")))] +pub(crate) fn extract_frame(_input: &Path, _output: &Path) -> Result<()> { + Err(TwonlyError::Generic( + "platform frame extraction is only available on Android and iOS".into(), + )) +} diff --git a/rust/src/services/avatars.rs b/rust/src/services/avatars.rs new file mode 100644 index 00000000..ac8f1eac --- /dev/null +++ b/rust/src/services/avatars.rs @@ -0,0 +1,284 @@ +use crate::context::Context; +use crate::error::{Result, TwonlyError}; +use crate::user_config::UserConfig; +use flate2::read::GzDecoder; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +const DEFAULT_AVATAR_SVG: &str = r##" + + + + + + + + + + + + + + + + + + + + + + +"##; + +static AVATAR_RENDER_LOCK: Mutex<()> = Mutex::new(()); + +fn decode_svg_bytes(bytes: &[u8]) -> Result> { + if bytes.starts_with(&[0x1f, 0x8b]) { + let mut decoded = Vec::new(); + GzDecoder::new(bytes).read_to_end(&mut decoded)?; + Ok(decoded) + } else { + Ok(bytes.to_vec()) + } +} + +pub(crate) fn decode_avatar_svg(bytes: Vec) -> String { + match decode_svg_bytes(&bytes).and_then(|decoded| Ok(String::from_utf8(decoded)?)) { + Ok(svg) => svg, + Err(error) => { + tracing::error!(%error, "failed to decode avatar SVG"); + DEFAULT_AVATAR_SVG.to_owned() + } + } +} + +pub(crate) fn contact_avatar_path(ctx: &Context, contact_id: i64, profile_counter: i64) -> PathBuf { + Path::new(&ctx.config.data_dir) + .join("notification_avatars") + .join(format!("{contact_id}-{profile_counter}.png")) +} + +pub(crate) fn current_user_avatar_path(ctx: &Context) -> Result> { + let Some(user) = UserConfig::load_from(ctx)? else { + return Ok(None); + }; + let Some(svg) = user.avatar_svg else { + return Ok(None); + }; + + let output = Path::new(&ctx.config.data_dir) + .join("avatars") + .join(format!("user_{}.png", user.avatar_counter)); + render_cached(&output, || render_at_size(svg.as_bytes(), 270, 300))?; + Ok(Some(output)) +} + +/// Resolves a contact's avatar PNG, rendering it from the stored SVG when it is +/// not on disk yet. Avatars are only ever displayed as PNG, so a missing file +/// has to be produced here rather than fall back to rasterising SVG in the UI. +pub(crate) async fn ensure_contact_avatar_png( + ctx: &Context, + contact_id: i64, +) -> Result> { + let database = ctx.app_db.read().await.clone(); + let row = sqlx::query!( + r#"SELECT avatar_svg_compressed, sender_profile_counter FROM contacts WHERE user_id = ?"#, + contact_id, + ) + .fetch_optional(&database.pool) + .await?; + + let Some(row) = row else { + return Ok(None); + }; + let Some(svg) = row.avatar_svg_compressed else { + return Ok(None); + }; + let profile_counter = row.sender_profile_counter; + let output = contact_avatar_path(ctx, contact_id, profile_counter); + if output.exists() { + return Ok(Some(output)); + } + + // Rasterising is CPU bound, so it must not run on an async worker. + let render_output = output.clone(); + tokio::task::spawn_blocking(move || { + render_cached(&render_output, || render_with_max_dimension(&svg, 256.0)) + }) + .await + .map_err(|error| TwonlyError::Generic(format!("avatar render task failed: {error}")))??; + + Ok(Some(output)) +} + +pub(crate) fn notification_avatar_path( + ctx: &Context, + sender_id: i64, + profile_counter: i64, + svg: Option<&[u8]>, +) -> Result> { + let Some(svg) = svg else { + return Ok(None); + }; + let output = contact_avatar_path(ctx, sender_id, profile_counter); + render_cached(&output, || render_with_max_dimension(svg, 256.0))?; + Ok(Some(output)) +} + +fn render_cached(output: &Path, render: F) -> Result<()> +where + F: FnOnce() -> Result>, +{ + let _guard = AVATAR_RENDER_LOCK + .lock() + .map_err(|_| TwonlyError::Generic("avatar render lock was poisoned".into()))?; + if output.exists() { + return Ok(()); + } + let directory = output + .parent() + .ok_or_else(|| TwonlyError::Generic("avatar output has no parent directory".into()))?; + std::fs::create_dir_all(directory)?; + let png = render()?; + let file_name = output + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| TwonlyError::Generic("avatar output has an invalid file name".into()))?; + let temporary = directory.join(format!(".{file_name}.tmp")); + std::fs::write(&temporary, png)?; + std::fs::rename(&temporary, output)?; + Ok(()) +} + +fn svg_tree(svg: &[u8]) -> Result { + let decoded = decode_svg_bytes(svg)?; + resvg::usvg::Tree::from_data(&decoded, &resvg::usvg::Options::default()) + .map_err(|error| TwonlyError::Generic(format!("invalid avatar SVG: {error}"))) +} + +fn render_at_size(svg: &[u8], width: u32, height: u32) -> Result> { + let tree = svg_tree(svg)?; + let mut pixmap = resvg::tiny_skia::Pixmap::new(width, height) + .ok_or_else(|| TwonlyError::Generic("invalid avatar dimensions".into()))?; + resvg::render( + &tree, + resvg::tiny_skia::Transform::identity(), + &mut pixmap.as_mut(), + ); + pixmap + .encode_png() + .map_err(|error| TwonlyError::Generic(format!("avatar PNG encoding failed: {error}"))) +} + +fn render_with_max_dimension(svg: &[u8], max_dimension: f32) -> Result> { + let tree = svg_tree(svg)?; + let original = tree.size(); + let scale = (max_dimension / original.width().max(original.height())).min(1.0); + let width = (original.width() * scale).round().max(1.0) as u32; + let height = (original.height() * scale).round().max(1.0) as u32; + let mut pixmap = resvg::tiny_skia::Pixmap::new(width, height) + .ok_or_else(|| TwonlyError::Generic("invalid avatar dimensions".into()))?; + resvg::render( + &tree, + resvg::tiny_skia::Transform::from_scale(scale, scale), + &mut pixmap.as_mut(), + ); + pixmap + .encode_png() + .map_err(|error| TwonlyError::Generic(format!("avatar PNG encoding failed: {error}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + use flate2::write::GzEncoder; + use flate2::Compression; + use std::io::Write; + + const SVG: &str = r#""#; + + #[test] + fn decodes_plain_and_gzipped_svg() { + assert_eq!(decode_avatar_svg(SVG.as_bytes().to_vec()), SVG); + + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); + encoder.write_all(SVG.as_bytes()).unwrap(); + assert_eq!(decode_avatar_svg(encoder.finish().unwrap()), SVG); + } + + #[test] + fn invalid_svg_encoding_uses_default() { + assert_eq!(decode_avatar_svg(vec![0xff]), DEFAULT_AVATAR_SVG); + } + + #[tokio::test] + async fn renders_a_missing_contact_avatar_png_on_demand() { + let temporary_directory = tempfile::tempdir().unwrap(); + let context = Context::init_for_testing( + temporary_directory.path().join("database"), + temporary_directory.path().join("data"), + ) + .await + .unwrap(); + + let database = context.app_db.read().await.clone(); + sqlx::query!( + "INSERT INTO contacts(user_id, username, avatar_svg_compressed) VALUES (?, ?, ?)", + 7_i64, + "peer", + SVG.as_bytes(), + ) + .execute(&database.pool) + .await + .unwrap(); + + let expected = contact_avatar_path(&context, 7, 0); + assert!(!expected.exists(), "no PNG should exist up front"); + + let path = ensure_contact_avatar_png(&context, 7).await.unwrap(); + assert_eq!(path.as_deref(), Some(expected.as_path())); + assert!(expected.exists(), "the PNG has to be rendered on demand"); + image::load_from_memory(&std::fs::read(&expected).unwrap()).unwrap(); + } + + #[tokio::test] + async fn contacts_without_an_avatar_render_nothing() { + let temporary_directory = tempfile::tempdir().unwrap(); + let context = Context::init_for_testing( + temporary_directory.path().join("database"), + temporary_directory.path().join("data"), + ) + .await + .unwrap(); + + let database = context.app_db.read().await.clone(); + sqlx::query!( + "INSERT INTO contacts(user_id, username) VALUES (?, ?)", + 8_i64, + "peer", + ) + .execute(&database.pool) + .await + .unwrap(); + + assert!(ensure_contact_avatar_png(&context, 8) + .await + .unwrap() + .is_none()); + assert!(ensure_contact_avatar_png(&context, 999) + .await + .unwrap() + .is_none()); + } + + #[test] + fn renders_current_user_avatar_dimensions() { + let png = render_at_size(SVG.as_bytes(), 270, 300).unwrap(); + let image = image::load_from_memory(&png).unwrap(); + assert_eq!((image.width(), image.height()), (270, 300)); + } +} diff --git a/rust/src/services/contacts.rs b/rust/src/services/contacts.rs index 615a5cf7..2571cb53 100644 --- a/rust/src/services/contacts.rs +++ b/rust/src/services/contacts.rs @@ -3,6 +3,7 @@ * */ +use crate::api::messages::incoming::messages::release_deferred_receipts; use crate::api::messages::outgoing::send_c2c_message_to_contact; use crate::api::proto::client::{self as proto, encrypted_content}; use crate::api::proto::server_to_client; @@ -61,6 +62,21 @@ impl ContactService { .await } + /// Whether a v2 Signal session for this peer already exists locally. + /// + /// A session built from an inbound prekey message is enough to encrypt for + /// the peer, even when the server holds no prekey bundle for them. + pub(crate) async fn has_v2_session(&self, user_id: i64) -> Result { + let rust_database = self.ctx.rust_db.read().await.clone(); + Ok(sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM signal_sessions WHERE name = ? AND device_id = 1)", + user_id.to_string(), + ) + .fetch_one(&rust_database.pool) + .await? + != 0) + } + pub(crate) async fn establish_signal_session( &self, user_id: i64, @@ -98,17 +114,37 @@ impl ContactService { ) .execute(&database.pool) .await?; - database.notify_committed(["contacts"]); + release_deferred_receipts(&database, user_id).await?; + database.notify_committed(["contacts", "receipts"]); Ok(()) } + pub(crate) async fn request_by_id( + &self, + user_id: i64, + expected_public_key: Vec, + blocking: bool, + ) -> Result<()> { + self.establish_signal_session(user_id, Some(expected_public_key)) + .await?; + self.send_contact_request( + user_id, + encrypted_content::contact_request::Type::Request, + blocking, + ) + .await + } + async fn process_user_prekey_bundle( &self, user: &server_to_client::response::UserData, ) -> Result<()> { let missing = TwonlyError::ApiResponseMissingField; - let pqc_bundle = user.pqc_bundle.as_ref().ok_or(missing("pqc_bundle"))?; + let pqc_bundle = user + .pqc_bundle + .as_ref() + .ok_or(TwonlyError::PeerHasNoPrekeyBundle(user.user_id))?; let identity_key = user .public_identity_key .clone() diff --git a/rust/src/services/direct_media_upload.rs b/rust/src/services/direct_media_upload.rs new file mode 100644 index 00000000..68cfe4cb --- /dev/null +++ b/rust/src/services/direct_media_upload.rs @@ -0,0 +1,787 @@ +use crate::api::messages::outgoing::send_c2c_message_to_contact; +use crate::api::proto::client::{encrypted_content, EncryptedContent}; +use crate::api::proto::http_requests::{ + AttachmentDispatch, AttachmentManifest, AttachmentState, AttachmentStatus, RequestUploadSlots, + UploadSlots, +}; +use crate::bridge::api::RustApi; +use crate::context::Context; +use crate::database::app::tables::MediaFile; +use crate::error::{Result, TwonlyError}; +use crate::native::transfer; +use chacha20poly1305::aead::{AeadInPlace, KeyInit}; +use chacha20poly1305::{ChaCha20Poly1305, Nonce}; +use prost::Message; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use std::collections::HashMap; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +const REFRESH_BEFORE_SECONDS: i64 = 24 * 60 * 60; +/// How long a running app keeps asking the server about transfers the OS is +/// carrying for it. Anything still unfinished after this is picked up by the +/// reconciliation pass on the next launch. +const WATCH_BUDGET: Duration = Duration::from_secs(10 * 60); +const WATCH_FIRST_DELAY: Duration = Duration::from_secs(2); +const WATCH_MAX_DELAY: Duration = Duration::from_secs(120); + +static WATCHING: AtomicBool = AtomicBool::new(false); + +/// The OS finishes a transfer without telling this process, so a send would keep +/// showing as "sending" until the app is restarted. While the app is alive, poll +/// the server for the attachments it is still waiting on, backing off as the +/// wait grows, and stop as soon as everything has settled. +pub fn watch_pending_uploads(ctx: &Arc) { + if WATCHING.swap(true, Ordering::SeqCst) { + return; + } + let ctx = ctx.clone(); + tokio::spawn(async move { + let service = DirectMediaUploadService::new(&ctx); + let deadline = tokio::time::Instant::now() + WATCH_BUDGET; + let mut delay = WATCH_FIRST_DELAY; + while tokio::time::Instant::now() < deadline { + tokio::time::sleep(delay).await; + if let Err(error) = service.reconcile().await { + tracing::warn!(%error, "could not reconcile direct-media uploads"); + } + match service.pending_job_count().await { + Ok(0) => break, + Ok(_) => {} + Err(error) => { + tracing::warn!(%error, "could not count pending direct-media uploads"); + break; + } + } + delay = (delay * 2).min(WATCH_MAX_DELAY); + } + WATCHING.store(false, Ordering::SeqCst); + }); +} + +#[derive(FromRow)] +struct CachedSlot { + attachment_id: String, + expires_at: i64, + maximum_object_bytes: i64, + upload_url: String, + upload_fields_json: String, + capability: Vec, +} + +#[derive(FromRow)] +struct MediaRow { + media_type: String, + requires_authentication: i64, + display_limit_in_milliseconds: Option, + encryption_key: Option>, + encryption_nonce: Option>, +} + +#[derive(FromRow)] +struct MessageRow { + group_id: String, + message_id: String, + created_at: i64, + quotes_message_id: Option, + additional_message_data: Option>, +} + +#[derive(FromRow)] +struct PendingJob { + attachment_id: String, + media_id: String, + multipart_path: String, + manifest_path: String, + complete_body_path: String, + capability: Vec, + expires_at: i64, +} + +#[derive(FromRow)] +struct RecipientRow { + contact_id: i64, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +struct NativeRequest { + role: String, + url: String, + method: String, + headers: HashMap, + body_path: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +struct NativeUploadDescriptor { + attachment_id: String, + expires_at: i64, + media: NativeRequest, + manifest: NativeRequest, + complete: NativeRequest, +} + +#[derive(Clone, Copy)] +enum Outcome { + Uploaded, + Rejected, + Retry, +} + +pub struct DirectMediaUploadService { + ctx: Arc, +} + +impl DirectMediaUploadService { + pub fn new(ctx: &Arc) -> Self { + Self { ctx: ctx.clone() } + } + + fn api_url(path: &str) -> String { + format!("{}{}", RustApi::api_base_url("https".into()), path) + } + + async fn authenticated_request( + &self, + request: reqwest::RequestBuilder, + ) -> Result { + let user_id = self.ctx.user_id().await?; + let login_token = self.ctx.key_manager.lock().await.main_key.get_login_token(); + Ok(request + .header("x-twonly-user-id", hex::encode(user_id.to_be_bytes())) + .header("x-twonly-login-token", hex::encode(login_token))) + } + + pub async fn preload_slots(&self) -> Result { + let database = self.ctx.app_db.read().await.clone(); + let known = sqlx::query_scalar::<_, String>( + "SELECT attachment_id FROM direct_media_upload_slots WHERE state IN ('cached', 'reserved') AND expires_at > CAST(strftime('%s','now') AS INTEGER)", + ) + .fetch_all(&database.pool) + .await?; + let request = RequestUploadSlots { + locally_known_slot_ids: known, + }; + let request_body = request.encode_to_vec(); + let client = reqwest::Client::new(); + let response = self + .authenticated_request( + client + .post(Self::api_url("v2/media/upload-slots")) + .header("content-type", "application/x-protobuf") + // Warp's bounded-body filter requires an explicit length even + // when the protobuf is the valid zero-byte default message. + .header("content-length", request_body.len()) + .body(request_body), + ) + .await? + .send() + .await + .map_err(|error| TwonlyError::Generic(error.to_string()))?; + if !response.status().is_success() { + return Err(TwonlyError::Generic(format!( + "upload slot preload returned HTTP {}", + response.status() + ))); + } + let slots = UploadSlots::decode( + response + .bytes() + .await + .map_err(|error| TwonlyError::Generic(error.to_string()))?, + )?; + let count = slots.slots.len(); + let mut transaction = database.pool.begin().await?; + for slot in slots.slots { + let upload = slot.media_upload.ok_or_else(|| { + TwonlyError::Generic("server returned an upload slot without a POST".into()) + })?; + sqlx::query( + r#"INSERT INTO direct_media_upload_slots + (attachment_id, expires_at, maximum_object_bytes, upload_url, + upload_fields_json, capability, state) + VALUES (?, ?, ?, ?, ?, ?, 'cached') + ON CONFLICT(attachment_id) DO NOTHING"#, + ) + .bind(slot.attachment_id) + .bind(slot.expires_at_unix_seconds) + .bind(slot.maximum_object_bytes) + .bind(upload.url) + .bind(serde_json::to_string(&upload.fields)?) + .bind(slot.background_capability) + .execute(&mut *transaction) + .await?; + } + transaction.commit().await?; + Ok(count) + } + + async fn usable_slot_count(&self) -> Result { + let database = self.ctx.app_db.read().await.clone(); + Ok(sqlx::query_scalar( + r#"SELECT COUNT(*) FROM direct_media_upload_slots + WHERE state = 'cached' AND expires_at > CAST(strftime('%s','now') AS INTEGER) + ?"#, + ) + .bind(REFRESH_BEFORE_SECONDS) + .fetch_one(&database.pool) + .await?) + } + + async fn ensure_slots(&self) -> Result<()> { + let usable = self.usable_slot_count().await?; + if usable > 5 { + return Ok(()); + } + match self.preload_slots().await { + Ok(_) => Ok(()), + Err(error) if usable > 0 => { + tracing::warn!(%error, usable, "slot refill failed; using cached direct-media slot"); + Ok(()) + } + Err(error) => Err(error), + } + } + + async fn reserve_slot(&self) -> Result { + self.ensure_slots().await?; + let database = self.ctx.app_db.read().await.clone(); + let mut transaction = database.pool.begin().await?; + let slot = sqlx::query_as::<_, CachedSlot>( + r#"UPDATE direct_media_upload_slots SET state = 'reserved' + WHERE attachment_id = ( + SELECT attachment_id FROM direct_media_upload_slots + WHERE state = 'cached' + AND expires_at > CAST(strftime('%s','now') AS INTEGER) + ? + ORDER BY expires_at ASC LIMIT 1 + ) + RETURNING attachment_id, expires_at, maximum_object_bytes, upload_url, + upload_fields_json, capability"#, + ) + .bind(REFRESH_BEFORE_SECONDS) + .fetch_optional(&mut *transaction) + .await? + .ok_or_else(|| TwonlyError::Generic("no usable direct-media upload slot".into()))?; + transaction.commit().await?; + Ok(slot) + } + + pub async fn pending_job_count(&self) -> Result { + let database = self.ctx.app_db.read().await.clone(); + Ok(sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM direct_media_upload_jobs WHERE state IN ('scheduled', 'waiting_for_server')", + ) + .fetch_one(&database.pool) + .await?) + } + + /// Native background transfers cannot report back into a process that may + /// not be running, so the server's attachment state is the authority. Every + /// launch settles the jobs the device believes are still in flight. + pub async fn reconcile(&self) -> Result<()> { + let database = self.ctx.app_db.read().await.clone(); + let jobs = sqlx::query_as::<_, PendingJob>( + r#"SELECT j.attachment_id, j.media_id, j.multipart_path, j.manifest_path, + j.complete_body_path, s.capability, j.expires_at + FROM direct_media_upload_jobs j + JOIN direct_media_upload_slots s ON s.attachment_id = j.attachment_id + WHERE j.state IN ('scheduled', 'waiting_for_server')"#, + ) + .fetch_all(&database.pool) + .await?; + drop(database); + + let now = chrono::Utc::now().timestamp(); + for job in jobs { + let state = match self.fetch_status(&job).await { + Ok(status) => { + AttachmentState::try_from(status.state).unwrap_or(AttachmentState::Unknown) + } + Err(error) => { + tracing::warn!( + attachment_id = job.attachment_id, + %error, + "could not read direct-media attachment status" + ); + continue; + } + }; + match state { + AttachmentState::Ready => self.settle(&job, Outcome::Uploaded).await?, + AttachmentState::Rejected => self.settle(&job, Outcome::Rejected).await?, + AttachmentState::Expired | AttachmentState::Abandoned => { + self.settle(&job, Outcome::Retry).await?; + } + // Still reserved or uploading. Only give up once the slot's own + // capability has expired, because the native task stops then too. + _ if job.expires_at <= now => self.settle(&job, Outcome::Retry).await?, + _ => {} + } + } + Ok(()) + } + + async fn fetch_status(&self, job: &PendingJob) -> Result { + let response = reqwest::Client::new() + .get(Self::api_url(&format!( + "v2/attachments/{}/status", + job.attachment_id + ))) + .header("x-twonly-upload-capability", hex::encode(&job.capability)) + .timeout(std::time::Duration::from_secs(30)) + .send() + .await + .map_err(|error| TwonlyError::Generic(error.to_string()))?; + if !response.status().is_success() { + return Err(TwonlyError::Generic(format!( + "attachment status returned HTTP {}", + response.status() + ))); + } + Ok(AttachmentStatus::decode(response.bytes().await.map_err( + |error| TwonlyError::Generic(error.to_string()), + )?)?) + } + + /// Releases the request files and the slot, then applies the outcome to the + /// media file. Safe to run twice: the job row is the claim. + async fn settle(&self, job: &PendingJob, outcome: Outcome) -> Result<()> { + let database = self.ctx.app_db.read().await.clone(); + let claimed = sqlx::query( + "DELETE FROM direct_media_upload_jobs WHERE attachment_id = ? AND state IN ('scheduled', 'waiting_for_server')", + ) + .bind(&job.attachment_id) + .execute(&database.pool) + .await? + .rows_affected(); + if claimed == 0 { + return Ok(()); + } + + for path in [ + &job.multipart_path, + &job.manifest_path, + &job.complete_body_path, + ] { + match std::fs::remove_file(path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => tracing::warn!(path, %error, "could not remove upload request file"), + } + } + let job_dir = PathBuf::from(&self.ctx.config.data_dir) + .join("direct-media-upload") + .join(&job.attachment_id); + let _ = std::fs::remove_dir(&job_dir); + + let slot_state = if matches!(outcome, Outcome::Uploaded) { + "consumed" + } else { + "abandoned" + }; + sqlx::query("UPDATE direct_media_upload_slots SET state = ? WHERE attachment_id = ?") + .bind(slot_state) + .bind(&job.attachment_id) + .execute(&database.pool) + .await?; + + match outcome { + Outcome::Uploaded => { + let mut transaction = database.pool.begin().await?; + MediaFile::mark_uploaded(&mut transaction, &job.media_id).await?; + transaction.commit().await?; + self.remove_encrypted_copy(&job.media_id).await; + } + // A rejection is a policy decision (quota, size, plan). Retrying it + // with the same bytes would fail the same way. + Outcome::Rejected => { + sqlx::query( + "UPDATE media_files SET upload_state = 'uploadLimitReached' WHERE media_id = ?", + ) + .bind(&job.media_id) + .execute(&database.pool) + .await?; + } + Outcome::Retry => { + sqlx::query( + r#"UPDATE media_files SET upload_state = 'preprocessing' + WHERE media_id = ? AND upload_state != 'uploaded'"#, + ) + .bind(&job.media_id) + .execute(&database.pool) + .await?; + } + } + database.notify_committed(["media_files", "messages", "receipts"]); + Ok(()) + } + + async fn remove_encrypted_copy(&self, media_id: &str) { + let database = self.ctx.app_db.read().await.clone(); + let media_type = + sqlx::query_scalar::<_, String>("SELECT type FROM media_files WHERE media_id = ?") + .bind(media_id) + .fetch_optional(&database.pool) + .await; + if let Ok(Some(media_type)) = media_type { + let path = PathBuf::from(&self.ctx.config.data_dir) + .join("mediafiles/tmp") + .join(format!( + "{media_id}.encrypted.{}", + media_extension(&media_type) + )); + let _ = std::fs::remove_file(path); + } + } + + pub async fn prepare_and_schedule(&self, media_id: &str) -> Result { + let slot = self.reserve_slot().await?; + match self.prepare_reserved(media_id, &slot).await { + Ok(()) => Ok(slot.attachment_id), + Err(error) => { + self.release_reservation(&slot.attachment_id).await; + Err(error) + } + } + } + + /// Preparation failed somewhere between writing the job row and handing it + /// to the native uploader. Nothing was scheduled, so the half-built job and + /// its files are removed and the slot goes back into the cache unused. + async fn release_reservation(&self, attachment_id: &str) { + let database = self.ctx.app_db.read().await.clone(); + let paths = sqlx::query_as::<_, (String, String, String)>( + r#"DELETE FROM direct_media_upload_jobs WHERE attachment_id = ? AND state = 'prepared' + RETURNING multipart_path, manifest_path, complete_body_path"#, + ) + .bind(attachment_id) + .fetch_optional(&database.pool) + .await; + if let Ok(Some((multipart, manifest, complete))) = paths { + for path in [multipart, manifest, complete] { + let _ = std::fs::remove_file(path); + } + } + let _ = std::fs::remove_dir( + PathBuf::from(&self.ctx.config.data_dir) + .join("direct-media-upload") + .join(attachment_id), + ); + let _ = sqlx::query( + "UPDATE direct_media_upload_slots SET state = 'cached' WHERE attachment_id = ? AND state = 'reserved'", + ) + .bind(attachment_id) + .execute(&database.pool) + .await; + } + + async fn prepare_reserved(&self, media_id: &str, slot: &CachedSlot) -> Result<()> { + let (encrypted_path, encrypted_size, manifest) = self.prepare_media(media_id).await?; + // The server rejects an oversized object anyway, but only once the whole + // upload has been spent: the manifest is refused outright, and an object + // that slips past it is deleted at finalize with `single_object_limit`. + // Catching it here costs the user nothing and is the only point at which + // a reason can still be shown next to the message. + if encrypted_size > slot.maximum_object_bytes { + return Err(TwonlyError::MediaTooLarge { + bytes: encrypted_size, + limit: slot.maximum_object_bytes, + }); + } + let fields: HashMap = serde_json::from_str(&slot.upload_fields_json)?; + let job_dir = PathBuf::from(&self.ctx.config.data_dir) + .join("direct-media-upload") + .join(&slot.attachment_id); + std::fs::create_dir_all(&job_dir)?; + let multipart_path = job_dir.join("media.multipart"); + let manifest_path = job_dir.join("manifest.pb"); + let complete_body_path = job_dir.join("complete.pb"); + let boundary = format!("twonly-{}", uuid::Uuid::new_v4().simple()); + write_multipart_body(&multipart_path, &boundary, &fields, &encrypted_path)?; + std::fs::write(&manifest_path, manifest.encode_to_vec())?; + std::fs::write(&complete_body_path, [])?; + + let capability = hex::encode(&slot.capability); + let mut capability_headers = HashMap::new(); + capability_headers.insert("x-twonly-upload-capability".into(), capability); + capability_headers.insert("content-type".into(), "application/x-protobuf".into()); + let descriptor = NativeUploadDescriptor { + attachment_id: slot.attachment_id.clone(), + expires_at: slot.expires_at, + media: NativeRequest { + role: "media".into(), + url: slot.upload_url.clone(), + method: "POST".into(), + headers: HashMap::from([( + "content-type".into(), + format!("multipart/form-data; boundary={boundary}"), + )]), + body_path: multipart_path.to_string_lossy().into_owned(), + }, + manifest: NativeRequest { + role: "manifest".into(), + url: Self::api_url(&format!("v2/attachments/{}/manifest", slot.attachment_id)), + method: "POST".into(), + headers: capability_headers.clone(), + body_path: manifest_path.to_string_lossy().into_owned(), + }, + complete: NativeRequest { + role: "complete".into(), + url: Self::api_url(&format!("v2/attachments/{}/complete", slot.attachment_id)), + method: "POST".into(), + headers: capability_headers, + body_path: complete_body_path.to_string_lossy().into_owned(), + }, + }; + let descriptor_json = serde_json::to_string(&descriptor)?; + let database = self.ctx.app_db.read().await.clone(); + sqlx::query( + r#"INSERT INTO direct_media_upload_jobs + (attachment_id, media_id, multipart_path, manifest_path, + complete_body_path, native_descriptor_json, state, expires_at) + VALUES (?, ?, ?, ?, ?, ?, 'prepared', ?)"#, + ) + .bind(&slot.attachment_id) + .bind(media_id) + .bind(multipart_path.to_string_lossy().as_ref()) + .bind(manifest_path.to_string_lossy().as_ref()) + .bind(complete_body_path.to_string_lossy().as_ref()) + .bind(&descriptor_json) + .bind(slot.expires_at) + .execute(&database.pool) + .await?; + + transfer::schedule(&descriptor_json)?; + sqlx::query( + "UPDATE direct_media_upload_jobs SET state = 'scheduled', updated_at = CAST(strftime('%s','now') AS INTEGER) WHERE attachment_id = ?", + ) + .bind(&slot.attachment_id) + .execute(&database.pool) + .await?; + watch_pending_uploads(&self.ctx); + Ok(()) + } + + async fn prepare_media(&self, media_id: &str) -> Result<(PathBuf, i64, AttachmentManifest)> { + let database = self.ctx.app_db.read().await.clone(); + let media = sqlx::query_as::<_, MediaRow>( + r#"SELECT type AS media_type, requires_authentication, + display_limit_in_milliseconds, encryption_key, encryption_nonce + FROM media_files WHERE media_id = ?"#, + ) + .bind(media_id) + .fetch_optional(&database.pool) + .await? + .ok_or_else(|| TwonlyError::Generic(format!("media {media_id} does not exist")))?; + let extension = media_extension(&media.media_type); + let temp_path = PathBuf::from(&self.ctx.config.data_dir) + .join("mediafiles/tmp") + .join(format!("{media_id}.{extension}")); + let encrypted_path = PathBuf::from(&self.ctx.config.data_dir) + .join("mediafiles/tmp") + .join(format!("{media_id}.encrypted.{extension}")); + let key = media + .encryption_key + .as_deref() + .ok_or_else(|| TwonlyError::Generic("media encryption key is missing".into()))?; + let nonce = media + .encryption_nonce + .as_deref() + .ok_or_else(|| TwonlyError::Generic("media encryption nonce is missing".into()))?; + if nonce.len() != 12 { + return Err(TwonlyError::Generic( + "invalid media encryption nonce".into(), + )); + } + let mut encrypted = std::fs::read(&temp_path)?; + let cipher = ChaCha20Poly1305::new_from_slice(key) + .map_err(|_| TwonlyError::Generic("invalid media encryption key".into()))?; + let tag = cipher + .encrypt_in_place_detached(Nonce::from_slice(nonce), b"", &mut encrypted) + .map_err(|_| TwonlyError::Generic("media encryption failed".into()))?; + if let Some(parent) = encrypted_path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&encrypted_path, &encrypted)?; + sqlx::query("UPDATE media_files SET encryption_mac = ? WHERE media_id = ?") + .bind(tag.as_slice()) + .bind(media_id) + .execute(&database.pool) + .await?; + + let messages = sqlx::query_as::<_, MessageRow>( + r#"SELECT group_id, message_id, created_at, quotes_message_id, + additional_message_data + FROM messages WHERE media_id = ?"#, + ) + .bind(media_id) + .fetch_all(&database.pool) + .await?; + if messages.is_empty() { + return Err(TwonlyError::Generic( + "media has no recipient messages".into(), + )); + } + let media_type = match media.media_type.as_str() { + "video" => encrypted_content::media::Type::Video, + "gif" => encrypted_content::media::Type::Gif, + "audio" => encrypted_content::media::Type::Audio, + _ => encrypted_content::media::Type::Image, + }; + let mut dispatches = Vec::new(); + for message in messages { + let recipients = sqlx::query_as::<_, RecipientRow>( + r#"SELECT gm.contact_id + FROM group_members gm + JOIN contacts c ON c.user_id = gm.contact_id + WHERE gm.group_id = ? + AND (gm.member_state IS NULL OR gm.member_state != 'leftGroup') + AND c.account_deleted = 0"#, + ) + .bind(&message.group_id) + .fetch_all(&database.pool) + .await?; + for recipient in recipients { + let mut download_token = vec![0_u8; 32]; + rand::rng().fill_bytes(&mut download_token); + let content = EncryptedContent { + group_id: Some(message.group_id.clone()), + media: Some(encrypted_content::Media { + sender_message_id: message.message_id.clone(), + r#type: media_type as i32, + display_limit_in_milliseconds: media.display_limit_in_milliseconds, + requires_authentication: media.requires_authentication != 0, + timestamp: message.created_at.saturating_mul(1_000), + quote_message_id: message.quotes_message_id.clone(), + download_token: Some(download_token.clone()), + encryption_key: Some(key.to_vec()), + encryption_mac: Some(tag.to_vec()), + encryption_nonce: Some(nonce.to_vec()), + additional_message_data: message.additional_message_data.clone(), + }), + ..Default::default() + }; + let encrypted_body = send_c2c_message_to_contact() + .ctx(&self.ctx) + .contact_id(recipient.contact_id) + .encrypted_content(content.encode_to_vec()) + .message_id(message.message_id.clone()) + .only_return_encrypted_data(true) + .blocking(true) + .call() + .await? + .ok_or_else(|| { + TwonlyError::Generic("Signal engine returned no encrypted envelope".into()) + })?; + dispatches.push(AttachmentDispatch { + recipient_user_id: recipient.contact_id, + encrypted_body, + push_data: Some(vec![1]), + download_token, + }); + } + } + if dispatches.is_empty() || dispatches.len() > 300 { + return Err(TwonlyError::Generic(format!( + "direct-media fan-out has {} dispatches", + dispatches.len() + ))); + } + Ok(( + encrypted_path, + encrypted.len() as i64, + AttachmentManifest { + encrypted_object_size: encrypted.len() as i64, + dispatches, + }, + )) + } +} + +fn media_extension(media_type: &str) -> &'static str { + match media_type { + "video" => "mp4", + "gif" => "gif", + "audio" => "m4a", + _ => "webp", + } +} + +fn quoted(value: &str) -> String { + value.replace('\\', "\\\\").replace('"', "\\\"") +} + +fn write_multipart_body( + output_path: &Path, + boundary: &str, + fields: &HashMap, + media_path: &Path, +) -> Result<()> { + let mut entries: Vec<_> = fields.iter().collect(); + entries.sort_unstable_by(|left, right| left.0.cmp(right.0)); + let mut output = std::fs::File::create(output_path)?; + for (name, value) in entries { + write!( + output, + "--{boundary}\r\nContent-Disposition: form-data; name=\"{}\"\r\n\r\n{}\r\n", + quoted(name), + value + )?; + } + write!( + output, + "--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"encrypted-media\"\r\nContent-Type: application/octet-stream\r\n\r\n" + )?; + let mut media = std::fs::File::open(media_path)?; + std::io::copy(&mut media, &mut output)?; + write!(output, "\r\n--{boundary}--\r\n")?; + output.sync_all()?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn multipart_body_is_deterministic_and_keeps_exact_media_bytes() { + let directory = tempfile::tempdir().unwrap(); + let media = directory.path().join("media"); + let body = directory.path().join("body"); + let media_bytes = [0_u8, 1, b'\r', b'\n', 255]; + std::fs::write(&media, media_bytes).unwrap(); + let fields = HashMap::from([ + ("z-field".to_owned(), "last".to_owned()), + ("key".to_owned(), "object/path".to_owned()), + ]); + write_multipart_body(&body, "boundary", &fields, &media).unwrap(); + let bytes = std::fs::read(body).unwrap(); + let key = b"name=\"key\"\r\n\r\nobject/path"; + let z = b"name=\"z-field\"\r\n\r\nlast"; + assert!(bytes.windows(key.len()).any(|window| window == key)); + let key_position = bytes + .windows(key.len()) + .position(|window| window == key) + .unwrap(); + let z_position = bytes + .windows(z.len()) + .position(|window| window == z) + .unwrap(); + assert!(key_position < z_position); + let marker = b"Content-Type: application/octet-stream\r\n\r\n"; + let media_start = bytes + .windows(marker.len()) + .position(|window| window == marker) + .unwrap() + + marker.len(); + assert_eq!( + &bytes[media_start..media_start + media_bytes.len()], + &media_bytes + ); + assert!(bytes.ends_with(b"\r\n--boundary--\r\n")); + } +} diff --git a/rust/src/services/groups/crypto.rs b/rust/src/services/groups/crypto.rs index 466a2b08..5851291a 100644 --- a/rust/src/services/groups/crypto.rs +++ b/rust/src/services/groups/crypto.rs @@ -9,6 +9,9 @@ use chacha20poly1305::aead::{Aead, AeadCore, KeyInit, OsRng}; use chacha20poly1305::{ChaCha20Poly1305, Nonce}; use prost::Message; +const NONCE_SIZE: usize = 12; +const TAG_SIZE: usize = 16; + pub(crate) fn encrypt(key: &[u8], plaintext: &[u8]) -> Result> { let cipher = ChaCha20Poly1305::new_from_slice(key) .map_err(|_| TwonlyError::Generic("invalid group state key".into()))?; @@ -28,6 +31,15 @@ pub(crate) fn encrypt(key: &[u8], plaintext: &[u8]) -> Result> { pub(crate) fn decrypt(key: &[u8], envelope: &[u8]) -> Result> { let envelope = EncryptedGroupStateEnvelop::decode(envelope) .map_err(|error| TwonlyError::Generic(error.to_string()))?; + // The envelope arrives from the group server, which accepts appends without + // checking membership, so its lengths are attacker-chosen. `Nonce::from_slice` + // asserts on a length mismatch, so both are checked before the conversion. + if envelope.nonce.len() != NONCE_SIZE { + return Err(TwonlyError::Generic("invalid group state nonce".into())); + } + if envelope.mac.len() != TAG_SIZE { + return Err(TwonlyError::Generic("invalid group state MAC".into())); + } let cipher = ChaCha20Poly1305::new_from_slice(key) .map_err(|_| TwonlyError::Generic("invalid group state key".into()))?; let nonce = Nonce::from_slice(&envelope.nonce); @@ -40,10 +52,50 @@ pub(crate) fn decrypt(key: &[u8], envelope: &[u8]) -> Result> { #[cfg(test)] mod tests { + use super::*; + #[test] fn group_state_round_trip() { let key = [7_u8; 32]; let encrypted = super::encrypt(&key, b"state").unwrap(); assert_eq!(super::decrypt(&key, &encrypted).unwrap(), b"state"); } + + /// A nonce of the wrong length used to reach `Nonce::from_slice`, which + /// asserts on the length and panics. The group server takes appends from + /// anyone who knows the group ID, so this envelope is attacker-shaped. + #[test] + fn a_nonce_of_the_wrong_length_is_rejected_instead_of_panicking() { + let key = [7_u8; 32]; + let encrypted = super::encrypt(&key, b"state").unwrap(); + let mut envelope = EncryptedGroupStateEnvelop::decode(encrypted.as_slice()).unwrap(); + + for nonce in [vec![], vec![0_u8; 11], vec![0_u8; 13], vec![0_u8; 64]] { + let mut tampered = envelope.clone(); + tampered.nonce = nonce; + let error = super::decrypt(&key, &tampered.encode_to_vec()).unwrap_err(); + assert!(error.to_string().contains("invalid group state nonce")); + } + + envelope.nonce = vec![0_u8; NONCE_SIZE]; + assert!(super::decrypt(&key, &envelope.encode_to_vec()).is_err()); + } + + #[test] + fn a_mac_of_the_wrong_length_is_rejected() { + let key = [7_u8; 32]; + let encrypted = super::encrypt(&key, b"state").unwrap(); + let mut envelope = EncryptedGroupStateEnvelop::decode(encrypted.as_slice()).unwrap(); + envelope.mac = vec![0_u8; 15]; + + let error = super::decrypt(&key, &envelope.encode_to_vec()).unwrap_err(); + assert!(error.to_string().contains("invalid group state MAC")); + } + + #[test] + fn a_truncated_envelope_is_rejected() { + let key = [7_u8; 32]; + let error = super::decrypt(&key, &[]).unwrap_err(); + assert!(error.to_string().contains("invalid group state nonce")); + } } diff --git a/rust/src/services/groups/mod.rs b/rust/src/services/groups/mod.rs index b8a5ec02..2a73ce99 100644 --- a/rust/src/services/groups/mod.rs +++ b/rust/src/services/groups/mod.rs @@ -351,32 +351,25 @@ impl GroupService { .await?; return Ok(false); }; - let raw = crypto::decrypt(group.state_key()?, &server.encrypted_group_state)?; + let state_key = group.state_key()?; + let raw = crypto::decrypt(state_key, &server.encrypted_group_state)?; let mut group_state = EncryptedGroupState::decode(raw.as_slice()) .map_err(|error| TwonlyError::Generic(error.to_string()))?; + let local_user_id = self.ctx.user_id().await?; + let local_public_key = group.identity()?.identity_key().serialize(); let mut appended_changes = false; + // Every step below that touches `appended` skips the entry instead of + // returning. The group server accepts appends without checking group + // membership, and it stores them forever, so one malformed entry would + // otherwise break this group's state sync on every later refresh. for appended in &server.appended_group_states { let Some(tbs) = &appended.append_tbs else { continue; }; - let public_key = libsignal_protocol::PublicKey::try_from(tbs.public_key.as_slice()) - .map_err(|error| TwonlyError::Signal(error.to_string()))?; - if !public_key.verify_signature(&tbs.encode_to_vec(), &appended.signature) { - tracing::warn!( - group_id, - "ignored group-state append with invalid signature" - ); - continue; - } - let raw = crypto::decrypt(group.state_key()?, &tbs.encrypted_group_state_append)?; - let append = EncryptedAppendedGroupState::decode(raw.as_slice()) - .map_err(|error| TwonlyError::Generic(error.to_string()))?; - if append.r#type != encrypted_appended_group_state::Type::LeftGroup as i32 { - continue; - } - let local_public_key = group.identity()?.identity_key().serialize(); + // Resolve the author first: an append signed by a key that is not a + // member of this group is dropped before any of its bytes are parsed. let leaving_id = if tbs.public_key.as_slice() == local_public_key.as_ref() { - Some(self.ctx.user_id().await?) + Some(local_user_id) } else { sqlx::query_scalar!( "SELECT contact_id FROM group_members WHERE group_id = ? AND group_public_key = ?", @@ -386,13 +379,44 @@ impl GroupService { .fetch_optional(&mut **t) .await? }; - if let Some(leaving_id) = leaving_id { - group_state.member_ids.retain(|id| *id != leaving_id); - group_state.admin_ids.retain(|id| *id != leaving_id); - appended_changes = true; + let Some(leaving_id) = leaving_id else { + tracing::warn!(group_id, "ignored group-state append from a non-member"); + continue; + }; + let Ok(public_key) = libsignal_protocol::PublicKey::try_from(tbs.public_key.as_slice()) + else { + tracing::warn!(group_id, "ignored group-state append with an unusable key"); + continue; + }; + if !public_key.verify_signature(&tbs.encode_to_vec(), &appended.signature) { + tracing::warn!( + group_id, + "ignored group-state append with invalid signature" + ); + continue; } + let raw = match crypto::decrypt(state_key, &tbs.encrypted_group_state_append) { + Ok(raw) => raw, + Err(error) => { + tracing::warn!(group_id, %error, "ignored undecryptable group-state append"); + continue; + } + }; + let Ok(append) = EncryptedAppendedGroupState::decode(raw.as_slice()) else { + tracing::warn!( + group_id, + "ignored group-state append with an invalid payload" + ); + continue; + }; + if append.r#type != encrypted_appended_group_state::Type::LeftGroup as i32 { + continue; + } + group_state.member_ids.retain(|id| *id != leaving_id); + group_state.admin_ids.retain(|id| *id != leaving_id); + appended_changes = true; } - if appended_changes & group_state.admin_ids.contains(&self.ctx.user_id().await?) { + if appended_changes & group_state.admin_ids.contains(&local_user_id) { GroupApi::update_remote(&group, server.version_id, &group_state, None, None).await?; } self.apply_state(t, group_id, server.version_id as i64, &group_state) diff --git a/rust/src/services/media_codec.rs b/rust/src/services/media_codec.rs new file mode 100644 index 00000000..b2258314 --- /dev/null +++ b/rust/src/services/media_codec.rs @@ -0,0 +1,261 @@ +/* + * Copyright (c) 2026, Tobias Müller git@tsmr.eu + * + */ + +//! Image codec work, previously done by `flutter_image_compress`. +//! +//! Both platforms encode WebP with libwebp — iOS has no system WebP encoder at +//! all and Android's `Bitmap.compress` routes through Skia to the same library +//! — so linking libwebp here is the identical code path without the Dart hop. +//! Only formats this crate cannot decode (HEIC/HEIF/AVIF) are handed to the +//! platform, which decodes them to PNG for us. + +use crate::error::{Result, TwonlyError}; +use crate::native::image as native_image; +use image::{DynamicImage, ImageReader}; +use std::path::Path; + +/// Quality the sender's media is encoded with, and the lower quality retried +/// when the first attempt is too large to be worth sending. +const SEND_QUALITY: f32 = 90.0; +const SEND_QUALITY_LARGE: f32 = 60.0; +const LARGE_IMAGE_BYTES: usize = 2_000_000; + +const THUMBNAIL_MIN_EDGE: u32 = 300; +const THUMBNAIL_QUALITY: f32 = 50.0; +const CROP_QUALITY: f32 = 90.0; + +/// Alpha at or below this counts as transparent when trimming borders. Matches +/// the threshold the Dart implementation used, so existing media crops the same. +const TRANSPARENT_ALPHA: u8 = 10; +/// Never crop down to a sliver; a result this small means the analysis was wrong. +const MIN_CROPPED_EDGE: u32 = 10; + +/// Encodes with libwebp. RGB input is encoded without an alpha channel so +/// opaque photos do not pay for one. +fn encode_webp(image: &DynamicImage, quality: f32) -> Result> { + let has_alpha = image.color().has_alpha(); + let encoder = if has_alpha { + let rgba = image.to_rgba8(); + webp::Encoder::from_rgba(rgba.as_raw(), rgba.width(), rgba.height()) + .encode_simple(false, quality) + } else { + let rgb = image.to_rgb8(); + webp::Encoder::from_rgb(rgb.as_raw(), rgb.width(), rgb.height()) + .encode_simple(false, quality) + }; + let memory = encoder + .map_err(|error| TwonlyError::Generic(format!("webp encoding failed: {error:?}")))?; + Ok(memory.to_vec()) +} + +/// Decodes any still image. The platform is only consulted for the formats this +/// crate has no decoder for, which in practice means HEIC/HEIF from an iPhone. +pub(crate) fn decode(path: &Path) -> Result { + let reader = ImageReader::open(path)? + .with_guessed_format() + .map_err(|error| TwonlyError::Generic(error.to_string()))?; + match reader.decode() { + Ok(image) => Ok(image), + Err(error) => { + tracing::info!( + path = %path.display(), + %error, + "asking the platform to decode an unsupported image format" + ); + let decoded = path.with_extension("decoded.png"); + native_image::decode_to_png(path, &decoded)?; + let image = ImageReader::open(&decoded)? + .with_guessed_format() + .map_err(|error| TwonlyError::Generic(error.to_string()))? + .decode() + .map_err(|error| TwonlyError::Generic(error.to_string()))?; + let _ = std::fs::remove_file(&decoded); + Ok(image) + } + } +} + +/// Produces the file that actually gets encrypted and uploaded. A first pass at +/// high quality is re-encoded lower only when the result is big enough that the +/// quality loss is worth the transfer. +pub(crate) fn compress_for_send(source: &Path, destination: &Path) -> Result<()> { + let image = decode(source)?; + let mut encoded = encode_webp(&image, SEND_QUALITY)?; + if encoded.len() >= LARGE_IMAGE_BYTES { + match encode_webp(&image, SEND_QUALITY_LARGE) { + Ok(smaller) => encoded = smaller, + Err(error) => { + tracing::warn!(%error, "keeping the high quality image after a failed re-encode"); + } + } + } + write_atomically(destination, &encoded) +} + +/// Scales down so the shorter edge lands on `THUMBNAIL_MIN_EDGE`, never scaling +/// an already small image up. A GIF decodes to its first frame, which is +/// exactly what its thumbnail should show. +pub(crate) fn create_image_thumbnail(source: &Path, destination: &Path) -> Result<()> { + let image = decode(source)?; + let thumbnail = downscale(&image, THUMBNAIL_MIN_EDGE); + let encoded = encode_webp(&thumbnail, THUMBNAIL_QUALITY)?; + write_atomically(destination, &encoded) +} + +fn downscale(image: &DynamicImage, min_edge: u32) -> DynamicImage { + let (width, height) = (image.width(), image.height()); + if width == 0 || height == 0 || (width <= min_edge && height <= min_edge) { + return image.clone(); + } + let scale = (min_edge as f32 / width as f32).max(min_edge as f32 / height as f32); + if scale >= 1.0 { + return image.clone(); + } + let target_width = ((width as f32 * scale).round() as u32).max(1); + let target_height = ((height as f32 * scale).round() as u32).max(1); + image.resize_exact( + target_width, + target_height, + image::imageops::FilterType::Lanczos3, + ) +} + +/// Trims fully transparent borders left by the editor. Returns whether the +/// image was rewritten. +pub(crate) fn crop_transparent_borders(path: &Path) -> Result { + let image = decode(path)?; + if !image.color().has_alpha() { + return Ok(false); + } + let rgba = image.to_rgba8(); + let Some(bounds) = opaque_bounds(&rgba) else { + // Every pixel is transparent; there is nothing meaningful to keep. + return Ok(false); + }; + let (min_x, min_y, max_x, max_y) = bounds; + if min_x == 0 && min_y == 0 && max_x == rgba.width() - 1 && max_y == rgba.height() - 1 { + return Ok(false); + } + let width = max_x - min_x + 1; + let height = max_y - min_y + 1; + if width <= MIN_CROPPED_EDGE || height <= MIN_CROPPED_EDGE { + return Ok(false); + } + let cropped = DynamicImage::ImageRgba8(rgba).crop_imm(min_x, min_y, width, height); + let encoded = encode_webp(&cropped, CROP_QUALITY)?; + write_atomically(path, &encoded)?; + Ok(true) +} + +/// Inclusive bounding box of every pixel that is not effectively transparent. +fn opaque_bounds(image: &image::RgbaImage) -> Option<(u32, u32, u32, u32)> { + let (mut min_x, mut min_y) = (u32::MAX, u32::MAX); + let (mut max_x, mut max_y) = (0_u32, 0_u32); + let mut found = false; + for (x, y, pixel) in image.enumerate_pixels() { + if pixel.0[3] > TRANSPARENT_ALPHA { + found = true; + min_x = min_x.min(x); + min_y = min_y.min(y); + max_x = max_x.max(x); + max_y = max_y.max(y); + } + } + found.then_some((min_x, min_y, max_x, max_y)) +} + +/// The destination is often the file being read elsewhere, so it is replaced in +/// one step rather than being briefly truncated. +fn write_atomically(destination: &Path, bytes: &[u8]) -> Result<()> { + if let Some(parent) = destination.parent() { + std::fs::create_dir_all(parent)?; + } + let temporary = destination.with_extension("codec-tmp"); + std::fs::write(&temporary, bytes)?; + std::fs::rename(&temporary, destination)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use image::{Rgba, RgbaImage}; + + fn transparent_bordered(width: u32, height: u32, inset: u32) -> RgbaImage { + let mut image = RgbaImage::from_pixel(width, height, Rgba([0, 0, 0, 0])); + for y in inset..height - inset { + for x in inset..width - inset { + image.put_pixel(x, y, Rgba([10, 200, 30, 255])); + } + } + image + } + + #[test] + fn encodes_a_real_webp_that_decodes_back_to_the_same_size() { + let directory = tempfile::tempdir().unwrap(); + let source = directory.path().join("source.png"); + let destination = directory.path().join("out.webp"); + DynamicImage::ImageRgba8(transparent_bordered(64, 48, 0)) + .save(&source) + .unwrap(); + + compress_for_send(&source, &destination).unwrap(); + + let bytes = std::fs::read(&destination).unwrap(); + assert_eq!(&bytes[0..4], b"RIFF"); + assert_eq!(&bytes[8..12], b"WEBP"); + let decoded = decode(&destination).unwrap(); + assert_eq!((decoded.width(), decoded.height()), (64, 48)); + } + + #[test] + fn thumbnails_shrink_the_shorter_edge_and_never_upscale() { + let directory = tempfile::tempdir().unwrap(); + let source = directory.path().join("large.png"); + let destination = directory.path().join("thumb.webp"); + DynamicImage::ImageRgba8(transparent_bordered(1200, 600, 0)) + .save(&source) + .unwrap(); + + create_image_thumbnail(&source, &destination).unwrap(); + let thumbnail = decode(&destination).unwrap(); + assert_eq!(thumbnail.height(), THUMBNAIL_MIN_EDGE); + assert_eq!(thumbnail.width(), 600); + + let small = directory.path().join("small.png"); + DynamicImage::ImageRgba8(transparent_bordered(64, 64, 0)) + .save(&small) + .unwrap(); + create_image_thumbnail(&small, &destination).unwrap(); + assert_eq!(decode(&destination).unwrap().width(), 64); + } + + #[test] + fn crops_transparent_borders_only_when_it_changes_the_image() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("bordered.png"); + DynamicImage::ImageRgba8(transparent_bordered(100, 100, 20)) + .save(&path) + .unwrap(); + + assert!(crop_transparent_borders(&path).unwrap()); + let cropped = decode(&path).unwrap(); + assert_eq!((cropped.width(), cropped.height()), (60, 60)); + + // A second pass has nothing left to trim. + assert!(!crop_transparent_borders(&path).unwrap()); + } + + #[test] + fn a_fully_opaque_image_is_never_cropped() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("opaque.png"); + DynamicImage::ImageRgb8(image::RgbImage::from_pixel(32, 32, image::Rgb([1, 2, 3]))) + .save(&path) + .unwrap(); + assert!(!crop_transparent_borders(&path).unwrap()); + } +} diff --git a/rust/src/services/media_exif.rs b/rust/src/services/media_exif.rs new file mode 100644 index 00000000..7cc90452 --- /dev/null +++ b/rust/src/services/media_exif.rs @@ -0,0 +1,336 @@ +/* + * Copyright (c) 2026, Tobias Müller git@tsmr.eu + * + */ + +//! Writes EXIF metadata into a WebP file. +//! +//! A WebP that carries metadata has to use the extended layout: a `VP8X` chunk +//! declares which optional chunks follow, and the metadata lives in its own +//! `EXIF` chunk. Neither the `image` crate nor libwebp will write that, so the +//! RIFF container is assembled here. +//! +//! Only the fields the gallery needs exist today. `ExifMetadata` is the place to +//! add more: give it a field, then a matching entry in `tiff_block`. + +use crate::error::{Result, TwonlyError}; +use chrono::{DateTime, Utc}; + +/// EXIF stores timestamps as local wall-clock text with no zone. +const DATE_FORMAT: &str = "%Y:%m:%d %H:%M:%S"; + +const TAG_DATE_TIME: u16 = 0x0132; +const TAG_EXIF_IFD: u16 = 0x8769; +const TAG_DATE_TIME_ORIGINAL: u16 = 0x9003; +const TAG_DATE_TIME_DIGITIZED: u16 = 0x9004; +const TYPE_ASCII: u16 = 2; +const TYPE_LONG: u16 = 4; + +/// The `VP8X` flag bit that says an `EXIF` chunk is present. +const FLAG_EXIF: u8 = 0b0000_1000; +/// The `VP8X` flag bit that says the image has transparency. +const FLAG_ALPHA: u8 = 0b0001_0000; + +#[derive(Default)] +pub(crate) struct ExifMetadata { + pub created_at: Option>, +} + +impl ExifMetadata { + fn is_empty(&self) -> bool { + self.created_at.is_none() + } +} + +/// Returns the WebP bytes with an `EXIF` chunk attached, converting a simple +/// file to the extended layout when needed. +pub(crate) fn with_exif(webp: &[u8], metadata: &ExifMetadata) -> Result> { + if metadata.is_empty() { + return Ok(webp.to_vec()); + } + let chunks = parse_chunks(webp)?; + let (width, height) = dimensions(&chunks)?; + let has_alpha = chunks.iter().any(has_alpha_channel); + + let mut body = Vec::new(); + let mut flags = FLAG_EXIF; + if has_alpha { + flags |= FLAG_ALPHA; + } + // VP8X carries the canvas size as width-1/height-1 in 24 bits each. + let mut vp8x = Vec::with_capacity(10); + vp8x.push(flags); + vp8x.extend_from_slice(&[0, 0, 0]); + vp8x.extend_from_slice(&(width - 1).to_le_bytes()[0..3]); + vp8x.extend_from_slice(&(height - 1).to_le_bytes()[0..3]); + write_chunk(&mut body, b"VP8X", &vp8x); + + for chunk in &chunks { + // A stale header or metadata block from the source is replaced. + if chunk.id == *b"VP8X" || chunk.id == *b"EXIF" { + continue; + } + write_chunk(&mut body, &chunk.id, chunk.payload); + } + write_chunk(&mut body, b"EXIF", &tiff_block(metadata)); + + let mut output = Vec::with_capacity(body.len() + 12); + output.extend_from_slice(b"RIFF"); + output.extend_from_slice(&((body.len() + 4) as u32).to_le_bytes()); + output.extend_from_slice(b"WEBP"); + output.extend_from_slice(&body); + Ok(output) +} + +struct Chunk<'a> { + id: [u8; 4], + payload: &'a [u8], +} + +fn parse_chunks(webp: &[u8]) -> Result>> { + if webp.len() < 12 || &webp[0..4] != b"RIFF" || &webp[8..12] != b"WEBP" { + return Err(TwonlyError::Generic("not a WebP file".into())); + } + let mut chunks = Vec::new(); + let mut offset = 12; + while offset + 8 <= webp.len() { + let mut id = [0_u8; 4]; + id.copy_from_slice(&webp[offset..offset + 4]); + let size = u32::from_le_bytes(webp[offset + 4..offset + 8].try_into().unwrap()) as usize; + let start = offset + 8; + let end = start + .checked_add(size) + .filter(|end| *end <= webp.len()) + .ok_or_else(|| { + TwonlyError::Generic("WebP chunk runs past the end of the file".into()) + })?; + chunks.push(Chunk { + id, + payload: &webp[start..end], + }); + // Chunks are padded to an even length. + offset = end + (size & 1); + } + Ok(chunks) +} + +/// Lossy WebP keeps transparency in a separate `ALPH` chunk, while lossless +/// declares it with a flag inside its own header. +fn has_alpha_channel(chunk: &Chunk<'_>) -> bool { + if chunk.id == *b"ALPH" { + return true; + } + if chunk.id == *b"VP8L" && chunk.payload.len() >= 5 { + let bits = u32::from_le_bytes(chunk.payload[1..5].try_into().unwrap()); + // width(14) height(14) alpha(1) version(3) + return (bits >> 28) & 1 == 1; + } + false +} + +/// Canvas size, read from whichever bitstream chunk this file uses. +fn dimensions(chunks: &[Chunk<'_>]) -> Result<(u32, u32)> { + for chunk in chunks { + match &chunk.id { + b"VP8X" if chunk.payload.len() >= 10 => { + let width = + u32::from_le_bytes([chunk.payload[4], chunk.payload[5], chunk.payload[6], 0]) + + 1; + let height = + u32::from_le_bytes([chunk.payload[7], chunk.payload[8], chunk.payload[9], 0]) + + 1; + return Ok((width, height)); + } + // Lossy: the dimensions sit after the 3-byte start code and sync. + b"VP8 " if chunk.payload.len() >= 10 => { + let width = u16::from_le_bytes([chunk.payload[6], chunk.payload[7]]) & 0x3FFF; + let height = u16::from_le_bytes([chunk.payload[8], chunk.payload[9]]) & 0x3FFF; + return Ok((u32::from(width), u32::from(height))); + } + // Lossless: 14-bit width and height packed after the signature byte. + b"VP8L" if chunk.payload.len() >= 5 => { + let bits = u32::from_le_bytes(chunk.payload[1..5].try_into().unwrap()); + return Ok(((bits & 0x3FFF) + 1, ((bits >> 14) & 0x3FFF) + 1)); + } + _ => {} + } + } + Err(TwonlyError::Generic( + "WebP file has no bitstream chunk".into(), + )) +} + +fn write_chunk(output: &mut Vec, id: &[u8; 4], payload: &[u8]) { + output.extend_from_slice(id); + output.extend_from_slice(&(payload.len() as u32).to_le_bytes()); + output.extend_from_slice(payload); + if payload.len() % 2 == 1 { + output.push(0); + } +} + +/// Builds a little-endian TIFF header with one IFD0 and one Exif sub-IFD. +fn tiff_block(metadata: &ExifMetadata) -> Vec { + let mut ifd0: Vec<(u16, u16, Vec)> = Vec::new(); + let mut exif: Vec<(u16, u16, Vec)> = Vec::new(); + if let Some(created_at) = metadata.created_at { + let text = format!("{}\0", created_at.format(DATE_FORMAT)).into_bytes(); + ifd0.push((TAG_DATE_TIME, TYPE_ASCII, text.clone())); + exif.push((TAG_DATE_TIME_ORIGINAL, TYPE_ASCII, text.clone())); + exif.push((TAG_DATE_TIME_DIGITIZED, TYPE_ASCII, text)); + } + + let mut output = Vec::new(); + output.extend_from_slice(b"II\x2a\x00"); + output.extend_from_slice(&8_u32.to_le_bytes()); + + // IFD0 gains a pointer entry to the Exif sub-IFD, so reserve room for it. + let ifd0_entries = ifd0.len() + usize::from(!exif.is_empty()); + let ifd0_start = 8; + let ifd0_end = ifd0_start + 2 + ifd0_entries * 12 + 4; + let mut values = Vec::new(); + let mut entries = Vec::new(); + let mut value_offset = ifd0_end; + + for (tag, kind, data) in &ifd0 { + entries.push(entry(*tag, *kind, data, &mut values, &mut value_offset)); + } + // The Exif sub-IFD starts after IFD0 and its value block. + let exif_ifd_offset = ifd0_end + values.len(); + if !exif.is_empty() { + entries.push(entry_inline( + TAG_EXIF_IFD, + TYPE_LONG, + 1, + (exif_ifd_offset as u32).to_le_bytes(), + )); + } + + output.extend_from_slice(&(ifd0_entries as u16).to_le_bytes()); + for bytes in &entries { + output.extend_from_slice(bytes); + } + output.extend_from_slice(&0_u32.to_le_bytes()); + output.extend_from_slice(&values); + + if !exif.is_empty() { + let sub_start = exif_ifd_offset; + let sub_end = sub_start + 2 + exif.len() * 12 + 4; + let mut sub_values = Vec::new(); + let mut sub_entries = Vec::new(); + let mut sub_offset = sub_end; + for (tag, kind, data) in &exif { + sub_entries.push(entry(*tag, *kind, data, &mut sub_values, &mut sub_offset)); + } + output.extend_from_slice(&(exif.len() as u16).to_le_bytes()); + for bytes in &sub_entries { + output.extend_from_slice(bytes); + } + output.extend_from_slice(&0_u32.to_le_bytes()); + output.extend_from_slice(&sub_values); + } + output +} + +/// One 12-byte IFD entry. Values longer than four bytes live in the value block +/// and the entry stores their offset instead. +fn entry( + tag: u16, + kind: u16, + data: &[u8], + values: &mut Vec, + value_offset: &mut usize, +) -> [u8; 12] { + if data.len() <= 4 { + let mut inline = [0_u8; 4]; + inline[..data.len()].copy_from_slice(data); + return entry_inline(tag, kind, data.len() as u32, inline); + } + let offset = *value_offset; + values.extend_from_slice(data); + if data.len() % 2 == 1 { + values.push(0); + *value_offset += 1; + } + *value_offset += data.len(); + entry_inline(tag, kind, data.len() as u32, (offset as u32).to_le_bytes()) +} + +fn entry_inline(tag: u16, kind: u16, count: u32, value: [u8; 4]) -> [u8; 12] { + let mut bytes = [0_u8; 12]; + bytes[0..2].copy_from_slice(&tag.to_le_bytes()); + bytes[2..4].copy_from_slice(&kind.to_le_bytes()); + bytes[4..8].copy_from_slice(&count.to_le_bytes()); + bytes[8..12].copy_from_slice(&value); + bytes +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + + fn sample_webp() -> Vec { + let image = image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel( + 8, + 6, + image::Rgb([9, 9, 9]), + )); + let rgb = image.to_rgb8(); + webp::Encoder::from_rgb(rgb.as_raw(), 8, 6) + .encode_simple(false, 80.0) + .unwrap() + .to_vec() + } + + #[test] + fn without_metadata_the_file_is_untouched() { + let webp = sample_webp(); + let written = with_exif(&webp, &ExifMetadata::default()).unwrap(); + assert_eq!(written, webp); + } + + #[test] + fn adds_an_extended_header_and_an_exif_chunk_that_still_decodes() { + let webp = sample_webp(); + let metadata = ExifMetadata { + created_at: Some(Utc.with_ymd_and_hms(2026, 8, 30, 12, 34, 56).unwrap()), + }; + let written = with_exif(&webp, &metadata).unwrap(); + + let chunks = parse_chunks(&written).unwrap(); + assert_eq!(chunks[0].id, *b"VP8X", "VP8X has to come first"); + assert_eq!(chunks[0].payload[0] & FLAG_EXIF, FLAG_EXIF); + assert_eq!(dimensions(&chunks).unwrap(), (8, 6)); + + let exif = chunks + .iter() + .find(|chunk| chunk.id == *b"EXIF") + .expect("EXIF chunk"); + assert_eq!(&exif.payload[0..4], b"II\x2a\x00"); + let text = b"2026:08:30 12:34:56"; + assert!( + exif.payload + .windows(text.len()) + .any(|window| window == text), + "the timestamp has to be stored verbatim" + ); + + // The result must still be a readable image. + let decoded = image::load_from_memory(&written).unwrap(); + assert_eq!((decoded.width(), decoded.height()), (8, 6)); + } + + #[test] + fn writing_twice_replaces_rather_than_appends() { + let metadata = ExifMetadata { + created_at: Some(Utc.with_ymd_and_hms(2026, 1, 2, 3, 4, 5).unwrap()), + }; + let once = with_exif(&sample_webp(), &metadata).unwrap(); + let twice = with_exif(&once, &metadata).unwrap(); + assert_eq!(once, twice); + let chunks = parse_chunks(&twice).unwrap(); + assert_eq!(chunks.iter().filter(|c| c.id == *b"EXIF").count(), 1); + assert_eq!(chunks.iter().filter(|c| c.id == *b"VP8X").count(), 1); + } +} diff --git a/rust/src/services/media_upload.rs b/rust/src/services/media_upload.rs new file mode 100644 index 00000000..58c47fc2 --- /dev/null +++ b/rust/src/services/media_upload.rs @@ -0,0 +1,1191 @@ +/* + * Copyright (c) 2026, Tobias Müller git@tsmr.eu + * + */ + +//! Rust-owned media upload lifecycle. +//! +//! Everything that decides *what* happens to an outgoing media file lives here: +//! key generation, message fan-out rows, upload-state transitions, retry and +//! reupload policy, orphan cleanup, and handing the prepared transfer to the +//! platform background uploader. + +use crate::api::proto::client::EncryptedContent; +use crate::context::Context; +use crate::database::app::tables::{Group, MediaFile}; +use crate::error::{Result, TwonlyError}; +use crate::native::gallery; +use crate::native::video; +use crate::services::direct_media_upload::DirectMediaUploadService; +use crate::services::media_codec; +use crate::services::media_exif; +use crate::services::mediafiles::MediaFileService; +use crate::services::messages::MessageService; +use crate::user_config::UserConfig; +use crate::utils::new_uuid_v7; +use prost::Message as _; +use rand::RngCore; +use sqlx::FromRow; +use std::collections::HashMap; +use std::path::Path; +use std::sync::{Arc, LazyLock, Mutex as SyncMutex}; +use tokio::sync::Mutex; + +/// A media file with no source bytes and no referencing message is only deleted +/// once it is old enough that no in-flight editor can still be holding it. +const ORPHAN_GRACE_SECONDS: i64 = 60 * 60; +/// Matches the Dart retransmission window: a media file that already failed +/// twice is not retried more than once every six hours. +const RETRANSMISSION_BACKOFF_SECONDS: i64 = 6 * 60 * 60; +/// The server needs a moment to hand queued messages to the recipient before a +/// marked receipt is worth retrying. +const RETRY_MARK_GRACE_SECONDS: i64 = 20; + +static MEDIA_LOCKS: LazyLock>>>> = + LazyLock::new(|| SyncMutex::new(HashMap::new())); +static PREPROCESSING_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); +static RETRANSMISSION_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); + +/// One media file is only ever prepared by one task. Preparation reserves a +/// slot, encrypts, and writes request files, so a second concurrent run would +/// burn a slot and leave orphaned jobs behind. +fn media_lock(media_id: &str) -> Arc> { + let mut locks = MEDIA_LOCKS + .lock() + .unwrap_or_else(|error| error.into_inner()); + locks.entry(media_id.to_owned()).or_default().clone() +} + +#[derive(FromRow, Clone)] +struct MediaRow { + media_id: String, + media_type: String, + upload_state: Option, + requires_authentication: i64, + is_draft_media: i64, + display_limit_in_milliseconds: Option, + reupload_requested_by: Option, + remove_audio: Option, + created_at: i64, +} + +/// Why a media send stopped at `fileLimitReached`, for the chat entry to show. +pub struct MediaSizeReport { + /// The encoded media as it sits on disk, while it is still there. + pub media_bytes: Option, + /// The largest single object the user's plan accepts. + pub limit_bytes: Option, +} + +#[derive(FromRow)] +struct RetransmissionReceipt { + receipt_id: String, + contact_id: i64, + message_id: Option, + message: Vec, + mark_for_retry_after_accepted: Option, + ack_by_server_at: Option, + retry_count: i64, + last_retry: Option, +} + +pub struct MediaUploadService { + ctx: Arc, +} + +impl MediaUploadService { + pub fn new(ctx: &Arc) -> Self { + Self { ctx: ctx.clone() } + } + + /// Creates the media row and its content-encryption material. The row is + /// the only durable handle the UI needs; every later step is addressed by + /// its media id. + pub async fn initialize( + &self, + media_type: String, + display_limit_in_milliseconds: Option, + is_draft_media: bool, + ) -> Result { + // The UI historically passed seconds in some places and milliseconds in + // others; anything below a second can only have meant seconds. + let display_limit = + display_limit_in_milliseconds + .map(|limit| if limit < 1_000 { limit * 1_000 } else { limit }); + let mut encryption_key = vec![0_u8; 32]; + rand::rng().fill_bytes(&mut encryption_key); + let mut encryption_nonce = vec![0_u8; 12]; + rand::rng().fill_bytes(&mut encryption_nonce); + let media_id = new_uuid_v7(); + + let database = self.ctx.app_db.read().await.clone(); + let mut transaction = database.pool.begin().await?; + sqlx::query("UPDATE media_files SET is_draft_media = 0 WHERE is_draft_media = 1") + .execute(&mut *transaction) + .await?; + sqlx::query( + r#"INSERT INTO media_files + (media_id, type, upload_state, display_limit_in_milliseconds, + encryption_key, encryption_nonce, is_draft_media) + VALUES (?, ?, 'initialized', ?, ?, ?, ?)"#, + ) + .bind(&media_id) + .bind(&media_type) + .bind(display_limit) + .bind(&encryption_key) + .bind(&encryption_nonce) + .bind(i64::from(is_draft_media)) + .execute(&mut *transaction) + .await?; + transaction.commit().await?; + database.notify_committed(["media_files"]); + Ok(media_id) + } + + /// Turns an edited media file into one outgoing message per selected group + /// and hands the send to the background uploader. + pub async fn insert_into_messages( + &self, + media_id: String, + group_ids: Vec, + additional_message_data: Option>, + ) -> Result<()> { + let database = self.ctx.app_db.read().await.clone(); + let now = chrono::Utc::now().timestamp(); + + let mut transaction = database.pool.begin().await?; + sqlx::query("UPDATE media_files SET is_draft_media = 0 WHERE is_draft_media = 1") + .execute(&mut *transaction) + .await?; + for group_id in &group_ids { + let members = sqlx::query_as::<_, (i64, i64)>( + r#"SELECT gm.contact_id, c.account_deleted + FROM group_members gm + JOIN contacts c ON c.user_id = gm.contact_id + WHERE gm.group_id = ?"#, + ) + .bind(group_id) + .fetch_all(&mut *transaction) + .await?; + // A direct chat whose only peer deleted their account has nobody + // left to receive the media. + if members.len() == 1 && members[0].1 != 0 { + tracing::warn!(group_id, "skipping media send to a deleted account"); + continue; + } + + let message_id = new_uuid_v7(); + sqlx::query( + r#"INSERT INTO messages + (group_id, message_id, type, media_id, additional_message_data, created_at) + VALUES (?, ?, 'media', ?, ?, ?)"#, + ) + .bind(group_id) + .bind(&message_id) + .bind(&media_id) + .bind(additional_message_data.as_deref()) + .bind(now) + .execute(&mut *transaction) + .await?; + sqlx::query( + r#"UPDATE groups SET archived = 0, deleted_content = 0, + last_message_exchange = MAX(last_message_exchange, ?) + WHERE group_id = ?"#, + ) + .bind(now) + .bind(group_id) + .execute(&mut *transaction) + .await?; + Group::record_media_exchange(&mut transaction, group_id, false, now).await?; + } + transaction.commit().await?; + database.notify_committed(["messages", "media_files", "groups", "contacts"]); + drop(database); + + // Preparation compresses and encrypts, which is far too slow to keep the + // send button blocked; the media row is already durable at this point. + let ctx = self.ctx.clone(); + tokio::spawn(async move { + if let Err(error) = MediaUploadService::new(&ctx).start_upload(&media_id).await { + tracing::warn!(media_id, %error, "starting the media upload failed"); + } + }); + Ok(()) + } + + /// Advances one media file as far towards "scheduled" as it can get right + /// now. Safe to call repeatedly and from maintenance passes. + pub async fn start_upload(&self, media_id: &str) -> Result<()> { + let lock = media_lock(media_id); + let _guard = lock.lock().await; + self.start_upload_locked(media_id).await + } + + async fn start_upload_locked(&self, media_id: &str) -> Result<()> { + let Some(media) = self.load(media_id).await? else { + return Ok(()); + }; + // `uploading` and `uploadLimitReached` are legacy states from the + // Dart-driven upload; both simply restart preparation now. + if !matches!( + media.upload_state.as_deref(), + Some("initialized" | "preprocessing" | "uploading" | "uploadLimitReached") + ) { + return Ok(()); + } + self.set_upload_state(media_id, "preprocessing").await?; + + let files = MediaFileService::new(&self.ctx); + let temp_path = files.temp_path(media_id, &media.media_type); + let stored_path = files.stored_path(media_id, &media.media_type); + if !temp_path.exists() { + if stored_path.exists() { + MediaFileService::ensure_parent(&temp_path)?; + std::fs::copy(&stored_path, &temp_path)?; + } else { + self.compress(&media).await?; + } + } + if !temp_path.exists() { + return self.retire_media(&media).await; + } + + // Auto-storing has to happen before the plaintext is consumed, and only + // for media the recipient could have kept anyway. + let config = UserConfig::load_required_from(&self.ctx)?; + if config.auto_store_all_send_unlimited_media_files + && media.requires_authentication == 0 + && media.display_limit_in_milliseconds.is_none() + && !stored_path.exists() + { + self.store(media_id).await?; + } + + match DirectMediaUploadService::new(&self.ctx) + .prepare_and_schedule(media_id) + .await + { + Ok(attachment_id) => { + self.set_upload_state(media_id, "backgroundUploadTaskStarted") + .await?; + // Both native tasks now own durable request files, so the + // uncompressed original is no longer needed. + remove_file(&files.original_path(media_id, &media.media_type)); + tracing::info!(media_id, attachment_id, "scheduled native media upload"); + Ok(()) + } + // No amount of retrying shrinks the file, so this is where the + // send stops. `fileLimitReached` is outside the set this method + // restarts from, which is what keeps the maintenance pass from + // picking it up again, and the chat entry renders it as a warning. + Err(TwonlyError::MediaTooLarge { bytes, limit }) => { + tracing::warn!( + media_id, + bytes, + limit, + "media exceeds the plan's single-object limit" + ); + self.set_upload_state(media_id, "fileLimitReached").await + } + Err(error) => { + // Staying in `preprocessing` lets the next maintenance pass or + // reconnect retry without any extra bookkeeping. + tracing::warn!(media_id, %error, "direct media upload scheduling failed"); + Ok(()) + } + } + } + + /// Recovers every upload that a terminated process left mid-flight, and + /// settles the transfers the server has meanwhile accepted or rejected. + pub async fn finish_started_uploads(&self) -> Result<()> { + let _guard = PREPROCESSING_LOCK.lock().await; + let direct = DirectMediaUploadService::new(&self.ctx); + if let Err(error) = direct.reconcile().await { + tracing::warn!(%error, "direct media reconciliation failed"); + } + // Transfers the OS is still carrying settle without notifying this + // process, so keep asking while the app is up. + if direct.pending_job_count().await.unwrap_or(0) > 0 { + crate::services::direct_media_upload::watch_pending_uploads(&self.ctx); + } + + let database = self.ctx.app_db.read().await.clone(); + let pending = sqlx::query_as::<_, MediaRow>( + r#"SELECT media_id, type AS media_type, upload_state, requires_authentication, + is_draft_media, display_limit_in_milliseconds, reupload_requested_by, + remove_audio, created_at + FROM media_files + WHERE upload_state IN + ('initialized', 'preprocessing', 'uploading', 'uploadLimitReached')"#, + ) + .fetch_all(&database.pool) + .await?; + drop(database); + + for media in pending { + if media.is_draft_media != 0 { + continue; + } + if self.message_count(&media.media_id).await? == 0 { + self.retire_media(&media).await?; + continue; + } + if let Err(error) = self.start_upload(&media.media_id).await { + tracing::warn!(media_id = media.media_id, %error, "could not resume media upload"); + } + } + Ok(()) + } + + /// A recipient could not decrypt the media, so it has to be encrypted and + /// uploaded again — but only for the contacts that asked for it. + pub async fn reupload(&self, contact_id: i64, media_id: &str, message_id: &str) -> Result<()> { + let lock = media_lock(media_id); + let _guard = lock.lock().await; + + let database = self.ctx.app_db.read().await.clone(); + sqlx::query( + r#"UPDATE receipts SET mark_for_retry = NULL, mark_for_retry_after_accepted = NULL + WHERE contact_id = ? AND message_id = ?"#, + ) + .bind(contact_id) + .bind(message_id) + .execute(&database.pool) + .await?; + + let Some(media) = self.load(media_id).await? else { + return Ok(()); + }; + let mut requested_by = parse_reupload_requested_by(media.reupload_requested_by.as_deref()); + if !requested_by.contains(&contact_id) { + requested_by.push(contact_id); + } + sqlx::query( + r#"UPDATE media_files SET upload_state = 'preprocessing', reupload_requested_by = ? + WHERE media_id = ?"#, + ) + .bind(serde_json::to_string(&requested_by)?) + .bind(media_id) + .execute(&database.pool) + .await?; + database.notify_committed(["media_files", "receipts"]); + drop(database); + + // The legacy pre-built upload request would pin the old recipient set. + remove_file( + &MediaFileService::new(&self.ctx).upload_request_path(media_id, &media.media_type), + ); + self.start_upload_locked(media_id).await + } + + /// Periodic maintenance over receipts whose media send never completed. + pub async fn reupload_pending(&self) -> Result<()> { + let _guard = RETRANSMISSION_LOCK.lock().await; + let now = chrono::Utc::now().timestamp(); + let database = self.ctx.app_db.read().await.clone(); + let receipts = sqlx::query_as::<_, RetransmissionReceipt>( + r#"SELECT receipt_id, contact_id, message_id, message, + mark_for_retry_after_accepted, ack_by_server_at, retry_count, last_retry + FROM receipts + WHERE will_be_retried_by_media_upload = 1 + AND (mark_for_retry < ? OR mark_for_retry_after_accepted < ?)"#, + ) + .bind(now - RETRY_MARK_GRACE_SECONDS) + .bind(now - RETRY_MARK_GRACE_SECONDS) + .fetch_all(&database.pool) + .await?; + drop(database); + + if receipts.is_empty() { + return Ok(()); + } + tracing::info!(count = receipts.len(), "reuploading media files"); + + for receipt in receipts { + if let Err(error) = self.retransmit_receipt(receipt, now).await { + tracing::warn!(%error, "media retransmission failed"); + } + } + Ok(()) + } + + async fn retransmit_receipt(&self, receipt: RetransmissionReceipt, now: i64) -> Result<()> { + if receipt.retry_count > 1 + && receipt + .last_retry + .is_some_and(|last| last > now - RETRANSMISSION_BACKOFF_SECONDS) + { + return Ok(()); + } + + let database = self.ctx.app_db.read().await.clone(); + let message_id = match receipt.message_id.clone() { + Some(message_id) => message_id, + // Older receipts stored the message only inside their ciphertext. + None => { + let Some(message_id) = EncryptedContent::decode(receipt.message.as_slice()) + .ok() + .and_then(|content| content.media) + .map(|media| media.sender_message_id) + else { + tracing::warn!(receipt.receipt_id, "receipt has no recoverable message id"); + return Ok(()); + }; + let exists = sqlx::query_scalar::<_, i64>( + "SELECT EXISTS(SELECT 1 FROM messages WHERE message_id = ?)", + ) + .bind(&message_id) + .fetch_one(&database.pool) + .await? + != 0; + if !exists { + sqlx::query("DELETE FROM receipts WHERE receipt_id = ?") + .bind(&receipt.receipt_id) + .execute(&database.pool) + .await?; + return Ok(()); + } + sqlx::query("UPDATE receipts SET message_id = ? WHERE receipt_id = ?") + .bind(&message_id) + .bind(&receipt.receipt_id) + .execute(&database.pool) + .await?; + message_id + } + }; + + // Nothing can be delivered to a contact who has not accepted yet. + if receipt.mark_for_retry_after_accepted.is_some() { + let accepted = sqlx::query_scalar::<_, Option>( + "SELECT accepted FROM contacts WHERE user_id = ?", + ) + .bind(receipt.contact_id) + .fetch_optional(&database.pool) + .await? + .flatten(); + if accepted.unwrap_or(0) == 0 { + return Ok(()); + } + } + + if receipt.ack_by_server_at.is_some() { + // The object should still be on the server, so resending the + // envelope with its download token is enough. + drop(database); + return MessageService::new(&self.ctx) + .send_receipt(receipt.receipt_id) + .await; + } + + let media_id = sqlx::query_scalar::<_, Option>( + "SELECT media_id FROM messages WHERE message_id = ?", + ) + .bind(&message_id) + .fetch_optional(&database.pool) + .await? + .flatten(); + let Some(media_id) = media_id else { + // Neither the message nor its media exist any more. + sqlx::query("DELETE FROM messages WHERE message_id = ?") + .bind(&message_id) + .execute(&database.pool) + .await?; + sqlx::query("DELETE FROM receipts WHERE receipt_id = ?") + .bind(&receipt.receipt_id) + .execute(&database.pool) + .await?; + database.notify_committed(["messages", "receipts"]); + return Ok(()); + }; + drop(database); + + self.reupload(receipt.contact_id, &media_id, &message_id) + .await + } + + /// Keeps the local copy of a media file and derives everything the gallery + /// and chat list need from it. + pub async fn store(&self, media_id: &str) -> Result<()> { + let Some(media) = self.load(media_id).await? else { + return Ok(()); + }; + let database = self.ctx.app_db.read().await.clone(); + sqlx::query("UPDATE media_files SET stored = 1 WHERE media_id = ?") + .bind(media_id) + .execute(&database.pool) + .await?; + drop(database); + + let files = MediaFileService::new(&self.ctx); + let temp_path = files.temp_path(media_id, &media.media_type); + let stored_path = files.stored_path(media_id, &media.media_type); + if !temp_path.exists() && files.original_path(media_id, &media.media_type).exists() { + self.compress(&media).await?; + } + if !temp_path.exists() { + tracing::warn!(media_id, "cannot store media without its plaintext"); + return Ok(()); + } + MediaFileService::ensure_parent(&stored_path)?; + std::fs::copy(&temp_path, &stored_path)?; + + let config = UserConfig::load_required_from(&self.ctx)?; + if config.store_media_files_in_gallery { + if let Err(error) = self.save_to_gallery(media_id).await { + tracing::warn!(media_id, %error, "could not export the media to the gallery"); + } + } + self.create_thumbnail(&media).await?; + // Trust the filesystem rather than what the encoder reported: a + // thumbnail that was not written must not be advertised to the UI. + if files.thumbnail_path(media_id).exists() { + self.update_media( + "UPDATE media_files SET has_thumbnail = ? WHERE media_id = ?", + 1_i64, + media_id, + ) + .await?; + } + self.refresh_stored_metadata(media_id, &stored_path).await + } + + /// Size and content hash are read back from the file that was actually + /// written, never from the value the caller believed it wrote. Videos can + /// be tens of megabytes, so the hash streams instead of buffering. + async fn refresh_stored_metadata(&self, media_id: &str, stored_path: &Path) -> Result<()> { + let mut file = std::fs::File::open(stored_path)?; + let mut hasher = ::new(); + let size = std::io::copy(&mut file, &mut hasher)? as i64; + let hash = ::finalize(hasher).to_vec(); + let database = self.ctx.app_db.read().await.clone(); + sqlx::query( + "UPDATE media_files SET size_in_bytes = ?, stored_file_hash = ? WHERE media_id = ?", + ) + .bind(size) + .bind(hash) + .bind(media_id) + .execute(&database.pool) + .await?; + database.notify_committed(["media_files"]); + Ok(()) + } + + /// Called by Flutter after a plugin step rewrote a media file on disk, so + /// the derived state Rust owns is recomputed from what was actually written. + pub async fn media_step_finished(&self, media_id: &str, kind: &str) -> Result<()> { + let files = MediaFileService::new(&self.ctx); + match kind { + "thumbnail" => { + self.update_media( + "UPDATE media_files SET has_thumbnail = ? WHERE media_id = ?", + i64::from(files.thumbnail_path(media_id).exists()), + media_id, + ) + .await + } + "stored" => { + let Some(media) = self.load(media_id).await? else { + return Ok(()); + }; + let stored_path = files.stored_path(media_id, &media.media_type); + if stored_path.exists() { + return self.refresh_stored_metadata(media_id, &stored_path).await; + } + // A file the user chose to keep, that no cloud copy backs, and + // that has been gone long enough to not be a transient state, + // is not coming back. + let database = self.ctx.app_db.read().await.clone(); + let removable = sqlx::query_scalar::<_, i64>( + r#"SELECT EXISTS( + SELECT 1 FROM media_files + WHERE media_id = ? AND stored = 1 AND cloud_state = 'none' + AND created_at < ? + )"#, + ) + .bind(media_id) + .bind(chrono::Utc::now().timestamp() - 30 * 24 * 60 * 60) + .fetch_one(&database.pool) + .await? + != 0; + if removable { + self.delete(media_id).await?; + } + Ok(()) + } + "crop" => { + self.update_media( + "UPDATE media_files SET has_crop_analyzed = ? WHERE media_id = ?", + 1_i64, + media_id, + ) + .await?; + let Some(media) = self.load(media_id).await? else { + return Ok(()); + }; + let stored_path = files.stored_path(media_id, &media.media_type); + if stored_path.exists() { + self.refresh_stored_metadata(media_id, &stored_path).await?; + } + Ok(()) + } + _ => Ok(()), + } + } + + /// Drops every file of a media item while keeping its row, so a message + /// that still references it keeps rendering as an expired media message. + pub async fn remove_files(&self, media_id: &str) -> Result<()> { + let Some(media) = self.load(media_id).await? else { + return Ok(()); + }; + MediaFileService::new(&self.ctx).remove_files(media_id, &media.media_type) + } + + /// Drops the files and the row. Only for media nothing references any more. + async fn delete(&self, media_id: &str) -> Result<()> { + self.remove_files(media_id).await?; + let database = self.ctx.app_db.read().await.clone(); + sqlx::query("DELETE FROM media_files WHERE media_id = ?") + .bind(media_id) + .execute(&database.pool) + .await?; + database.notify_committed(["media_files", "messages"]); + Ok(()) + } + + /// The media can never be sent — its source could not be produced at all. + /// The recipients' messages become "deleted by the sender" and the upload + /// loop stops instead of retrying something that cannot succeed. + pub async fn abandon(&self, media_id: &str) -> Result<()> { + let Some(media) = self.load(media_id).await? else { + return Ok(()); + }; + let database = self.ctx.app_db.read().await.clone(); + sqlx::query("UPDATE messages SET is_deleted_from_sender = 1 WHERE media_id = ?") + .bind(media_id) + .execute(&database.pool) + .await?; + sqlx::query("UPDATE media_files SET upload_state = 'uploaded' WHERE media_id = ?") + .bind(media_id) + .execute(&database.pool) + .await?; + database.notify_committed(["media_files", "messages"]); + MediaFileService::new(&self.ctx).remove_files(media_id, &media.media_type)?; + tracing::warn!( + media_id, + "abandoned a media file that could not be prepared" + ); + Ok(()) + } + + /// Deletes temporary media whose message is finished with it. Everything + /// that decides "finished" is a policy question, so it lives here rather + /// than in the view layer that happens to trigger the sweep. + pub async fn purge_temp_folder(&self) -> Result<()> { + let temp_directory = std::path::PathBuf::from(&self.ctx.config.data_dir) + .join("mediafiles") + .join("tmp"); + let Ok(entries) = std::fs::read_dir(&temp_directory) else { + return Ok(()); + }; + + let mut files_by_media: HashMap> = HashMap::new(); + for entry in entries.flatten() { + let path = entry.path(); + let Some(media_id) = path + .file_name() + .and_then(|name| name.to_str()) + .and_then(|name| name.split('.').next()) + else { + continue; + }; + files_by_media + .entry(media_id.to_owned()) + .or_default() + .push(path); + } + + for (media_id, paths) in files_by_media { + if !self.temp_files_are_purgeable(&media_id).await? { + continue; + } + tracing::info!(media_id, "purging temporary media files"); + for path in paths { + remove_file(&path); + } + } + Ok(()) + } + + /// What the chat entry needs to explain a `fileLimitReached` send: how big + /// the media turned out and the largest single object the plan accepts. + /// + /// The limit is read back from the most recent upload slot rather than + /// tracked separately, because the slot is where the server states it. The + /// size is whatever is still on disk, so it goes missing once the temporary + /// files are purged — the limit alone is what the user can act on. + pub async fn size_limit_report(&self, media_id: &str) -> Result { + let database = self.ctx.app_db.read().await.clone(); + let limit_bytes = sqlx::query_scalar::<_, i64>( + "SELECT maximum_object_bytes FROM direct_media_upload_slots ORDER BY created_at DESC LIMIT 1", + ) + .fetch_optional(&database.pool) + .await?; + let media_bytes = match self.load(media_id).await? { + Some(media) => { + let files = MediaFileService::new(&self.ctx); + [ + files.temp_path(media_id, &media.media_type), + files.stored_path(media_id, &media.media_type), + ] + .iter() + .find_map(|path| std::fs::metadata(path).ok()) + .map(|metadata| metadata.len() as i64) + } + None => None, + }; + Ok(MediaSizeReport { + media_bytes, + limit_bytes, + }) + } + + async fn temp_files_are_purgeable(&self, media_id: &str) -> Result { + let database = self.ctx.app_db.read().await.clone(); + let Some(media) = self.load(media_id).await? else { + // Nothing in the database refers to these bytes any more. + return Ok(true); + }; + if media.is_draft_media != 0 { + return Ok(false); + } + // The plaintext is the upload's input, so it must outlive the transfer. + if !matches!( + media.upload_state.as_deref(), + Some("uploaded" | "fileLimitReached") + ) { + return Ok(false); + } + // Voice messages stay playable. + if media.media_type == "audio" { + let has_message = sqlx::query_scalar::<_, i64>( + "SELECT EXISTS(SELECT 1 FROM messages WHERE media_id = ?)", + ) + .bind(media_id) + .fetch_one(&database.pool) + .await? + != 0; + if has_message { + return Ok(false); + } + } + + let now = chrono::Utc::now().timestamp(); + let keepers = sqlx::query_scalar::<_, i64>( + r#"SELECT COUNT(*) FROM messages m + JOIN media_files f ON f.media_id = m.media_id + JOIN groups g ON g.group_id = m.group_id + WHERE m.media_id = ? + AND ( + m.opened_at IS NULL + -- Opening a message races this sweep; give the viewer a + -- moment before its source disappears underneath it. + OR m.opened_at > ? + OR ( + f.requires_authentication = 0 + AND f.display_limit_in_milliseconds IS NULL + AND m.opened_at > ? + -- A group member may still store it, and the sender may + -- still reopen their own copy. + AND (m.sender_id IS NULL OR g.is_direct_chat = 0) + ) + )"#, + ) + .bind(media_id) + .bind(now - 3 * 60) + .bind(now - 2 * 24 * 60 * 60) + .fetch_one(&database.pool) + .await?; + Ok(keepers == 0) + } + + pub async fn set_display_limit( + &self, + media_id: &str, + display_limit_in_milliseconds: Option, + ) -> Result<()> { + self.update_media( + "UPDATE media_files SET display_limit_in_milliseconds = ? WHERE media_id = ?", + display_limit_in_milliseconds, + media_id, + ) + .await + } + + pub async fn set_requires_authentication( + &self, + media_id: &str, + requires_authentication: bool, + ) -> Result<()> { + let database = self.ctx.app_db.read().await.clone(); + // Authenticated media is always time limited; the UI has no control + // that could set both independently. + if requires_authentication { + sqlx::query( + r#"UPDATE media_files + SET requires_authentication = 1, display_limit_in_milliseconds = 12000 + WHERE media_id = ?"#, + ) + .bind(media_id) + .execute(&database.pool) + .await?; + } else { + sqlx::query("UPDATE media_files SET requires_authentication = 0 WHERE media_id = ?") + .bind(media_id) + .execute(&database.pool) + .await?; + } + database.notify_committed(["media_files"]); + Ok(()) + } + + pub async fn toggle_remove_audio(&self, media_id: &str) -> Result<()> { + let database = self.ctx.app_db.read().await.clone(); + sqlx::query( + "UPDATE media_files SET remove_audio = 1 - COALESCE(remove_audio, 0) WHERE media_id = ?", + ) + .bind(media_id) + .execute(&database.pool) + .await?; + database.notify_committed(["media_files"]); + Ok(()) + } + + async fn update_media(&self, query: &'static str, value: T, media_id: &str) -> Result<()> + where + T: for<'q> sqlx::Encode<'q, sqlx::Sqlite> + sqlx::Type + Send + 'static, + { + let database = self.ctx.app_db.read().await.clone(); + sqlx::query(query) + .bind(value) + .bind(media_id) + .execute(&database.pool) + .await?; + database.notify_committed(["media_files"]); + Ok(()) + } + + async fn set_upload_state(&self, media_id: &str, state: &str) -> Result<()> { + self.update_media( + "UPDATE media_files SET upload_state = ? WHERE media_id = ?", + state.to_owned(), + media_id, + ) + .await + } + + /// The media cannot be sent as it stands: either its plaintext is gone or + /// nothing references it any more. What that means depends entirely on who + /// is still waiting for it. + async fn retire_media(&self, media: &MediaRow) -> Result<()> { + let media_id = &media.media_id; + let database = self.ctx.app_db.read().await.clone(); + + if media.reupload_requested_by.is_some() { + tracing::warn!(media_id, "reupload requested but the source is gone"); + sqlx::query( + r#"UPDATE media_files SET upload_state = 'uploaded', reupload_requested_by = NULL + WHERE media_id = ?"#, + ) + .bind(media_id) + .execute(&database.pool) + .await?; + database.notify_committed(["media_files"]); + return Ok(()); + } + + if self.message_count(media_id).await? > 0 { + // Deleting the row would break the chat history, so only stop the + // retry loop. + tracing::warn!( + media_id, + "media source is gone but messages still reference it" + ); + sqlx::query("UPDATE media_files SET upload_state = 'uploaded' WHERE media_id = ?") + .bind(media_id) + .execute(&database.pool) + .await?; + database.notify_committed(["media_files"]); + return Ok(()); + } + + if media.created_at > chrono::Utc::now().timestamp() - ORPHAN_GRACE_SECONDS { + // Still inside the editing window: the user may not have picked + // recipients yet. + return Ok(()); + } + tracing::info!(media_id, "deleting orphaned media file"); + MediaFileService::new(&self.ctx).remove_files(media_id, &media.media_type)?; + sqlx::query("DELETE FROM media_files WHERE media_id = ?") + .bind(media_id) + .execute(&database.pool) + .await?; + database.notify_committed(["media_files"]); + Ok(()) + } + + /// Marks an upload as delivered when a receiver's response proves the media + /// arrived, even though the transfer callback never reached this device. + pub async fn mark_uploaded(&self, media_id: &str) -> Result<()> { + let database = self.ctx.app_db.read().await.clone(); + let mut transaction = database.pool.begin().await?; + MediaFile::mark_uploaded(&mut transaction, media_id).await?; + transaction.commit().await?; + database.notify_committed(["media_files", "messages", "receipts"]); + Ok(()) + } + + async fn load(&self, media_id: &str) -> Result> { + let database = self.ctx.app_db.read().await.clone(); + Ok(sqlx::query_as::<_, MediaRow>( + r#"SELECT media_id, type AS media_type, upload_state, requires_authentication, + is_draft_media, display_limit_in_milliseconds, reupload_requested_by, + remove_audio, created_at + FROM media_files WHERE media_id = ?"#, + ) + .bind(media_id) + .fetch_optional(&database.pool) + .await?) + } + + async fn message_count(&self, media_id: &str) -> Result { + let database = self.ctx.app_db.read().await.clone(); + Ok( + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM messages WHERE media_id = ?") + .bind(media_id) + .fetch_one(&database.pool) + .await?, + ) + } + + /// Produces the plaintext that actually gets encrypted and sent. Stills are + /// encoded here with libwebp; only video still needs the Flutter editor, + /// and GIF and audio are sent in their original container. + async fn compress(&self, media: &MediaRow) -> Result<()> { + let files = MediaFileService::new(&self.ctx); + let original = files.original_path(&media.media_id, &media.media_type); + let temp = files.temp_path(&media.media_id, &media.media_type); + if !original.exists() { + tracing::warn!(media_id = media.media_id, "no original media to compress"); + return Ok(()); + } + match media.media_type.as_str() { + "video" => self.render_video(media, &original, &temp).await, + "image" => { + let (source, destination) = (original.clone(), temp.clone()); + if let Err(error) = + blocking(move || media_codec::compress_for_send(&source, &destination)).await + { + // Sending the original beats not sending at all, which is + // what the Flutter implementation did on a codec failure. + tracing::warn!(media_id = media.media_id, %error, "sending the uncompressed image"); + MediaFileService::ensure_parent(&temp)?; + std::fs::copy(&original, &temp)?; + } + Ok(()) + } + // GIF keeps its animation and audio is already in its delivery + // container, so both are sent byte for byte. + _ => { + MediaFileService::ensure_parent(&temp)?; + std::fs::copy(&original, &temp)?; + Ok(()) + } + } + } + + /// Composites the editor's overlay and transcodes, in one hardware pass on + /// the platform's own encoder. Flutter is not involved, so this also works + /// from a background task. + async fn render_video(&self, media: &MediaRow, original: &Path, temp: &Path) -> Result<()> { + let files = MediaFileService::new(&self.ctx); + let overlay = files.overlay_image_path(&media.media_id); + let overlay = overlay.exists().then_some(overlay); + // Every clip is rendered, including small ones with no overlay. What a + // camera produces varies by device and is not guaranteed to play on the + // other platform; the render is what makes the output predictable. + let request = video::RenderRequest { + media_id: media.media_id.clone(), + input: original.to_path_buf(), + overlay, + output: temp.to_path_buf(), + remove_audio: media.remove_audio.unwrap_or(0) != 0, + }; + let media_id = media.media_id.clone(); + if let Err(error) = blocking(move || video::render(&request)).await { + // Last resort: the camera file is the only remaining way to deliver + // the moment, but it carries no overlay and the recipient's platform + // may not decode it, so this is worth seeing in the logs. + tracing::error!(media_id, %error, "sending the unrendered camera file"); + MediaFileService::ensure_parent(temp)?; + std::fs::copy(original, temp)?; + } + Ok(()) + } + + /// Renders the preview shown in chat lists and the gallery. /// Renders the preview shown in chat lists and the gallery. + async fn create_thumbnail(&self, media: &MediaRow) -> Result<()> { + let files = MediaFileService::new(&self.ctx); + let stored = files.stored_path(&media.media_id, &media.media_type); + let thumbnail = files.thumbnail_path(&media.media_id); + if media.media_type == "audio" || !stored.exists() { + return Ok(()); + } + let media_id = media.media_id.clone(); + let is_video = media.media_type == "video"; + if let Err(error) = blocking(move || { + if is_video { + // Only the frame grab needs the platform's video decoder; the + // scaling and encoding is the same code stills use. + let frame = thumbnail.with_extension("frame.png"); + video::extract_frame(&stored, &frame)?; + let result = media_codec::create_image_thumbnail(&frame, &thumbnail); + let _ = std::fs::remove_file(&frame); + result + } else { + media_codec::create_image_thumbnail(&stored, &thumbnail) + } + }) + .await + { + tracing::warn!(media_id, %error, "could not create a thumbnail"); + } + Ok(()) + } + + /// Exports a stored media file to the user's photo library, stamping the + /// capture time into the file so it survives being copied elsewhere. + pub async fn save_to_gallery(&self, media_id: &str) -> Result<()> { + let Some(media) = self.load(media_id).await? else { + return Ok(()); + }; + let files = MediaFileService::new(&self.ctx); + let stored = files.stored_path(media_id, &media.media_type); + if !stored.exists() { + return Err(TwonlyError::Generic(format!( + "media {media_id} has no stored file to export" + ))); + } + let created_at_millis = media.created_at.saturating_mul(1_000); + let is_video = media.media_type == "video"; + let media_id = media_id.to_owned(); + blocking(move || { + // Video containers carry their own metadata, and the photo library + // is told the capture time either way; only stills are rewritten. + let exported = if is_video { + stored.clone() + } else { + let metadata = media_exif::ExifMetadata { + created_at: chrono::DateTime::from_timestamp_millis(created_at_millis), + }; + let bytes = std::fs::read(&stored)?; + match media_exif::with_exif(&bytes, &metadata) { + Ok(stamped) => { + let path = stored.with_extension("export.webp"); + std::fs::write(&path, stamped)?; + path + } + Err(error) => { + tracing::warn!(%error, "exporting without EXIF metadata"); + stored.clone() + } + } + }; + let result = gallery::save(&exported, is_video, &media_id, created_at_millis); + if exported != stored { + let _ = std::fs::remove_file(&exported); + } + result + }) + .await + } + + /// Trims fully transparent borders the editor left around a stored image. + pub async fn crop_transparent_borders(&self, media_id: &str) -> Result<()> { + let Some(media) = self.load(media_id).await? else { + return Ok(()); + }; + let files = MediaFileService::new(&self.ctx); + let stored = files.stored_path(media_id, &media.media_type); + if media.media_type == "image" && stored.exists() { + match blocking(move || media_codec::crop_transparent_borders(&stored)).await { + Ok(true) => { + // The pixels changed, so the preview and the content hash + // derived from them are stale. + remove_file(&files.thumbnail_path(media_id)); + self.create_thumbnail(&media).await?; + } + Ok(false) => {} + Err(error) => { + tracing::warn!(media_id, %error, "could not crop transparent borders"); + } + } + } + self.media_step_finished(media_id, "crop").await + } +} + +/// Image codecs are CPU-bound: a full-resolution photo takes long enough to +/// encode that it must not run on an async worker. +async fn blocking(task: F) -> Result +where + F: FnOnce() -> Result + Send + 'static, + T: Send + 'static, +{ + tokio::task::spawn_blocking(task) + .await + .map_err(|error| TwonlyError::Generic(error.to_string()))? +} + +fn remove_file(path: &Path) { + match std::fs::remove_file(path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => tracing::warn!(path = %path.display(), %error, "could not remove file"), + } +} + +/// Drift persists this column as a JSON array of contact ids. Tolerate the +/// single-scalar form that an earlier Rust write produced. +fn parse_reupload_requested_by(value: Option<&str>) -> Vec { + let Some(value) = value else { + return Vec::new(); + }; + if let Ok(ids) = serde_json::from_str::>(value) { + return ids; + } + value.parse::().map(|id| vec![id]).unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_both_reupload_requested_by_encodings() { + assert!(parse_reupload_requested_by(None).is_empty()); + assert_eq!(parse_reupload_requested_by(Some("[3,7]")), vec![3, 7]); + assert_eq!(parse_reupload_requested_by(Some("5")), vec![5]); + assert!(parse_reupload_requested_by(Some("nonsense")).is_empty()); + } + + #[test] + fn media_lock_is_shared_per_media_id() { + let first = media_lock("media-a"); + let second = media_lock("media-a"); + let other = media_lock("media-b"); + assert!(Arc::ptr_eq(&first, &second)); + assert!(!Arc::ptr_eq(&first, &other)); + } +} diff --git a/rust/src/services/mediafiles.rs b/rust/src/services/mediafiles.rs index 39fcad99..fe71ddb5 100644 --- a/rust/src/services/mediafiles.rs +++ b/rust/src/services/mediafiles.rs @@ -320,6 +320,21 @@ impl MediaFileService { Ok(()) } + pub async fn retry_pending_reuploads(&self) -> Result<()> { + let database = self.ctx.app_db.read().await.clone(); + let media_ids = sqlx::query_scalar!( + "SELECT media_id FROM media_files WHERE download_state = 'reuploadRequested'" + ) + .fetch_all(&database.pool) + .await?; + drop(database); + + for media_id in media_ids { + self.request_reupload(&media_id).await?; + } + Ok(()) + } + pub fn remove_files(&self, media_id: &str, media_type: &str) -> Result<()> { for path in self.paths(media_id, media_type) { match std::fs::remove_file(path) { @@ -371,13 +386,49 @@ impl MediaFileService { ] } - fn temp_path(&self, media_id: &str, media_type: &str) -> PathBuf { + /// Media lives under one directory per lifecycle stage. Both Rust and the + /// Flutter view layer derive these names from the media id, so they must + /// stay in sync with `MediaFileService` on the Dart side. + fn media_path( + &self, + directory: &str, + media_id: &str, + suffix: &str, + extension: &str, + ) -> PathBuf { + PathBuf::from(&self.ctx.config.data_dir) + .join("mediafiles") + .join(directory) + .join(format!("{media_id}{suffix}.{extension}")) + } + + pub(crate) fn stored_path(&self, media_id: &str, media_type: &str) -> PathBuf { + self.media_path("stored", media_id, "", Self::extension(media_type)) + } + + pub(crate) fn thumbnail_path(&self, media_id: &str) -> PathBuf { + self.media_path("stored", media_id, ".thumbnail", "webp") + } + + pub(crate) fn original_path(&self, media_id: &str, media_type: &str) -> PathBuf { + self.media_path("tmp", media_id, ".original", Self::extension(media_type)) + } + + pub(crate) fn overlay_image_path(&self, media_id: &str) -> PathBuf { + self.media_path("tmp", media_id, ".overlay", "png") + } + + pub(crate) fn upload_request_path(&self, media_id: &str, media_type: &str) -> PathBuf { + self.media_path("tmp", media_id, ".upload", Self::extension(media_type)) + } + + pub(crate) fn temp_path(&self, media_id: &str, media_type: &str) -> PathBuf { PathBuf::from(&self.ctx.config.data_dir) .join("mediafiles/tmp") .join(format!("{media_id}.{}", Self::extension(media_type))) } - fn encrypted_path(&self, media_id: &str, media_type: &str) -> PathBuf { + pub(crate) fn encrypted_path(&self, media_id: &str, media_type: &str) -> PathBuf { PathBuf::from(&self.ctx.config.data_dir) .join("mediafiles/tmp") .join(format!( @@ -386,7 +437,7 @@ impl MediaFileService { )) } - fn extension(media_type: &str) -> &'static str { + pub(crate) fn extension(media_type: &str) -> &'static str { match media_type { "video" => "mp4", "gif" => "gif", @@ -395,7 +446,7 @@ impl MediaFileService { } } - fn ensure_parent(path: &Path) -> Result<()> { + pub(crate) fn ensure_parent(path: &Path) -> Result<()> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } diff --git a/rust/src/services/messages.rs b/rust/src/services/messages.rs index e56529ce..9c960b32 100644 --- a/rust/src/services/messages.rs +++ b/rust/src/services/messages.rs @@ -276,13 +276,27 @@ impl MessageService { ) .fetch_optional(&signal.pool) .await?; - if let (Some(contact), Some(public_identity_key)) = (contact, identity) { - contacts.push(proto::SharedContact { - user_id: contact_id, - public_identity_key, - display_name: contact.display_name.unwrap_or(contact.username), - }); + let Some(contact) = contact else { + tracing::warn!("skipping unknown contact {contact_id} in contact share"); + continue; + }; + // A contact without a locally known identity key is still worth + // sharing: the recipient only needs the user id to add them. The + // key is what lets the recipient inherit the trust edge, so an + // empty one simply means they cannot, not that the share fails. + if identity.is_none() { + tracing::info!("sharing contact {contact_id} without an identity key"); } + contacts.push(proto::SharedContact { + user_id: contact_id, + public_identity_key: identity.unwrap_or_default(), + display_name: contact.display_name.unwrap_or(contact.username), + }); + } + if contacts.is_empty() { + return Err(TwonlyError::Generic( + "none of the selected contacts could be shared".into(), + )); } let data = proto::AdditionalMessageData { r#type: proto::additional_message_data::Type::Contacts as i32, diff --git a/rust/src/services/mod.rs b/rust/src/services/mod.rs index a81b24cf..324aa577 100644 --- a/rust/src/services/mod.rs +++ b/rust/src/services/mod.rs @@ -3,8 +3,13 @@ * */ +pub mod avatars; pub mod contacts; +pub mod direct_media_upload; pub mod groups; +pub mod media_codec; +pub mod media_exif; +pub mod media_upload; pub mod mediafiles; pub mod messages; pub mod notifications; diff --git a/rust/src/services/notifications.rs b/rust/src/services/notifications.rs index 19a5d318..71fa4d14 100644 --- a/rust/src/services/notifications.rs +++ b/rust/src/services/notifications.rs @@ -14,7 +14,7 @@ use crate::utils::{current_time, milliseconds_to_seconds}; use serde::{Deserialize, Serialize}; use sqlx::{Sqlite, Transaction}; use std::collections::HashMap; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::sync::{Arc, LazyLock}; const MAX_BATCH_SIZE: i64 = 100; @@ -649,45 +649,13 @@ fn localized_body(locale: &str, row: &PendingRow) -> String { .replace("{reaction}", row.content.as_deref().unwrap_or_default()) } -fn notification_avatar_path( +pub(crate) fn notification_avatar_path( ctx: &Context, sender_id: i64, profile_counter: i64, svg: Option<&[u8]>, ) -> Result> { - let Some(svg) = svg else { - return Ok(None); - }; - let directory = Path::new(&ctx.config.data_dir).join("notification_avatars"); - std::fs::create_dir_all(&directory)?; - let output = directory.join(format!("{sender_id}-{profile_counter}.png")); - if output.exists() { - return Ok(Some(output)); - } - - let options = resvg::usvg::Options::default(); - let tree = resvg::usvg::Tree::from_data(svg, &options).map_err(|error| { - crate::error::TwonlyError::Generic(format!("invalid avatar SVG: {error}")) - })?; - let original = tree.size(); - let max_dimension = original.width().max(original.height()); - let scale = (256.0 / max_dimension).min(1.0); - let width = (original.width() * scale).round().max(1.0) as u32; - let height = (original.height() * scale).round().max(1.0) as u32; - let mut pixmap = resvg::tiny_skia::Pixmap::new(width, height) - .ok_or_else(|| crate::error::TwonlyError::Generic("invalid avatar dimensions".into()))?; - resvg::render( - &tree, - resvg::tiny_skia::Transform::from_scale(scale, scale), - &mut pixmap.as_mut(), - ); - let png = pixmap.encode_png().map_err(|error| { - crate::error::TwonlyError::Generic(format!("avatar PNG encoding failed: {error}")) - })?; - let temporary = directory.join(format!(".{sender_id}-{profile_counter}.tmp")); - std::fs::write(&temporary, png)?; - std::fs::rename(&temporary, &output)?; - Ok(Some(output)) + crate::services::avatars::notification_avatar_path(ctx, sender_id, profile_counter, svg) } #[cfg(test)] diff --git a/rust/src/services/privacy_pass.rs b/rust/src/services/privacy_pass.rs index 9020d356..8159c5a9 100644 --- a/rust/src/services/privacy_pass.rs +++ b/rust/src/services/privacy_pass.rs @@ -27,17 +27,29 @@ use std::sync::Arc; use std::time::{Duration, Instant}; /// Refill once the pool can no longer cover a short burst of messages. -const REFILL_THRESHOLD: i64 = 5; +/// +/// The issuer only lets a session mint every few seconds, so this has to leave +/// room for a whole cooldown's worth of sends: a threshold that trips only once +/// the pool is nearly empty guarantees a stretch of named messages while the +/// refill waits its turn. +const REFILL_THRESHOLD: i64 = 10; /// Never ask for more than this, however large the server's batch limit is. /// Every token in a batch costs a blind and an unblind, both P-384 scalar /// multiplications, so the batch size is what bounds the CPU spike a refill /// puts on the device. -const MAX_TOKENS_PER_REFILL: usize = 10; +const MAX_TOKENS_PER_REFILL: usize = 20; /// Stop using a token slightly before the server would reject it, so a message /// in flight around midnight UTC is not refused. const EXPIRY_SAFETY_MARGIN_SECONDS: i64 = 5 * 60; -/// Back off this long after the server refused to issue tokens. -const REFUSED_ISSUANCE_BACKOFF: Duration = Duration::from_secs(15 * 60); +/// Back off this long when the issuer refuses for a reason we cannot classify. +const UNKNOWN_REFUSAL_BACKOFF: Duration = Duration::from_secs(15 * 60); +/// Assumed issuance cooldown while the server's own value is unknown, which is +/// the case only when the parameter request itself failed. +const ASSUMED_ISSUANCE_COOLDOWN: Duration = Duration::from_secs(5); +/// A refill waits out a backoff no longer than this and gives up on anything +/// beyond it. The issuance cooldown is seconds long and worth waiting for; an +/// exhausted daily quota lasts until midnight and is not. +const MAX_BACKOFF_WAIT: Duration = Duration::from_secs(30); pub(crate) struct PrivacyPassTokens; @@ -123,8 +135,18 @@ impl PrivacyPassTokens { let Ok(mut issuance) = ctx.privacy_pass_issuance.try_lock() else { return Ok(()); }; - if issuance.is_some_and(|next| Instant::now() < next) { - return Ok(()); + // A backoff short enough to be the issuer's cooldown is waited out + // rather than skipped: returning here would leave the pool empty with + // nothing scheduled to fill it, so every message until the next send + // would go named. Longer backoffs are not worth holding a task for. + if let Some(next) = *issuance { + let wait = next.saturating_duration_since(Instant::now()); + if wait > MAX_BACKOFF_WAIT { + return Ok(()); + } + if !wait.is_zero() { + tokio::time::sleep(wait).await; + } } // Another caller may have filled the pool while this one waited. if Self::count(ctx).await? >= REFILL_THRESHOLD { @@ -134,7 +156,7 @@ impl PrivacyPassTokens { let parameters = match Server::get_privacy_pass_parameters(ctx).await? { ServerResult::Ok(parameters) => parameters, ServerResult::ErrorCode(code) => { - *issuance = Some(Instant::now() + REFUSED_ISSUANCE_BACKOFF); + *issuance = Some(Instant::now() + backoff_for(code, ASSUMED_ISSUANCE_COOLDOWN)); return Err(TwonlyError::Generic(format!( "server rejected the Privacy Pass parameter request with code {code}" ))); @@ -180,10 +202,18 @@ impl PrivacyPassTokens { .await .map_err(|error| TwonlyError::Generic(format!("token blinding panicked: {error}")))??; + let cooldown = Duration::from_secs(u64::from(parameters.issuance_cooldown_seconds)); let responses = match Server::issue_privacy_pass_tokens(ctx, requests).await? { - ServerResult::Ok(responses) => responses, + ServerResult::Ok(responses) => { + // The issuer refuses a session that mints again inside its + // cooldown, and that refusal is indistinguishable from real + // trouble. Pacing the next refill here keeps the client from + // provoking one. + *issuance = Some(Instant::now() + cooldown); + responses + } ServerResult::ErrorCode(code) => { - *issuance = Some(Instant::now() + REFUSED_ISSUANCE_BACKOFF); + *issuance = Some(Instant::now() + backoff_for(code, cooldown)); return Err(TwonlyError::Generic(format!( "server rejected the Privacy Pass issuance with code {code}" ))); @@ -248,6 +278,35 @@ fn now_seconds() -> i64 { chrono::Utc::now().timestamp() } +/// How long to leave the issuer alone after it refused to mint. +/// +/// The two refusals the server sends routinely are minutes apart in cost, and +/// treating them alike is what took sealed sender down for a quarter of an hour +/// over a burst the client only had to pace. +fn backoff_for(code: i32, cooldown: Duration) -> Duration { + use crate::api::proto::error::ErrorCode; + + if code == ErrorCode::TooManyRequests as i32 { + // The per-session issuance cooldown. It clears in seconds. + cooldown + } else if code == ErrorCode::PrivacyPassQuotaExhausted as i32 { + // The daily counter is keyed on the server's calendar date, so nothing + // this account does before midnight earns another token. + duration_until_next_utc_day() + } else { + UNKNOWN_REFUSAL_BACKOFF + } +} + +/// Time left in the current UTC day, which is when the issuer's daily counter +/// rolls over. Never zero, so a refusal always costs at least one wait. +fn duration_until_next_utc_day() -> Duration { + const SECONDS_PER_DAY: i64 = 24 * 60 * 60; + let now = now_seconds(); + let remaining = SECONDS_PER_DAY - now.rem_euclid(SECONDS_PER_DAY); + Duration::from_secs(remaining.max(1) as u64) +} + /// The server derives its challenge from the current UTC day and accepts a /// token for `max_age_seconds` counted from the start of that day, not from the /// moment it was issued. @@ -273,4 +332,33 @@ mod tests { ); assert!(expiry > now_seconds()); } + + #[test] + fn the_issuance_cooldown_does_not_cost_a_quarter_of_an_hour() { + use crate::api::proto::error::ErrorCode; + + let cooldown = Duration::from_secs(5); + assert_eq!( + backoff_for(ErrorCode::TooManyRequests as i32, cooldown), + cooldown + ); + assert_eq!( + backoff_for(ErrorCode::InternalError as i32, cooldown), + UNKNOWN_REFUSAL_BACKOFF + ); + // Nothing earns a token before the daily counter rolls over, so this + // one must not come back after a mere cooldown. + assert!( + backoff_for(ErrorCode::PrivacyPassQuotaExhausted as i32, cooldown) + == duration_until_next_utc_day() + ); + } + + #[test] + fn the_daily_quota_backoff_ends_within_the_day() { + const SECONDS_PER_DAY: u64 = 24 * 60 * 60; + let remaining = duration_until_next_utc_day(); + assert!(!remaining.is_zero()); + assert!(remaining <= Duration::from_secs(SECONDS_PER_DAY)); + } } diff --git a/rust/src/utils.rs b/rust/src/utils.rs index 96ec2989..5a243732 100644 --- a/rust/src/utils.rs +++ b/rust/src/utils.rs @@ -26,6 +26,12 @@ pub(crate) fn new_uuid_v4() -> String { uuid::Uuid::new_v4().to_string() } +/// Media and message identifiers are time-ordered so that inserting them keeps +/// the primary-key index append-only, matching what Drift generated before. +pub(crate) fn new_uuid_v7() -> String { + uuid::Uuid::now_v7().to_string() +} + #[derive(Default, Clone)] pub(crate) struct Shared(Arc>); impl Shared { diff --git a/rust/tests/api.rs b/rust/tests/api.rs index 1ef8cb78..6791aa0d 100644 --- a/rust/tests/api.rs +++ b/rust/tests/api.rs @@ -1,5 +1,7 @@ #[path = "api/contacts.rs"] mod contacts; +#[path = "api/direct_media_upload.rs"] +mod direct_media_upload; #[path = "api/group_resilience.rs"] mod group_resilience; #[path = "api/media.rs"] diff --git a/rust/tests/api/direct_media_upload.rs b/rust/tests/api/direct_media_upload.rs new file mode 100644 index 00000000..d8f8f34a --- /dev/null +++ b/rust/tests/api/direct_media_upload.rs @@ -0,0 +1,249 @@ +use super::{init_tracing, Tester}; +use rust_lib_twonly::bridge::api::ApiConnectionState; +use rust_lib_twonly::services::direct_media_upload::DirectMediaUploadService; +use rust_lib_twonly::services::media_upload::MediaUploadService; + +async fn create_authenticated_tester() -> anyhow::Result { + let mut tester = Tester::new().await?; + tester.wait_until(ApiConnectionState::Connected).await?; + tester.register_and_authenticate().await?; + tester.wait_until(ApiConnectionState::Authenticated).await?; + Ok(tester) +} + +/// A native background transfer cannot report into a process that is not +/// running, so the client asks the server what happened to each attachment it +/// still believes is in flight. This drives that path against the live API. +#[tokio::test] +async fn test_direct_media_upload_reconciliation() -> anyhow::Result<()> { + init_tracing(); + + let tester = create_authenticated_tester().await?; + let service = DirectMediaUploadService::new(&tester.context); + + let issued = service.preload_slots().await?; + assert!(issued > 0, "the server must issue direct-media slots"); + + let (attachment_id, capability) = { + let database = tester.context.app_db.read().await.clone(); + sqlx::query_as::<_, (String, Vec)>( + "SELECT attachment_id, capability FROM direct_media_upload_slots WHERE state = 'cached' LIMIT 1", + ) + .fetch_one(&database.pool) + .await? + }; + + // Stand in for a media send whose native transfer was handed off but never + // reported back. + let media_id = uuid::Uuid::new_v4().to_string(); + let job_directory = std::path::PathBuf::from(&tester.context.config.data_dir) + .join("direct-media-upload") + .join(&attachment_id); + std::fs::create_dir_all(&job_directory)?; + let multipart_path = job_directory.join("media.multipart"); + let manifest_path = job_directory.join("manifest.pb"); + let complete_path = job_directory.join("complete.pb"); + for path in [&multipart_path, &manifest_path, &complete_path] { + std::fs::write(path, b"")?; + } + { + let database = tester.context.app_db.read().await.clone(); + sqlx::query!( + "INSERT INTO media_files(media_id, type, upload_state) VALUES (?, 'image', 'backgroundUploadTaskStarted')", + media_id, + ) + .execute(&database.pool) + .await?; + let multipart = multipart_path.to_string_lossy().into_owned(); + let manifest = manifest_path.to_string_lossy().into_owned(); + let complete = complete_path.to_string_lossy().into_owned(); + sqlx::query!( + r#"INSERT INTO direct_media_upload_jobs + (attachment_id, media_id, multipart_path, manifest_path, + complete_body_path, native_descriptor_json, state, expires_at) + SELECT ?, ?, ?, ?, ?, '{}', 'scheduled', expires_at + FROM direct_media_upload_slots WHERE attachment_id = ?"#, + attachment_id, + media_id, + multipart, + manifest, + complete, + attachment_id, + ) + .execute(&database.pool) + .await?; + sqlx::query!( + "UPDATE direct_media_upload_slots SET state = 'reserved' WHERE attachment_id = ?", + attachment_id, + ) + .execute(&database.pool) + .await?; + } + + // Nothing has been uploaded, so the attachment is still reserved and the + // job must survive: giving up here would drop a transfer the OS may still + // be running. + service.reconcile().await?; + { + let database = tester.context.app_db.read().await.clone(); + let jobs = sqlx::query_scalar!( + "SELECT COUNT(*) FROM direct_media_upload_jobs WHERE attachment_id = ?", + attachment_id, + ) + .fetch_one(&database.pool) + .await?; + assert_eq!(jobs, 1, "a reserved attachment must not be settled"); + } + assert!(multipart_path.exists(), "request files must be kept"); + + // The server now reports the attachment as abandoned. Reconciliation has to + // release the slot and the files, and requeue the media for another try. + abandon_attachment(&attachment_id, &capability).await?; + service.reconcile().await?; + + { + let database = tester.context.app_db.read().await.clone(); + let jobs = sqlx::query_scalar!( + "SELECT COUNT(*) FROM direct_media_upload_jobs WHERE attachment_id = ?", + attachment_id, + ) + .fetch_one(&database.pool) + .await?; + assert_eq!(jobs, 0, "an abandoned attachment must settle its job"); + + let slot_state = sqlx::query_scalar!( + "SELECT state FROM direct_media_upload_slots WHERE attachment_id = ?", + attachment_id, + ) + .fetch_one(&database.pool) + .await?; + assert_eq!(slot_state, "abandoned"); + + let upload_state = sqlx::query_scalar!( + "SELECT upload_state FROM media_files WHERE media_id = ?", + media_id, + ) + .fetch_one(&database.pool) + .await?; + assert_eq!( + upload_state.as_deref(), + Some("preprocessing"), + "an abandoned upload has to be retried, not lost" + ); + } + assert!( + !multipart_path.exists(), + "settling must delete the request files" + ); + + Ok(()) +} + +/// Rust owns the media row and its key material; Flutter only names the type. +#[tokio::test] +async fn test_media_initialization_is_rust_owned() -> anyhow::Result<()> { + init_tracing(); + + let tester = create_authenticated_tester().await?; + let service = MediaUploadService::new(&tester.context); + + // A value below one second can only have been meant as seconds. + let media_id = service.initialize("image".into(), Some(12), false).await?; + let database = tester.context.app_db.read().await.clone(); + let row = sqlx::query!( + r#"SELECT upload_state, display_limit_in_milliseconds, encryption_key, + encryption_nonce, is_draft_media + FROM media_files WHERE media_id = ?"#, + media_id, + ) + .fetch_one(&database.pool) + .await?; + assert_eq!(row.upload_state.as_deref(), Some("initialized")); + assert_eq!(row.display_limit_in_milliseconds, Some(12_000)); + assert_eq!(row.encryption_key.map(|key| key.len()), Some(32)); + assert_eq!(row.encryption_nonce.map(|nonce| nonce.len()), Some(12)); + assert_eq!(row.is_draft_media, 0); + + // Only one media file may be the draft, so creating a new draft clears the + // previous one. + let draft = service.initialize("image".into(), None, true).await?; + let second_draft = service.initialize("video".into(), None, true).await?; + let drafts = sqlx::query_scalar!("SELECT COUNT(*) FROM media_files WHERE is_draft_media = 1") + .fetch_one(&database.pool) + .await?; + assert_eq!(drafts, 1, "only the newest draft stays a draft"); + let first_draft_state = sqlx::query_scalar!( + "SELECT is_draft_media FROM media_files WHERE media_id = ?", + draft + ) + .fetch_one(&database.pool) + .await?; + assert_eq!(first_draft_state, 0); + let second_draft_state = sqlx::query_scalar!( + "SELECT is_draft_media FROM media_files WHERE media_id = ?", + second_draft + ) + .fetch_one(&database.pool) + .await?; + assert_eq!(second_draft_state, 1); + + Ok(()) +} + +/// The send pipeline encodes stills itself now. This drives a real media file +/// through `start_upload` and checks that the plaintext handed to encryption is +/// a WebP that Rust produced, with no Flutter involvement. +#[tokio::test] +async fn test_outgoing_images_are_encoded_to_webp_by_rust() -> anyhow::Result<()> { + init_tracing(); + + let tester = create_authenticated_tester().await?; + let service = MediaUploadService::new(&tester.context); + let media_id = service.initialize("image".into(), None, false).await?; + + let media_root = std::path::PathBuf::from(&tester.context.config.data_dir).join("mediafiles"); + let original = media_root + .join("tmp") + .join(format!("{media_id}.original.webp")); + std::fs::create_dir_all(original.parent().expect("tmp directory"))?; + // A PNG carrying an alpha channel, so the encoder has to preserve it. + let mut source = image::RgbaImage::from_pixel(320, 240, image::Rgba([12, 34, 56, 255])); + source.put_pixel(0, 0, image::Rgba([0, 0, 0, 0])); + image::DynamicImage::ImageRgba8(source).save_with_format(&original, image::ImageFormat::Png)?; + + // Scheduling the transfer needs a mobile background uploader and a + // recipient, so preparation stops right after compression on this host. + service.start_upload(&media_id).await?; + + let plaintext = media_root.join("tmp").join(format!("{media_id}.webp")); + let bytes = std::fs::read(&plaintext)?; + assert_eq!(&bytes[0..4], b"RIFF", "the send plaintext must be a WebP"); + assert_eq!(&bytes[8..12], b"WEBP"); + let decoded = image::load_from_memory(&bytes)?; + assert_eq!((decoded.width(), decoded.height()), (320, 240)); + assert!( + bytes.len() < std::fs::metadata(&original)?.len() as usize, + "the encoded image should be smaller than the PNG original" + ); + + Ok(()) +} + +async fn abandon_attachment(attachment_id: &str, capability: &[u8]) -> anyhow::Result<()> { + let url = format!( + "{}v2/attachments/{attachment_id}/abandon", + rust_lib_twonly::bridge::api::RustApi::api_base_url("https".into()) + ); + let response = reqwest::Client::new() + .post(url) + .header("x-twonly-upload-capability", hex::encode(capability)) + .header("content-length", 0) + .send() + .await?; + anyhow::ensure!( + response.status().is_success(), + "abandon returned HTTP {}", + response.status() + ); + Ok(()) +} diff --git a/rust/tests/api/media.rs b/rust/tests/api/media.rs index 274117b7..b2e6f54f 100644 --- a/rust/tests/api/media.rs +++ b/rust/tests/api/media.rs @@ -49,9 +49,16 @@ async fn test_media_lifecycle_actions_and_reupload() -> anyhow::Result<()> { let local_media_id = uuid::Uuid::new_v4().to_string(); { let db_a = tester_a.context.app_db.read().await.clone(); + // Give the media real key material and a plaintext file so the reupload + // below exercises encryption and envelope creation rather than the + // "source is gone" shortcut. sqlx::query!( - "INSERT INTO media_files(media_id, type, download_state, upload_state) VALUES (?, 'image', 'ready', 'uploaded')", + r#"INSERT INTO media_files + (media_id, type, download_state, upload_state, encryption_key, encryption_nonce) + VALUES (?, 'image', 'ready', 'uploaded', ?, ?)"#, local_media_id, + encryption_key, + encryption_nonce, ) .execute(&db_a.pool) .await?; @@ -65,6 +72,14 @@ async fn test_media_lifecycle_actions_and_reupload() -> anyhow::Result<()> { .await?; } + // Keep the plaintext on disk for the whole test: the sender needs it to + // re-encrypt when a recipient later reports a decryption error. + let plaintext_path = std::path::PathBuf::from(&tester_a.context.config.data_dir) + .join("mediafiles/tmp") + .join(format!("{local_media_id}.webp")); + std::fs::create_dir_all(plaintext_path.parent().expect("tmp directory"))?; + std::fs::write(&plaintext_path, b"plaintext media bytes")?; + let media_content = proto::EncryptedContent { group_id: Some(group_id.clone()), media: Some(encrypted_content::Media { @@ -113,7 +128,7 @@ async fn test_media_lifecycle_actions_and_reupload() -> anyhow::Result<()> { "media_id must be generated on receiver" ); tester_b - .wait_for_media_download_state(&media_id_on_b, "pending") + .wait_for_media_download_token(&media_id_on_b, &download_token) .await?; // 2. Tester B marks media as stored @@ -155,9 +170,25 @@ async fn test_media_lifecycle_actions_and_reupload() -> anyhow::Result<()> { .request_reupload(&media_id_on_b) .await?; + // Rust owns the reupload: it records who asked, re-encrypts, and reserves a + // direct-media slot. Scheduling the transfer needs a mobile background + // uploader, so on this host preparation stops there and the media stays + // queued instead of being lost. tester_a - .wait_for_media_upload_state(&local_media_id, "reuploadRequested") + .wait_for_media_reupload_requested(&local_media_id, tester_b.user_id) .await?; + tester_a + .wait_for_media_upload_state(&local_media_id, "preprocessing") + .await?; + + tester_a + .wait_for_media_encryption_mac(&local_media_id) + .await?; + + // The slot was reserved but never handed to a native uploader, so no job may + // be left behind and the slot has to become reusable again. A recipient can + // ask more than once, so wait for preparation to quiesce first. + tester_a.wait_for_no_pending_upload_jobs().await?; // 5. Tester A responds with MediaType::Reupload let reupload_content = proto::EncryptedContent { @@ -184,8 +215,9 @@ async fn test_media_lifecycle_actions_and_reupload() -> anyhow::Result<()> { .call() .await?; + // The reupload replaces the capability B holds for this attachment. tester_b - .wait_for_media_download_state(&media_id_on_b, "pending") + .wait_for_media_download_token(&media_id_on_b, &[9u8; 32]) .await?; // 6. Tester A deletes the message diff --git a/rust/tests/api/tester.rs b/rust/tests/api/tester.rs index ca3b0e9a..85b37eba 100644 --- a/rust/tests/api/tester.rs +++ b/rust/tests/api/tester.rs @@ -902,6 +902,102 @@ impl Tester { )) } + /// Every direct-media preparation either scheduled a transfer or released + /// everything it took, so nothing may be left half-built. + pub async fn wait_for_no_pending_upload_jobs(&self) -> anyhow::Result<()> { + for _ in 0..100 { + let database = self.context.app_db.read().await.clone(); + let jobs = sqlx::query_scalar!("SELECT COUNT(*) FROM direct_media_upload_jobs") + .fetch_one(&database.pool) + .await?; + let reserved = sqlx::query_scalar!( + "SELECT COUNT(*) FROM direct_media_upload_slots WHERE state = 'reserved'" + ) + .fetch_one(&database.pool) + .await?; + if jobs == 0 && reserved == 0 { + return Ok(()); + } + sleep(Duration::from_millis(100)).await; + } + Err(anyhow::anyhow!( + "a failed upload preparation kept its job or slot" + )) + } + + /// Proves that upload preparation ran: the authentication tag only exists + /// once Rust has encrypted the plaintext for this send. + pub async fn wait_for_media_encryption_mac(&self, media_id: &str) -> anyhow::Result<()> { + for _ in 0..100 { + let database = self.context.app_db.read().await.clone(); + let mac = sqlx::query_scalar!( + "SELECT encryption_mac FROM media_files WHERE media_id = ?", + media_id + ) + .fetch_optional(&database.pool) + .await? + .flatten(); + if mac.is_some_and(|mac| mac.len() == 16) { + return Ok(()); + } + sleep(Duration::from_millis(100)).await; + } + Err(anyhow::anyhow!( + "media {media_id} was never encrypted for upload" + )) + } + + /// The download state cycles quickly once Rust starts a real transfer, so + /// assert on the capability the message actually delivered. + pub async fn wait_for_media_download_token( + &self, + media_id: &str, + expected: &[u8], + ) -> anyhow::Result<()> { + for _ in 0..100 { + let database = self.context.app_db.read().await.clone(); + let token = sqlx::query_scalar!( + "SELECT download_token FROM media_files WHERE media_id = ?", + media_id + ) + .fetch_optional(&database.pool) + .await? + .flatten(); + if token.as_deref() == Some(expected) { + return Ok(()); + } + sleep(Duration::from_millis(100)).await; + } + Err(anyhow::anyhow!( + "media {media_id} did not receive the expected download token" + )) + } + + pub async fn wait_for_media_reupload_requested( + &self, + media_id: &str, + contact_id: i64, + ) -> anyhow::Result<()> { + let expected = format!("[{contact_id}]"); + for _ in 0..100 { + let database = self.context.app_db.read().await.clone(); + let requested_by = sqlx::query_scalar!( + "SELECT reupload_requested_by FROM media_files WHERE media_id = ?", + media_id + ) + .fetch_optional(&database.pool) + .await? + .flatten(); + if requested_by.as_deref() == Some(expected.as_str()) { + return Ok(()); + } + sleep(Duration::from_millis(100)).await; + } + Err(anyhow::anyhow!( + "media {media_id} was not marked as reupload-requested by {contact_id}" + )) + } + pub async fn wait_for_media_download_state( &self, media_id: &str, diff --git a/test/mocks/platform_channels.dart b/test/mocks/platform_channels.dart index 35f65abf..9128ea87 100644 --- a/test/mocks/platform_channels.dart +++ b/test/mocks/platform_channels.dart @@ -62,13 +62,6 @@ void setupPlatformChannelMocks() { return null; }, ); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler( - const MethodChannel( - 'be.tramesch.workmanager/foreground_channel_workmanager', - ), - (call) async => true, - ); TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler( const MethodChannel('com.bbflight.background_downloader'), diff --git a/test/mocks/user_config.dart b/test/mocks/user_config.dart index c578f42a..deeb4063 100644 --- a/test/mocks/user_config.dart +++ b/test/mocks/user_config.dart @@ -34,6 +34,7 @@ UserConfig testUserConfig({ screenLockEnabled: false, isCloudBackupEnabled: false, isUserDiscoveryEnabled: false, + sealedSenderEnabled: true, requiredSendImages: 4, userDiscoveryThreshold: 3, userDiscoveryRequiresManualApproval: false, diff --git a/test/visual/chat_contacts_entry_test.dart b/test/visual/chat_contacts_entry_test.dart new file mode 100644 index 00000000..9c90f12f --- /dev/null +++ b/test/visual/chat_contacts_entry_test.dart @@ -0,0 +1,113 @@ +import 'package:drift/drift.dart' hide isNotNull; +import 'package:drift/native.dart'; +import 'package:fixnum/fixnum.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:twonly/locator.dart'; +import 'package:twonly/src/database/tables/messages.table.dart'; +import 'package:twonly/src/database/twonly.db.dart'; +import 'package:twonly/src/localization/generated/app_localizations.dart'; +import 'package:twonly/src/model/protobuf/client/generated/data.pb.dart'; +import 'package:twonly/src/providers/settings.provider.dart'; +import 'package:twonly/src/services/user.service.dart'; +import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/chat_contacts.entry.dart'; +import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/common.dart'; + +import '../mocks/user_config.dart'; + +Message buildMessage(List? payload) => Message( + groupId: 'group-1', + messageId: 'msg-1', + senderId: 42, + type: MessageType.contacts.name, + additionalMessageData: payload == null ? null : Uint8List.fromList(payload), + mediaStored: false, + mediaReopened: false, + isDeletedFromSender: false, + createdAt: DateTime.now(), +); + +BubbleInfo buildInfo() => BubbleInfo() + ..text = '' + ..textColor = Colors.white + ..displayTime = true + ..displayUserName = '' + ..color = Colors.blue + ..expanded = false + ..spacerWidth = 0 + ..padding = EdgeInsets.zero + ..minWidth = 0; + +void main() { + setUp(() async { + await locator.reset(); + locator + ..registerSingleton( + TwonlyDB.forTesting( + DatabaseConnection( + NativeDatabase.memory(), + closeStreamsSynchronously: true, + ), + ), + ) + ..registerSingleton(UserService()); + + userService.currentUser = testUserConfig( + userId: 0x133337, + username: 'test_user', + displayName: 'Test User', + subscriptionPlan: 'Free', + currentSetupPage: null, + appVersion: 62, + ); + }); + + Future pump(WidgetTester tester, List? payload) async { + await tester.pumpWidget( + ChangeNotifierProvider( + create: (_) => SettingsChangeProvider()..loadSettings(), + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: ChatContactsEntry( + message: buildMessage(payload), + borderRadius: BorderRadius.circular(12), + info: buildInfo(), + contactsById: const {}, + ), + ), + ), + ), + ); + await tester.pump(); + } + + testWidgets('renders a shared contact', (tester) async { + final payload = AdditionalMessageData( + type: AdditionalMessageData_Type.CONTACTS, + contacts: [ + SharedContact( + userId: Int64(4711), + publicIdentityKey: [1, 2, 3], + displayName: 'Shared Person', + ), + ], + ).writeToBuffer(); + + await pump(tester, payload); + expect(find.textContaining('Shared Person'), findsOneWidget); + }); + + testWidgets('stays visible when the payload carries no contacts', ( + tester, + ) async { + final payload = AdditionalMessageData( + type: AdditionalMessageData_Type.CONTACTS, + ).writeToBuffer(); + + await pump(tester, payload); + expect(tester.getSize(find.byType(ChatContactsEntry)), isNot(Size.zero)); + }); +}