diff --git a/android/app/build.gradle b/android/app/build.gradle
index 674b2bd6..f0777537 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -56,9 +56,12 @@ android {
applicationIdSuffix ".testing"
manifestPlaceholders = [appName: "twonly [dev]"]
}
- // profile {
- // applicationIdSuffix ".STOP"
- // }
+ profile {
+ // Profile builds are installed while profiling production-like
+ // performance. They must never replace the store app.
+ applicationIdSuffix ".testing"
+ manifestPlaceholders = [appName: "twonly [profile]"]
+ }
release {
shrinkResources false
minifyEnabled false
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 224bb24c..b619df21 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -94,12 +94,25 @@
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/kotlin/eu/twonly/MyApplication.kt b/android/app/src/main/kotlin/eu/twonly/MyApplication.kt
index 268eb43b..aaad9a93 100644
--- a/android/app/src/main/kotlin/eu/twonly/MyApplication.kt
+++ b/android/app/src/main/kotlin/eu/twonly/MyApplication.kt
@@ -1,5 +1,6 @@
package eu.twonly
+import eu.twonly.directmedia.DirectMediaPrepare
import io.flutter.app.FlutterApplication
import io.crates.keyring.Keyring
@@ -13,5 +14,9 @@ class MyApplication : FlutterApplication() {
super.onCreate()
instance = this
Keyring.initializeNdkContext(this)
+ // Registered from the application rather than the activity: a process
+ // started by a push or by WorkManager itself must keep the flush alive
+ // just as much as one the user opened.
+ DirectMediaPrepare.ensurePeriodicFlush()
}
}
diff --git a/android/app/src/main/kotlin/eu/twonly/directmedia/BootCompletedReceiver.kt b/android/app/src/main/kotlin/eu/twonly/directmedia/BootCompletedReceiver.kt
new file mode 100644
index 00000000..a5d10a88
--- /dev/null
+++ b/android/app/src/main/kotlin/eu/twonly/directmedia/BootCompletedReceiver.kt
@@ -0,0 +1,23 @@
+package eu.twonly.directmedia
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+
+/**
+ * A reboot clears the periodic flush, and a phone that was rebooted with unsent
+ * messages would otherwise wait for the user to open the app. Registering again
+ * here is what makes "it sends as soon as you are online" true across a
+ * restart.
+ */
+class BootCompletedReceiver : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ if (intent.action != Intent.ACTION_BOOT_COMPLETED &&
+ intent.action != Intent.ACTION_MY_PACKAGE_REPLACED
+ ) {
+ return
+ }
+ DirectMediaPrepare.ensurePeriodicFlush()
+ DirectMediaPrepare.flushNow()
+ }
+}
diff --git a/android/app/src/main/kotlin/eu/twonly/directmedia/DirectMediaPrepare.kt b/android/app/src/main/kotlin/eu/twonly/directmedia/DirectMediaPrepare.kt
new file mode 100644
index 00000000..c670f48a
--- /dev/null
+++ b/android/app/src/main/kotlin/eu/twonly/directmedia/DirectMediaPrepare.kt
@@ -0,0 +1,113 @@
+package eu.twonly.directmedia
+
+import androidx.work.BackoffPolicy
+import androidx.work.Constraints
+import androidx.work.Data
+import androidx.work.ExistingPeriodicWorkPolicy
+import androidx.work.ExistingWorkPolicy
+import androidx.work.NetworkType
+import androidx.work.OneTimeWorkRequest
+import androidx.work.OutOfQuotaPolicy
+import androidx.work.PeriodicWorkRequestBuilder
+import androidx.work.WorkManager
+import eu.twonly.MyApplication
+import java.util.concurrent.TimeUnit
+
+/**
+ * Owns the window between the send button and the point where a transfer
+ * belongs to the OS.
+ *
+ * Compressing, encrypting and encrypting-for-each-recipient all run in the app
+ * process, so swiping the app away mid-send used to strand the message until
+ * the next launch. Running that work inside WorkManager instead means the
+ * system restarts it: a job survives the task being removed from recents and is
+ * re-run after a reboot.
+ *
+ * [schedule] is called directly from Rust over JNI.
+ */
+object DirectMediaPrepare {
+ /** Prepares one media file. Unique per media id, so a send that is already
+ * being prepared is never started twice. */
+ @JvmStatic
+ fun schedule(mediaId: String): Boolean = try {
+ val data = Data.Builder()
+ .putString(MediaPrepareWorker.MEDIA_ID, mediaId)
+ .build()
+ val request = OneTimeWorkRequest.Builder(MediaPrepareWorker::class.java)
+ .setInputData(data)
+ // Preparation is what the user is waiting on, and it is short, so it
+ // asks for an expedited slot and settles for a normal one.
+ .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
+ .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
+ .addTag(TAG)
+ .build()
+ WorkManager.getInstance(MyApplication.instance).enqueueUniqueWork(
+ "$UNIQUE_PREFIX$mediaId",
+ ExistingWorkPolicy.KEEP,
+ request,
+ )
+ true
+ } catch (_: Throwable) {
+ false
+ }
+
+ /**
+ * Keeps a periodic flush registered.
+ *
+ * Preparation and the message outbox are both resumed by a connection that
+ * only exists while something is running. This is what gives a device that
+ * regained a network hours after the app was closed somewhere to resume
+ * from. Registered from [eu.twonly.MyApplication] and after a reboot.
+ */
+ @JvmStatic
+ fun ensurePeriodicFlush() {
+ try {
+ val request = PeriodicWorkRequestBuilder(
+ FLUSH_INTERVAL_MINUTES,
+ TimeUnit.MINUTES,
+ )
+ .setConstraints(
+ Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build(),
+ )
+ .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 5, TimeUnit.MINUTES)
+ .addTag(TAG)
+ .build()
+ WorkManager.getInstance(MyApplication.instance).enqueueUniquePeriodicWork(
+ UNIQUE_FLUSH,
+ // KEEP would silently ignore a changed interval or constraint.
+ ExistingPeriodicWorkPolicy.UPDATE,
+ request,
+ )
+ } catch (_: Throwable) {
+ // A device that will not schedule the flush still resumes on the
+ // next launch, which is where this behaviour was before.
+ }
+ }
+
+ /** Runs the flush once, now. Used after a reboot and on connectivity
+ * changes, where waiting for the periodic slot would be pointless. */
+ @JvmStatic
+ fun flushNow() {
+ try {
+ val request = OneTimeWorkRequest.Builder(MediaPrepareWorker::class.java)
+ .setConstraints(
+ Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build(),
+ )
+ .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES)
+ .addTag(TAG)
+ .build()
+ WorkManager.getInstance(MyApplication.instance).enqueueUniqueWork(
+ UNIQUE_FLUSH_NOW,
+ ExistingWorkPolicy.KEEP,
+ request,
+ )
+ } catch (_: Throwable) {
+ }
+ }
+
+ const val TAG = "twonly-media-prepare"
+ private const val UNIQUE_PREFIX = "media-prepare-"
+ private const val UNIQUE_FLUSH = "twonly-outbox-flush"
+ private const val UNIQUE_FLUSH_NOW = "twonly-outbox-flush-now"
+ private const val FLUSH_INTERVAL_MINUTES = 30L
+}
diff --git a/android/app/src/main/kotlin/eu/twonly/directmedia/DirectMediaTransfer.kt b/android/app/src/main/kotlin/eu/twonly/directmedia/DirectMediaTransfer.kt
index 751d3929..119ac460 100644
--- a/android/app/src/main/kotlin/eu/twonly/directmedia/DirectMediaTransfer.kt
+++ b/android/app/src/main/kotlin/eu/twonly/directmedia/DirectMediaTransfer.kt
@@ -13,7 +13,12 @@ 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. */
+ * storage and enqueues every OS-owned transfer it names before reporting
+ * success.
+ *
+ * A descriptor carries a `media` request and, for a media upload, a `manifest`
+ * alongside it. A queued message envelope is the single-request form: there is
+ * nothing to describe beyond the POST itself. */
object DirectMediaTransfer {
@JvmStatic
fun schedule(descriptorJson: String): Boolean = try {
@@ -28,13 +33,15 @@ object DirectMediaTransfer {
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,
- )
+ if (descriptor.has("manifest")) {
+ val manifest = request(attachmentId, "manifest", descriptorFile, expiresAt)
+ manager.enqueueUniqueWork(
+ "direct-media-$attachmentId-manifest",
+ ExistingWorkPolicy.KEEP,
+ manifest,
+ )
+ }
true
} catch (_: Throwable) {
false
diff --git a/android/app/src/main/kotlin/eu/twonly/directmedia/DirectMediaUploadWorker.kt b/android/app/src/main/kotlin/eu/twonly/directmedia/DirectMediaUploadWorker.kt
index 1e0af529..1571c03e 100644
--- a/android/app/src/main/kotlin/eu/twonly/directmedia/DirectMediaUploadWorker.kt
+++ b/android/app/src/main/kotlin/eu/twonly/directmedia/DirectMediaUploadWorker.kt
@@ -35,9 +35,11 @@ class DirectMediaUploadWorker(
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.
+ // Best effort only. A 202 is success because server reconciliation
+ // owns completion when the manifest has not arrived yet. A
+ // descriptor with no completion step — a queued message envelope —
+ // is finished as soon as its POST is accepted.
+ if (role == "media" && descriptor.has("complete")) {
upload(descriptor.getJSONObject("complete"))
}
Result.success()
diff --git a/android/app/src/main/kotlin/eu/twonly/directmedia/MediaPrepareWorker.kt b/android/app/src/main/kotlin/eu/twonly/directmedia/MediaPrepareWorker.kt
new file mode 100644
index 00000000..db3f13e0
--- /dev/null
+++ b/android/app/src/main/kotlin/eu/twonly/directmedia/MediaPrepareWorker.kt
@@ -0,0 +1,95 @@
+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 org.json.JSONObject
+
+/**
+ * Runs one Rust maintenance job.
+ *
+ * With no media id this is the flush: resume interrupted preparations, settle
+ * transfers the OS finished while nothing was listening, and drain the message
+ * outbox. With one, it prepares that single send.
+ *
+ * The Rust call is synchronous and owns its own runtime, so this worker only
+ * has to hold a foreground service around it — without one, Android stops long
+ * background work, which is the exact failure this whole path exists to fix.
+ */
+class MediaPrepareWorker(
+ appContext: Context,
+ params: WorkerParameters,
+) : Worker(appContext, params) {
+ override fun doWork(): Result {
+ val mediaId = inputData.getString(MEDIA_ID)
+ return try {
+ if (mediaId != null) {
+ // Only a user-visible send justifies a foreground service; the
+ // periodic flush runs as ordinary background work.
+ setForegroundAsync(foregroundInfo()).get()
+ }
+ val directory = applicationContext.filesDir.absolutePath
+ val response = JSONObject(
+ NativeMediaPrepareBridge.run(directory, directory, mediaId),
+ )
+ if (response.optBoolean("ok", false)) {
+ // Rust performed a synchronous status pass before returning.
+ // Keep this WorkManager job as the durable owner until the
+ // server says the attachment is terminal; a Tokio task created
+ // inside NativeMediaPrepareBridge would die with that call.
+ if (response.optBoolean("pending_uploads", false)) {
+ Result.retry()
+ } else {
+ Result.success()
+ }
+ } else if (runAttemptCount < MAX_RETRIES) {
+ Result.retry()
+ } else {
+ // Giving up here loses nothing: the media row still says the
+ // send is unfinished, so the next flush picks it up again.
+ Result.failure()
+ }
+ } catch (_: Throwable) {
+ if (runAttemptCount < MAX_RETRIES) Result.retry() else Result.failure()
+ }
+ }
+
+ private fun foregroundInfo(): 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("Preparing media")
+ .setContentText("twonly will finish sending this in the background")
+ .setOngoing(true)
+ .setOnlyAlertOnce(true)
+ .build()
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ ForegroundInfo(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
+ } else {
+ ForegroundInfo(NOTIFICATION_ID, notification)
+ }
+ }
+
+ companion object {
+ const val MEDIA_ID = "media_id"
+ private const val CHANNEL_ID = "twonly_direct_media_upload"
+ private const val NOTIFICATION_ID = 0x7A01
+ private const val MAX_RETRIES = 5
+ }
+}
diff --git a/android/app/src/main/kotlin/eu/twonly/directmedia/NativeMediaPrepareBridge.kt b/android/app/src/main/kotlin/eu/twonly/directmedia/NativeMediaPrepareBridge.kt
new file mode 100644
index 00000000..75d45157
--- /dev/null
+++ b/android/app/src/main/kotlin/eu/twonly/directmedia/NativeMediaPrepareBridge.kt
@@ -0,0 +1,19 @@
+package eu.twonly.directmedia
+
+/** Rust entry point for work the platform's schedulers drive: preparing one
+ * media file for upload, or flushing everything a terminated process left
+ * behind. Neither needs a Flutter engine. */
+internal object NativeMediaPrepareBridge {
+ init {
+ System.loadLibrary("rust_lib_twonly")
+ }
+
+ /** A null [mediaId] runs the full flush instead of one preparation.
+ * Returns `{"ok":bool,"error":string?}`. */
+ @JvmStatic
+ external fun run(
+ databaseDirectory: String,
+ dataDirectory: String,
+ mediaId: String?,
+ ): String
+}
diff --git a/android/app/src/main/kotlin/eu/twonly/media/NativeVideoCodec.kt b/android/app/src/main/kotlin/eu/twonly/media/NativeVideoCodec.kt
index 911e99d0..350e66d6 100644
--- a/android/app/src/main/kotlin/eu/twonly/media/NativeVideoCodec.kt
+++ b/android/app/src/main/kotlin/eu/twonly/media/NativeVideoCodec.kt
@@ -89,6 +89,8 @@ object NativeVideoCodec {
overlayPath: String?,
outputPath: String,
removeAudio: Boolean,
+ trimStartMs: Long,
+ trimEndMs: Long,
mediaId: String,
): Boolean {
val context = MyApplication.instance
@@ -110,7 +112,14 @@ object NativeVideoCodec {
handler.post {
try {
val effects = buildEffects(overlayPath, source)
- val editedItem = EditedMediaItem.Builder(MediaItem.fromUri(File(inputPath).toURI().toString()))
+ val mediaItem = MediaItem.Builder()
+ .setUri(File(inputPath).toURI().toString())
+ // Transformer applies the cut while it decodes, so trimming
+ // costs nothing on top of the render that was happening
+ // anyway, and the recording on disk is left alone.
+ .setClippingConfiguration(clipping(trimStartMs, trimEndMs))
+ .build()
+ val editedItem = EditedMediaItem.Builder(mediaItem)
.setRemoveAudio(removeAudio)
.setEffects(effects)
.build()
@@ -232,6 +241,28 @@ object NativeVideoCodec {
* settings, so a source that refuses to be probed still renders — it just
* falls back to the 1080p30 defaults.
*/
+ /**
+ * The slice of the recording the editor's cutter selected.
+ *
+ * Bounds arrive as milliseconds, with a negative value meaning the clip
+ * keeps that end. A pair that does not describe a real slice is dropped
+ * altogether: sending the untrimmed moment beats sending an empty file.
+ */
+ private fun clipping(trimStartMs: Long, trimEndMs: Long): MediaItem.ClippingConfiguration {
+ if (trimStartMs <= 0L && trimEndMs <= 0L) {
+ return MediaItem.ClippingConfiguration.UNSET
+ }
+ val start = trimStartMs.coerceAtLeast(0L)
+ if (trimEndMs > 0L && trimEndMs <= start) {
+ return MediaItem.ClippingConfiguration.UNSET
+ }
+ val builder = MediaItem.ClippingConfiguration.Builder().setStartPositionMs(start)
+ if (trimEndMs > 0L) {
+ builder.setEndPositionMs(trimEndMs)
+ }
+ return builder.build()
+ }
+
private fun probe(inputPath: String): SourceVideo? {
val retriever = MediaMetadataRetriever()
return try {
diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj
index 2fba3030..bca121e8 100644
--- a/ios/Runner.xcodeproj/project.pbxproj
+++ b/ios/Runner.xcodeproj/project.pbxproj
@@ -23,6 +23,7 @@
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, ); }; };
D3A100022F70000100D1A001 /* DirectMediaTransfer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A100012F70000100D1A001 /* DirectMediaTransfer.swift */; };
+ D3A100062F70000100D1A006 /* BackgroundWork.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A100052F70000100D1A005 /* BackgroundWork.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 */; };
@@ -114,6 +115,7 @@
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 = ""; };
D3A100012F70000100D1A001 /* DirectMediaTransfer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectMediaTransfer.swift; sourceTree = ""; };
+ D3A100052F70000100D1A005 /* BackgroundWork.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWork.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 = ""; };
@@ -248,6 +250,7 @@
isa = PBXGroup;
children = (
D3A100012F70000100D1A001 /* DirectMediaTransfer.swift */,
+ D3A100052F70000100D1A005 /* BackgroundWork.swift */,
D3A100042F70000100D1A002 /* NativeImageCodec.swift */,
D3A100062F70000100D1A003 /* NativeVideoCodec.swift */,
D3A100082F70000100D1A004 /* NativeGallery.swift */,
@@ -647,6 +650,7 @@
buildActionMask = 2147483647;
files = (
D3A100022F70000100D1A001 /* DirectMediaTransfer.swift in Sources */,
+ D3A100062F70000100D1A006 /* BackgroundWork.swift in Sources */,
D3A100032F70000100D1A002 /* NativeImageCodec.swift in Sources */,
D3A100052F70000100D1A003 /* NativeVideoCodec.swift in Sources */,
D3A100072F70000100D1A004 /* NativeGallery.swift in Sources */,
@@ -776,7 +780,7 @@
DEVELOPMENT_TEAM = CN332ZUGRP;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
- INFOPLIST_KEY_CFBundleDisplayName = twonly;
+ INFOPLIST_KEY_CFBundleDisplayName = "twonly [profile]";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
LD_RUNPATH_SEARCH_PATHS = (
@@ -784,7 +788,7 @@
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.0.5;
- PRODUCT_BUNDLE_IDENTIFIER = eu.twonly;
+ PRODUCT_BUNDLE_IDENTIFIER = eu.twonly.testing;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
@@ -975,7 +979,7 @@
DEVELOPMENT_TEAM = CN332ZUGRP;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
- INFOPLIST_KEY_CFBundleDisplayName = twonly;
+ INFOPLIST_KEY_CFBundleDisplayName = "twonly [dev]";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
LD_RUNPATH_SEARCH_PATHS = (
@@ -983,7 +987,7 @@
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.0.5;
- PRODUCT_BUNDLE_IDENTIFIER = eu.twonly;
+ PRODUCT_BUNDLE_IDENTIFIER = eu.twonly.testing;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
@@ -1061,7 +1065,7 @@
MARKETING_VERSION = 1.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
- PRODUCT_BUNDLE_IDENTIFIER = eu.twonly.NotificationService;
+ PRODUCT_BUNDLE_IDENTIFIER = eu.twonly.testing.NotificationService;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
@@ -1139,7 +1143,7 @@
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
MTL_FAST_MATH = YES;
- PRODUCT_BUNDLE_IDENTIFIER = eu.twonly.NotificationService;
+ PRODUCT_BUNDLE_IDENTIFIER = eu.twonly.testing.NotificationService;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
@@ -1179,7 +1183,7 @@
MARKETING_VERSION = 1.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
- PRODUCT_BUNDLE_IDENTIFIER = eu.twonly.ShareExtension;
+ PRODUCT_BUNDLE_IDENTIFIER = eu.twonly.testing.ShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -1265,7 +1269,7 @@
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
MTL_FAST_MATH = YES;
- PRODUCT_BUNDLE_IDENTIFIER = eu.twonly.ShareExtension;
+ PRODUCT_BUNDLE_IDENTIFIER = eu.twonly.testing.ShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift
index cacce4a0..40421803 100644
--- a/ios/Runner/AppDelegate.swift
+++ b/ios/Runner/AppDelegate.swift
@@ -12,10 +12,20 @@ import flutter_sharing_intent
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
UNUserNotificationCenter.current().delegate = self
+ // Must happen before launching finishes: BGTaskScheduler refuses an
+ // identifier registered any later.
+ BackgroundWork.register()
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
+ override func applicationDidEnterBackground(_ application: UIApplication) {
+ // The app is about to be suspended, and the socket with it. Reserve a later
+ // slot so anything still queued is sent without the user coming back.
+ BackgroundWork.scheduleFlush()
+ super.applicationDidEnterBackground(application)
+ }
+
override func application(
_ application: UIApplication,
handleEventsForBackgroundURLSession identifier: String,
diff --git a/ios/Runner/BackgroundWork.swift b/ios/Runner/BackgroundWork.swift
new file mode 100644
index 00000000..9feea5c5
--- /dev/null
+++ b/ios/Runner/BackgroundWork.swift
@@ -0,0 +1,158 @@
+import BackgroundTasks
+import Foundation
+import UIKit
+
+/// Keeps a send alive while iOS is taking the app away.
+///
+/// Everything between the send button and the point where a background
+/// `URLSession` owns the transfer — transcoding, encryption, one Signal ratchet
+/// step per recipient — runs inside this process. iOS suspends a backgrounded
+/// app within seconds unless something has asked it not to, which used to leave
+/// a send stranded until the next launch.
+///
+/// Two mechanisms, because iOS offers no single one that covers both cases:
+///
+/// * A `UIApplication` background task assertion, taken by Rust for the length
+/// of a preparation. It buys the roughly thirty seconds an app gets after
+/// being backgrounded, which is what an ordinary send needs.
+/// * A `BGProcessingTask`, scheduled whenever the app goes away with work
+/// still outstanding. The system runs it later, on its own schedule, and it
+/// is the only thing that resumes a send after the app has been suspended for
+/// real. Nothing survives a force quit; that case is picked up on next launch.
+enum BackgroundWork {
+ static let processingTaskIdentifier = "eu.twonly.outbox-flush"
+
+ /// Registered from `didFinishLaunchingWithOptions`. iOS requires every
+ /// identifier to be registered before the app finishes launching.
+ static func register() {
+ BGTaskScheduler.shared.register(
+ forTaskWithIdentifier: processingTaskIdentifier,
+ using: nil
+ ) { task in
+ guard let task = task as? BGProcessingTask else {
+ task.setTaskCompleted(success: false)
+ return
+ }
+ run(task)
+ }
+ }
+
+ /// Asks the system for a later chance to finish whatever is still queued.
+ /// Submitting again replaces the pending request rather than stacking up.
+ static func scheduleFlush() {
+ let request = BGProcessingTaskRequest(identifier: processingTaskIdentifier)
+ request.requiresNetworkConnectivity = true
+ request.requiresExternalPower = false
+ do {
+ try BGTaskScheduler.shared.submit(request)
+ } catch {
+ // Simulators and devices with background refresh disabled refuse this.
+ // The next launch still resumes everything, which is where this
+ // behaviour was before.
+ NSLog("twonly: could not schedule the outbox flush: \(error.localizedDescription)")
+ }
+ }
+
+ /// The App Group container the app and its extensions share, which is where
+ /// the Rust databases live.
+ private static func runtimeDirectory() -> String? {
+ guard
+ let container = FileManager.default.containerURL(
+ forSecurityApplicationGroupIdentifier: runtimeAppGroup
+ )
+ else { return nil }
+ let directory = container.appendingPathComponent("runtime", isDirectory: true)
+ do {
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+ return directory.path
+ } catch {
+ NSLog("twonly: could not open the runtime directory: \(error.localizedDescription)")
+ return nil
+ }
+ }
+
+ private static let runtimeAppGroup = "group.eu.twonly.runtime"
+
+ private static func run(_ task: BGProcessingTask) {
+ // The system may reclaim the task at any point; ask Rust to stop by ending
+ // the run, and reschedule so the work is not simply dropped.
+ let queue = DispatchQueue(label: "eu.twonly.outbox-flush", qos: .utility)
+ var finished = false
+ let finish: (Bool) -> Void = { success in
+ guard !finished else { return }
+ finished = true
+ task.setTaskCompleted(success: success)
+ }
+ task.expirationHandler = {
+ scheduleFlush()
+ finish(false)
+ }
+ queue.async {
+ guard let directory = runtimeDirectory() else {
+ finish(false)
+ return
+ }
+ let response = directory.withCString { databaseDirectory in
+ directory.withCString { dataDirectory in
+ twonly_background_run(databaseDirectory, dataDirectory, nil)
+ }
+ }
+ let json = response.map { pointer -> String in
+ let value = String(cString: pointer)
+ twonly_background_string_free(pointer)
+ return value
+ }
+ let ok = json?.contains("\"ok\":true") ?? false
+ // Keep a rolling reservation: as long as anything is unsent, there is a
+ // scheduled chance to send it.
+ scheduleFlush()
+ finish(ok)
+ }
+ }
+}
+
+/// The background task assertion Rust takes around a media preparation.
+///
+/// Called over the C ABI so the preparation does not have to know it is on iOS.
+/// Returns zero when no assertion could be taken, which Rust reads as "run
+/// unprotected" rather than as a failure.
+/// Rust calls this from one of its own worker threads, never from the main
+/// one, so `UIApplication` is reached through the main queue rather than
+/// directly. A `sync` hop cannot deadlock here for that same reason, and the
+/// caller needs the identifier back before it can start working.
+private func onMain(_ work: @escaping () -> T) -> T {
+ if Thread.isMainThread {
+ return work()
+ }
+ return DispatchQueue.main.sync(execute: work)
+}
+
+@_cdecl("twonly_begin_background_task")
+func twonlyBeginBackgroundTask() -> UInt64 {
+ let identifier: UIBackgroundTaskIdentifier = onMain {
+ var identifier = UIBackgroundTaskIdentifier.invalid
+ identifier = UIApplication.shared.beginBackgroundTask(withName: "eu.twonly.media-preparation")
+ {
+ // The system is reclaiming the time. Ending the assertion here is
+ // required; the preparation itself is resumed by the flush task or by
+ // the next launch.
+ if identifier != .invalid {
+ UIApplication.shared.endBackgroundTask(identifier)
+ identifier = .invalid
+ }
+ }
+ return identifier
+ }
+ guard identifier != .invalid else { return 0 }
+ // A preparation that outlives its assertion has to be picked up later.
+ BackgroundWork.scheduleFlush()
+ return UInt64(identifier.rawValue)
+}
+
+@_cdecl("twonly_end_background_task")
+func twonlyEndBackgroundTask(_ identifier: UInt64) {
+ guard identifier != 0, let raw = Int(exactly: identifier) else { return }
+ onMain {
+ UIApplication.shared.endBackgroundTask(UIBackgroundTaskIdentifier(rawValue: raw))
+ }
+}
diff --git a/ios/Runner/DirectMediaTransfer.swift b/ios/Runner/DirectMediaTransfer.swift
index 37e078b8..99364757 100644
--- a/ios/Runner/DirectMediaTransfer.swift
+++ b/ios/Runner/DirectMediaTransfer.swift
@@ -8,12 +8,14 @@ private struct DirectMediaRequest: Codable {
let bodyPath: String
}
+/// A media upload names all three requests. A queued message envelope is the
+/// single-request form: there is nothing to describe beyond the POST itself.
private struct DirectMediaDescriptor: Codable {
let attachmentId: String
let expiresAt: Int64
let media: DirectMediaRequest
- let manifest: DirectMediaRequest
- let complete: DirectMediaRequest
+ let manifest: DirectMediaRequest?
+ let complete: DirectMediaRequest?
}
/// Thin transport-only adapter. Rust supplies immutable request files and all
@@ -52,19 +54,29 @@ final class DirectMediaTransfer: NSObject, URLSessionTaskDelegate, URLSessionDel
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)
+ FileManager.default.fileExists(atPath: descriptor.media.bodyPath)
else { return false }
+ if let manifest = descriptor.manifest,
+ !FileManager.default.fileExists(atPath: manifest.bodyPath)
+ {
+ 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)
+ // Every durable transfer is created before any of them is resumed, so
+ // process death cannot leave a media object with no corresponding manifest
+ // task.
+ guard let mediaTask = makeTask(descriptor.media, attachmentId: descriptor.attachmentId)
else { return false }
+ var manifestTask: URLSessionUploadTask?
+ if let manifest = descriptor.manifest {
+ guard let task = makeTask(manifest, attachmentId: descriptor.attachmentId) else {
+ return false
+ }
+ manifestTask = task
+ }
mediaTask.resume()
- manifestTask.resume()
+ manifestTask?.resume()
return true
}
@@ -121,7 +133,11 @@ final class DirectMediaTransfer: NSObject, URLSessionTaskDelegate, URLSessionDel
let success = error == nil && (200...299).contains(status)
if success && role == "media" {
- makeTask(descriptor.complete, attachmentId: attachmentId)?.resume()
+ // A descriptor with no completion step — a queued message envelope — is
+ // finished as soon as its POST is accepted.
+ if let complete = descriptor.complete {
+ makeTask(complete, attachmentId: attachmentId)?.resume()
+ }
return
}
if success {
@@ -138,6 +154,10 @@ final class DirectMediaTransfer: NSObject, URLSessionTaskDelegate, URLSessionDel
}
func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
+ // The HTTP tasks can finish while Rust is not loaded. Ask the OS for a
+ // durable reconciliation opportunity rather than relying on an in-process
+ // callback or websocket receipt.
+ BackgroundWork.scheduleFlush()
DispatchQueue.main.async { [weak self] in
let completion = self?.backgroundCompletion
self?.backgroundCompletion = nil
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
index 26d64017..4d8b0fe0 100644
--- a/ios/Runner/Info.plist
+++ b/ios/Runner/Info.plist
@@ -86,6 +86,10 @@
UIApplicationSupportsIndirectInputEvents
+ BGTaskSchedulerPermittedIdentifiers
+
+ eu.twonly.outbox-flush
+
UIBackgroundModes
fetch
diff --git a/ios/Runner/NativeVideoCodec.swift b/ios/Runner/NativeVideoCodec.swift
index b295e8c3..9f17ca06 100644
--- a/ios/Runner/NativeVideoCodec.swift
+++ b/ios/Runner/NativeVideoCodec.swift
@@ -41,6 +41,8 @@ enum NativeVideoCodec {
overlayPath: String?,
outputPath: String,
removeAudio: Bool,
+ trimStartMs: Int64,
+ trimEndMs: Int64,
onProgress: @escaping (Int) -> Void
) -> Bool {
let asset = AVURLAsset(url: URL(fileURLWithPath: inputPath))
@@ -79,7 +81,11 @@ enum NativeVideoCodec {
)
do {
+ let trim = trimRange(for: asset, startMs: trimStartMs, endMs: trimEndMs)
let reader = try AVAssetReader(asset: asset)
+ // Every output of a reader is bounded by this, so the cut costs nothing
+ // beyond the samples it stops the decoder from reading.
+ reader.timeRange = trim
let writer = try AVAssetWriter(outputURL: outputURL, fileType: .mp4)
writer.shouldOptimizeForNetworkUse = true
@@ -150,15 +156,20 @@ enum NativeVideoCodec {
}
guard reader.startReading(), writer.startWriting() else { return false }
- writer.startSession(atSourceTime: .zero)
+ // Samples keep the timestamps they had in the recording, so the session
+ // has to start where the cut does. Starting it at zero would prepend the
+ // trimmed-off head as an empty edit instead of removing it.
+ writer.startSession(atSourceTime: trim.start)
- let duration = CMTimeGetSeconds(asset.duration)
+ let startSeconds = CMTimeGetSeconds(trim.start)
+ let duration = CMTimeGetSeconds(trim.duration)
let group = DispatchGroup()
pump(
input: videoInput,
output: videoOutput,
label: "video",
group: group,
+ startSeconds: startSeconds,
duration: duration,
onProgress: onProgress
)
@@ -168,6 +179,7 @@ enum NativeVideoCodec {
output: audioOutput,
label: "audio",
group: group,
+ startSeconds: 0,
duration: nil,
onProgress: nil
)
@@ -267,11 +279,36 @@ enum NativeVideoCodec {
)
}
+ /// The slice of the recording the editor's cutter selected.
+ ///
+ /// Bounds arrive as milliseconds, with a negative value meaning the clip
+ /// keeps that end. Anything that does not describe a real slice - a start
+ /// past the end of the clip, an inverted pair, a zero-length result - falls
+ /// back to the whole asset: sending the untrimmed moment beats sending an
+ /// empty file.
+ private static func trimRange(for asset: AVAsset, startMs: Int64, endMs: Int64) -> CMTimeRange {
+ let whole = CMTimeRange(start: .zero, duration: asset.duration)
+ guard startMs > 0 || endMs > 0 else { return whole }
+
+ let milliseconds: CMTimeScale = 1000
+ let requestedStart =
+ startMs > 0 ? CMTime(value: CMTimeValue(startMs), timescale: milliseconds) : .zero
+ let requestedEnd =
+ endMs > 0 ? CMTime(value: CMTimeValue(endMs), timescale: milliseconds) : asset.duration
+
+ let start = CMTimeMaximum(.zero, CMTimeMinimum(requestedStart, asset.duration))
+ let end = CMTimeMinimum(requestedEnd, asset.duration)
+ let duration = CMTimeSubtract(end, start)
+ guard duration.isValid, CMTimeGetSeconds(duration) > 0 else { return whole }
+ return CMTimeRange(start: start, duration: duration)
+ }
+
private static func pump(
input: AVAssetWriterInput,
output: AVAssetReaderOutput,
label: String,
group: DispatchGroup,
+ startSeconds: Double,
duration: Double?,
onProgress: ((Int) -> Void)?
) {
@@ -285,8 +322,11 @@ enum NativeVideoCodec {
return
}
if let duration, duration > 0, let onProgress {
+ // Timestamps are still the recording's, so the trimmed-off head has
+ // to come off before this is a fraction of the work being done.
let seconds = CMTimeGetSeconds(CMSampleBufferGetPresentationTimeStamp(sample))
- onProgress(Int((seconds / duration * 100).rounded()))
+ let done = (seconds - startSeconds) / duration
+ onProgress(Int((min(max(done, 0), 1) * 100).rounded()))
}
input.append(sample)
}
@@ -301,6 +341,8 @@ func twonlyRenderVideo(
_ overlay: UnsafePointer?,
_ output: UnsafePointer?,
_ removeAudio: Bool,
+ _ trimStartMs: Int64,
+ _ trimEndMs: Int64,
_ mediaId: UnsafePointer?,
_ progress: @convention(c) (UnsafePointer?, Int32) -> Void
) -> Bool {
@@ -311,6 +353,8 @@ func twonlyRenderVideo(
overlayPath: overlay.map { String(cString: $0) },
outputPath: String(cString: output),
removeAudio: removeAudio,
+ trimStartMs: trimStartMs,
+ trimEndMs: trimEndMs,
onProgress: { percent in
mediaIdString.withCString { progress($0, Int32(percent)) }
}
diff --git a/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h
index 308a2a56..12417ae4 100644
--- a/ios/Runner/Runner-Bridging-Header.h
+++ b/ios/Runner/Runner-Bridging-Header.h
@@ -1 +1,2 @@
#import "GeneratedPluginRegistrant.h"
+#import
diff --git a/lib/app.dart b/lib/app.dart
index b6786418..ce837b3d 100644
--- a/lib/app.dart
+++ b/lib/app.dart
@@ -45,8 +45,6 @@ class App extends StatefulWidget {
}
class _AppState extends State with WidgetsBindingObserver {
- bool _wasPaused = false;
-
@override
void initState() {
super.initState();
@@ -58,23 +56,31 @@ class _AppState extends State with WidgetsBindingObserver {
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
if (state == AppLifecycleState.resumed) {
- if (_wasPaused) {
- AppState.isAppInBackground = false;
- twonlyDB.markUpdated();
- unawaited(
- rust_api.RustApi.setBackground(inBackground: false),
- );
- // The notification service extension wrote to the outbox while the app
- // was suspended, and its Rust change broadcast never reached this
- // process, so the badge has to be re-read on the way back in.
- unawaited(NativeNotificationService.refreshBadgeCount());
- }
+ AppState.isAppInBackground = false;
+ twonlyDB.markUpdated();
+ // Resuming can follow `inactive` without a `paused` event. Always notify
+ // Rust so every transition back to a focused app gets an immediate
+ // WebSocket attempt.
+ unawaited(
+ rust_api.RustApi.setBackground(inBackground: false),
+ );
+ // The notification service extension wrote to the outbox while the app
+ // was suspended, and its Rust change broadcast never reached this
+ // process, so the badge has to be re-read on the way back in.
+ unawaited(NativeNotificationService.refreshBadgeCount());
} else if (state == AppLifecycleState.paused) {
- _wasPaused = true;
AppState.isAppInBackground = true;
unawaited(
rust_api.RustApi.setBackground(inBackground: true),
);
+ } else if (state == AppLifecycleState.detached) {
+ // Last chance before the engine goes away: hand anything still unsent to
+ // the OS, which delivers it once there is a network again whether or not
+ // this process is ever started back up. A no-op when `paused` already
+ // did it.
+ unawaited(
+ rust_api.RustApi.handOutboxToOs(),
+ );
}
}
diff --git a/lib/core/bridge/api.dart b/lib/core/bridge/api.dart
index db4938a0..6ed165da 100644
--- a/lib/core/bridge/api.dart
+++ b/lib/core/bridge/api.dart
@@ -443,6 +443,11 @@ class RustApi {
username: username,
);
+ /// Hands every queued envelope to an OS-owned transfer. Used when the app
+ /// is being torn down while messages are still unsent.
+ static Future handOutboxToOs() =>
+ RustLib.instance.api.crateBridgeApiRustApiHandOutboxToOs();
+
/// Creates the media row and its content-encryption material and returns
/// the media id the UI addresses every later step by.
static Future initializeMediaUpload({
@@ -541,6 +546,13 @@ class RustApi {
.api
.crateBridgeApiRustApiPerformPasswordlessRecoveryHeartbeat();
+ /// Transcodes a captured video while the user is still editing it, so the
+ /// send itself only has to encrypt and hand over.
+ static Future prerenderMedia({required String mediaId}) => RustLib
+ .instance
+ .api
+ .crateBridgeApiRustApiPrerenderMedia(mediaId: mediaId);
+
/// Deletes temporary media whose messages are finished with it.
static Future purgeMediaTempFolder() =>
RustLib.instance.api.crateBridgeApiRustApiPurgeMediaTempFolder();
@@ -736,6 +748,18 @@ class RustApi {
requiresAuthentication: requiresAuthentication,
);
+ /// Stores the cut the editor's video trimmer asked for. Both bounds are
+ /// milliseconds into the recording, `None` keeps that end of the clip.
+ static Future setMediaTrim({
+ required String mediaId,
+ PlatformInt64? trimStartMs,
+ PlatformInt64? trimEndMs,
+ }) => RustLib.instance.api.crateBridgeApiRustApiSetMediaTrim(
+ mediaId: mediaId,
+ trimStartMs: trimStartMs,
+ trimEndMs: trimEndMs,
+ );
+
static Future setNetworkAvailable({required bool available}) => RustLib
.instance
.api
diff --git a/lib/core/frb_generated.dart b/lib/core/frb_generated.dart
index 0a34940a..f3438700 100644
--- a/lib/core/frb_generated.dart
+++ b/lib/core/frb_generated.dart
@@ -86,7 +86,7 @@ class RustLib extends BaseEntrypoint {
String get codegenVersion => '2.12.0';
@override
- int get rustContentHash => -1370816897;
+ int get rustContentHash => 1963960341;
static const kDefaultExternalLibraryLoaderConfig =
ExternalLibraryLoaderConfig(
@@ -285,6 +285,8 @@ abstract class RustLibApi extends BaseApi {
required String username,
});
+ Future crateBridgeApiRustApiHandOutboxToOs();
+
Future crateBridgeApiRustApiInitializeMediaUpload({
required String mediaType,
PlatformInt64? displayLimitInMilliseconds,
@@ -339,6 +341,8 @@ abstract class RustLibApi extends BaseApi {
Future crateBridgeApiRustApiPerformPasswordlessRecoveryHeartbeat();
+ Future crateBridgeApiRustApiPrerenderMedia({required String mediaId});
+
Future crateBridgeApiRustApiPurgeMediaTempFolder();
Future crateBridgeApiRustApiRegister({
@@ -457,6 +461,12 @@ abstract class RustLibApi extends BaseApi {
required bool requiresAuthentication,
});
+ Future crateBridgeApiRustApiSetMediaTrim({
+ required String mediaId,
+ PlatformInt64? trimStartMs,
+ PlatformInt64? trimEndMs,
+ });
+
Future crateBridgeApiRustApiSetNetworkAvailable({
required bool available,
});
@@ -2536,6 +2546,36 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
argNames: ["username"],
);
+ @override
+ Future crateBridgeApiRustApiHandOutboxToOs() {
+ return handler.executeNormal(
+ NormalTask(
+ callFfi: (port_) {
+ final serializer = SseSerializer(generalizedFrbRustBinding);
+ pdeCallFfi(
+ generalizedFrbRustBinding,
+ serializer,
+ funcId: 57,
+ port: port_,
+ );
+ },
+ codec: SseCodec(
+ decodeSuccessData: sse_decode_unit,
+ decodeErrorData: sse_decode_AnyhowException,
+ ),
+ constMeta: kCrateBridgeApiRustApiHandOutboxToOsConstMeta,
+ argValues: [],
+ apiImpl: this,
+ ),
+ );
+ }
+
+ TaskConstMeta get kCrateBridgeApiRustApiHandOutboxToOsConstMeta =>
+ const TaskConstMeta(
+ debugName: "rust_api_hand_outbox_to_os",
+ argNames: [],
+ );
+
@override
Future crateBridgeApiRustApiInitializeMediaUpload({
required String mediaType,
@@ -2555,7 +2595,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 57,
+ funcId: 58,
port: port_,
);
},
@@ -2592,7 +2632,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 58,
+ funcId: 59,
port: port_,
);
},
@@ -2628,7 +2668,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 59,
+ funcId: 60,
port: port_,
);
},
@@ -2663,7 +2703,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 60,
+ funcId: 61,
port: port_,
);
},
@@ -2700,7 +2740,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 61,
+ funcId: 62,
port: port_,
);
},
@@ -2737,7 +2777,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 62,
+ funcId: 63,
port: port_,
);
},
@@ -2767,7 +2807,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 63,
+ funcId: 64,
port: port_,
);
},
@@ -2800,7 +2840,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 64,
+ funcId: 65,
port: port_,
);
},
@@ -2835,7 +2875,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 65,
+ funcId: 66,
port: port_,
);
},
@@ -2865,7 +2905,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 66,
+ funcId: 67,
port: port_,
);
},
@@ -2900,7 +2940,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 67,
+ funcId: 68,
port: port_,
);
},
@@ -2930,7 +2970,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 68,
+ funcId: 69,
port: port_,
);
},
@@ -2953,6 +2993,37 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
argNames: [],
);
+ @override
+ Future crateBridgeApiRustApiPrerenderMedia({required String mediaId}) {
+ return handler.executeNormal(
+ NormalTask(
+ callFfi: (port_) {
+ final serializer = SseSerializer(generalizedFrbRustBinding);
+ sse_encode_String(mediaId, serializer);
+ pdeCallFfi(
+ generalizedFrbRustBinding,
+ serializer,
+ funcId: 70,
+ port: port_,
+ );
+ },
+ codec: SseCodec(
+ decodeSuccessData: sse_decode_unit,
+ decodeErrorData: sse_decode_AnyhowException,
+ ),
+ constMeta: kCrateBridgeApiRustApiPrerenderMediaConstMeta,
+ argValues: [mediaId],
+ apiImpl: this,
+ ),
+ );
+ }
+
+ TaskConstMeta get kCrateBridgeApiRustApiPrerenderMediaConstMeta =>
+ const TaskConstMeta(
+ debugName: "rust_api_prerender_media",
+ argNames: ["mediaId"],
+ );
+
@override
Future crateBridgeApiRustApiPurgeMediaTempFolder() {
return handler.executeNormal(
@@ -2962,7 +3033,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 69,
+ funcId: 71,
port: port_,
);
},
@@ -3001,7 +3072,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 70,
+ funcId: 72,
port: port_,
);
},
@@ -3040,7 +3111,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 71,
+ funcId: 73,
port: port_,
);
},
@@ -3082,7 +3153,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 72,
+ funcId: 74,
port: port_,
);
},
@@ -3113,7 +3184,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 73,
+ funcId: 75,
port: port_,
);
},
@@ -3146,7 +3217,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 74,
+ funcId: 76,
port: port_,
);
},
@@ -3179,7 +3250,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 75,
+ funcId: 77,
port: port_,
);
},
@@ -3214,7 +3285,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 76,
+ funcId: 78,
port: port_,
);
},
@@ -3247,7 +3318,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 77,
+ funcId: 79,
port: port_,
);
},
@@ -3280,7 +3351,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 78,
+ funcId: 80,
port: port_,
);
},
@@ -3313,7 +3384,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 79,
+ funcId: 81,
port: port_,
);
},
@@ -3350,7 +3421,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 80,
+ funcId: 82,
port: port_,
);
},
@@ -3380,7 +3451,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 81,
+ funcId: 83,
port: port_,
);
},
@@ -3410,7 +3481,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 82,
+ funcId: 84,
port: port_,
);
},
@@ -3440,7 +3511,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 83,
+ funcId: 85,
port: port_,
);
},
@@ -3473,7 +3544,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 84,
+ funcId: 86,
port: port_,
);
},
@@ -3504,7 +3575,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 85,
+ funcId: 87,
port: port_,
);
},
@@ -3537,7 +3608,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 86,
+ funcId: 88,
port: port_,
);
},
@@ -3583,7 +3654,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 87,
+ funcId: 89,
port: port_,
);
},
@@ -3639,7 +3710,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 88,
+ funcId: 90,
port: port_,
);
},
@@ -3685,7 +3756,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 89,
+ funcId: 91,
port: port_,
);
},
@@ -3718,7 +3789,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 90,
+ funcId: 92,
port: port_,
);
},
@@ -3753,7 +3824,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 91,
+ funcId: 93,
port: port_,
);
},
@@ -3788,7 +3859,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 92,
+ funcId: 94,
port: port_,
);
},
@@ -3821,7 +3892,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 93,
+ funcId: 95,
port: port_,
);
},
@@ -3852,7 +3923,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 94,
+ funcId: 96,
port: port_,
);
},
@@ -3890,7 +3961,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 95,
+ funcId: 97,
port: port_,
);
},
@@ -3925,7 +3996,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 96,
+ funcId: 98,
port: port_,
);
},
@@ -3948,6 +4019,43 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
argNames: ["mediaId", "requiresAuthentication"],
);
+ @override
+ Future crateBridgeApiRustApiSetMediaTrim({
+ required String mediaId,
+ PlatformInt64? trimStartMs,
+ PlatformInt64? trimEndMs,
+ }) {
+ return handler.executeNormal(
+ NormalTask(
+ callFfi: (port_) {
+ final serializer = SseSerializer(generalizedFrbRustBinding);
+ sse_encode_String(mediaId, serializer);
+ sse_encode_opt_box_autoadd_i_64(trimStartMs, serializer);
+ sse_encode_opt_box_autoadd_i_64(trimEndMs, serializer);
+ pdeCallFfi(
+ generalizedFrbRustBinding,
+ serializer,
+ funcId: 99,
+ port: port_,
+ );
+ },
+ codec: SseCodec(
+ decodeSuccessData: sse_decode_unit,
+ decodeErrorData: sse_decode_AnyhowException,
+ ),
+ constMeta: kCrateBridgeApiRustApiSetMediaTrimConstMeta,
+ argValues: [mediaId, trimStartMs, trimEndMs],
+ apiImpl: this,
+ ),
+ );
+ }
+
+ TaskConstMeta get kCrateBridgeApiRustApiSetMediaTrimConstMeta =>
+ const TaskConstMeta(
+ debugName: "rust_api_set_media_trim",
+ argNames: ["mediaId", "trimStartMs", "trimEndMs"],
+ );
+
@override
Future crateBridgeApiRustApiSetNetworkAvailable({
required bool available,
@@ -3960,7 +4068,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 97,
+ funcId: 100,
port: port_,
);
},
@@ -3991,7 +4099,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 98,
+ funcId: 101,
port: port_,
);
},
@@ -4026,7 +4134,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 99,
+ funcId: 102,
port: port_,
);
},
@@ -4059,7 +4167,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 100,
+ funcId: 103,
port: port_,
);
},
@@ -4094,7 +4202,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 101,
+ funcId: 104,
port: port_,
);
},
@@ -4125,7 +4233,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 102,
+ funcId: 105,
port: port_,
);
},
@@ -4162,7 +4270,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 103,
+ funcId: 106,
port: port_,
);
},
@@ -4214,7 +4322,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 104,
+ funcId: 107,
port: port_,
);
},
@@ -4267,7 +4375,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 105,
+ funcId: 108,
port: port_,
);
},
@@ -4307,7 +4415,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 106,
+ funcId: 109,
port: port_,
);
},
@@ -4340,7 +4448,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 107,
+ funcId: 110,
port: port_,
);
},
@@ -4373,7 +4481,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 108,
+ funcId: 111,
port: port_,
);
},
@@ -4410,7 +4518,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 109,
+ funcId: 112,
port: port_,
);
},
@@ -4442,7 +4550,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 110,
+ funcId: 113,
port: port_,
);
},
@@ -4475,7 +4583,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 111,
+ funcId: 114,
port: port_,
);
},
@@ -4510,7 +4618,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 112,
+ funcId: 115,
port: port_,
);
},
@@ -4542,7 +4650,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 113,
+ funcId: 116,
port: port_,
);
},
@@ -4580,7 +4688,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 114,
+ funcId: 117,
port: port_,
);
},
@@ -4613,7 +4721,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 115,
+ funcId: 118,
port: port_,
);
},
@@ -4651,7 +4759,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 116,
+ funcId: 119,
port: port_,
);
},
@@ -4688,7 +4796,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 117,
+ funcId: 120,
port: port_,
);
},
@@ -4725,7 +4833,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 118,
+ funcId: 121,
port: port_,
);
},
@@ -4763,7 +4871,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 119,
+ funcId: 122,
port: port_,
);
},
@@ -4801,7 +4909,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 120,
+ funcId: 123,
port: port_,
);
},
@@ -4834,7 +4942,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 121,
+ funcId: 124,
port: port_,
);
},
@@ -4866,7 +4974,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 122,
+ funcId: 125,
port: port_,
);
},
@@ -4901,7 +5009,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 123,
+ funcId: 126,
port: port_,
);
},
@@ -4938,7 +5046,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 124,
+ funcId: 127,
port: port_,
);
},
@@ -4971,7 +5079,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 125,
+ funcId: 128,
port: port_,
);
},
@@ -5003,7 +5111,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 126,
+ funcId: 129,
port: port_,
);
},
@@ -5038,7 +5146,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 127,
+ funcId: 130,
port: port_,
);
},
@@ -5077,7 +5185,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 128,
+ funcId: 131,
port: port_,
);
},
@@ -5114,7 +5222,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 129,
+ funcId: 132,
port: port_,
);
},
@@ -5144,7 +5252,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 130,
+ funcId: 133,
port: port_,
);
},
@@ -5176,7 +5284,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 131,
+ funcId: 134,
port: port_,
);
},
@@ -5211,7 +5319,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 132,
+ funcId: 135,
port: port_,
);
},
@@ -5243,7 +5351,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 133,
+ funcId: 136,
port: port_,
);
},
@@ -5281,7 +5389,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 134,
+ funcId: 137,
port: port_,
);
},
@@ -5320,7 +5428,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 135,
+ funcId: 138,
port: port_,
);
},
@@ -5355,7 +5463,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 136,
+ funcId: 139,
port: port_,
);
},
@@ -5390,7 +5498,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 137,
+ funcId: 140,
port: port_,
);
},
@@ -5425,7 +5533,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 138,
+ funcId: 141,
port: port_,
);
},
@@ -5458,7 +5566,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
return pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 139,
+ funcId: 142,
)!;
},
codec: SseCodec(
@@ -5498,7 +5606,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 140,
+ funcId: 143,
port: port_,
);
},
@@ -5543,7 +5651,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 141,
+ funcId: 144,
port: port_,
);
},
@@ -5573,7 +5681,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 142,
+ funcId: 145,
port: port_,
);
},
@@ -5606,7 +5714,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 143,
+ funcId: 146,
port: port_,
);
},
@@ -5641,7 +5749,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 144,
+ funcId: 147,
port: port_,
);
},
@@ -5680,7 +5788,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
return pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 145,
+ funcId: 148,
)!;
},
codec: SseCodec(
diff --git a/lib/globals.dart b/lib/globals.dart
index 1b053354..49c7ad7e 100644
--- a/lib/globals.dart
+++ b/lib/globals.dart
@@ -90,7 +90,6 @@ class AppEnvironment {
class AppState {
static bool isAppInBackground = true;
- static bool isInBackgroundTask = false;
static bool allowErrorTrackingViaSentry = false;
static bool gotMessageFromServer = false;
static int latestAppVersionId = 119;
diff --git a/lib/src/database/tables/mediafiles.table.dart b/lib/src/database/tables/mediafiles.table.dart
index b1191e7b..f6517813 100644
--- a/lib/src/database/tables/mediafiles.table.dart
+++ b/lib/src/database/tables/mediafiles.table.dart
@@ -71,6 +71,13 @@ class MediaFiles extends Table {
IntColumn get displayLimitInMilliseconds => integer().nullable()();
BoolColumn get removeAudio => boolean().nullable()();
+ /// Where the editor's cutter placed the two ends of a video, in milliseconds
+ /// into the recording. Null on either side means the clip keeps that end.
+ /// The recording on disk is never cut; the transcode every send performs
+ /// applies these.
+ IntColumn get trimStartMs => integer().nullable()();
+ IntColumn get trimEndMs => integer().nullable()();
+
BlobColumn get downloadToken => blob().nullable()();
BlobColumn get encryptionKey => blob().nullable()();
BlobColumn get encryptionMac => blob().nullable()();
diff --git a/lib/src/database/twonly.db.g.dart b/lib/src/database/twonly.db.g.dart
index 86d82cb6..a03fcb28 100644
--- a/lib/src/database/twonly.db.g.dart
+++ b/lib/src/database/twonly.db.g.dart
@@ -3297,6 +3297,28 @@ class $MediaFilesTable extends MediaFiles
'CHECK ("remove_audio" IN (0, 1))',
),
);
+ static const VerificationMeta _trimStartMsMeta = const VerificationMeta(
+ 'trimStartMs',
+ );
+ @override
+ late final GeneratedColumn trimStartMs = GeneratedColumn(
+ 'trim_start_ms',
+ aliasedName,
+ true,
+ type: DriftSqlType.int,
+ requiredDuringInsert: false,
+ );
+ static const VerificationMeta _trimEndMsMeta = const VerificationMeta(
+ 'trimEndMs',
+ );
+ @override
+ late final GeneratedColumn trimEndMs = GeneratedColumn(
+ 'trim_end_ms',
+ aliasedName,
+ true,
+ type: DriftSqlType.int,
+ requiredDuringInsert: false,
+ );
static const VerificationMeta _downloadTokenMeta = const VerificationMeta(
'downloadToken',
);
@@ -3423,6 +3445,8 @@ class $MediaFilesTable extends MediaFiles
reuploadRequestedBy,
displayLimitInMilliseconds,
removeAudio,
+ trimStartMs,
+ trimEndMs,
downloadToken,
encryptionKey,
encryptionMac,
@@ -3525,6 +3549,21 @@ class $MediaFilesTable extends MediaFiles
),
);
}
+ if (data.containsKey('trim_start_ms')) {
+ context.handle(
+ _trimStartMsMeta,
+ trimStartMs.isAcceptableOrUnknown(
+ data['trim_start_ms']!,
+ _trimStartMsMeta,
+ ),
+ );
+ }
+ if (data.containsKey('trim_end_ms')) {
+ context.handle(
+ _trimEndMsMeta,
+ trimEndMs.isAcceptableOrUnknown(data['trim_end_ms']!, _trimEndMsMeta),
+ );
+ }
if (data.containsKey('download_token')) {
context.handle(
_downloadTokenMeta,
@@ -3683,6 +3722,14 @@ class $MediaFilesTable extends MediaFiles
DriftSqlType.bool,
data['${effectivePrefix}remove_audio'],
),
+ trimStartMs: attachedDatabase.typeMapping.read(
+ DriftSqlType.int,
+ data['${effectivePrefix}trim_start_ms'],
+ ),
+ trimEndMs: attachedDatabase.typeMapping.read(
+ DriftSqlType.int,
+ data['${effectivePrefix}trim_end_ms'],
+ ),
downloadToken: attachedDatabase.typeMapping.read(
DriftSqlType.blob,
data['${effectivePrefix}download_token'],
@@ -3765,6 +3812,13 @@ class MediaFile extends DataClass implements Insertable {
final List? reuploadRequestedBy;
final int? displayLimitInMilliseconds;
final bool? removeAudio;
+
+ /// Where the editor's cutter placed the two ends of a video, in milliseconds
+ /// into the recording. Null on either side means the clip keeps that end.
+ /// The recording on disk is never cut; the transcode every send performs
+ /// applies these.
+ final int? trimStartMs;
+ final int? trimEndMs;
final Uint8List? downloadToken;
final Uint8List? encryptionKey;
final Uint8List? encryptionMac;
@@ -3790,6 +3844,8 @@ class MediaFile extends DataClass implements Insertable {
this.reuploadRequestedBy,
this.displayLimitInMilliseconds,
this.removeAudio,
+ this.trimStartMs,
+ this.trimEndMs,
this.downloadToken,
this.encryptionKey,
this.encryptionMac,
@@ -3850,6 +3906,12 @@ class MediaFile extends DataClass implements Insertable {
if (!nullToAbsent || removeAudio != null) {
map['remove_audio'] = Variable(removeAudio);
}
+ if (!nullToAbsent || trimStartMs != null) {
+ map['trim_start_ms'] = Variable(trimStartMs);
+ }
+ if (!nullToAbsent || trimEndMs != null) {
+ map['trim_end_ms'] = Variable(trimEndMs);
+ }
if (!nullToAbsent || downloadToken != null) {
map['download_token'] = Variable(downloadToken);
}
@@ -3908,6 +3970,12 @@ class MediaFile extends DataClass implements Insertable {
removeAudio: removeAudio == null && nullToAbsent
? const Value.absent()
: Value(removeAudio),
+ trimStartMs: trimStartMs == null && nullToAbsent
+ ? const Value.absent()
+ : Value(trimStartMs),
+ trimEndMs: trimEndMs == null && nullToAbsent
+ ? const Value.absent()
+ : Value(trimEndMs),
downloadToken: downloadToken == null && nullToAbsent
? const Value.absent()
: Value(downloadToken),
@@ -3971,6 +4039,8 @@ class MediaFile extends DataClass implements Insertable {
json['displayLimitInMilliseconds'],
),
removeAudio: serializer.fromJson(json['removeAudio']),
+ trimStartMs: serializer.fromJson(json['trimStartMs']),
+ trimEndMs: serializer.fromJson(json['trimEndMs']),
downloadToken: serializer.fromJson(json['downloadToken']),
encryptionKey: serializer.fromJson(json['encryptionKey']),
encryptionMac: serializer.fromJson(json['encryptionMac']),
@@ -4011,6 +4081,8 @@ class MediaFile extends DataClass implements Insertable {
displayLimitInMilliseconds,
),
'removeAudio': serializer.toJson(removeAudio),
+ 'trimStartMs': serializer.toJson(trimStartMs),
+ 'trimEndMs': serializer.toJson(trimEndMs),
'downloadToken': serializer.toJson(downloadToken),
'encryptionKey': serializer.toJson(encryptionKey),
'encryptionMac': serializer.toJson(encryptionMac),
@@ -4039,6 +4111,8 @@ class MediaFile extends DataClass implements Insertable {
Value?> reuploadRequestedBy = const Value.absent(),
Value displayLimitInMilliseconds = const Value.absent(),
Value removeAudio = const Value.absent(),
+ Value trimStartMs = const Value.absent(),
+ Value trimEndMs = const Value.absent(),
Value downloadToken = const Value.absent(),
Value encryptionKey = const Value.absent(),
Value encryptionMac = const Value.absent(),
@@ -4073,6 +4147,8 @@ class MediaFile extends DataClass implements Insertable {
? displayLimitInMilliseconds.value
: this.displayLimitInMilliseconds,
removeAudio: removeAudio.present ? removeAudio.value : this.removeAudio,
+ trimStartMs: trimStartMs.present ? trimStartMs.value : this.trimStartMs,
+ trimEndMs: trimEndMs.present ? trimEndMs.value : this.trimEndMs,
downloadToken: downloadToken.present
? downloadToken.value
: this.downloadToken,
@@ -4134,6 +4210,10 @@ class MediaFile extends DataClass implements Insertable {
removeAudio: data.removeAudio.present
? data.removeAudio.value
: this.removeAudio,
+ trimStartMs: data.trimStartMs.present
+ ? data.trimStartMs.value
+ : this.trimStartMs,
+ trimEndMs: data.trimEndMs.present ? data.trimEndMs.value : this.trimEndMs,
downloadToken: data.downloadToken.present
? data.downloadToken.value
: this.downloadToken,
@@ -4180,6 +4260,8 @@ class MediaFile extends DataClass implements Insertable {
..write('reuploadRequestedBy: $reuploadRequestedBy, ')
..write('displayLimitInMilliseconds: $displayLimitInMilliseconds, ')
..write('removeAudio: $removeAudio, ')
+ ..write('trimStartMs: $trimStartMs, ')
+ ..write('trimEndMs: $trimEndMs, ')
..write('downloadToken: $downloadToken, ')
..write('encryptionKey: $encryptionKey, ')
..write('encryptionMac: $encryptionMac, ')
@@ -4210,6 +4292,8 @@ class MediaFile extends DataClass implements Insertable {
reuploadRequestedBy,
displayLimitInMilliseconds,
removeAudio,
+ trimStartMs,
+ trimEndMs,
$driftBlobEquality.hash(downloadToken),
$driftBlobEquality.hash(encryptionKey),
$driftBlobEquality.hash(encryptionMac),
@@ -4239,6 +4323,8 @@ class MediaFile extends DataClass implements Insertable {
other.reuploadRequestedBy == this.reuploadRequestedBy &&
other.displayLimitInMilliseconds == this.displayLimitInMilliseconds &&
other.removeAudio == this.removeAudio &&
+ other.trimStartMs == this.trimStartMs &&
+ other.trimEndMs == this.trimEndMs &&
$driftBlobEquality.equals(other.downloadToken, this.downloadToken) &&
$driftBlobEquality.equals(other.encryptionKey, this.encryptionKey) &&
$driftBlobEquality.equals(other.encryptionMac, this.encryptionMac) &&
@@ -4272,6 +4358,8 @@ class MediaFilesCompanion extends UpdateCompanion {
final Value?> reuploadRequestedBy;
final Value displayLimitInMilliseconds;
final Value removeAudio;
+ final Value trimStartMs;
+ final Value trimEndMs;
final Value downloadToken;
final Value encryptionKey;
final Value encryptionMac;
@@ -4298,6 +4386,8 @@ class MediaFilesCompanion extends UpdateCompanion {
this.reuploadRequestedBy = const Value.absent(),
this.displayLimitInMilliseconds = const Value.absent(),
this.removeAudio = const Value.absent(),
+ this.trimStartMs = const Value.absent(),
+ this.trimEndMs = const Value.absent(),
this.downloadToken = const Value.absent(),
this.encryptionKey = const Value.absent(),
this.encryptionMac = const Value.absent(),
@@ -4325,6 +4415,8 @@ class MediaFilesCompanion extends UpdateCompanion {
this.reuploadRequestedBy = const Value.absent(),
this.displayLimitInMilliseconds = const Value.absent(),
this.removeAudio = const Value.absent(),
+ this.trimStartMs = const Value.absent(),
+ this.trimEndMs = const Value.absent(),
this.downloadToken = const Value.absent(),
this.encryptionKey = const Value.absent(),
this.encryptionMac = const Value.absent(),
@@ -4353,6 +4445,8 @@ class MediaFilesCompanion extends UpdateCompanion {
Expression? reuploadRequestedBy,
Expression? displayLimitInMilliseconds,
Expression? removeAudio,
+ Expression? trimStartMs,
+ Expression? trimEndMs,
Expression? downloadToken,
Expression? encryptionKey,
Expression? encryptionMac,
@@ -4384,6 +4478,8 @@ class MediaFilesCompanion extends UpdateCompanion {
if (displayLimitInMilliseconds != null)
'display_limit_in_milliseconds': displayLimitInMilliseconds,
if (removeAudio != null) 'remove_audio': removeAudio,
+ if (trimStartMs != null) 'trim_start_ms': trimStartMs,
+ if (trimEndMs != null) 'trim_end_ms': trimEndMs,
if (downloadToken != null) 'download_token': downloadToken,
if (encryptionKey != null) 'encryption_key': encryptionKey,
if (encryptionMac != null) 'encryption_mac': encryptionMac,
@@ -4413,6 +4509,8 @@ class MediaFilesCompanion extends UpdateCompanion {
Value?>? reuploadRequestedBy,
Value? displayLimitInMilliseconds,
Value? removeAudio,
+ Value? trimStartMs,
+ Value? trimEndMs,
Value? downloadToken,
Value? encryptionKey,
Value? encryptionMac,
@@ -4443,6 +4541,8 @@ class MediaFilesCompanion extends UpdateCompanion {
displayLimitInMilliseconds:
displayLimitInMilliseconds ?? this.displayLimitInMilliseconds,
removeAudio: removeAudio ?? this.removeAudio,
+ trimStartMs: trimStartMs ?? this.trimStartMs,
+ trimEndMs: trimEndMs ?? this.trimEndMs,
downloadToken: downloadToken ?? this.downloadToken,
encryptionKey: encryptionKey ?? this.encryptionKey,
encryptionMac: encryptionMac ?? this.encryptionMac,
@@ -4522,6 +4622,12 @@ class MediaFilesCompanion extends UpdateCompanion {
if (removeAudio.present) {
map['remove_audio'] = Variable(removeAudio.value);
}
+ if (trimStartMs.present) {
+ map['trim_start_ms'] = Variable(trimStartMs.value);
+ }
+ if (trimEndMs.present) {
+ map['trim_end_ms'] = Variable(trimEndMs.value);
+ }
if (downloadToken.present) {
map['download_token'] = Variable(downloadToken.value);
}
@@ -4573,6 +4679,8 @@ class MediaFilesCompanion extends UpdateCompanion {
..write('reuploadRequestedBy: $reuploadRequestedBy, ')
..write('displayLimitInMilliseconds: $displayLimitInMilliseconds, ')
..write('removeAudio: $removeAudio, ')
+ ..write('trimStartMs: $trimStartMs, ')
+ ..write('trimEndMs: $trimEndMs, ')
..write('downloadToken: $downloadToken, ')
..write('encryptionKey: $encryptionKey, ')
..write('encryptionMac: $encryptionMac, ')
@@ -15510,6 +15618,8 @@ typedef $$MediaFilesTableCreateCompanionBuilder =
Value?> reuploadRequestedBy,
Value displayLimitInMilliseconds,
Value removeAudio,
+ Value trimStartMs,
+ Value trimEndMs,
Value downloadToken,
Value encryptionKey,
Value encryptionMac,
@@ -15538,6 +15648,8 @@ typedef $$MediaFilesTableUpdateCompanionBuilder =
Value?> reuploadRequestedBy,
Value displayLimitInMilliseconds,
Value removeAudio,
+ Value trimStartMs,
+ Value trimEndMs,
Value downloadToken,
Value encryptionKey,
Value encryptionMac,
@@ -15662,6 +15774,16 @@ class $$MediaFilesTableFilterComposer
builder: (column) => ColumnFilters(column),
);
+ ColumnFilters get trimStartMs => $composableBuilder(
+ column: $table.trimStartMs,
+ builder: (column) => ColumnFilters(column),
+ );
+
+ ColumnFilters get trimEndMs => $composableBuilder(
+ column: $table.trimEndMs,
+ builder: (column) => ColumnFilters(column),
+ );
+
ColumnFilters get downloadToken => $composableBuilder(
column: $table.downloadToken,
builder: (column) => ColumnFilters(column),
@@ -15817,6 +15939,16 @@ class $$MediaFilesTableOrderingComposer
builder: (column) => ColumnOrderings(column),
);
+ ColumnOrderings get trimStartMs => $composableBuilder(
+ column: $table.trimStartMs,
+ builder: (column) => ColumnOrderings(column),
+ );
+
+ ColumnOrderings get trimEndMs => $composableBuilder(
+ column: $table.trimEndMs,
+ builder: (column) => ColumnOrderings(column),
+ );
+
ColumnOrderings get downloadToken => $composableBuilder(
column: $table.downloadToken,
builder: (column) => ColumnOrderings(column),
@@ -15943,6 +16075,14 @@ class $$MediaFilesTableAnnotationComposer
builder: (column) => column,
);
+ GeneratedColumn get trimStartMs => $composableBuilder(
+ column: $table.trimStartMs,
+ builder: (column) => column,
+ );
+
+ GeneratedColumn get trimEndMs =>
+ $composableBuilder(column: $table.trimEndMs, builder: (column) => column);
+
GeneratedColumn get downloadToken => $composableBuilder(
column: $table.downloadToken,
builder: (column) => column,
@@ -16055,6 +16195,8 @@ class $$MediaFilesTableTableManager
Value?> reuploadRequestedBy = const Value.absent(),
Value displayLimitInMilliseconds = const Value.absent(),
Value removeAudio = const Value.absent(),
+ Value trimStartMs = const Value.absent(),
+ Value trimEndMs = const Value.absent(),
Value downloadToken = const Value.absent(),
Value encryptionKey = const Value.absent(),
Value encryptionMac = const Value.absent(),
@@ -16081,6 +16223,8 @@ class $$MediaFilesTableTableManager
reuploadRequestedBy: reuploadRequestedBy,
displayLimitInMilliseconds: displayLimitInMilliseconds,
removeAudio: removeAudio,
+ trimStartMs: trimStartMs,
+ trimEndMs: trimEndMs,
downloadToken: downloadToken,
encryptionKey: encryptionKey,
encryptionMac: encryptionMac,
@@ -16109,6 +16253,8 @@ class $$MediaFilesTableTableManager
Value?> reuploadRequestedBy = const Value.absent(),
Value displayLimitInMilliseconds = const Value.absent(),
Value removeAudio = const Value.absent(),
+ Value trimStartMs = const Value.absent(),
+ Value trimEndMs = const Value.absent(),
Value downloadToken = const Value.absent(),
Value encryptionKey = const Value.absent(),
Value encryptionMac = const Value.absent(),
@@ -16135,6 +16281,8 @@ class $$MediaFilesTableTableManager
reuploadRequestedBy: reuploadRequestedBy,
displayLimitInMilliseconds: displayLimitInMilliseconds,
removeAudio: removeAudio,
+ trimStartMs: trimStartMs,
+ trimEndMs: trimEndMs,
downloadToken: downloadToken,
encryptionKey: encryptionKey,
encryptionMac: encryptionMac,
diff --git a/lib/src/localization/translations b/lib/src/localization/translations
index 17d10180..66a65b77 160000
--- a/lib/src/localization/translations
+++ b/lib/src/localization/translations
@@ -1 +1 @@
-Subproject commit 17d101801f02cfeaa2a175083111a59fa6d6822c
+Subproject commit 66a65b7787ae6c66b8fefbb8d016494de0355ef8
diff --git a/lib/src/providers/connection.provider.dart b/lib/src/providers/connection.provider.dart
index c41790b0..5e9a4d6c 100644
--- a/lib/src/providers/connection.provider.dart
+++ b/lib/src/providers/connection.provider.dart
@@ -9,16 +9,23 @@ class CustomChangeProvider with ChangeNotifier, DiagnosticableTreeMixin {
unawaited(_loadConnectionState());
_connSub = apiService.events
.where((event) => event.kind == ApiEventKind.connectionStateChanged)
- .listen(
- (event) => updateConnectionState(
- event.state == ApiConnectionState.authenticated,
- ),
- );
+ .listen(_handleConnectionState);
}
late bool _isConnected;
late StreamSubscription _connSub;
bool get isConnected => _isConnected;
+ void _handleConnectionState(ApiEvent event) {
+ // `suspended` is a lifecycle hint, not a transport disconnect: the socket
+ // remains alive while Flutter is briefly covered or backgrounded. Keeping
+ // the last known value prevents the avatar from flashing red during such a
+ // transition.
+ if (event.state == ApiConnectionState.suspended) return;
+ unawaited(
+ updateConnectionState(event.state == ApiConnectionState.authenticated),
+ );
+ }
+
Future _loadConnectionState() async {
final state = await RustApi.connectionState();
await updateConnectionState(state == ApiConnectionState.authenticated);
diff --git a/lib/src/services/api/api.service.dart b/lib/src/services/api/api.service.dart
index e9b1e076..ef219f0b 100644
--- a/lib/src/services/api/api.service.dart
+++ b/lib/src/services/api/api.service.dart
@@ -49,9 +49,7 @@ class ApiService {
Future onAuthenticated() async {
await FcmNotificationService.initFCMAfterAuthenticated();
- if (AppState.isInBackgroundTask) {
- await rust_api.RustApi.reuploadPendingMedia();
- } else if (!AppState.isAppInBackground) {
+ if (!AppState.isAppInBackground) {
unawaitedRustCall(
rust_api.RustApi.reuploadPendingMedia(),
'reuploadPendingMedia',
diff --git a/lib/src/services/mediafiles/mediafile.service.dart b/lib/src/services/mediafiles/mediafile.service.dart
index 2f37a15a..0305f307 100644
--- a/lib/src/services/mediafiles/mediafile.service.dart
+++ b/lib/src/services/mediafiles/mediafile.service.dart
@@ -59,6 +59,27 @@ class MediaFileService {
bool get removeAudio => mediaFile.removeAudio ?? false;
+ /// Where the editor's cutter placed the two ends of a video. Null on either
+ /// side means the clip keeps that end.
+ Duration? get trimStart => mediaFile.trimStartMs == null
+ ? null
+ : Duration(milliseconds: mediaFile.trimStartMs!);
+ Duration? get trimEnd => mediaFile.trimEndMs == null
+ ? null
+ : Duration(milliseconds: mediaFile.trimEndMs!);
+
+ /// Rust stores the bounds and applies them in the transcode every send
+ /// performs, so the recording itself is never rewritten and the cut stays
+ /// reversible for as long as the editor is open.
+ Future setTrim(Duration? start, Duration? end) async {
+ await RustApi.setMediaTrim(
+ mediaId: mediaFile.mediaId,
+ trimStartMs: start?.inMilliseconds,
+ trimEndMs: end?.inMilliseconds,
+ );
+ await updateFromDB();
+ }
+
Future toggleRemoveAudio() async {
await RustApi.toggleMediaRemoveAudio(mediaId: mediaFile.mediaId);
await updateFromDB();
diff --git a/lib/src/utils/log.dart b/lib/src/utils/log.dart
index 78fc8cf1..1b2bb80d 100644
--- a/lib/src/utils/log.dart
+++ b/lib/src/utils/log.dart
@@ -29,7 +29,7 @@ class Log {
record.level >= Level.WARNING) {
// ignore: avoid_print
print(
- '${record.level.name} [${AppState.isInBackgroundTask ? 'b' : 'f'}] [twonly] ${record.loggerName} > ${record.message}',
+ '${record.level.name} [f] [twonly] ${record.loggerName} > ${record.message}',
);
}
}
@@ -72,7 +72,9 @@ class Log {
},
source: record.loggerName,
message: record.message,
- inBackground: AppState.isInBackgroundTask,
+ // Background work runs natively now; anything logged from Dart is by
+ // definition the foreground runtime.
+ inBackground: false,
);
return true;
} catch (error) {
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 6c00a352..e4cd1223 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
@@ -16,6 +16,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/services/mediafiles/mediafile.service.dart';
+import 'package:twonly/src/services/subscription.service.dart';
import 'package:twonly/src/services/user.service.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart';
@@ -31,13 +32,12 @@ import 'package:twonly/src/visual/views/camera/camera_preview_components/face_fi
import 'package:twonly/src/visual/views/camera/camera_preview_components/main_camera_controller.dart';
import 'package:twonly/src/visual/views/camera/camera_preview_components/permissions_view.dart';
import 'package:twonly/src/visual/views/camera/camera_preview_components/send_to.dart';
+import 'package:twonly/src/visual/views/camera/camera_preview_components/video_recording_budget.dart';
import 'package:twonly/src/visual/views/camera/camera_preview_components/video_recording_time.dart';
import 'package:twonly/src/visual/views/camera/share_image_editor.view.dart';
import 'package:twonly/src/visual/views/camera/share_image_editor_components/action_button.dart';
import 'package:twonly/src/visual/views/home.view.dart';
-int maxVideoRecordingTime = 60;
-
class SelectedCameraDetails {
double maxAvailableZoom = 1;
double minAvailableZoom = 1;
@@ -153,6 +153,7 @@ class _CameraPreviewViewState extends State {
DateTime? _videoRecordingStarted;
Timer? _videoRecordingTimer;
bool _videoRecordingLocked = false;
+ Duration _currentMaxRecordingTime = Duration.zero;
DateTime _currentTime = clock.now();
final GlobalKey keyTriggerButton = GlobalKey();
@@ -166,6 +167,7 @@ class _CameraPreviewViewState extends State {
@override
void initState() {
super.initState();
+ unawaited(VideoRecordingBudget.ensureLoaded());
initVolumeControl();
initAsync();
_checkAndInitCamera();
@@ -411,8 +413,14 @@ class _CameraPreviewViewState extends State {
..copySync(mediaFileService.originalPath.path)
..deleteSync();
- // Start with compressing the video, to speed up the process in case the video is not changed.
- // unawaited(mediaFileService.compressMedia());
+ // Transcode while the user is still editing. A send then only has to
+ // encrypt and hand over, which is short enough to survive the app being
+ // closed straight after; the render is discarded if the editor ends up
+ // drawing something on the clip.
+ unawaitedRustCall(
+ RustApi.prerenderMedia(mediaId: mediaId),
+ 'prerenderMedia',
+ );
}
await _deInitVolumeControl();
@@ -611,6 +619,15 @@ class _CameraPreviewViewState extends State {
mc.setFilter(mc.currentFilterType.goRight());
}
+ /// How long this recording may run before the file stops being uploadable
+ /// on the user's plan. Frozen when the recording starts so that the ring and
+ /// the cut-off cannot drift apart if the plan or the camera changes midway.
+ Duration get _maxVideoRecordingTime => VideoRecordingBudget.maxRecordingTime(
+ plan: planFromString(userService.currentUser.subscriptionPlan),
+ recordingSize: mc.cameraController?.value.previewSize,
+ hasAudio: _hasAudioPermission,
+ );
+
Future startVideoRecording() async {
if (mc.cameraController != null &&
mc.cameraController!.value.isRecordingVideo) {
@@ -626,24 +643,31 @@ class _CameraPreviewViewState extends State {
try {
await mc.cameraController?.startVideoRecording();
- _videoRecordingTimer = Timer.periodic(const Duration(milliseconds: 15), (
+ final maxRecordingTime = _maxVideoRecordingTime;
+ setState(() {
+ _currentTime = clock.now();
+ _videoRecordingStarted = _currentTime;
+ _currentMaxRecordingTime = maxRecordingTime;
+ mc.isVideoRecording = true;
+ });
+ _videoRecordingTimer = Timer.periodic(const Duration(milliseconds: 50), (
timer,
) {
+ final startedAt = _videoRecordingStarted;
+ if (startedAt == null) {
+ timer.cancel();
+ _videoRecordingTimer = null;
+ return;
+ }
setState(() {
_currentTime = clock.now();
});
- if (_videoRecordingStarted != null &&
- _currentTime.difference(_videoRecordingStarted!).inSeconds >=
- maxVideoRecordingTime) {
- timer.cancel();
- _videoRecordingTimer = null;
- stopVideoRecording();
+ if (_currentTime.difference(startedAt) >= maxRecordingTime) {
+ // The budget is spent, so this stop is not the user letting go of
+ // the button: it has to go through even while recording is locked.
+ unawaited(stopVideoRecording(force: true));
}
});
- setState(() {
- _videoRecordingStarted = clock.now();
- mc.isVideoRecording = true;
- });
} on CameraException catch (e) {
setState(() {
mc.isVideoRecording = false;
@@ -663,6 +687,15 @@ class _CameraPreviewViewState extends State {
_videoRecordingTimer = null;
}
+ final startedAt = _videoRecordingStarted;
+ // Taken here rather than after the recorder has been asked to stop: the
+ // tail it flushes would otherwise read as recorded time and talk the
+ // measured write rate down.
+ final recordedFor = startedAt == null
+ ? null
+ : clock.now().difference(startedAt);
+ final recordingSize = mc.cameraController?.value.previewSize;
+
await mc.cameraController?.setFlashMode(FlashMode.off);
setState(() {
@@ -684,7 +717,24 @@ class _CameraPreviewViewState extends State {
final videoPath = await mc.cameraController?.stopVideoRecording();
if (videoPath == null) return;
await mc.cameraController?.pausePreview();
- if (await pushMediaEditor(null, File(videoPath.path))) {
+ final videoFile = File(videoPath.path);
+ // Read before the editor takes the file over, and tell the budget what
+ // this device really writes so the next recording is measured, not
+ // estimated.
+ if (recordedFor != null) {
+ try {
+ unawaited(
+ VideoRecordingBudget.recordMeasurement(
+ fileSizeInBytes: videoFile.statSync().size,
+ duration: recordedFor,
+ recordingSize: recordingSize,
+ ),
+ );
+ } catch (e) {
+ Log.warn('Could not measure the recorded video: $e');
+ }
+ }
+ if (await pushMediaEditor(null, videoFile)) {
return;
}
} on CameraException catch (e) {
@@ -838,7 +888,8 @@ class _CameraPreviewViewState extends State {
),
VideoRecordingTimer(
videoRecordingStarted: _videoRecordingStarted,
- maxVideoRecordingTime: maxVideoRecordingTime,
+ currentTime: _currentTime,
+ maxRecordingTime: _currentMaxRecordingTime,
),
if (!mc.isSharePreviewIsShown && widget.sendToGroup != null ||
widget.hideControllers)
diff --git a/lib/src/visual/views/camera/camera_preview_components/video_recording_budget.dart b/lib/src/visual/views/camera/camera_preview_components/video_recording_budget.dart
new file mode 100644
index 00000000..f5f240b0
--- /dev/null
+++ b/lib/src/visual/views/camera/camera_preview_components/video_recording_budget.dart
@@ -0,0 +1,180 @@
+import 'dart:ui' show Size;
+
+import 'package:flutter/foundation.dart' show visibleForTesting;
+import 'package:twonly/src/services/subscription.service.dart';
+import 'package:twonly/src/utils/keyvalue.dart';
+import 'package:twonly/src/utils/log.dart';
+
+/// How long the camera may keep recording before the file outgrows the largest
+/// single object the user's plan lets them upload.
+///
+/// A recorded video is sent exactly as the camera wrote it: nothing re-encodes
+/// it between the recorder and the upload, and the upload encrypts in place,
+/// so the recorder's output size *is* the size the server measures against
+/// `maximal_upload_size_of_single_media_size`. That makes the recording budget
+/// a plain division: plan limit divided by the rate the encoder writes at.
+///
+/// The rate is the part that cannot be known up front. Both platforms record
+/// `ResolutionPreset.high`, which is 720p, but what an encoder spends on those
+/// pixels differs by a factor of several between devices - the same minute
+/// that costs 18 MB on one phone costs far more on a phone whose camcorder
+/// profile asks for a high bitrate. So the first recording on a device is
+/// budgeted from a deliberately pessimistic estimate, and every recording
+/// after that is budgeted from what this device actually wrote.
+abstract final class VideoRecordingBudget {
+ /// Mirrors `maximal_upload_size_of_single_media_size` of the server's plans.
+ /// Every paid plan shares the same per-file limit.
+ static const int freePlanUploadLimitBytes = 50000000;
+ static const int paidPlanUploadLimitBytes = 100000000;
+
+ /// Bits the encoder is assumed to spend per pixel per frame before this
+ /// device has recorded anything. Devices measured so far sit well below
+ /// this; guessing high only costs a shorter first recording, while guessing
+ /// low costs a recording the server refuses once it is already made.
+ static const double _assumedBitsPerPixel = 0.25;
+
+ /// Neither platform reports the recording frame rate, and both record at
+ /// 30 fps unless the scene is too dark for it.
+ static const double _assumedFrameRate = 30;
+
+ /// AAC alongside the video, when the microphone is available.
+ static const double _audioBitsPerSecond = 128000;
+
+ /// Falls back to 720p when the camera has not reported its preview size.
+ static const Size _assumedRecordingSize = Size(1280, 720);
+
+ /// Leaves the container overhead and a scene busier than the one that was
+ /// measured somewhere to go.
+ static const double _headroom = 0.9;
+
+ /// A budget shorter than this makes the camera useless, and one longer than
+ /// this makes an unwieldy file however well it fits the plan.
+ static const Duration _shortestBudget = Duration(seconds: 10);
+ static const Duration _longestBudget = Duration(minutes: 5);
+
+ static const String _storeKey = 'video_recording_bitrate';
+ static const String _samplesField = 'bytesPerSecondByResolution';
+
+ /// Measured write rates in bytes per second, keyed by recording resolution
+ /// so that a different camera or preset does not inherit a rate that was
+ /// never measured for it.
+ static Map _measured = {};
+ static bool _loaded = false;
+
+ static int uploadLimitBytesFor(SubscriptionPlan plan) =>
+ plan == SubscriptionPlan.Free
+ ? freePlanUploadLimitBytes
+ : paidPlanUploadLimitBytes;
+
+ /// Reads back what earlier recordings measured. Cheap to call repeatedly;
+ /// the camera view calls it while it is starting up, long before the first
+ /// recording can be started.
+ static Future ensureLoaded() async {
+ if (_loaded) return;
+ _loaded = true;
+ try {
+ final stored = await KeyValueStore.get(_storeKey);
+ final samples = stored?[_samplesField];
+ if (samples is Map) {
+ _measured = {
+ for (final entry in samples.entries)
+ if (entry.value is num)
+ entry.key.toString(): (entry.value as num).toDouble(),
+ };
+ }
+ } catch (e) {
+ Log.warn('Could not read the measured video bitrate: $e');
+ }
+ }
+
+ @visibleForTesting
+ static void resetForTesting() {
+ _measured = {};
+ _loaded = false;
+ }
+
+ /// How long the camera may record for [plan] before the file would no longer
+ /// be uploadable.
+ static Duration maxRecordingTime({
+ required SubscriptionPlan plan,
+ required Size? recordingSize,
+ required bool hasAudio,
+ }) {
+ final bytesPerSecond = _bytesPerSecond(
+ recordingSize: recordingSize,
+ hasAudio: hasAudio,
+ );
+ final seconds = (uploadLimitBytesFor(plan) * _headroom) / bytesPerSecond;
+ final budget = Duration(milliseconds: (seconds * 1000).round());
+ if (budget < _shortestBudget) return _shortestBudget;
+ if (budget > _longestBudget) return _longestBudget;
+ return budget;
+ }
+
+ /// Feeds back what the encoder really wrote, so the next recording on this
+ /// device is budgeted from a measurement instead of the estimate.
+ ///
+ /// A rate above the one on record is adopted at once - it is proof the
+ /// budget in use was too generous - while a lower one is eased in, because a
+ /// single still scene encodes far smaller than the device's usual output and
+ /// should not talk the budget up.
+ static Future recordMeasurement({
+ required int fileSizeInBytes,
+ required Duration duration,
+ required Size? recordingSize,
+ }) async {
+ // Too short to divide by: the fixed cost of the container and the first
+ // key frame would swamp the rate.
+ if (duration.inMilliseconds < 2000 || fileSizeInBytes <= 0) return;
+
+ await ensureLoaded();
+
+ final key = _resolutionKey(recordingSize);
+ final observed = fileSizeInBytes / (duration.inMilliseconds / 1000);
+ final previous = _measured[key];
+ final updated = (previous == null || observed > previous)
+ ? observed
+ : previous * 0.8 + observed * 0.2;
+
+ _measured[key] = updated;
+ try {
+ await KeyValueStore.put(_storeKey, {_samplesField: _measured});
+ } catch (e) {
+ Log.warn('Could not store the measured video bitrate: $e');
+ }
+ }
+
+ static double _bytesPerSecond({
+ required Size? recordingSize,
+ required bool hasAudio,
+ }) {
+ final measured = _measured[_resolutionKey(recordingSize)];
+ if (measured != null && measured > 0) return measured;
+
+ final size = _sizeOrDefault(recordingSize);
+ final videoBitsPerSecond =
+ _assumedBitsPerPixel * size.width * size.height * _assumedFrameRate;
+ final bitsPerSecond =
+ videoBitsPerSecond + (hasAudio ? _audioBitsPerSecond : 0);
+ return bitsPerSecond / 8;
+ }
+
+ static Size _sizeOrDefault(Size? recordingSize) {
+ if (recordingSize == null ||
+ recordingSize.width <= 0 ||
+ recordingSize.height <= 0) {
+ return _assumedRecordingSize;
+ }
+ return recordingSize;
+ }
+
+ /// Orientation is not part of the key: the camera reports the preview size
+ /// rotated on one platform and not on the other, and only the pixel count
+ /// matters here.
+ static String _resolutionKey(Size? recordingSize) {
+ final size = _sizeOrDefault(recordingSize);
+ final shorter = size.shortestSide.round();
+ final longer = size.longestSide.round();
+ return '${longer}x$shorter';
+ }
+}
diff --git a/lib/src/visual/views/camera/camera_preview_components/video_recording_time.dart b/lib/src/visual/views/camera/camera_preview_components/video_recording_time.dart
index 5177ecb9..57589293 100644
--- a/lib/src/visual/views/camera/camera_preview_components/video_recording_time.dart
+++ b/lib/src/visual/views/camera/camera_preview_components/video_recording_time.dart
@@ -1,66 +1,66 @@
-import 'package:clock/clock.dart';
import 'package:flutter/material.dart';
class VideoRecordingTimer extends StatelessWidget {
const VideoRecordingTimer({
required this.videoRecordingStarted,
- required this.maxVideoRecordingTime,
+ required this.currentTime,
+ required this.maxRecordingTime,
super.key,
});
final DateTime? videoRecordingStarted;
- final int maxVideoRecordingTime;
+
+ /// Read once per tick by the camera view, so the ring and the stop that ends
+ /// the recording agree on how far along it is.
+ final DateTime currentTime;
+ final Duration maxRecordingTime;
@override
Widget build(BuildContext context) {
- if (videoRecordingStarted != null) {
- final currentTime = clock.now();
- return Positioned(
- top: 50,
- left: 0,
- right: 0,
- child: Center(
- child: SizedBox(
- width: 50,
- height: 50,
- child: Stack(
- children: [
- Center(
- child: CircularProgressIndicator(
- value:
- currentTime
- .difference(videoRecordingStarted!)
- .inMilliseconds /
- (maxVideoRecordingTime * 1000),
- strokeWidth: 4,
- valueColor: const AlwaysStoppedAnimation(Colors.red),
- backgroundColor: Colors.grey[300],
- ),
- ),
- Center(
- child: Text(
- currentTime
- .difference(videoRecordingStarted!)
- .inSeconds
- .toString(),
- textAlign: TextAlign.center,
- style: const TextStyle(
- fontSize: 17,
- shadows: [
- Shadow(
- color: Color.fromARGB(122, 0, 0, 0),
- blurRadius: 5,
- ),
- ],
- ),
- ),
- ),
- ],
- ),
- ),
- ),
- );
- } else {
+ if (videoRecordingStarted == null) {
return const SizedBox.shrink();
}
+ final elapsed = currentTime.difference(videoRecordingStarted!);
+ return Positioned(
+ top: 50,
+ left: 0,
+ right: 0,
+ child: Center(
+ child: SizedBox(
+ width: 50,
+ height: 50,
+ child: Stack(
+ children: [
+ Center(
+ child: CircularProgressIndicator(
+ value: maxRecordingTime.inMilliseconds == 0
+ ? 0
+ : (elapsed.inMilliseconds /
+ maxRecordingTime.inMilliseconds)
+ .clamp(0.0, 1.0),
+ strokeWidth: 4,
+ valueColor: const AlwaysStoppedAnimation(Colors.red),
+ backgroundColor: Colors.grey[300],
+ ),
+ ),
+ Center(
+ child: Text(
+ elapsed.inSeconds.toString(),
+ textAlign: TextAlign.center,
+ style: const TextStyle(
+ fontSize: 17,
+ shadows: [
+ Shadow(
+ color: Color.fromARGB(122, 0, 0, 0),
+ blurRadius: 5,
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
}
}
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 18c9e8f2..9d5ef0f8 100644
--- a/lib/src/visual/views/camera/share_image_editor.view.dart
+++ b/lib/src/visual/views/camera/share_image_editor.view.dart
@@ -1,40 +1,40 @@
-// ignore_for_file: inference_failure_on_function_invocation
-
import 'dart:async';
import 'dart:collection';
-import 'dart:typed_data';
import 'package:drift/drift.dart' show Value;
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/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/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';
-import 'package:twonly/src/visual/components/emoji_picker.bottom.dart';
-import 'package:twonly/src/visual/components/notification_badge.comp.dart';
-import 'package:twonly/src/visual/elements/my_button.element.dart';
import 'package:twonly/src/visual/helpers/media_view_sizing.helper.dart';
import 'package:twonly/src/visual/helpers/screenshot.helper.dart';
import 'package:twonly/src/visual/views/camera/camera_preview_components/main_camera_controller.dart';
-import 'package:twonly/src/visual/views/camera/camera_preview_components/save_to_gallery.dart';
import 'package:twonly/src/visual/views/camera/share_image_contact_selection.view.dart';
-import 'package:twonly/src/visual/views/camera/share_image_contact_selection_components/select_show_time.dart';
-import 'package:twonly/src/visual/views/camera/share_image_editor_components/action_button.dart';
+import 'package:twonly/src/visual/views/camera/share_image_editor_components/discard_media_dialog.dart';
+import 'package:twonly/src/visual/views/camera/share_image_editor_components/display_time_picker.dart';
+import 'package:twonly/src/visual/views/camera/share_image_editor_components/editor_bottom_bar.dart';
+import 'package:twonly/src/visual/views/camera/share_image_editor_components/editor_canvas.dart';
+import 'package:twonly/src/visual/views/camera/share_image_editor_components/editor_layer_stack.dart';
+import 'package:twonly/src/visual/views/camera/share_image_editor_components/editor_media_writer.dart';
+import 'package:twonly/src/visual/views/camera/share_image_editor_components/editor_side_toolbar.dart';
+import 'package:twonly/src/visual/views/camera/share_image_editor_components/editor_top_toolbar.dart';
import 'package:twonly/src/visual/views/camera/share_image_editor_components/image_item.dart';
-import 'package:twonly/src/visual/views/camera/share_image_editor_components/layer_data.dart';
-import 'package:twonly/src/visual/views/camera/share_image_editor_components/layers_viewer.dart';
+import 'package:twonly/src/visual/views/camera/share_image_editor_components/video_trimmer.dart';
import 'package:video_player/video_player.dart';
-List layers = [];
-List undoLayers = [];
-List removedLayers = [];
-
+/// Lets the user edit a just taken (or shared) photo/video/gif and send it.
+///
+/// The screen is built out of four parts:
+/// * [EditorCanvas] shows the media with all edits stacked on top of it
+/// * [EditorTopToolbar] holds close/undo/redo
+/// * [EditorSideToolbar] holds the editing tools
+/// * [EditorBottomBar] holds save to gallery and send
+///
+/// The edits themselves live in an [EditorLayerStack], writing them to disk is
+/// done by an [EditorMediaWriter].
class ShareImageEditorView extends StatefulWidget {
const ShareImageEditorView({
required this.sharedFromGallery,
@@ -56,15 +56,29 @@ class ShareImageEditorView extends StatefulWidget {
}
class _ShareImageEditorView extends State {
+ final layerStack = EditorLayerStack();
+ late final EditorMediaWriter mediaWriter;
+
double tabDownPosition = 0;
bool sendingOrLoadingImage = true;
bool loadingImage = true;
bool isDisposed = false;
HashSet selectedGroupIds = HashSet();
- double widthRatio = 1;
- double heightRatio = 1;
double pixelRatio = 1;
VideoPlayerController? videoController;
+
+ /// The cut the trimmer is showing. Held here as well as in the media row so
+ /// dragging a handle repaints immediately instead of waiting on a write, and
+ /// so the send path and the preview always agree on the same bounds.
+ Duration _trimStart = Duration.zero;
+
+ /// Null until the trimmer is dragged or a stored cut is loaded; it then means
+ /// "the clip ends here" while null keeps the recording's own end.
+ Duration? _trimEnd;
+
+ /// The cutter lies over the video, so it can be put away to see the frame
+ /// underneath it. Open to begin with, otherwise nothing says it is there.
+ bool _trimmerVisible = true;
ImageItem currentImage = ImageItem();
ScreenshotController screenshotController = ScreenshotController();
Timer? _imageLoadingTimer;
@@ -76,63 +90,42 @@ class _ShareImageEditorView extends State {
void initState() {
super.initState();
+ mediaWriter = EditorMediaWriter(
+ mediaService: mediaService,
+ layerStack: layerStack,
+ screenshotController: screenshotController,
+ gifSource: widget.screenshotImage,
+ requestRebuild: () {
+ if (mounted) setState(() {});
+ },
+ );
+
if (media.type != MediaType.gif) {
- layers.add(FilterLayerData(key: GlobalKey()));
+ layerStack.addFilterLayer();
}
- if (widget.previewLink != null &&
- widget.previewLink!.shouldGeneratePreview) {
- layers.add(
- LinkPreviewLayerData(key: GlobalKey(), link: widget.previewLink!.url),
- );
+ final previewLink = widget.previewLink;
+ if (previewLink != null && previewLink.shouldGeneratePreview) {
+ layerStack.addLinkPreviewLayer(previewLink.url);
}
if (widget.sendToGroup != null) {
selectedGroupIds.add(widget.sendToGroup!.groupId);
}
- if (widget.mediaFileService.mediaFile.type == MediaType.image ||
- widget.mediaFileService.mediaFile.type == MediaType.gif) {
- if (widget.screenshotImage != null) {
- loadImage(widget.screenshotImage!);
- } else {
- if (widget.mediaFileService.tempPath.existsSync()) {
- loadImage(
- ScreenshotImageHelper(file: widget.mediaFileService.tempPath),
- );
- } else if (widget.mediaFileService.originalPath.existsSync()) {
- loadImage(
- ScreenshotImageHelper(file: widget.mediaFileService.originalPath),
- );
- }
- }
+ if (media.type == MediaType.image || media.type == MediaType.gif) {
+ _loadInitialImage();
}
if (media.type == MediaType.video) {
- setState(() {
- sendingOrLoadingImage = false;
- loadingImage = false;
- });
- videoController = VideoPlayerController.file(
- mediaService.originalPath,
- videoPlayerOptions: VideoPlayerOptions(),
- );
- videoController?.setLooping(true);
- videoController
- ?.initialize()
- .then((_) async {
- await videoController!.play();
- setState(() {});
- })
- // ignore: argument_type_not_assignable_to_error_handler
- .catchError(Log.error);
+ _initVideoController();
}
}
@override
void dispose() {
isDisposed = true;
- layers.clear();
+ layerStack.clear();
videoController?.dispose();
twonlyDB.mediaFilesDao.updateAllMediaFiles(
const MediaFilesCompanion(
@@ -143,424 +136,74 @@ class _ShareImageEditorView extends State {
super.dispose();
}
- void updateSelectedGroupIds(String groupId, bool checked) {
- if (checked) {
- if (media.requiresAuthentication) {
- selectedGroupIds.clear();
- }
- selectedGroupIds.add(groupId);
- } else {
- selectedGroupIds.remove(groupId);
- }
- setState(() {});
- }
+ // ---------------------------------------------------------------------------
+ // loading the media
+ // ---------------------------------------------------------------------------
- Future _setMaxShowTime(int? maxShowTime, bool storeAsDefault) async {
- await mediaService.setDisplayLimit(maxShowTime);
- if (!mounted) return;
- setState(() {});
- if (storeAsDefault) {
- await UserService.update((user) {
- user.defaultShowTime = maxShowTime;
- });
- }
- }
-
- Future _setImageDisplayTime() async {
- if (media.type == MediaType.video) {
- await mediaService.setDisplayLimit(
- (media.displayLimitInMilliseconds == null) ? 0 : null,
- );
- if (!mounted) return;
- setState(() {});
+ void _loadInitialImage() {
+ if (widget.screenshotImage != null) {
+ loadImage(widget.screenshotImage!);
return;
}
-
- final options = [
- 1000,
- 2000,
- 3000,
- 4000,
- 5000,
- 6000,
- 7000,
- 8000,
- 9000,
- 10000,
- 15000,
- 20000,
- null,
- ];
-
- var initialItem = options.length - 1;
- if (media.displayLimitInMilliseconds != null) {
- initialItem = options.indexOf(media.displayLimitInMilliseconds);
- if (initialItem == -1) {
- initialItem = options.length - 1;
- }
- }
- await showModalBottomSheet(
- context: context,
- backgroundColor: Colors.black,
- builder: (context) {
- return SelectShowTime(
- initialItem: initialItem,
- setMaxShowTime: _setMaxShowTime,
- options: options,
- );
- },
- );
- }
-
- List get actionsAtTheRight {
- if (layers.isNotEmpty &&
- (layers.first.isEditing ||
- (layers.last.isEditing && layers.last.hasCustomActionButtons))) {
- return [];
- }
- return [
- if (media.type != MediaType.gif)
- ActionButton(
- Icons.text_fields_rounded,
- tooltipText: context.lang.addTextItem,
- onPressed: () async {
- layers = layers.where((x) => !x.isDeleted).toList();
- if (layers.any((x) => x.isEditing)) return;
- undoLayers.clear();
- removedLayers.clear();
- layers.add(
- TextLayerData(
- key: GlobalKey(),
- textLayersBefore: layers.whereType().length,
- ),
- );
- setState(() {});
- },
- ),
- const SizedBox(height: 8),
- if (media.type != MediaType.gif)
- ActionButton(
- Icons.draw_rounded,
- tooltipText: context.lang.addDrawing,
- onPressed: () async {
- undoLayers.clear();
- removedLayers.clear();
- layers.add(DrawLayerData(key: GlobalKey()));
- setState(() {});
- },
- ),
- const SizedBox(height: 8),
- if (media.type != MediaType.gif)
- ActionButton(
- Icons.add_reaction_outlined,
- tooltipText: context.lang.addEmoji,
- onPressed: () async {
- final layer =
- await showModalBottomSheet(
- context: context,
- backgroundColor: Colors.black,
- builder: (context) {
- return const EmojiPickerBottom();
- },
- )
- as Layer?;
- if (layer == null) return;
- undoLayers.clear();
- removedLayers.clear();
- layers.add(layer);
- setState(() {});
- },
- ),
- const SizedBox(height: 8),
- NotificationBadgeComp(
- count: (media.type == MediaType.video)
- ? '0'
- : media.displayLimitInMilliseconds == null
- ? '∞'
- : (media.displayLimitInMilliseconds! ~/ 1000).toString(),
- child: ActionButton(
- (media.type == MediaType.video)
- ? media.displayLimitInMilliseconds == null
- ? Icons.repeat_rounded
- : Icons.repeat_one_rounded
- : Icons.timer_outlined,
- tooltipText: context.lang.protectAsARealTwonly,
- onPressed: _setImageDisplayTime,
- ),
- ),
- if (media.type == MediaType.video) ...[
- const SizedBox(height: 8),
- ActionButton(
- (mediaService.removeAudio)
- ? Icons.volume_off_rounded
- : Icons.volume_up_rounded,
- tooltipText: 'Enable Audio in Video',
- color: (mediaService.removeAudio)
- ? Colors.white.withAlpha(160)
- : Colors.white,
- onPressed: () async {
- await mediaService.toggleRemoveAudio();
- if (mediaService.removeAudio) {
- await videoController?.setVolume(0);
- } else {
- await videoController?.setVolume(100);
- }
- if (mounted) setState(() {});
- },
- ),
- ],
- if (media.type == MediaType.image) ...[
- const SizedBox(height: 8),
- ActionButton(
- Icons.crop_rotate_outlined,
- tooltipText: 'Crop or rotate image',
- color: Colors.white,
- onPressed: () async {
- final first = layers.first;
- if (first is BackgroundLayerData) {
- first.isEditing = !first.isEditing;
- }
- setState(() {});
- // await mediaService.toggleRemoveAudio();
- // if (mediaService.removeAudio) {
- // await videoController?.setVolume(0);
- // } else {
- // await videoController?.setVolume(100);
- // }
- // if (mounted) setState(() {});
- },
- ),
- ],
- const SizedBox(height: 8),
- ActionButton(
- FontAwesomeIcons.shieldHeart,
- tooltipText: context.lang.protectAsARealTwonly,
- color: media.requiresAuthentication
- ? Theme.of(context).colorScheme.primary
- : Colors.white,
- onPressed: () async {
- await mediaService.setRequiresAuth(!media.requiresAuthentication);
- selectedGroupIds = HashSet();
- setState(() {});
- },
- ),
- ];
- }
-
- Future _showBackDialog() {
- return showDialog(
- context: context,
- builder: (context) {
- return AlertDialog(
- title: Text(
- context.lang.dialogAskDeleteMediaFilePopTitle,
- ),
- actions: [
- MyButton(
- variant: MyButtonVariant.primaryMiddle,
- onPressed: () {
- Navigator.pop(context, true);
- },
- child: Text(context.lang.dialogAskDeleteMediaFilePopDelete),
- ),
- TextButton(
- child: Text(context.lang.cancel),
- onPressed: () {
- Navigator.pop(context, false);
- },
- ),
- ],
- );
- },
- );
- }
-
- Future askToCloseThenClose() async {
- final shouldPop = await _showBackDialog() ?? false;
- if (mounted && shouldPop) {
- Navigator.pop(context);
- }
- }
-
- List get actionsAtTheTop {
- if (layers.isNotEmpty &&
- (layers.first.isEditing ||
- (layers.last.isEditing && layers.last.hasCustomActionButtons))) {
- return [];
- }
- return [
- ActionButton(
- FontAwesomeIcons.xmark,
- tooltipText: context.lang.close,
- onPressed: () async {
- final nonImageFilterLayer = layers.where(
- (x) => x is! BackgroundLayerData && x is! FilterLayerData,
- );
- if (nonImageFilterLayer.isEmpty) {
- Navigator.pop(context, false);
- } else {
- await askToCloseThenClose();
- }
- },
- ),
- Expanded(child: Container()),
- const SizedBox(width: 8),
- ActionButton(
- FontAwesomeIcons.rotateLeft,
- tooltipText: context.lang.undo,
- disable: layers.where((x) => !x.isDeleted).length <= 2,
- onPressed: () {
- if (removedLayers.isNotEmpty) {
- final lastLayer = removedLayers.removeLast()
- ..isDeleted = false
- ..isEditing = false;
- layers.add(lastLayer);
- setState(() {});
- return;
- }
- layers = layers.where((x) => !x.isDeleted).toList();
- if (layers.length <= 2) {
- // do not remove image layer and filter layer
- return;
- }
- undoLayers.add(layers.removeLast());
- setState(() {});
- },
- ),
- const SizedBox(width: 8),
- ActionButton(
- FontAwesomeIcons.rotateRight,
- tooltipText: context.lang.redo,
- disable: undoLayers.isEmpty,
- onPressed: () {
- if (undoLayers.isEmpty) return;
- layers.add(undoLayers.removeLast());
- setState(() {});
- },
- ),
- const SizedBox(width: 70),
- ];
- }
-
- Future pushShareImageView() async {
- final mediaStoreFuture = storeImageAsOriginal();
-
- await videoController?.pause();
- if (isDisposed || !mounted) return;
- final wasSend =
- await Navigator.push(
- context,
- MaterialPageRoute(
- builder: (context) => ShareImageView(
- selectedGroupIds: selectedGroupIds,
- updateSelectedGroupIds: updateSelectedGroupIds,
- mediaStoreFuture: mediaStoreFuture,
- mediaFileService: mediaService,
- additionalData: getAdditionalData(),
- ),
- ),
- )
- as bool?;
- if (wasSend != null && wasSend && mounted) {
- widget.mainCameraController?.onImageSend();
- Navigator.pop(context, true);
- } else {
- await videoController?.play();
- }
- }
-
- Future getEditedImageBytes() async {
- if (layers.length == 1) {
- if (layers.first is BackgroundLayerData) {
- return (layers.first as BackgroundLayerData).image.image;
- }
- }
- if (layers.length == 2) {
- final filterLayer = layers[1];
- if (layers.first is BackgroundLayerData &&
- filterLayer is FilterLayerData) {
- if (filterLayer.page == 1) {
- return (layers.first as BackgroundLayerData).image.image;
- }
- }
- }
-
- for (final x in layers) {
- x.showCustomButtons = false;
- }
- setState(() {});
-
- // Make a short delay, so the setState does have its effect...
- await Future.delayed(const Duration(milliseconds: 80));
-
- final image = await screenshotController.capture(
- pixelRatio: pixelRatio,
- );
- if (image == null) {
- Log.warn('screenshotController did not return image bytes');
- return null;
- }
-
- for (final x in layers) {
- x.showCustomButtons = true;
- }
- if (mounted) {
- setState(() {});
- }
- return image;
- }
-
- Future storeImageAsOriginal() async {
- Uint8List? gifBytes;
- ScreenshotImageHelper? image;
- if (media.type == MediaType.gif) {
- gifBytes = await widget.screenshotImage?.getBytes();
- } else {
- image = await getEditedImageBytes();
- if (image != null) {
- await image.getBytes();
- }
- }
-
- if (mediaService.overlayImagePath.existsSync()) {
- mediaService.overlayImagePath.deleteSync();
- }
if (mediaService.tempPath.existsSync()) {
- mediaService.tempPath.deleteSync();
+ loadImage(ScreenshotImageHelper(file: mediaService.tempPath));
+ } else if (mediaService.originalPath.existsSync()) {
+ loadImage(ScreenshotImageHelper(file: mediaService.originalPath));
}
- if (mediaService.originalPath.existsSync()) {
- if (media.type == MediaType.image) {
- mediaService.originalPath.deleteSync();
- }
- }
-
- if (media.type == MediaType.gif) {
- if (gifBytes != null) {
- mediaService.originalPath.writeAsBytesSync(gifBytes.toList());
- }
- } else {
- if (image == null) return null;
- final bytes = await image.getBytes();
- if (bytes == null) {
- Log.warn('imageBytes are empty');
- return null;
- }
- if (media.type == MediaType.image || media.type == MediaType.gif) {
- mediaService.originalPath.writeAsBytesSync(bytes);
- } else if (media.type == MediaType.video) {
- mediaService.overlayImagePath.writeAsBytesSync(bytes);
- } else {
- Log.error('MediaType not supported: ${media.type}');
- }
- }
- return image;
}
- Future storeIoImageAsDraft(
- ScreenshotImageHelper screenshotImage,
- ) async {
- final imageBytes = await screenshotImage.getBytes();
- mediaService.originalPath.writeAsBytesSync(imageBytes!.toList());
+ void _initVideoController() {
+ setState(() {
+ sendingOrLoadingImage = false;
+ loadingImage = false;
+ });
+ videoController = VideoPlayerController.file(
+ mediaService.originalPath,
+ videoPlayerOptions: VideoPlayerOptions(),
+ );
+ videoController?.setLooping(true);
+ videoController
+ ?.initialize()
+ .then((_) async {
+ _loadStoredTrim();
+ if (_trimStart > Duration.zero) {
+ await videoController!.seekTo(_trimStart);
+ }
+ await videoController!.play();
+ setState(() {});
+ })
+ // ignore: argument_type_not_assignable_to_error_handler
+ .catchError(Log.error);
+ }
+
+ /// Restores a cut made before the editor was closed and reopened on the same
+ /// draft. Bounds that no longer fit the recording are dropped rather than
+ /// clamped, because a mismatch means they belong to a different clip.
+ void _loadStoredTrim() {
+ final duration = videoController?.value.duration ?? Duration.zero;
+ final start = mediaService.trimStart;
+ final end = mediaService.trimEnd;
+ if (start != null && start > Duration.zero && start < duration) {
+ _trimStart = start;
+ }
+ if (end != null && end > _trimStart && end < duration) {
+ _trimEnd = end;
+ }
+ }
+
+ /// Where the clip ends, with "not cut" resolved to the end of the recording.
+ Duration get _effectiveTrimEnd =>
+ _trimEnd ?? videoController?.value.duration ?? Duration.zero;
+
+ /// Stores the cut once the finger lifts. An end on the last frame is stored
+ /// as "not cut" so a clip nobody shortened never carries a bound that a
+ /// re-encode could round past its own duration.
+ Future _persistTrim(Duration start, Duration end) async {
+ final duration = videoController?.value.duration ?? Duration.zero;
+ await mediaService.setTrim(
+ start > Duration.zero ? start : null,
+ end < duration ? end : null,
+ );
}
Future loadImage(ScreenshotImageHelper screenshotImage) async {
@@ -568,10 +211,10 @@ class _ShareImageEditorView extends State {
screenshotImage.imageBytes == null &&
screenshotImage.imageBytesFuture != null) {
// this ensures that the imageBytes are defined
- await storeIoImageAsDraft(screenshotImage);
+ await mediaWriter.storeAsDraft(screenshotImage);
} else {
// store this image so it can be used as a draft in case the app is restarted
- unawaited(storeIoImageAsDraft(screenshotImage));
+ unawaited(mediaWriter.storeAsDraft(screenshotImage));
}
if (screenshotImage.image == null) {
@@ -598,46 +241,126 @@ class _ShareImageEditorView extends State {
});
setState(() {
- layers.insert(
- 0,
- BackgroundLayerData(
- key: GlobalKey(),
- image: currentImage,
- ),
- );
+ layerStack.insertBackgroundLayer(currentImage);
});
- // It is important that the user can sending the image only when the image is fully loaded otherwise if the user
- // will click on send before the image is painted the screenshot will be transparent..
+
+ _waitUntilBackgroundIsPainted();
+ }
+
+ /// The user may only send the image once it is fully painted, otherwise the
+ /// screenshot taken for the export would be transparent.
+ void _waitUntilBackgroundIsPainted() {
_imageLoadingTimer = Timer.periodic(const Duration(milliseconds: 10), (
timer,
) {
- final imageLayer = layers.first;
- if (imageLayer is BackgroundLayerData) {
- if (imageLayer.imageLoaded) {
- timer.cancel();
- Future.delayed(const Duration(milliseconds: 50), () {
- if (context.mounted) {
- setState(() {
- sendingOrLoadingImage = false;
- loadingImage = false;
- });
- }
+ if (!layerStack.isBackgroundLoaded) return;
+ timer.cancel();
+ Future.delayed(const Duration(milliseconds: 50), () {
+ if (context.mounted) {
+ setState(() {
+ sendingOrLoadingImage = false;
+ loadingImage = false;
});
}
- }
+ });
});
}
- AdditionalMessageData? getAdditionalData() {
- AdditionalMessageData? additionalData;
+ // ---------------------------------------------------------------------------
+ // toolbar actions
+ // ---------------------------------------------------------------------------
- if (widget.previewLink != null) {
- additionalData = AdditionalMessageData(
- type: AdditionalMessageData_Type.LINK,
- link: widget.previewLink!.url.toString(),
- );
+ Future _toggleAudio() async {
+ await mediaService.toggleRemoveAudio();
+ if (mediaService.removeAudio) {
+ await videoController?.setVolume(0);
+ } else {
+ await videoController?.setVolume(100);
+ }
+ if (mounted) setState(() {});
+ }
+
+ Future _toggleRequiresAuth() async {
+ await mediaService.setRequiresAuth(!media.requiresAuthentication);
+ selectedGroupIds = HashSet();
+ if (mounted) setState(() {});
+ }
+
+ Future _editDisplayTime() async {
+ await showDisplayTimePicker(
+ context,
+ mediaService: mediaService,
+ onChanged: () {
+ if (mounted) setState(() {});
+ },
+ );
+ }
+
+ Future _onClosePressed() async {
+ if (!layerStack.hasUserAddedLayers) {
+ Navigator.pop(context, false);
+ return;
+ }
+ await askToCloseThenClose();
+ }
+
+ Future askToCloseThenClose() async {
+ final shouldPop = await askToDiscardMedia(context);
+ if (mounted && shouldPop) {
+ Navigator.pop(context);
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // sending
+ // ---------------------------------------------------------------------------
+
+ void updateSelectedGroupIds(String groupId, bool checked) {
+ if (checked) {
+ if (media.requiresAuthentication) {
+ selectedGroupIds.clear();
+ }
+ selectedGroupIds.add(groupId);
+ } else {
+ selectedGroupIds.remove(groupId);
+ }
+ setState(() {});
+ }
+
+ Future storeImageAsOriginal() =>
+ mediaWriter.storeImageAsOriginal(pixelRatio);
+
+ AdditionalMessageData? getAdditionalData() {
+ if (widget.previewLink == null) return null;
+ return AdditionalMessageData(
+ type: AdditionalMessageData_Type.LINK,
+ link: widget.previewLink!.url.toString(),
+ );
+ }
+
+ Future pushShareImageView() async {
+ final mediaStoreFuture = storeImageAsOriginal();
+
+ await videoController?.pause();
+ if (isDisposed || !mounted) return;
+ final wasSend = await Navigator.push(
+ context,
+ MaterialPageRoute(
+ builder: (context) => ShareImageView(
+ selectedGroupIds: selectedGroupIds,
+ updateSelectedGroupIds: updateSelectedGroupIds,
+ mediaStoreFuture: mediaStoreFuture,
+ mediaFileService: mediaService,
+ additionalData: getAdditionalData(),
+ ),
+ ),
+ );
+ if (wasSend != null && wasSend && mounted) {
+ widget.mainCameraController?.onImageSend();
+ Navigator.pop(context, true);
+ } else {
+ await videoController?.play();
}
- return additionalData;
}
Future sendImageToSinglePerson() async {
@@ -665,23 +388,22 @@ class _ShareImageEditorView extends State {
}
}
- Widget _buildScreenshotViewer() {
- return Screenshot(
- controller: screenshotController,
- child: LayersViewer(
- layers: layers.where((x) => !x.isDeleted).toList(),
- onUpdate: () {
- for (final layer in layers) {
- layer.isEditing = false;
- if (layer.isDeleted) {
- removedLayers.add(layer);
- }
- }
- layers = layers.where((x) => !x.isDeleted).toList();
- setState(() {});
- },
- ),
- );
+ // ---------------------------------------------------------------------------
+ // layout
+ // ---------------------------------------------------------------------------
+
+ /// The cutter only makes sense once the player knows how long the recording
+ /// is, and only for a video that is still being edited.
+ bool get _canTrim =>
+ media.type == MediaType.video &&
+ videoController != null &&
+ videoController!.value.isInitialized &&
+ videoController!.value.duration > Duration.zero;
+
+ /// Tapping anywhere on the media adds a text layer at that position.
+ void _onCanvasTap() {
+ layerStack.addTextLayer(offset: Offset(0, tabDownPosition));
+ setState(() {});
}
@override
@@ -710,120 +432,44 @@ class _ShareImageEditorView extends State {
tabDownPosition = details.globalPosition.dy;
}
},
- onTap: () {
- if (layers.any((x) => x.isEditing)) {
- return;
- }
- layers = layers.where((x) => !x.isDeleted).toList();
- undoLayers.clear();
- removedLayers.clear();
- layers.add(
- TextLayerData(
- key: GlobalKey(),
- offset: Offset(0, tabDownPosition),
- textLayersBefore: layers.whereType().length,
- ),
- );
- setState(() {});
- },
+ onTap: _onCanvasTap,
child: MediaViewSizingHelper(
requiredHeight: 59,
- bottomNavigation: ColoredBox(
- color: Theme.of(context).colorScheme.surface,
- child: Row(
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- SaveToGalleryButton(
- storeImageAsOriginal: storeImageAsOriginal,
- mediaService: mediaService,
- displayButtonLabel: widget.sendToGroup == null,
- isLoading: loadingImage,
- ),
- if (widget.sendToGroup != null) const SizedBox(width: 10),
- if (widget.sendToGroup != null)
- MyButton(
- variant: MyButtonVariant.secondaryMiddle,
- onPressed: pushShareImageView,
- child: const FaIcon(
- FontAwesomeIcons.userPlus,
- size: 14,
- ),
- ),
- SizedBox(width: widget.sendToGroup == null ? 20 : 10),
- IntrinsicWidth(
- child: MyButton(
- variant: MyButtonVariant.primaryMiddle,
- onPressed: sendingOrLoadingImage
- ? null
- : () async {
- if (widget.sendToGroup == null) {
- return pushShareImageView();
- }
- await sendImageToSinglePerson();
- },
- child: Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- if (sendingOrLoadingImage)
- const SizedBox(
- height: 12,
- width: 12,
- child: CircularProgressIndicator.adaptive(
- strokeWidth: 2,
- valueColor: AlwaysStoppedAnimation(
- Colors.black87,
- ),
- ),
- )
- else
- const FaIcon(
- FontAwesomeIcons.solidPaperPlane,
- size: 14,
- ),
- const SizedBox(width: 8),
- Text(
- (widget.sendToGroup == null)
- ? context.lang.shareImagedEditorShareWith
- : substringBy(
- widget.sendToGroup!.groupName,
- 15,
- ),
- ),
- ],
- ),
- ),
- ),
- ],
- ),
+ bottomNavigation: EditorBottomBar(
+ mediaService: mediaService,
+ sendToGroup: widget.sendToGroup,
+ isLoadingImage: loadingImage,
+ isSending: sendingOrLoadingImage,
+ storeImageAsOriginal: storeImageAsOriginal,
+ onAddMoreRecipients: pushShareImageView,
+ onSend: () async {
+ if (widget.sendToGroup == null) {
+ return pushShareImageView();
+ }
+ await sendImageToSinglePerson();
+ },
),
- child: SizedBox(
- height: currentImage.height / pixelRatio,
- width: currentImage.width / pixelRatio,
- child: Stack(
- children: [
- if (videoController != null &&
- videoController!.value.isInitialized)
- Positioned.fill(
- child: Center(
- child: AspectRatio(
- aspectRatio: videoController!.value.aspectRatio,
- child: Stack(
- children: [
- Positioned.fill(
- child: VideoPlayer(videoController!),
- ),
- Positioned.fill(
- child: _buildScreenshotViewer(),
- ),
- ],
- ),
- ),
- ),
+ child: EditorCanvas(
+ layerStack: layerStack,
+ screenshotController: screenshotController,
+ image: currentImage,
+ pixelRatio: pixelRatio,
+ videoController: videoController,
+ onLayersUpdated: () => setState(() {}),
+ bottomOverlay: (_canTrim && _trimmerVisible)
+ ? VideoTrimmer(
+ controller: videoController!,
+ start: _trimStart,
+ end: _effectiveTrimEnd,
+ onChanged: (start, end) {
+ setState(() {
+ _trimStart = start;
+ _trimEnd = end;
+ });
+ },
+ onChangeEnd: _persistTrim,
)
- else
- _buildScreenshotViewer(),
- ],
- ),
+ : null,
),
),
),
@@ -832,8 +478,10 @@ class _ShareImageEditorView extends State {
left: 5,
right: 0,
child: SafeArea(
- child: Row(
- children: actionsAtTheTop,
+ child: EditorTopToolbar(
+ layerStack: layerStack,
+ onClose: _onClosePressed,
+ onChanged: () => setState(() {}),
),
),
),
@@ -844,9 +492,17 @@ class _ShareImageEditorView extends State {
alignment: Alignment.bottomCenter,
padding: const EdgeInsets.symmetric(vertical: 16),
child: SafeArea(
- child: Column(
- mainAxisAlignment: MainAxisAlignment.center,
- children: actionsAtTheRight,
+ child: EditorSideToolbar(
+ layerStack: layerStack,
+ mediaService: mediaService,
+ onChanged: () => setState(() {}),
+ onEditDisplayTime: _editDisplayTime,
+ onToggleAudio: _toggleAudio,
+ onToggleRequiresAuth: _toggleRequiresAuth,
+ canTrim: _canTrim,
+ trimmerVisible: _trimmerVisible,
+ onToggleTrimmer: () =>
+ setState(() => _trimmerVisible = !_trimmerVisible),
),
),
),
diff --git a/lib/src/visual/views/camera/share_image_editor_components/discard_media_dialog.dart b/lib/src/visual/views/camera/share_image_editor_components/discard_media_dialog.dart
new file mode 100644
index 00000000..875f9ded
--- /dev/null
+++ b/lib/src/visual/views/camera/share_image_editor_components/discard_media_dialog.dart
@@ -0,0 +1,31 @@
+import 'package:flutter/material.dart';
+import 'package:twonly/src/utils/misc.dart';
+import 'package:twonly/src/visual/elements/my_button.element.dart';
+
+/// Asks the user whether the media and all its edits should be thrown away.
+Future askToDiscardMedia(BuildContext context) async {
+ final shouldDiscard = await showDialog(
+ context: context,
+ builder: (context) {
+ return AlertDialog(
+ title: Text(context.lang.dialogAskDeleteMediaFilePopTitle),
+ actions: [
+ MyButton(
+ variant: MyButtonVariant.primaryMiddle,
+ onPressed: () {
+ Navigator.pop(context, true);
+ },
+ child: Text(context.lang.dialogAskDeleteMediaFilePopDelete),
+ ),
+ TextButton(
+ child: Text(context.lang.cancel),
+ onPressed: () {
+ Navigator.pop(context, false);
+ },
+ ),
+ ],
+ );
+ },
+ );
+ return shouldDiscard ?? false;
+}
diff --git a/lib/src/visual/views/camera/share_image_editor_components/display_time_picker.dart b/lib/src/visual/views/camera/share_image_editor_components/display_time_picker.dart
new file mode 100644
index 00000000..7d9211ee
--- /dev/null
+++ b/lib/src/visual/views/camera/share_image_editor_components/display_time_picker.dart
@@ -0,0 +1,74 @@
+import 'package:flutter/material.dart';
+import 'package:twonly/src/database/tables/mediafiles.table.dart';
+import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
+import 'package:twonly/src/services/user.service.dart';
+import 'package:twonly/src/visual/views/camera/share_image_contact_selection_components/select_show_time.dart';
+
+const _displayTimeOptionsInMs = [
+ 1000,
+ 2000,
+ 3000,
+ 4000,
+ 5000,
+ 6000,
+ 7000,
+ 8000,
+ 9000,
+ 10000,
+ 15000,
+ 20000,
+ null, // unlimited
+];
+
+/// Lets the user choose how long the receiver may look at the media.
+///
+/// Videos have no picker, for them this only toggles between playing once and
+/// looping. [onChanged] is called whenever the limit was updated, the picker
+/// stays open while the user scrolls through the options.
+Future showDisplayTimePicker(
+ BuildContext context, {
+ required MediaFileService mediaService,
+ required VoidCallback onChanged,
+}) async {
+ final media = mediaService.mediaFile;
+
+ if (media.type == MediaType.video) {
+ await mediaService.setDisplayLimit(
+ (media.displayLimitInMilliseconds == null) ? 0 : null,
+ );
+ if (!context.mounted) return;
+ onChanged();
+ return;
+ }
+
+ var initialItem = _displayTimeOptionsInMs.length - 1;
+ if (media.displayLimitInMilliseconds != null) {
+ initialItem = _displayTimeOptionsInMs.indexOf(
+ media.displayLimitInMilliseconds,
+ );
+ if (initialItem == -1) {
+ initialItem = _displayTimeOptionsInMs.length - 1;
+ }
+ }
+
+ await showModalBottomSheet(
+ context: context,
+ backgroundColor: Colors.black,
+ builder: (sheetContext) {
+ return SelectShowTime(
+ initialItem: initialItem,
+ options: _displayTimeOptionsInMs,
+ setMaxShowTime: (maxShowTime, storeAsDefault) async {
+ await mediaService.setDisplayLimit(maxShowTime);
+ if (!context.mounted) return;
+ onChanged();
+ if (storeAsDefault) {
+ await UserService.update((user) {
+ user.defaultShowTime = maxShowTime;
+ });
+ }
+ },
+ );
+ },
+ );
+}
diff --git a/lib/src/visual/views/camera/share_image_editor_components/editor_bottom_bar.dart b/lib/src/visual/views/camera/share_image_editor_components/editor_bottom_bar.dart
new file mode 100644
index 00000000..f605ce2c
--- /dev/null
+++ b/lib/src/visual/views/camera/share_image_editor_components/editor_bottom_bar.dart
@@ -0,0 +1,92 @@
+import 'package:flutter/material.dart';
+import 'package:font_awesome_flutter/font_awesome_flutter.dart';
+import 'package:twonly/src/database/daos/contacts.dao.dart';
+import 'package:twonly/src/database/twonly.db.dart';
+import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
+import 'package:twonly/src/utils/misc.dart';
+import 'package:twonly/src/visual/elements/my_button.element.dart';
+import 'package:twonly/src/visual/helpers/screenshot.helper.dart';
+import 'package:twonly/src/visual/views/camera/camera_preview_components/save_to_gallery.dart';
+
+/// Save to gallery and send, shown below the media.
+///
+/// When the editor was opened for a single group ([sendToGroup]) the send
+/// button sends directly to that group and an additional button opens the
+/// contact selection.
+class EditorBottomBar extends StatelessWidget {
+ const EditorBottomBar({
+ required this.mediaService,
+ required this.isLoadingImage,
+ required this.isSending,
+ required this.storeImageAsOriginal,
+ required this.onAddMoreRecipients,
+ required this.onSend,
+ this.sendToGroup,
+ super.key,
+ });
+
+ final MediaFileService mediaService;
+ final Group? sendToGroup;
+ final bool isLoadingImage;
+ final bool isSending;
+ final Future Function() storeImageAsOriginal;
+ final VoidCallback onAddMoreRecipients;
+ final VoidCallback onSend;
+
+ @override
+ Widget build(BuildContext context) {
+ final hasFixedReceiver = sendToGroup != null;
+
+ return ColoredBox(
+ color: Theme.of(context).colorScheme.surface,
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ SaveToGalleryButton(
+ storeImageAsOriginal: storeImageAsOriginal,
+ mediaService: mediaService,
+ displayButtonLabel: !hasFixedReceiver,
+ isLoading: isLoadingImage,
+ ),
+ if (hasFixedReceiver) ...[
+ const SizedBox(width: 10),
+ MyButton(
+ variant: MyButtonVariant.secondaryMiddle,
+ onPressed: onAddMoreRecipients,
+ child: const FaIcon(FontAwesomeIcons.userPlus, size: 14),
+ ),
+ ],
+ SizedBox(width: hasFixedReceiver ? 10 : 20),
+ IntrinsicWidth(
+ child: MyButton(
+ variant: MyButtonVariant.primaryMiddle,
+ onPressed: isSending ? null : onSend,
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ if (isSending)
+ const SizedBox(
+ height: 12,
+ width: 12,
+ child: CircularProgressIndicator.adaptive(
+ strokeWidth: 2,
+ valueColor: AlwaysStoppedAnimation(Colors.black87),
+ ),
+ )
+ else
+ const FaIcon(FontAwesomeIcons.solidPaperPlane, size: 14),
+ const SizedBox(width: 8),
+ Text(
+ hasFixedReceiver
+ ? substringBy(sendToGroup!.groupName, 15)
+ : context.lang.shareImagedEditorShareWith,
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/src/visual/views/camera/share_image_editor_components/editor_canvas.dart b/lib/src/visual/views/camera/share_image_editor_components/editor_canvas.dart
new file mode 100644
index 00000000..5dd6f806
--- /dev/null
+++ b/lib/src/visual/views/camera/share_image_editor_components/editor_canvas.dart
@@ -0,0 +1,83 @@
+import 'package:flutter/material.dart';
+import 'package:twonly/src/visual/helpers/screenshot.helper.dart';
+import 'package:twonly/src/visual/views/camera/share_image_editor_components/editor_layer_stack.dart';
+import 'package:twonly/src/visual/views/camera/share_image_editor_components/image_item.dart';
+import 'package:twonly/src/visual/views/camera/share_image_editor_components/layers_viewer.dart';
+import 'package:video_player/video_player.dart';
+
+/// The media itself with all editor layers stacked on top of it.
+///
+/// For videos the layers are rendered above the running video, for images the
+/// background image is just another layer. The whole stack is wrapped in a
+/// [Screenshot] so it can be exported as a single image.
+class EditorCanvas extends StatelessWidget {
+ const EditorCanvas({
+ required this.layerStack,
+ required this.screenshotController,
+ required this.image,
+ required this.pixelRatio,
+ required this.onLayersUpdated,
+ this.videoController,
+ this.bottomOverlay,
+ super.key,
+ });
+
+ final EditorLayerStack layerStack;
+ final ScreenshotController screenshotController;
+ final ImageItem image;
+ final double pixelRatio;
+
+ /// Called after the user finished interacting with a layer.
+ final VoidCallback onLayersUpdated;
+ final VideoPlayerController? videoController;
+
+ /// Editor chrome laid over the bottom of the media, outside the [Screenshot]
+ /// so it never ends up burnt into what gets sent. Used by the video trimmer.
+ final Widget? bottomOverlay;
+
+ bool get _hasVideo =>
+ videoController != null && videoController!.value.isInitialized;
+
+ Widget _buildLayers() {
+ return Screenshot(
+ controller: screenshotController,
+ child: LayersViewer(
+ layers: layerStack.visible,
+ onUpdate: () {
+ layerStack.commitPendingEdits();
+ onLayersUpdated();
+ },
+ ),
+ );
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return SizedBox(
+ height: image.height / pixelRatio,
+ width: image.width / pixelRatio,
+ child: Stack(
+ children: [
+ if (_hasVideo)
+ Positioned.fill(
+ child: Center(
+ child: AspectRatio(
+ aspectRatio: videoController!.value.aspectRatio,
+ child: Stack(
+ children: [
+ Positioned.fill(child: VideoPlayer(videoController!)),
+ Positioned.fill(child: _buildLayers()),
+ ],
+ ),
+ ),
+ ),
+ )
+ else
+ _buildLayers(),
+ if (bottomOverlay != null)
+ Positioned(left: 0, right: 0, bottom: 0, child: bottomOverlay!),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/src/visual/views/camera/share_image_editor_components/editor_layer_stack.dart b/lib/src/visual/views/camera/share_image_editor_components/editor_layer_stack.dart
new file mode 100644
index 00000000..ace3a603
--- /dev/null
+++ b/lib/src/visual/views/camera/share_image_editor_components/editor_layer_stack.dart
@@ -0,0 +1,157 @@
+import 'package:flutter/material.dart';
+import 'package:twonly/src/visual/helpers/screenshot.helper.dart';
+import 'package:twonly/src/visual/views/camera/share_image_editor_components/image_item.dart';
+import 'package:twonly/src/visual/views/camera/share_image_editor_components/layer_data.dart';
+
+/// Owns the layer stack of the image editor together with its undo/redo
+/// history.
+///
+/// The stack always starts with the [BackgroundLayerData] (the photo itself)
+/// followed by the [FilterLayerData]; everything the user adds (text, drawings,
+/// emojis, link previews) is stacked on top of those two.
+///
+/// Layers are not removed immediately when the user deletes them. They are
+/// flagged via [Layer.isDeleted] by the layer widgets and only collected later,
+/// which is what makes restoring them through [undo] possible.
+class EditorLayerStack {
+ List _layers = [];
+ final List _undone = [];
+ final List _removed = [];
+
+ /// All layers including the ones flagged as deleted.
+ List get all => _layers;
+
+ /// The layers that should actually be painted and exported.
+ List get visible => _layers.where((x) => !x.isDeleted).toList();
+
+ bool get isEmpty => _layers.isEmpty;
+
+ bool get anyIsEditing => _layers.any((x) => x.isEditing);
+
+ /// While a layer takes over the whole screen (cropping the background or a
+ /// layer bringing its own action buttons) the editor toolbars are hidden.
+ bool get takesOverScreen =>
+ _layers.isNotEmpty &&
+ (_layers.first.isEditing ||
+ (_layers.last.isEditing && _layers.last.hasCustomActionButtons));
+
+ /// Whether the user added anything on top of the background and the filter.
+ bool get hasUserAddedLayers => _layers.any(
+ (x) => x is! BackgroundLayerData && x is! FilterLayerData,
+ );
+
+ bool get canUndo => visible.length > 2;
+
+ bool get canRedo => _undone.isNotEmpty;
+
+ bool get isBackgroundLoaded {
+ final first = _layers.firstOrNull;
+ return first is BackgroundLayerData && first.imageLoaded;
+ }
+
+ /// The untouched background image, but only when nothing was drawn on top of
+ /// it. In that case the editor can skip taking a screenshot and export the
+ /// original image instead.
+ ScreenshotImageHelper? get unmodifiedBackgroundImage {
+ final background = _layers.firstOrNull;
+ if (background is! BackgroundLayerData) return null;
+ if (_layers.length == 1) return background.image.image;
+ if (_layers.length == 2) {
+ final filter = _layers[1];
+ // page == 1 is the "no filter selected" page.
+ if (filter is FilterLayerData && filter.page == 1) {
+ return background.image.image;
+ }
+ }
+ return null;
+ }
+
+ void addFilterLayer() => _layers.add(FilterLayerData(key: GlobalKey()));
+
+ void addLinkPreviewLayer(Uri link) =>
+ _layers.add(LinkPreviewLayerData(key: GlobalKey(), link: link));
+
+ void insertBackgroundLayer(ImageItem image) => _layers.insert(
+ 0,
+ BackgroundLayerData(key: GlobalKey(), image: image),
+ );
+
+ /// Adds a new layer on top and drops the redo history.
+ void add(Layer layer) {
+ _undone.clear();
+ _removed.clear();
+ _layers.add(layer);
+ }
+
+ /// Adds an empty text layer at [offset]. Does nothing while another layer is
+ /// still being edited.
+ void addTextLayer({Offset offset = Offset.zero}) {
+ _layers = visible;
+ if (anyIsEditing) return;
+ add(
+ TextLayerData(
+ key: GlobalKey(),
+ offset: offset,
+ textLayersBefore: _layers.whereType().length,
+ ),
+ );
+ }
+
+ void addDrawLayer() => add(DrawLayerData(key: GlobalKey()));
+
+ /// Toggles the crop/rotate mode of the background layer.
+ void toggleBackgroundEditing() {
+ final background = _layers.firstOrNull;
+ if (background is BackgroundLayerData) {
+ background.isEditing = !background.isEditing;
+ }
+ }
+
+ /// Restores the last deleted layer, or removes the topmost one.
+ void undo() {
+ if (_removed.isNotEmpty) {
+ _layers.add(
+ _removed.removeLast()
+ ..isDeleted = false
+ ..isEditing = false,
+ );
+ return;
+ }
+ _layers = visible;
+ if (_layers.length <= 2) {
+ // never remove the background and the filter layer
+ return;
+ }
+ _undone.add(_layers.removeLast());
+ }
+
+ void redo() {
+ if (_undone.isEmpty) return;
+ _layers.add(_undone.removeLast());
+ }
+
+ /// Called after the user finished interacting with a layer: stop editing all
+ /// layers and move the ones flagged as deleted into the undo history.
+ void commitPendingEdits() {
+ for (final layer in _layers) {
+ layer.isEditing = false;
+ if (layer.isDeleted) {
+ _removed.add(layer);
+ }
+ }
+ _layers = visible;
+ }
+
+ /// The per-layer action buttons must not end up on the exported screenshot.
+ void setCustomButtonsVisible({required bool visible}) {
+ for (final layer in _layers) {
+ layer.showCustomButtons = visible;
+ }
+ }
+
+ void clear() {
+ _layers.clear();
+ _undone.clear();
+ _removed.clear();
+ }
+}
diff --git a/lib/src/visual/views/camera/share_image_editor_components/editor_media_writer.dart b/lib/src/visual/views/camera/share_image_editor_components/editor_media_writer.dart
new file mode 100644
index 00000000..a3b6f708
--- /dev/null
+++ b/lib/src/visual/views/camera/share_image_editor_components/editor_media_writer.dart
@@ -0,0 +1,118 @@
+import 'dart:typed_data';
+
+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';
+import 'package:twonly/src/visual/helpers/screenshot.helper.dart';
+import 'package:twonly/src/visual/views/camera/share_image_editor_components/editor_layer_stack.dart';
+
+/// Turns the edited layer stack into the files the upload/gallery code expects.
+///
+/// * images and gifs are written to [MediaFileService.originalPath]
+/// * videos keep their original file and get the edits written next to them as
+/// [MediaFileService.overlayImagePath]
+class EditorMediaWriter {
+ EditorMediaWriter({
+ required this.mediaService,
+ required this.layerStack,
+ required this.screenshotController,
+ required this.gifSource,
+ required this.requestRebuild,
+ });
+
+ final MediaFileService mediaService;
+ final EditorLayerStack layerStack;
+ final ScreenshotController screenshotController;
+
+ /// Gifs are not edited, their bytes are taken as they came in.
+ final ScreenshotImageHelper? gifSource;
+
+ /// Rebuilds the editor and waits until the change is on screen. Needed to
+ /// hide the per-layer action buttons before taking the screenshot.
+ final void Function() requestRebuild;
+
+ MediaFile get media => mediaService.mediaFile;
+
+ /// Renders the visible layers into a single image.
+ Future captureEditedImage(double pixelRatio) async {
+ final unmodified = layerStack.unmodifiedBackgroundImage;
+ if (unmodified != null) return unmodified;
+
+ layerStack.setCustomButtonsVisible(visible: false);
+ requestRebuild();
+
+ // Make a short delay, so the rebuild does have its effect...
+ await Future.delayed(const Duration(milliseconds: 80));
+
+ final image = await screenshotController.capture(pixelRatio: pixelRatio);
+ if (image == null) {
+ Log.warn('screenshotController did not return image bytes');
+ return null;
+ }
+
+ layerStack.setCustomButtonsVisible(visible: true);
+ requestRebuild();
+
+ return image;
+ }
+
+ /// Writes the edited media to disk, replacing any previously written
+ /// temporary/overlay files.
+ Future storeImageAsOriginal(double pixelRatio) async {
+ Uint8List? gifBytes;
+ ScreenshotImageHelper? image;
+ if (media.type == MediaType.gif) {
+ gifBytes = await gifSource?.getBytes();
+ } else {
+ image = await captureEditedImage(pixelRatio);
+ if (image != null) {
+ await image.getBytes();
+ }
+ }
+
+ _deleteStaleFiles();
+
+ if (media.type == MediaType.gif) {
+ if (gifBytes != null) {
+ mediaService.originalPath.writeAsBytesSync(gifBytes.toList());
+ }
+ return image;
+ }
+
+ if (image == null) return null;
+ final bytes = await image.getBytes();
+ if (bytes == null) {
+ Log.warn('imageBytes are empty');
+ return null;
+ }
+ if (media.type == MediaType.image || media.type == MediaType.gif) {
+ mediaService.originalPath.writeAsBytesSync(bytes);
+ } else if (media.type == MediaType.video) {
+ mediaService.overlayImagePath.writeAsBytesSync(bytes);
+ } else {
+ Log.error('MediaType not supported: ${media.type}');
+ }
+ return image;
+ }
+
+ void _deleteStaleFiles() {
+ if (mediaService.overlayImagePath.existsSync()) {
+ mediaService.overlayImagePath.deleteSync();
+ }
+ if (mediaService.tempPath.existsSync()) {
+ mediaService.tempPath.deleteSync();
+ }
+ if (mediaService.originalPath.existsSync() &&
+ media.type == MediaType.image) {
+ mediaService.originalPath.deleteSync();
+ }
+ }
+
+ /// Persists the unedited image so it can be restored as a draft in case the
+ /// app is restarted while the editor is open.
+ Future storeAsDraft(ScreenshotImageHelper screenshotImage) async {
+ final imageBytes = await screenshotImage.getBytes();
+ mediaService.originalPath.writeAsBytesSync(imageBytes!.toList());
+ }
+}
diff --git a/lib/src/visual/views/camera/share_image_editor_components/editor_side_toolbar.dart b/lib/src/visual/views/camera/share_image_editor_components/editor_side_toolbar.dart
new file mode 100644
index 00000000..762da209
--- /dev/null
+++ b/lib/src/visual/views/camera/share_image_editor_components/editor_side_toolbar.dart
@@ -0,0 +1,163 @@
+import 'package:flutter/material.dart';
+import 'package:font_awesome_flutter/font_awesome_flutter.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/misc.dart';
+import 'package:twonly/src/visual/components/emoji_picker.bottom.dart';
+import 'package:twonly/src/visual/components/notification_badge.comp.dart';
+import 'package:twonly/src/visual/views/camera/share_image_editor_components/action_button.dart';
+import 'package:twonly/src/visual/views/camera/share_image_editor_components/editor_layer_stack.dart';
+import 'package:twonly/src/visual/views/camera/share_image_editor_components/layer_data.dart';
+
+/// The editing tools next to the media: add text, drawing and emojis, pick the
+/// display time and protect the media.
+class EditorSideToolbar extends StatelessWidget {
+ const EditorSideToolbar({
+ required this.layerStack,
+ required this.mediaService,
+ required this.onChanged,
+ required this.onEditDisplayTime,
+ required this.onToggleAudio,
+ required this.onToggleRequiresAuth,
+ required this.canTrim,
+ required this.trimmerVisible,
+ required this.onToggleTrimmer,
+ super.key,
+ });
+
+ final EditorLayerStack layerStack;
+ final MediaFileService mediaService;
+
+ /// Called after the layer stack was modified so the editor can rebuild.
+ final VoidCallback onChanged;
+ final VoidCallback onEditDisplayTime;
+ final VoidCallback onToggleAudio;
+ final VoidCallback onToggleRequiresAuth;
+
+ /// Whether the clip is far enough along for the cutter to be openable at all;
+ /// it needs a player that knows how long the recording is.
+ final bool canTrim;
+ final bool trimmerVisible;
+ final VoidCallback onToggleTrimmer;
+
+ MediaFile get media => mediaService.mediaFile;
+
+ /// How long the receiver may look at the media, `∞` when unlimited and `0`
+ /// for videos (they are limited by their own length).
+ String get _displayTimeLabel {
+ if (media.type == MediaType.video) return '0';
+ final limit = media.displayLimitInMilliseconds;
+ if (limit == null) return '∞';
+ return (limit ~/ 1000).toString();
+ }
+
+ IconData get _displayTimeIcon {
+ if (media.type != MediaType.video) return Icons.timer_outlined;
+ return (media.displayLimitInMilliseconds == null)
+ ? Icons.repeat_rounded
+ : Icons.repeat_one_rounded;
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ if (layerStack.takesOverScreen) return const SizedBox.shrink();
+
+ final canBeEdited = media.type != MediaType.gif;
+
+ return Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ if (canBeEdited)
+ ActionButton(
+ Icons.text_fields_rounded,
+ tooltipText: context.lang.addTextItem,
+ onPressed: () {
+ layerStack.addTextLayer();
+ onChanged();
+ },
+ ),
+ const SizedBox(height: 8),
+ if (canBeEdited)
+ ActionButton(
+ Icons.draw_rounded,
+ tooltipText: context.lang.addDrawing,
+ onPressed: () {
+ layerStack.addDrawLayer();
+ onChanged();
+ },
+ ),
+ const SizedBox(height: 8),
+ if (canBeEdited)
+ ActionButton(
+ Icons.add_reaction_outlined,
+ tooltipText: context.lang.addEmoji,
+ onPressed: () async {
+ final layer = await showModalBottomSheet(
+ context: context,
+ backgroundColor: Colors.black,
+ builder: (context) => const EmojiPickerBottom(),
+ );
+ if (layer == null) return;
+ layerStack.add(layer);
+ onChanged();
+ },
+ ),
+ const SizedBox(height: 8),
+ NotificationBadgeComp(
+ count: _displayTimeLabel,
+ child: ActionButton(
+ _displayTimeIcon,
+ tooltipText: context.lang.protectAsARealTwonly,
+ onPressed: onEditDisplayTime,
+ ),
+ ),
+ if (canTrim) ...[
+ const SizedBox(height: 8),
+ ActionButton(
+ Icons.content_cut_rounded,
+ tooltipText: 'Trim video',
+ color: trimmerVisible
+ ? Theme.of(context).colorScheme.primary
+ : Colors.white,
+ onPressed: onToggleTrimmer,
+ ),
+ ],
+ if (media.type == MediaType.video) ...[
+ const SizedBox(height: 8),
+ ActionButton(
+ (mediaService.removeAudio)
+ ? Icons.volume_off_rounded
+ : Icons.volume_up_rounded,
+ tooltipText: 'Enable Audio in Video',
+ color: (mediaService.removeAudio)
+ ? Colors.white.withAlpha(160)
+ : Colors.white,
+ onPressed: onToggleAudio,
+ ),
+ ],
+ if (media.type == MediaType.image) ...[
+ const SizedBox(height: 8),
+ ActionButton(
+ Icons.crop_rotate_outlined,
+ tooltipText: 'Crop or rotate image',
+ color: Colors.white,
+ onPressed: () {
+ layerStack.toggleBackgroundEditing();
+ onChanged();
+ },
+ ),
+ ],
+ const SizedBox(height: 8),
+ ActionButton(
+ FontAwesomeIcons.shieldHeart,
+ tooltipText: context.lang.protectAsARealTwonly,
+ color: media.requiresAuthentication
+ ? Theme.of(context).colorScheme.primary
+ : Colors.white,
+ onPressed: onToggleRequiresAuth,
+ ),
+ ],
+ );
+ }
+}
diff --git a/lib/src/visual/views/camera/share_image_editor_components/editor_top_toolbar.dart b/lib/src/visual/views/camera/share_image_editor_components/editor_top_toolbar.dart
new file mode 100644
index 00000000..0aa13465
--- /dev/null
+++ b/lib/src/visual/views/camera/share_image_editor_components/editor_top_toolbar.dart
@@ -0,0 +1,58 @@
+import 'package:flutter/material.dart';
+import 'package:font_awesome_flutter/font_awesome_flutter.dart';
+import 'package:twonly/src/utils/misc.dart';
+import 'package:twonly/src/visual/views/camera/share_image_editor_components/action_button.dart';
+import 'package:twonly/src/visual/views/camera/share_image_editor_components/editor_layer_stack.dart';
+
+/// Close, undo and redo, shown above the media.
+class EditorTopToolbar extends StatelessWidget {
+ const EditorTopToolbar({
+ required this.layerStack,
+ required this.onClose,
+ required this.onChanged,
+ super.key,
+ });
+
+ final EditorLayerStack layerStack;
+ final VoidCallback onClose;
+
+ /// Called after the layer stack was modified so the editor can rebuild.
+ final VoidCallback onChanged;
+
+ @override
+ Widget build(BuildContext context) {
+ if (layerStack.takesOverScreen) return const SizedBox.shrink();
+
+ return Row(
+ children: [
+ ActionButton(
+ FontAwesomeIcons.xmark,
+ tooltipText: context.lang.close,
+ onPressed: onClose,
+ ),
+ Expanded(child: Container()),
+ const SizedBox(width: 8),
+ ActionButton(
+ FontAwesomeIcons.rotateLeft,
+ tooltipText: context.lang.undo,
+ disable: !layerStack.canUndo,
+ onPressed: () {
+ layerStack.undo();
+ onChanged();
+ },
+ ),
+ const SizedBox(width: 8),
+ ActionButton(
+ FontAwesomeIcons.rotateRight,
+ tooltipText: context.lang.redo,
+ disable: !layerStack.canRedo,
+ onPressed: () {
+ layerStack.redo();
+ onChanged();
+ },
+ ),
+ const SizedBox(width: 70),
+ ],
+ );
+ }
+}
diff --git a/lib/src/visual/views/camera/share_image_editor_components/video_trimmer.dart b/lib/src/visual/views/camera/share_image_editor_components/video_trimmer.dart
new file mode 100644
index 00000000..3a8204f0
--- /dev/null
+++ b/lib/src/visual/views/camera/share_image_editor_components/video_trimmer.dart
@@ -0,0 +1,358 @@
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+import 'package:video_player/video_player.dart';
+
+/// Cuts a recorded video down to the part the user wants to send.
+///
+/// The two handles move the ends of the clip and the line between them is the
+/// playhead, which can be dragged to look through the recording. Nothing is
+/// written while a handle is moving: [onChanged] keeps the preview in step and
+/// [onChangeEnd] fires once the finger lifts, which is the point where the cut
+/// is worth storing.
+///
+/// Playback is kept inside the selection here rather than by the editor,
+/// because this is already the widget listening to the player's position.
+class VideoTrimmer extends StatefulWidget {
+ const VideoTrimmer({
+ required this.controller,
+ required this.start,
+ required this.end,
+ required this.onChanged,
+ required this.onChangeEnd,
+ super.key,
+ });
+
+ final VideoPlayerController controller;
+
+ /// The current cut. Both are real positions in the recording; the editor
+ /// resolves "not cut on this end" to zero and the full duration before
+ /// handing them over.
+ final Duration start;
+ final Duration end;
+
+ /// Called continuously while a handle is dragged.
+ final void Function(Duration start, Duration end) onChanged;
+
+ /// Called once, when the finger lifts.
+ final void Function(Duration start, Duration end) onChangeEnd;
+
+ /// Nothing shorter than this can be selected. A clip the length of a single
+ /// frame is never what someone was aiming for, and it gives the two handles
+ /// room to stay apart.
+ static const Duration minimumSelection = Duration(seconds: 1);
+
+ @override
+ State createState() => _VideoTrimmerState();
+}
+
+/// What a drag started on.
+enum _Grip { start, end, playhead }
+
+class _VideoTrimmerState extends State {
+ static const double _trackHeight = 40;
+ static const double _handleWidth = 14;
+
+ /// How far from a handle a touch still counts as grabbing it. Fingers are
+ /// wider than the handles are drawn.
+ static const double _grabSlop = 22;
+
+ _Grip? _grip;
+
+ /// Whether the clip was playing when the finger went down, so scrubbing can
+ /// pause it and hand playback back the way it found it. Seeking against a
+ /// running player fights the drag and makes the preview stutter.
+ bool _resumeAfterDrag = false;
+
+ /// Where the player is, mirrored so the playhead can be repainted without
+ /// rebuilding the editor around it.
+ Duration _position = Duration.zero;
+
+ Duration get _duration => widget.controller.value.duration;
+
+ @override
+ void initState() {
+ super.initState();
+ _position = widget.controller.value.position;
+ widget.controller.addListener(_onPlaybackChanged);
+ }
+
+ @override
+ void dispose() {
+ widget.controller.removeListener(_onPlaybackChanged);
+ super.dispose();
+ }
+
+ void _onPlaybackChanged() {
+ if (!mounted) return;
+ final position = widget.controller.value.position;
+
+ // The player loops on the whole file, so the cut end has to bring it back
+ // by hand. Done before the repaint so the playhead never draws past the
+ // selection.
+ if (_grip == null &&
+ widget.controller.value.isPlaying &&
+ (position >= widget.end || position < widget.start)) {
+ unawaited(widget.controller.seekTo(widget.start));
+ return;
+ }
+
+ if (position != _position) {
+ setState(() => _position = position);
+ }
+ }
+
+ Duration _positionAt(double dx, double width) {
+ if (width <= 0 || _duration == Duration.zero) return Duration.zero;
+ final fraction = (dx / width).clamp(0.0, 1.0);
+ return Duration(
+ milliseconds: (_duration.inMilliseconds * fraction).round(),
+ );
+ }
+
+ double _offsetOf(Duration position, double width) {
+ if (_duration == Duration.zero) return 0;
+ final fraction = position.inMilliseconds / _duration.inMilliseconds;
+ return fraction.clamp(0.0, 1.0) * width;
+ }
+
+ /// The handle or the playhead nearest to where the finger landed, as long as
+ /// something is actually within reach.
+ _Grip _gripFor(double dx, double width) {
+ final distances = <_Grip, double>{
+ _Grip.start: (dx - _offsetOf(widget.start, width)).abs(),
+ _Grip.end: (dx - _offsetOf(widget.end, width)).abs(),
+ _Grip.playhead: (dx - _offsetOf(_position, width)).abs(),
+ };
+ final nearest = distances.entries.reduce(
+ (a, b) => a.value <= b.value ? a : b,
+ );
+ // Anywhere else on the track is a seek: tapping the middle of the clip to
+ // jump there is more useful than dragging an end that was not aimed at.
+ return nearest.value <= _grabSlop ? nearest.key : _Grip.playhead;
+ }
+
+ void _onDrag(double dx, double width) {
+ final at = _positionAt(dx, width);
+ switch (_grip) {
+ case _Grip.start:
+ final limit = widget.end - VideoTrimmer.minimumSelection;
+ final start = at > limit ? limit : at;
+ widget.onChanged(_atLeastZero(start), widget.end);
+ unawaited(widget.controller.seekTo(_atLeastZero(start)));
+ case _Grip.end:
+ final limit = widget.start + VideoTrimmer.minimumSelection;
+ final end = at < limit ? limit : at;
+ final capped = end > _duration ? _duration : end;
+ widget.onChanged(widget.start, capped);
+ // Seeking to the very last frame tends to land on a black one, so the
+ // preview shows the frame just before the new end instead.
+ unawaited(
+ widget.controller.seekTo(capped - const Duration(milliseconds: 40)),
+ );
+ case _Grip.playhead:
+ final clamped = at < widget.start
+ ? widget.start
+ : (at > widget.end ? widget.end : at);
+ setState(() => _position = clamped);
+ unawaited(widget.controller.seekTo(clamped));
+ case null:
+ break;
+ }
+ }
+
+ static Duration _atLeastZero(Duration value) =>
+ value < Duration.zero ? Duration.zero : value;
+
+ static String _format(Duration value) {
+ final seconds = value.inSeconds;
+ return '${seconds ~/ 60}:${(seconds % 60).toString().padLeft(2, '0')}';
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ if (_duration == Duration.zero) return const SizedBox.shrink();
+ final selection = widget.end - widget.start;
+ final isTrimmed =
+ widget.start > Duration.zero ||
+ widget.end < _duration - const Duration(milliseconds: 100);
+
+ return Container(
+ margin: const EdgeInsets.fromLTRB(12, 0, 12, 10),
+ padding: const EdgeInsets.fromLTRB(12, 6, 12, 6),
+ decoration: BoxDecoration(
+ // Translucent rather than opaque: the frames being cut away stay
+ // visible underneath, which is most of what makes a cut readable.
+ color: Colors.black.withAlpha(90),
+ borderRadius: BorderRadius.circular(16),
+ ),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ LayoutBuilder(
+ builder: (context, constraints) {
+ // The handles sit inside the ends of the track, so the positions
+ // they can be dragged to are one handle narrower than the box.
+ final width = constraints.maxWidth - _handleWidth * 2;
+ return GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ onHorizontalDragStart: (details) {
+ _grip = _gripFor(
+ details.localPosition.dx - _handleWidth,
+ width,
+ );
+ _resumeAfterDrag = widget.controller.value.isPlaying;
+ if (_resumeAfterDrag) {
+ unawaited(widget.controller.pause());
+ }
+ _onDrag(details.localPosition.dx - _handleWidth, width);
+ },
+ onHorizontalDragUpdate: (details) =>
+ _onDrag(details.localPosition.dx - _handleWidth, width),
+ onHorizontalDragEnd: (_) {
+ _grip = null;
+ widget.onChangeEnd(widget.start, widget.end);
+ if (_resumeAfterDrag) {
+ _resumeAfterDrag = false;
+ unawaited(widget.controller.play());
+ }
+ },
+ onTapUp: (details) {
+ _grip = _Grip.playhead;
+ _onDrag(details.localPosition.dx - _handleWidth, width);
+ _grip = null;
+ },
+ child: SizedBox(
+ height: _trackHeight,
+ child: _TrimTrack(
+ trackWidth: width,
+ handleWidth: _handleWidth,
+ startOffset: _offsetOf(widget.start, width),
+ endOffset: _offsetOf(widget.end, width),
+ playheadOffset: _offsetOf(_position, width),
+ accent: Theme.of(context).colorScheme.primary,
+ ),
+ ),
+ );
+ },
+ ),
+ const SizedBox(height: 4),
+ Text(
+ isTrimmed
+ ? '${_format(widget.start)} – ${_format(widget.end)} · ${_format(selection)}'
+ : _format(_duration),
+ style: TextStyle(
+ fontSize: 11,
+ color: Colors.white.withAlpha(isTrimmed ? 235 : 150),
+ fontFeatures: const [FontFeature.tabularFigures()],
+ shadows: const [
+ Shadow(color: Color.fromARGB(122, 0, 0, 0), blurRadius: 4),
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+/// The bar itself: the whole recording, the selected span, and the playhead.
+class _TrimTrack extends StatelessWidget {
+ const _TrimTrack({
+ required this.trackWidth,
+ required this.handleWidth,
+ required this.startOffset,
+ required this.endOffset,
+ required this.playheadOffset,
+ required this.accent,
+ });
+
+ final double trackWidth;
+ final double handleWidth;
+ final double startOffset;
+ final double endOffset;
+ final double playheadOffset;
+ final Color accent;
+
+ @override
+ Widget build(BuildContext context) {
+ final selectionWidth = (endOffset - startOffset).clamp(0.0, trackWidth);
+
+ return Stack(
+ children: [
+ // The recording in full, so the cut-off parts stay visible as the
+ // context the selection was taken out of.
+ Positioned.fill(
+ left: handleWidth,
+ right: handleWidth,
+ child: DecoratedBox(
+ decoration: BoxDecoration(
+ color: Colors.white.withAlpha(46),
+ borderRadius: BorderRadius.circular(6),
+ ),
+ ),
+ ),
+ Positioned(
+ left: startOffset,
+ width: selectionWidth + handleWidth * 2,
+ top: 0,
+ bottom: 0,
+ child: DecoratedBox(
+ decoration: BoxDecoration(
+ color: accent.withAlpha(40),
+ border: Border.symmetric(
+ horizontal: BorderSide(color: accent, width: 2),
+ ),
+ borderRadius: BorderRadius.circular(8),
+ ),
+ ),
+ ),
+ _handle(left: startOffset),
+ _handle(left: endOffset + handleWidth),
+ Positioned(
+ left: playheadOffset + handleWidth - 1,
+ top: 4,
+ bottom: 4,
+ width: 3,
+ child: DecoratedBox(
+ decoration: BoxDecoration(
+ color: Colors.white,
+ borderRadius: BorderRadius.circular(2),
+ boxShadow: const [
+ BoxShadow(color: Color.fromARGB(122, 0, 0, 0), blurRadius: 4),
+ ],
+ ),
+ ),
+ ),
+ ],
+ );
+ }
+
+ Widget _handle({required double left}) {
+ return Positioned(
+ left: left,
+ top: 0,
+ bottom: 0,
+ width: handleWidth,
+ child: Center(
+ child: Container(
+ width: handleWidth,
+ decoration: BoxDecoration(
+ color: accent,
+ borderRadius: BorderRadius.circular(6),
+ ),
+ child: Center(
+ child: Container(
+ width: 2,
+ height: 14,
+ decoration: BoxDecoration(
+ color: Colors.white.withAlpha(220),
+ borderRadius: BorderRadius.circular(1),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/rust/src/api/proto/api/http/http_requests.proto b/rust/src/api/proto/api/http/http_requests.proto
index 9e5e7011..2dd00075 100644
--- a/rust/src/api/proto/api/http/http_requests.proto
+++ b/rust/src/api/proto/api/http/http_requests.proto
@@ -31,6 +31,29 @@ message AttachmentDispatch {
bytes download_token = 4;
}
+// A message envelope handed to a background transfer instead of the websocket.
+//
+// The socket only exists while the app runs, so an envelope queued offline
+// would otherwise wait for the next app launch rather than the next network.
+message OutboxDispatch {
+ string dispatch_id = 1;
+ int64 recipient_user_id = 2;
+ bytes encrypted_body = 3;
+ bool wake_receiver = 4;
+}
+
+message OutboxDispatchBatch {
+ repeated OutboxDispatch dispatches = 1;
+}
+
+message OutboxDispatchResult {
+ // Envelopes the server has accepted, whether just now or on an earlier
+ // delivery of the same request.
+ int32 accepted = 1;
+ // Envelopes it will never accept, so the transfer must not be retried.
+ int32 rejected = 2;
+}
+
message AttachmentManifest {
int64 encrypted_object_size = 1;
repeated AttachmentDispatch dispatches = 2;
diff --git a/rust/src/api/runtime/client.rs b/rust/src/api/runtime/client.rs
index aa2f618a..510954c8 100644
--- a/rust/src/api/runtime/client.rs
+++ b/rust/src/api/runtime/client.rs
@@ -50,6 +50,10 @@ pub(crate) struct ApiClient {
pub(crate) config: ApiConfig,
pub(crate) state: RwLock,
pub(crate) ws_client: Mutex