improve media upload pipeline

This commit is contained in:
otsmr 2026-09-01 13:11:28 +02:00
parent 6f597a08a1
commit 08801b2583
67 changed files with 4867 additions and 1064 deletions

View file

@ -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

View file

@ -94,12 +94,25 @@
<receiver
android:name="io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingReceiver"
tools:node="remove" />
<!-- A reboot drops WorkManager's periodic flush, and with it the only
thing that resumes an unsent message without the user opening the
app. -->
<receiver
android:name=".directmedia.BootCompletedReceiver"
android:enabled="true"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
<meta-data
android:name="eu.twonly.service.TWONLY_LOGO"
android:resource="@drawable/ic_launcher_foreground" />
</application>
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>

View file

@ -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()
}
}

View file

@ -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()
}
}

View file

@ -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<MediaPrepareWorker>(
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
}

View file

@ -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

View file

@ -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()

View file

@ -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
}
}

View file

@ -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
}

View file

@ -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 {

View file

@ -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 = "<group>"; };
D3A100012F70000100D1A001 /* DirectMediaTransfer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectMediaTransfer.swift; sourceTree = "<group>"; };
D3A100052F70000100D1A005 /* BackgroundWork.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWork.swift; sourceTree = "<group>"; };
D3A100042F70000100D1A002 /* NativeImageCodec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeImageCodec.swift; sourceTree = "<group>"; };
D3A100062F70000100D1A003 /* NativeVideoCodec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeVideoCodec.swift; sourceTree = "<group>"; };
D3A100082F70000100D1A004 /* NativeGallery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeGallery.swift; sourceTree = "<group>"; };
@ -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;

View file

@ -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,

View file

@ -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<T>(_ 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))
}
}

View file

@ -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

View file

@ -86,6 +86,10 @@
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>eu.twonly.outbox-flush</string>
</array>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>

View file

@ -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<CChar>?,
_ output: UnsafePointer<CChar>?,
_ removeAudio: Bool,
_ trimStartMs: Int64,
_ trimEndMs: Int64,
_ mediaId: UnsafePointer<CChar>?,
_ progress: @convention(c) (UnsafePointer<CChar>?, 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)) }
}

View file

@ -1 +1,2 @@
#import "GeneratedPluginRegistrant.h"
#import <rust_lib_twonly/TwonlyNotifications.h>

View file

@ -45,8 +45,6 @@ class App extends StatefulWidget {
}
class _AppState extends State<App> with WidgetsBindingObserver {
bool _wasPaused = false;
@override
void initState() {
super.initState();
@ -58,23 +56,31 @@ class _AppState extends State<App> 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(),
);
}
}

View file

@ -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<void> 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<String> 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<void> prerenderMedia({required String mediaId}) => RustLib
.instance
.api
.crateBridgeApiRustApiPrerenderMedia(mediaId: mediaId);
/// Deletes temporary media whose messages are finished with it.
static Future<void> 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<void> setMediaTrim({
required String mediaId,
PlatformInt64? trimStartMs,
PlatformInt64? trimEndMs,
}) => RustLib.instance.api.crateBridgeApiRustApiSetMediaTrim(
mediaId: mediaId,
trimStartMs: trimStartMs,
trimEndMs: trimEndMs,
);
static Future<void> setNetworkAvailable({required bool available}) => RustLib
.instance
.api

View file

@ -86,7 +86,7 @@ class RustLib extends BaseEntrypoint<RustLibApi, RustLibApiImpl, RustLibWire> {
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<void> crateBridgeApiRustApiHandOutboxToOs();
Future<String> crateBridgeApiRustApiInitializeMediaUpload({
required String mediaType,
PlatformInt64? displayLimitInMilliseconds,
@ -339,6 +341,8 @@ abstract class RustLibApi extends BaseApi {
Future<void> crateBridgeApiRustApiPerformPasswordlessRecoveryHeartbeat();
Future<void> crateBridgeApiRustApiPrerenderMedia({required String mediaId});
Future<void> crateBridgeApiRustApiPurgeMediaTempFolder();
Future<PlatformInt64> crateBridgeApiRustApiRegister({
@ -457,6 +461,12 @@ abstract class RustLibApi extends BaseApi {
required bool requiresAuthentication,
});
Future<void> crateBridgeApiRustApiSetMediaTrim({
required String mediaId,
PlatformInt64? trimStartMs,
PlatformInt64? trimEndMs,
});
Future<void> crateBridgeApiRustApiSetNetworkAvailable({
required bool available,
});
@ -2536,6 +2546,36 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
argNames: ["username"],
);
@override
Future<void> 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<String> 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<void> 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<void> 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<void> 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<void> 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(

View file

@ -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;

View file

@ -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()();

View file

@ -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<int> trimStartMs = GeneratedColumn<int>(
'trim_start_ms',
aliasedName,
true,
type: DriftSqlType.int,
requiredDuringInsert: false,
);
static const VerificationMeta _trimEndMsMeta = const VerificationMeta(
'trimEndMs',
);
@override
late final GeneratedColumn<int> trimEndMs = GeneratedColumn<int>(
'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<MediaFile> {
final List<int>? 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<MediaFile> {
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<MediaFile> {
if (!nullToAbsent || removeAudio != null) {
map['remove_audio'] = Variable<bool>(removeAudio);
}
if (!nullToAbsent || trimStartMs != null) {
map['trim_start_ms'] = Variable<int>(trimStartMs);
}
if (!nullToAbsent || trimEndMs != null) {
map['trim_end_ms'] = Variable<int>(trimEndMs);
}
if (!nullToAbsent || downloadToken != null) {
map['download_token'] = Variable<Uint8List>(downloadToken);
}
@ -3908,6 +3970,12 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
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<MediaFile> {
json['displayLimitInMilliseconds'],
),
removeAudio: serializer.fromJson<bool?>(json['removeAudio']),
trimStartMs: serializer.fromJson<int?>(json['trimStartMs']),
trimEndMs: serializer.fromJson<int?>(json['trimEndMs']),
downloadToken: serializer.fromJson<Uint8List?>(json['downloadToken']),
encryptionKey: serializer.fromJson<Uint8List?>(json['encryptionKey']),
encryptionMac: serializer.fromJson<Uint8List?>(json['encryptionMac']),
@ -4011,6 +4081,8 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
displayLimitInMilliseconds,
),
'removeAudio': serializer.toJson<bool?>(removeAudio),
'trimStartMs': serializer.toJson<int?>(trimStartMs),
'trimEndMs': serializer.toJson<int?>(trimEndMs),
'downloadToken': serializer.toJson<Uint8List?>(downloadToken),
'encryptionKey': serializer.toJson<Uint8List?>(encryptionKey),
'encryptionMac': serializer.toJson<Uint8List?>(encryptionMac),
@ -4039,6 +4111,8 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
Value<List<int>?> reuploadRequestedBy = const Value.absent(),
Value<int?> displayLimitInMilliseconds = const Value.absent(),
Value<bool?> removeAudio = const Value.absent(),
Value<int?> trimStartMs = const Value.absent(),
Value<int?> trimEndMs = const Value.absent(),
Value<Uint8List?> downloadToken = const Value.absent(),
Value<Uint8List?> encryptionKey = const Value.absent(),
Value<Uint8List?> encryptionMac = const Value.absent(),
@ -4073,6 +4147,8 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
? 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<MediaFile> {
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<MediaFile> {
..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<MediaFile> {
reuploadRequestedBy,
displayLimitInMilliseconds,
removeAudio,
trimStartMs,
trimEndMs,
$driftBlobEquality.hash(downloadToken),
$driftBlobEquality.hash(encryptionKey),
$driftBlobEquality.hash(encryptionMac),
@ -4239,6 +4323,8 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
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<MediaFile> {
final Value<List<int>?> reuploadRequestedBy;
final Value<int?> displayLimitInMilliseconds;
final Value<bool?> removeAudio;
final Value<int?> trimStartMs;
final Value<int?> trimEndMs;
final Value<Uint8List?> downloadToken;
final Value<Uint8List?> encryptionKey;
final Value<Uint8List?> encryptionMac;
@ -4298,6 +4386,8 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
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<MediaFile> {
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<MediaFile> {
Expression<String>? reuploadRequestedBy,
Expression<int>? displayLimitInMilliseconds,
Expression<bool>? removeAudio,
Expression<int>? trimStartMs,
Expression<int>? trimEndMs,
Expression<Uint8List>? downloadToken,
Expression<Uint8List>? encryptionKey,
Expression<Uint8List>? encryptionMac,
@ -4384,6 +4478,8 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
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<MediaFile> {
Value<List<int>?>? reuploadRequestedBy,
Value<int?>? displayLimitInMilliseconds,
Value<bool?>? removeAudio,
Value<int?>? trimStartMs,
Value<int?>? trimEndMs,
Value<Uint8List?>? downloadToken,
Value<Uint8List?>? encryptionKey,
Value<Uint8List?>? encryptionMac,
@ -4443,6 +4541,8 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
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<MediaFile> {
if (removeAudio.present) {
map['remove_audio'] = Variable<bool>(removeAudio.value);
}
if (trimStartMs.present) {
map['trim_start_ms'] = Variable<int>(trimStartMs.value);
}
if (trimEndMs.present) {
map['trim_end_ms'] = Variable<int>(trimEndMs.value);
}
if (downloadToken.present) {
map['download_token'] = Variable<Uint8List>(downloadToken.value);
}
@ -4573,6 +4679,8 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
..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<List<int>?> reuploadRequestedBy,
Value<int?> displayLimitInMilliseconds,
Value<bool?> removeAudio,
Value<int?> trimStartMs,
Value<int?> trimEndMs,
Value<Uint8List?> downloadToken,
Value<Uint8List?> encryptionKey,
Value<Uint8List?> encryptionMac,
@ -15538,6 +15648,8 @@ typedef $$MediaFilesTableUpdateCompanionBuilder =
Value<List<int>?> reuploadRequestedBy,
Value<int?> displayLimitInMilliseconds,
Value<bool?> removeAudio,
Value<int?> trimStartMs,
Value<int?> trimEndMs,
Value<Uint8List?> downloadToken,
Value<Uint8List?> encryptionKey,
Value<Uint8List?> encryptionMac,
@ -15662,6 +15774,16 @@ class $$MediaFilesTableFilterComposer
builder: (column) => ColumnFilters(column),
);
ColumnFilters<int> get trimStartMs => $composableBuilder(
column: $table.trimStartMs,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<int> get trimEndMs => $composableBuilder(
column: $table.trimEndMs,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<Uint8List> get downloadToken => $composableBuilder(
column: $table.downloadToken,
builder: (column) => ColumnFilters(column),
@ -15817,6 +15939,16 @@ class $$MediaFilesTableOrderingComposer
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<int> get trimStartMs => $composableBuilder(
column: $table.trimStartMs,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<int> get trimEndMs => $composableBuilder(
column: $table.trimEndMs,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<Uint8List> get downloadToken => $composableBuilder(
column: $table.downloadToken,
builder: (column) => ColumnOrderings(column),
@ -15943,6 +16075,14 @@ class $$MediaFilesTableAnnotationComposer
builder: (column) => column,
);
GeneratedColumn<int> get trimStartMs => $composableBuilder(
column: $table.trimStartMs,
builder: (column) => column,
);
GeneratedColumn<int> get trimEndMs =>
$composableBuilder(column: $table.trimEndMs, builder: (column) => column);
GeneratedColumn<Uint8List> get downloadToken => $composableBuilder(
column: $table.downloadToken,
builder: (column) => column,
@ -16055,6 +16195,8 @@ class $$MediaFilesTableTableManager
Value<List<int>?> reuploadRequestedBy = const Value.absent(),
Value<int?> displayLimitInMilliseconds = const Value.absent(),
Value<bool?> removeAudio = const Value.absent(),
Value<int?> trimStartMs = const Value.absent(),
Value<int?> trimEndMs = const Value.absent(),
Value<Uint8List?> downloadToken = const Value.absent(),
Value<Uint8List?> encryptionKey = const Value.absent(),
Value<Uint8List?> 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<List<int>?> reuploadRequestedBy = const Value.absent(),
Value<int?> displayLimitInMilliseconds = const Value.absent(),
Value<bool?> removeAudio = const Value.absent(),
Value<int?> trimStartMs = const Value.absent(),
Value<int?> trimEndMs = const Value.absent(),
Value<Uint8List?> downloadToken = const Value.absent(),
Value<Uint8List?> encryptionKey = const Value.absent(),
Value<Uint8List?> 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,

@ -1 +1 @@
Subproject commit 17d101801f02cfeaa2a175083111a59fa6d6822c
Subproject commit 66a65b7787ae6c66b8fefbb8d016494de0355ef8

View file

@ -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<ApiEvent> _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<void> _loadConnectionState() async {
final state = await RustApi.connectionState();
await updateConnectionState(state == ApiConnectionState.authenticated);

View file

@ -49,9 +49,7 @@ class ApiService {
Future<void> 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',

View file

@ -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<void> setTrim(Duration? start, Duration? end) async {
await RustApi.setMediaTrim(
mediaId: mediaFile.mediaId,
trimStartMs: start?.inMilliseconds,
trimEndMs: end?.inMilliseconds,
);
await updateFromDB();
}
Future<void> toggleRemoveAudio() async {
await RustApi.toggleMediaRemoveAudio(mediaId: mediaFile.mediaId);
await updateFromDB();

View file

@ -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) {

View file

@ -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<CameraPreviewView> {
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<CameraPreviewView> {
@override
void initState() {
super.initState();
unawaited(VideoRecordingBudget.ensureLoaded());
initVolumeControl();
initAsync();
_checkAndInitCamera();
@ -411,8 +413,14 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
..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<CameraPreviewView> {
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<void> startVideoRecording() async {
if (mc.cameraController != null &&
mc.cameraController!.value.isRecordingVideo) {
@ -626,24 +643,31 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
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<CameraPreviewView> {
_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<CameraPreviewView> {
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<CameraPreviewView> {
),
VideoRecordingTimer(
videoRecordingStarted: _videoRecordingStarted,
maxVideoRecordingTime: maxVideoRecordingTime,
currentTime: _currentTime,
maxRecordingTime: _currentMaxRecordingTime,
),
if (!mc.isSharePreviewIsShown && widget.sendToGroup != null ||
widget.hideControllers)

View file

@ -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<String, double> _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<void> 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<void> 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';
}
}

View file

@ -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<Color>(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<Color>(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,
),
],
),
),
),
],
),
),
),
);
}
}

File diff suppressed because it is too large Load diff

View file

@ -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<bool> askToDiscardMedia(BuildContext context) async {
final shouldDiscard = await showDialog<bool>(
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;
}

View file

@ -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 = <int?>[
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<void> 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<void>(
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;
});
}
},
);
},
);
}

View file

@ -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<ScreenshotImageHelper?> 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,
),
],
),
),
),
],
),
);
}
}

View file

@ -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!),
],
),
);
}
}

View file

@ -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<Layer> _layers = [];
final List<Layer> _undone = [];
final List<Layer> _removed = [];
/// All layers including the ones flagged as deleted.
List<Layer> get all => _layers;
/// The layers that should actually be painted and exported.
List<Layer> 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<TextLayerData>().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();
}
}

View file

@ -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<ScreenshotImageHelper?> 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<void>.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<ScreenshotImageHelper?> 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<void> storeAsDraft(ScreenshotImageHelper screenshotImage) async {
final imageBytes = await screenshotImage.getBytes();
mediaService.originalPath.writeAsBytesSync(imageBytes!.toList());
}
}

View file

@ -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<Layer>(
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,
),
],
);
}
}

View file

@ -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),
],
);
}
}

View file

@ -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<VideoTrimmer> createState() => _VideoTrimmerState();
}
/// What a drag started on.
enum _Grip { start, end, playhead }
class _VideoTrimmerState extends State<VideoTrimmer> {
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),
),
),
),
),
),
);
}
}

View file

@ -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;

View file

@ -50,6 +50,10 @@ pub(crate) struct ApiClient {
pub(crate) config: ApiConfig,
pub(crate) state: RwLock<ApiConnectionState>,
pub(crate) ws_client: Mutex<Option<Arc<WebSocketClient>>>,
/// Serializes replacing/closing the client slot. The WebSocket runner emits
/// events asynchronously, so overlapping replacements must not clear each
/// other's pending requests or publish stale state.
connection_change: Mutex<()>,
pub(crate) pending: PendingRequests,
pub(crate) next_sequence: Mutex<u64>,
pub(crate) events: broadcast::Sender<ApiEvent>,
@ -77,6 +81,7 @@ impl ApiClient {
config,
state: RwLock::const_new(ApiConnectionState::Stopped),
ws_client: Mutex::const_new(None),
connection_change: Mutex::const_new(()),
pending: Arc::new(Mutex::const_new(HashMap::new())),
next_sequence: Mutex::const_new(1),
events: API_EVENTS.clone(),
@ -200,15 +205,15 @@ impl ApiClient {
}
async fn reconnect(self: &Arc<Self>, stale: &Arc<WebSocketClient>, reason: &str) -> Result<()> {
let connection_change = self.connection_change.lock().await;
{
let mut guard = self.ws_client.lock().await;
let guard = self.ws_client.lock().await;
if !guard
.as_ref()
.is_some_and(|current| Arc::ptr_eq(current, stale))
{
return Ok(());
}
guard.take();
}
// Counted only for a socket that was still the live one, so a stale
@ -226,14 +231,8 @@ impl ApiClient {
"API WebSocket is dead, reconnecting"
);
self.is_authenticated.store(false, Ordering::Release);
// Dropping the sender ends the catch-up loop bound to the dead socket.
*self.catch_up_tx.lock().await = None;
if let Err(error) = stale.shutdown_graceful(Duration::from_secs(5)).await {
tracing::warn!("dead WebSocket did not shut down cleanly: {error}");
}
self.fail_pending().await;
self.set_state(ApiConnectionState::Stopped).await;
self.discard_current_connection(reason).await;
drop(connection_change);
tokio::time::sleep(delay).await;
@ -259,6 +258,7 @@ impl ApiClient {
if *guard != state {
*guard = state;
drop(guard);
tracing::info!(?state, "API connection state changed");
let _ = self.events.send(ApiEvent {
kind: ApiEventKind::ConnectionStateChanged,
state: Some(state),
@ -267,7 +267,60 @@ impl ApiClient {
}
}
/// Returns whether `connection` still owns the live client slot.
///
/// A replaced socket can emit its final disconnect/shutdown events after
/// the new socket has started. Those events must not overwrite the new
/// connection's state or schedule another reconnect.
async fn is_current_connection(&self, connection: &Arc<WebSocketClient>) -> bool {
self.ws_client
.lock()
.await
.as_ref()
.is_some_and(|current| Arc::ptr_eq(current, connection))
}
/// Drops the current transport without waiting for its graceful-shutdown
/// timeout. Used for external reachability changes, where waiting would
/// defeat the purpose of an immediate reconnect.
async fn discard_current_connection(&self, reason: &str) {
let current = self.ws_client.lock().await.take();
self.is_authenticated.store(false, Ordering::Release);
// Dropping the sender ends the catch-up loop bound to the old socket.
*self.catch_up_tx.lock().await = None;
if let Some(current) = current {
tracing::info!(reason, "discarding API WebSocket");
current.shutdown();
}
self.fail_pending().await;
self.set_state(ApiConnectionState::Stopped).await;
}
/// Starts a fresh connection immediately, bypassing any retry sleep owned
/// by the previous transport. The replacement client still uses the normal
/// exponential backoff if this first attempt fails.
async fn reconnect_now(self: &Arc<Self>, reason: &str) -> Result<()> {
let _connection_change = self.connection_change.lock().await;
if API_PERMANENTLY_REJECTED.load(Ordering::Acquire)
|| self.deliberately_closed.load(Ordering::Acquire)
|| self.in_background.load(Ordering::Acquire)
|| !self.network_available.load(Ordering::Acquire)
{
return Ok(());
}
self.discard_current_connection(reason).await;
self.connect_inner().await
}
pub async fn connect(self: &Arc<Self>) -> Result<()> {
let _connection_change = self.connection_change.lock().await;
self.connect_inner().await
}
async fn connect_inner(self: &Arc<Self>) -> Result<()> {
if API_PERMANENTLY_REJECTED.load(Ordering::Acquire) {
self.set_state(ApiConnectionState::PermanentlyRejected)
.await;
@ -339,6 +392,12 @@ impl ApiClient {
msg = receiver.recv() => {
match msg {
Ok(msg) => {
let Some(connection) = connection.upgrade() else {
break;
};
if !self_clone.is_current_connection(&connection).await {
break;
}
// Tungstenite message
if let stream_tungstenite::tokio_tungstenite::tungstenite::Message::Binary(bytes) = &*msg {
self_clone.handle_incoming(bytes).await;
@ -349,6 +408,12 @@ impl ApiClient {
}
ev = events.recv() => {
use stream_tungstenite::ConnectionEvent;
let Some(connection) = connection.upgrade() else {
break;
};
if !self_clone.is_current_connection(&connection).await {
break;
}
match ev {
Ok(ConnectionEvent::Connected { .. }) => {
let is_auth = self_clone.is_authenticated.load(Ordering::Acquire);
@ -377,12 +442,10 @@ impl ApiClient {
// send. Replace it instead of sitting on it.
Ok(ConnectionEvent::FatalError { .. } | ConnectionEvent::Shutdown) => {
self_clone.is_authenticated.store(false, Ordering::Release);
if let Some(connection) = connection.upgrade() {
self_clone.schedule_reconnect(
&connection,
"supervisor stopped reconnecting",
);
}
self_clone.schedule_reconnect(
&connection,
"supervisor stopped reconnecting",
);
}
Ok(_) => {}
Err(_) => break,
@ -397,6 +460,7 @@ impl ApiClient {
pub async fn close(&self) {
self.deliberately_closed.store(true, Ordering::Release);
let _connection_change = self.connection_change.lock().await;
self.is_authenticated.store(false, Ordering::Release);
// Dropping the sender ends the catch-up loop for this connection.
*self.catch_up_tx.lock().await = None;
@ -422,24 +486,31 @@ impl ApiClient {
self.in_background.store(in_background, Ordering::Release);
if in_background {
self.set_state(ApiConnectionState::Suspended).await;
} else if self.ws_client.lock().await.is_some() {
self.set_state(ApiConnectionState::Authenticated).await;
// Returning to the foreground: pick up whatever arrived while the
// socket was suspended.
self.request_catch_up().await;
} else if self.network_available.load(Ordering::Acquire) {
self.connect().await?;
} else {
// Mobile platforms can freeze the process while keeping the socket
// object alive. Its TCP connection may be dead even though neither
// the OS nor tungstenite has reported that yet, so foregrounding
// always starts a fresh attempt instead of waiting for a timeout.
self.reconnect_now("application entered the foreground")
.await?;
}
Ok(())
}
pub(crate) async fn set_network_available(self: &Arc<Self>, available: bool) -> Result<()> {
self.network_available.store(available, Ordering::Release);
if available
& !self.in_background.load(Ordering::Acquire)
& self.ws_client.lock().await.is_none()
{
self.connect().await?;
if available {
// A Wi-Fi/mobile/route change can leave an apparently connected
// socket bound to the old network. Replace it now, including when
// its own supervisor is currently sleeping in reconnect backoff.
self.reconnect_now("network became available or changed")
.await?;
} else {
// Reflect loss of reachability immediately instead of showing an
// authenticated state until the receive timeout expires.
let _connection_change = self.connection_change.lock().await;
self.discard_current_connection("network became unavailable")
.await;
}
Ok(())
}

View file

@ -9,10 +9,11 @@ use crate::api::runtime::ApiRuntime;
use crate::api::Server;
use crate::bridge::api::ServerResult;
use crate::context::{Context, RuntimeMode};
use crate::context::Context;
use crate::error::{Result, TwonlyError};
use crate::services::direct_media_upload::DirectMediaUploadService;
use crate::services::groups::GroupService;
use crate::services::media_upload::MediaUploadService;
use crate::services::mediafiles::MediaFileService;
use prost::Message as ProstMessage;
use std::future::Future;
@ -43,7 +44,7 @@ pub(crate) fn response_error_code(bytes: &[u8]) -> Result<Option<i32>> {
pub(crate) fn schedule_post_authentication(ctx: &Arc<Context>, in_background: bool) {
// Notification workers only drain and commit the mailbox. Media downloads,
// maintenance, and outbox replay belong to the main application runtime.
if ctx.runtime_mode == RuntimeMode::Notification {
if ctx.is_notification_runtime() {
return;
}
let ctx = ctx.clone();
@ -77,6 +78,12 @@ pub(crate) fn schedule_post_authentication(ctx: &Arc<Context>, in_background: bo
if let Err(error) = messages::retransmit_queued_receipts(&ctx).await {
tracing::warn!("failed to retransmit queued receipts: {error}");
}
// A preparation that a terminated process left half-finished is only
// resumed by a sweep like this one; a mid-session reconnect is just as
// good a moment for it as a cold start, and far more frequent.
if let Err(error) = MediaUploadService::new(&ctx).finish_started_uploads().await {
tracing::warn!("failed to finish started media uploads: {error}");
}
if let Err(error) = MediaFileService::new(&ctx).download_pending().await {
tracing::warn!("failed to download pending media: {error}");
}

View file

@ -7,13 +7,13 @@ use crate::api::ApiRuntime;
pub use crate::api::PqcPreKeyInput;
use crate::api::Server;
use crate::context::Context;
use crate::context::RuntimeMode;
use crate::error::{Result, TwonlyError};
use crate::frb_generated::StreamSink;
use crate::services::avatars;
use crate::services::contacts::ContactService;
use crate::services::media_upload::{MediaSizeReport, MediaUploadService};
use crate::services::messages::MessageService;
use crate::services::outbox_dispatch::OutboxDispatchService;
use crate::user_config::UserConfig;
use flutter_rust_bridge::frb;
use std::collections::HashMap;
@ -54,7 +54,7 @@ impl ApiConfig {
Ok(Self {
websocket_url: format!("{}client", RustApi::api_base_url("wss".to_owned())),
legacy_user_app_version: user.as_ref().map_or(0, |value| value.app_version),
in_background: context.runtime_mode == RuntimeMode::Notification,
in_background: context.is_notification_runtime(),
can_use_login_token_for_auth: user
.as_ref()
.is_some_and(|value| value.can_use_login_token_for_auth),
@ -302,6 +302,19 @@ impl RustApi {
.await
}
/// Stores the cut the editor's video trimmer asked for. Both bounds are
/// milliseconds into the recording, `None` keeps that end of the clip.
pub async fn set_media_trim(
media_id: String,
trim_start_ms: Option<i64>,
trim_end_ms: Option<i64>,
) -> Result<()> {
let ctx = Context::get_static()?;
MediaUploadService::new(ctx)
.set_trim(&media_id, trim_start_ms, trim_end_ms)
.await
}
pub async fn toggle_media_remove_audio(media_id: String) -> Result<()> {
let ctx = Context::get_static()?;
MediaUploadService::new(ctx)
@ -390,14 +403,47 @@ impl RustApi {
pub async fn set_background(in_background: bool) -> Result<()> {
let ctx = Context::get_static()?;
if !in_background {
if in_background {
// The socket is about to stop being an option, so anything still
// waiting on it is handed to the OS, which delivers it whether or
// not this process survives.
let ctx = ctx.clone();
tokio::spawn(async move {
if let Err(error) = OutboxDispatchService::new(&ctx).hand_pending_to_os().await {
tracing::warn!(%error, "could not hand queued envelopes to the OS");
}
});
} else {
// Coming back to the foreground is the first chance to notice that
// the OS finished a media transfer while this process was idle.
crate::services::direct_media_upload::watch_pending_uploads(ctx);
let ctx = ctx.clone();
tokio::spawn(async move {
// Envelopes the OS has been carrying are cleaned up here; the
// socket takes over for anything still unacknowledged.
if let Err(error) = OutboxDispatchService::new(&ctx).purge_expired().await {
tracing::warn!(%error, "could not purge finished outbox dispatches");
}
});
}
ApiRuntime::set_background(ctx, in_background).await
}
/// Transcodes a captured video while the user is still editing it, so the
/// send itself only has to encrypt and hand over.
pub async fn prerender_media(media_id: String) -> Result<()> {
let ctx = Context::get_static()?;
MediaUploadService::new(ctx).prerender(&media_id).await
}
/// Hands every queued envelope to an OS-owned transfer. Used when the app
/// is being torn down while messages are still unsent.
pub async fn hand_outbox_to_os() -> Result<()> {
let ctx = Context::get_static()?;
OutboxDispatchService::new(ctx).hand_pending_to_os().await?;
Ok(())
}
pub async fn set_network_available(available: bool) -> Result<()> {
let ctx = Context::get_static()?;
ApiRuntime::set_network_available(ctx, available).await

View file

@ -19,7 +19,7 @@ use crate::utils::Shared;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::{
path::{Path, PathBuf},
sync::Arc,
sync::{Arc, OnceLock as StdOnceLock},
};
use tokio::sync::{Mutex, Notify, OnceCell, RwLock};
use zeroize::Zeroize;
@ -36,6 +36,14 @@ pub enum RuntimeMode {
pub struct Context {
pub config: InitConfig,
pub(crate) runtime_mode: RuntimeMode,
/// A native worker can initialize this process before Flutter does. Once
/// Flutter claims it, background jobs must neither open nor close a second
/// API connection through the shared client.
flutter_claimed: AtomicBool,
/// Runtime owned by flutter_rust_bridge. Native workers may call into the
/// same library from a temporary runtime; UI-facing pollers must be spawned
/// here so they survive that native call returning.
foreground_runtime: StdOnceLock<tokio::runtime::Handle>,
pub rust_db: Arc<RwLock<Arc<Database>>>,
pub app_db: Arc<RwLock<Arc<AppDatabase>>>,
pub(crate) secure_storage: SecureStorage,
@ -116,6 +124,8 @@ impl Context {
let ctx = Arc::new(Context {
config,
runtime_mode: RuntimeMode::Standalone,
flutter_claimed: AtomicBool::new(false),
foreground_runtime: StdOnceLock::new(),
rust_db,
app_db,
secure_storage,
@ -191,13 +201,20 @@ impl Context {
// Logging is process-wide and owns app.log directly. Initialize it
// before the context check so notification and Flutter runtimes both
// have a sink even when the main context already exists.
let foreground_already_owns_process = GLOBAL_CONTEXT
.get()
.is_some_and(|context| context.is_flutter_runtime());
init_tracing(
Path::new(&config.data_dir),
runtime_mode != RuntimeMode::Flutter,
runtime_mode != RuntimeMode::Flutter && !foreground_already_owns_process,
);
if GLOBAL_CONTEXT.initialized() {
tracing::info!("twonly already initialized. Ensuring storage directories exist.");
if runtime_mode == RuntimeMode::Flutter {
let context = GLOBAL_CONTEXT.get().ok_or(TwonlyError::Initialization)?;
Self::claim_for_flutter(context).await?;
}
return Ok(());
}
@ -278,6 +295,8 @@ impl Context {
let ctx = Arc::new(Context {
config,
runtime_mode,
flutter_claimed: AtomicBool::new(true),
foreground_runtime: StdOnceLock::new(),
secure_storage,
rust_db: rust_db_handle,
app_db,
@ -319,6 +338,8 @@ impl Context {
let ctx = Arc::new(Context {
config,
runtime_mode,
flutter_claimed: AtomicBool::new(false),
foreground_runtime: StdOnceLock::new(),
rust_db: rust_db_handle,
app_db,
key_manager,
@ -341,12 +362,45 @@ impl Context {
})
.await;
let ctx = res?;
if runtime_mode != RuntimeMode::Notification {
if runtime_mode == RuntimeMode::Flutter {
Self::claim_for_flutter(ctx).await?;
} else if runtime_mode != RuntimeMode::Notification {
ApiRuntime::connect(ctx).await?;
}
Ok(())
}
async fn claim_for_flutter(context: &Arc<Context>) -> Result<()> {
if let Ok(runtime) = tokio::runtime::Handle::try_current() {
let _ = context.foreground_runtime.set(runtime);
}
let newly_claimed = !context.flutter_claimed.swap(true, Ordering::AcqRel);
if newly_claimed && context.runtime_mode != RuntimeMode::Flutter {
tracing::info!("promoting native background runtime to Flutter ownership");
// Rebuild the client so the next authentication is explicitly
// foreground. The old background client is closed before Flutter
// starts observing connection state.
ApiRuntime::reload_configuration(context).await
} else {
ApiRuntime::connect(context).await
}
}
pub(crate) fn is_flutter_runtime(&self) -> bool {
self.runtime_mode == RuntimeMode::Flutter || self.flutter_claimed.load(Ordering::Acquire)
}
pub(crate) fn is_notification_runtime(&self) -> bool {
self.runtime_mode == RuntimeMode::Notification
&& !self.flutter_claimed.load(Ordering::Acquire)
}
pub(crate) fn foreground_runtime(&self) -> Option<tokio::runtime::Handle> {
self.is_flutter_runtime()
.then(|| self.foreground_runtime.get().cloned())
.flatten()
}
pub(super) fn get_static() -> Result<&'static Arc<Context>> {
GLOBAL_CONTEXT.get().ok_or(TwonlyError::Initialization)
}

View file

@ -0,0 +1,21 @@
-- Envelopes handed to an OS-owned transfer instead of the websocket.
--
-- The socket only exists while the app runs, so a message queued while offline
-- waits for the next app launch rather than the next network. An envelope
-- prepared here is POSTed by WorkManager or a background URLSession, which both
-- wait for connectivity themselves and survive the app being closed.
--
-- The receipt itself stays in `receipts` and keeps being retried over the
-- socket: this is an accelerator, not a replacement. A recipient that receives
-- both copies deduplicates them by receipt id, exactly as it already does for a
-- socket retransmission.
CREATE TABLE outbox_dispatch_jobs (
receipt_id TEXT PRIMARY KEY,
dispatch_id TEXT NOT NULL UNIQUE,
contact_id INTEGER NOT NULL,
body_path TEXT NOT NULL,
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX outbox_dispatch_jobs_expiry_idx ON outbox_dispatch_jobs(expires_at);

View file

@ -0,0 +1,11 @@
-- Where the editor's cutter placed the two ends of a recorded video.
--
-- Only the bounds are stored, never a cut copy of the clip: the send already
-- transcodes every video once, so the trim is applied by that pass and the
-- original recording stays intact until the media is retired. That also keeps
-- the cut reversible while the editor is open.
--
-- NULL on either side means "not cut there", which is what an untouched clip
-- and every non-video media file carries.
ALTER TABLE media_files ADD COLUMN trim_start_ms INTEGER;
ALTER TABLE media_files ADD COLUMN trim_end_ms INTEGER;

View file

@ -16,7 +16,7 @@ mod legacy_import;
pub mod tables;
pub const APP_DATABASE_FILE: &str = "app_db.sqlite";
pub const APP_SCHEMA_VERSION: i64 = 5;
pub const APP_SCHEMA_VERSION: i64 = 6;
/// Tables imported from the legacy Drift database. Every entry must exist in
/// Drift schema 25, because a missing table aborts the whole import. Rust-only

View file

@ -32,20 +32,24 @@ impl MediaFile {
/// Single owner of the "media reached the server" transition. The state
/// change, the per-recipient message actions, and the receipt bookkeeping
/// have to become visible together, so they share one transaction.
///
/// Every statement below is written to touch only the rows that still need
/// it, because this runs more than once for the same media: the upload
/// settles it, and a receiver's response or reaction can arrive later and
/// settle it again. It must not short-circuit on the media row already
/// being `uploaded` - a message added to a media that was uploaded earlier
/// would then never leave the "sending" state, however long ago the
/// recipient received it.
pub async fn mark_uploaded(
transaction: &mut Transaction<'_, Sqlite>,
media_id: &str,
) -> Result<()> {
let updated = sqlx::query!(
sqlx::query!(
"UPDATE media_files SET upload_state = 'uploaded' WHERE media_id = ? AND upload_state IS NOT 'uploaded'",
media_id,
)
.execute(&mut **transaction)
.await?
.rows_affected();
if updated == 0 {
return Ok(());
}
.await?;
let now = chrono::Utc::now().timestamp();
sqlx::query!(
@ -56,8 +60,10 @@ impl MediaFile {
JOIN group_members ON group_members.group_id = messages.group_id
WHERE messages.media_id = ?
AND (group_members.member_state IS NULL OR group_members.member_state != 'leftGroup')
-- The first acknowledgement is the true one; a later settle of the
-- same media must not move a timestamp that already stands.
ON CONFLICT(message_id, contact_id, type)
DO UPDATE SET action_at = excluded.action_at
DO NOTHING
"#,
now,
media_id,
@ -65,19 +71,21 @@ impl MediaFile {
.execute(&mut **transaction)
.await?;
sqlx::query!(
"UPDATE messages SET ack_by_server = ? WHERE media_id = ?",
let acknowledged = sqlx::query!(
"UPDATE messages SET ack_by_server = ? WHERE media_id = ? AND ack_by_server IS NULL",
now,
media_id,
)
.execute(&mut **transaction)
.await?;
.await?
.rows_affected();
sqlx::query!(
r#"
UPDATE receipts
SET ack_by_server_at = ?, retry_count = 1, last_retry = ?, mark_for_retry = NULL
WHERE EXISTS(
WHERE ack_by_server_at IS NULL
AND EXISTS(
SELECT 1
FROM messages
JOIN group_members ON group_members.group_id = messages.group_id
@ -94,10 +102,13 @@ impl MediaFile {
.execute(&mut **transaction)
.await?;
tracing::info!(
media_id,
"marked media upload as successful after receiver response"
);
if acknowledged > 0 {
tracing::info!(
media_id,
acknowledged,
"marked media upload as successful after receiver response"
);
}
Ok(())
}
}

View file

@ -38,7 +38,7 @@ flutter_rust_bridge::frb_generated_boilerplate!(
default_rust_auto_opaque = RustAutoOpaqueMoi,
);
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0";
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -1370816897;
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 1963960341;
// Section: executor
@ -2078,6 +2078,41 @@ fn wire__crate__bridge__api__rust_api_get_user_id_from_username_impl(
},
)
}
fn wire__crate__bridge__api__rust_api_hand_outbox_to_os_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec, _, _, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "rust_api_hand_outbox_to_os",
port: Some(port_),
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
},
move || {
let message = unsafe {
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
ptr_,
rust_vec_len_,
data_len_,
)
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
deserializer.end();
move |context| async move {
transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>(
(move || async move {
let output_ok = crate::bridge::api::RustApi::hand_outbox_to_os().await?;
Ok(output_ok)
})()
.await,
)
}
},
)
}
fn wire__crate__bridge__api__rust_api_initialize_media_upload_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
@ -2562,6 +2597,43 @@ fn wire__crate__bridge__api__rust_api_perform_passwordless_recovery_heartbeat_im
},
)
}
fn wire__crate__bridge__api__rust_api_prerender_media_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec, _, _, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "rust_api_prerender_media",
port: Some(port_),
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
},
move || {
let message = unsafe {
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
ptr_,
rust_vec_len_,
data_len_,
)
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
let api_media_id = <String>::sse_decode(&mut deserializer);
deserializer.end();
move |context| async move {
transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>(
(move || async move {
let output_ok =
crate::bridge::api::RustApi::prerender_media(api_media_id).await?;
Ok(output_ok)
})()
.await,
)
}
},
)
}
fn wire__crate__bridge__api__rust_api_purge_media_temp_folder_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
@ -3669,6 +3741,49 @@ fn wire__crate__bridge__api__rust_api_set_media_requires_authentication_impl(
},
)
}
fn wire__crate__bridge__api__rust_api_set_media_trim_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec, _, _, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "rust_api_set_media_trim",
port: Some(port_),
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
},
move || {
let message = unsafe {
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
ptr_,
rust_vec_len_,
data_len_,
)
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
let api_media_id = <String>::sse_decode(&mut deserializer);
let api_trim_start_ms = <Option<i64>>::sse_decode(&mut deserializer);
let api_trim_end_ms = <Option<i64>>::sse_decode(&mut deserializer);
deserializer.end();
move |context| async move {
transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>(
(move || async move {
let output_ok = crate::bridge::api::RustApi::set_media_trim(
api_media_id,
api_trim_start_ms,
api_trim_end_ms,
)
.await?;
Ok(output_ok)
})()
.await,
)
}
},
)
}
fn wire__crate__bridge__api__rust_api_set_network_available_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
@ -6545,93 +6660,96 @@ fn pde_ffi_dispatcher_primary_impl(
54 => wire__crate__bridge__api__rust_api_get_user_by_id_impl(port, ptr, rust_vec_len, data_len),
55 => wire__crate__bridge__api__rust_api_get_user_data_impl(port, ptr, rust_vec_len, data_len),
56 => wire__crate__bridge__api__rust_api_get_user_id_from_username_impl(port, ptr, rust_vec_len, data_len),
57 => wire__crate__bridge__api__rust_api_initialize_media_upload_impl(port, ptr, rust_vec_len, data_len),
58 => wire__crate__bridge__api__rust_api_insert_and_send_additional_data_impl(port, ptr, rust_vec_len, data_len),
59 => wire__crate__bridge__api__rust_api_insert_and_send_ask_about_user_impl(port, ptr, rust_vec_len, data_len),
60 => wire__crate__bridge__api__rust_api_insert_and_send_contact_share_impl(port, ptr, rust_vec_len, data_len),
61 => wire__crate__bridge__api__rust_api_insert_and_send_text_impl(port, ptr, rust_vec_len, data_len),
62 => wire__crate__bridge__api__rust_api_ipa_purchase_impl(port, ptr, rust_vec_len, data_len),
63 => wire__crate__bridge__api__rust_api_load_plan_balance_impl(port, ptr, rust_vec_len, data_len),
64 => wire__crate__bridge__api__rust_api_media_size_limit_report_impl(port, ptr, rust_vec_len, data_len),
65 => wire__crate__bridge__api__rust_api_media_step_finished_impl(port, ptr, rust_vec_len, data_len),
66 => wire__crate__bridge__api__rust_api_notification_badge_count_impl(port, ptr, rust_vec_len, data_len),
67 => wire__crate__bridge__api__rust_api_notify_messages_opened_impl(port, ptr, rust_vec_len, data_len),
68 => wire__crate__bridge__api__rust_api_perform_passwordless_recovery_heartbeat_impl(port, ptr, rust_vec_len, data_len),
69 => wire__crate__bridge__api__rust_api_purge_media_temp_folder_impl(port, ptr, rust_vec_len, data_len),
70 => wire__crate__bridge__api__rust_api_register_impl(port, ptr, rust_vec_len, data_len),
71 => wire__crate__bridge__api__rust_api_register_passwordless_notification_impl(port, ptr, rust_vec_len, data_len),
72 => wire__crate__bridge__api__rust_api_register_passwordless_recovery_impl(port, ptr, rust_vec_len, data_len),
73 => wire__crate__bridge__api__rust_api_reload_configuration_impl(port, ptr, rust_vec_len, data_len),
74 => wire__crate__bridge__api__rust_api_remove_additional_user_impl(port, ptr, rust_vec_len, data_len),
75 => wire__crate__bridge__api__rust_api_remove_media_files_impl(port, ptr, rust_vec_len, data_len),
76 => wire__crate__bridge__api__rust_api_report_user_impl(port, ptr, rust_vec_len, data_len),
77 => wire__crate__bridge__api__rust_api_request_binary_impl(port, ptr, rust_vec_len, data_len),
78 => wire__crate__bridge__api__rust_api_request_contact_by_username_impl(port, ptr, rust_vec_len, data_len),
79 => wire__crate__bridge__api__rust_api_request_media_reupload_impl(port, ptr, rust_vec_len, data_len),
80 => wire__crate__bridge__api__rust_api_request_memories_upload_impl(port, ptr, rust_vec_len, data_len),
81 => wire__crate__bridge__api__rust_api_retransmit_all_messages_impl(port, ptr, rust_vec_len, data_len),
82 => wire__crate__bridge__api__rust_api_retry_pending_media_reuploads_impl(port, ptr, rust_vec_len, data_len),
83 => wire__crate__bridge__api__rust_api_reupload_pending_media_impl(port, ptr, rust_vec_len, data_len),
84 => wire__crate__bridge__api__rust_api_save_media_to_gallery_impl(port, ptr, rust_vec_len, data_len),
85 => wire__crate__bridge__api__rust_api_send_binary_impl(port, ptr, rust_vec_len, data_len),
86 => wire__crate__bridge__api__rust_api_send_contact_profile_impl(port, ptr, rust_vec_len, data_len),
87 => wire__crate__bridge__api__rust_api_send_encrypted_content_impl(port, ptr, rust_vec_len, data_len),
88 => wire__crate__bridge__api__rust_api_send_encrypted_content_to_group_impl(port, ptr, rust_vec_len, data_len),
89 => wire__crate__bridge__api__rust_api_send_media_to_groups_impl(port, ptr, rust_vec_len, data_len),
90 => wire__crate__bridge__api__rust_api_send_queued_message_impl(port, ptr, rust_vec_len, data_len),
91 => wire__crate__bridge__api__rust_api_send_text_message_impl(port, ptr, rust_vec_len, data_len),
92 => wire__crate__bridge__api__rust_api_send_typing_impl(port, ptr, rust_vec_len, data_len),
93 => wire__crate__bridge__api__rust_api_set_background_impl(port, ptr, rust_vec_len, data_len),
94 => wire__crate__bridge__api__rust_api_set_login_token_impl(port, ptr, rust_vec_len, data_len),
95 => wire__crate__bridge__api__rust_api_set_media_display_limit_impl(port, ptr, rust_vec_len, data_len),
96 => wire__crate__bridge__api__rust_api_set_media_requires_authentication_impl(port, ptr, rust_vec_len, data_len),
97 => wire__crate__bridge__api__rust_api_set_network_available_impl(port, ptr, rust_vec_len, data_len),
98 => wire__crate__bridge__api__rust_api_store_media_impl(port, ptr, rust_vec_len, data_len),
99 => wire__crate__bridge__api__rust_api_submit_recovery_share_impl(port, ptr, rust_vec_len, data_len),
100 => wire__crate__bridge__api__rust_api_toggle_media_remove_audio_impl(port, ptr, rust_vec_len, data_len),
101 => wire__crate__bridge__api__rust_api_try_request_contact_by_id_impl(port, ptr, rust_vec_len, data_len),
102 => wire__crate__bridge__api__rust_api_update_fcm_token_impl(port, ptr, rust_vec_len, data_len),
103 => wire__crate__bridge__api__rust_api_update_signed_pre_key_impl(port, ptr, rust_vec_len, data_len),
104 => wire__crate__bridge__api__rust_api_upload_pqc_pre_keys_impl(port, ptr, rust_vec_len, data_len),
105 => wire__crate__bridge__wrapper__app_database__rust_app_database_changes_impl(port, ptr, rust_vec_len, data_len),
106 => wire__crate__bridge__wrapper__app_database__rust_app_database_execute_impl(port, ptr, rust_vec_len, data_len),
107 => wire__crate__bridge__wrapper__app_database__rust_app_database_legacy_import_complete_impl(port, ptr, rust_vec_len, data_len),
108 => wire__crate__bridge__wrapper__app_database__rust_app_database_migrate_legacy_database_impl(port, ptr, rust_vec_len, data_len),
109 => wire__crate__bridge__wrapper__app_database__rust_app_database_select_impl(port, ptr, rust_vec_len, data_len),
110 => wire__crate__bridge__wrapper__backup__rust_backup_archive_create_backup_archive_impl(port, ptr, rust_vec_len, data_len),
111 => wire__crate__bridge__wrapper__backup__rust_backup_archive_get_backup_download_token_impl(port, ptr, rust_vec_len, data_len),
112 => wire__crate__bridge__wrapper__backup__rust_backup_archive_restore_backup_archive_impl(port, ptr, rust_vec_len, data_len),
113 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_backup_id_impl(port, ptr, rust_vec_len, data_len),
114 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_backup_password_keys_impl(port, ptr, rust_vec_len, data_len),
115 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_identity_backup_bytes_impl(port, ptr, rust_vec_len, data_len),
116 => wire__crate__bridge__wrapper__backup__rust_backup_identity_import_backup_password_keys_impl(port, ptr, rust_vec_len, data_len),
117 => wire__crate__bridge__wrapper__backup__rust_backup_identity_restore_identity_backup_impl(port, ptr, rust_vec_len, data_len),
118 => wire__crate__bridge__wrapper__backup__rust_backup_identity_set_backup_password_keys_impl(port, ptr, rust_vec_len, data_len),
119 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_decrypt_cloud_media_key_impl(port, ptr, rust_vec_len, data_len),
120 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_encrypt_cloud_media_key_impl(port, ptr, rust_vec_len, data_len),
121 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_signal_identity_impl(port, ptr, rust_vec_len, data_len),
122 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_user_id_impl(port, ptr, rust_vec_len, data_len),
123 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_import_serialized_impl(port, ptr, rust_vec_len, data_len),
124 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_import_signal_identity_impl(port, ptr, rust_vec_len, data_len),
125 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_remove_local_credentials_impl(port, ptr, rust_vec_len, data_len),
126 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_serialize_impl(port, ptr, rust_vec_len, data_len),
127 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_set_user_id_impl(port, ptr, rust_vec_len, data_len),
128 => wire__crate__bridge__wrapper__signal__rust_signal_decrypt_impl(port, ptr, rust_vec_len, data_len),
129 => wire__crate__bridge__wrapper__signal__rust_signal_encrypt_impl(port, ptr, rust_vec_len, data_len),
130 => wire__crate__bridge__wrapper__signal__rust_signal_generate_bundle_impl(port, ptr, rust_vec_len, data_len),
131 => wire__crate__bridge__wrapper__signal__rust_signal_generate_pqc_prekeys_impl(port, ptr, rust_vec_len, data_len),
132 => wire__crate__bridge__wrapper__signal__rust_signal_get_contact_public_key_impl(port, ptr, rust_vec_len, data_len),
133 => wire__crate__bridge__wrapper__signal__rust_signal_get_user_public_key_impl(port, ptr, rust_vec_len, data_len),
134 => wire__crate__bridge__wrapper__signal__rust_signal_process_prekey_bundle_impl(port, ptr, rust_vec_len, data_len),
135 => wire__crate__bridge__wrapper__rust_utils_generate_shares_impl(port, ptr, rust_vec_len, data_len),
136 => wire__crate__bridge__wrapper__rust_utils_recover_secret_impl(port, ptr, rust_vec_len, data_len),
137 => wire__crate__bridge__groups__update_chat_deletion_time_impl(port, ptr, rust_vec_len, data_len),
138 => wire__crate__bridge__groups__update_group_name_impl(port, ptr, rust_vec_len, data_len),
140 => wire__crate__bridge__user_config__user_config_api_create_impl(port, ptr, rust_vec_len, data_len),
141 => wire__crate__bridge__user_config__user_config_api_import_json_impl(port, ptr, rust_vec_len, data_len),
142 => wire__crate__bridge__user_config__user_config_api_load_impl(port, ptr, rust_vec_len, data_len),
143 => wire__crate__bridge__user_config__user_config_api_save_impl(port, ptr, rust_vec_len, data_len),
144 => wire__crate__bridge__user_config__user_config_api_update_impl(port, ptr, rust_vec_len, data_len),
57 => wire__crate__bridge__api__rust_api_hand_outbox_to_os_impl(port, ptr, rust_vec_len, data_len),
58 => wire__crate__bridge__api__rust_api_initialize_media_upload_impl(port, ptr, rust_vec_len, data_len),
59 => wire__crate__bridge__api__rust_api_insert_and_send_additional_data_impl(port, ptr, rust_vec_len, data_len),
60 => wire__crate__bridge__api__rust_api_insert_and_send_ask_about_user_impl(port, ptr, rust_vec_len, data_len),
61 => wire__crate__bridge__api__rust_api_insert_and_send_contact_share_impl(port, ptr, rust_vec_len, data_len),
62 => wire__crate__bridge__api__rust_api_insert_and_send_text_impl(port, ptr, rust_vec_len, data_len),
63 => wire__crate__bridge__api__rust_api_ipa_purchase_impl(port, ptr, rust_vec_len, data_len),
64 => wire__crate__bridge__api__rust_api_load_plan_balance_impl(port, ptr, rust_vec_len, data_len),
65 => wire__crate__bridge__api__rust_api_media_size_limit_report_impl(port, ptr, rust_vec_len, data_len),
66 => wire__crate__bridge__api__rust_api_media_step_finished_impl(port, ptr, rust_vec_len, data_len),
67 => wire__crate__bridge__api__rust_api_notification_badge_count_impl(port, ptr, rust_vec_len, data_len),
68 => wire__crate__bridge__api__rust_api_notify_messages_opened_impl(port, ptr, rust_vec_len, data_len),
69 => wire__crate__bridge__api__rust_api_perform_passwordless_recovery_heartbeat_impl(port, ptr, rust_vec_len, data_len),
70 => wire__crate__bridge__api__rust_api_prerender_media_impl(port, ptr, rust_vec_len, data_len),
71 => wire__crate__bridge__api__rust_api_purge_media_temp_folder_impl(port, ptr, rust_vec_len, data_len),
72 => wire__crate__bridge__api__rust_api_register_impl(port, ptr, rust_vec_len, data_len),
73 => wire__crate__bridge__api__rust_api_register_passwordless_notification_impl(port, ptr, rust_vec_len, data_len),
74 => wire__crate__bridge__api__rust_api_register_passwordless_recovery_impl(port, ptr, rust_vec_len, data_len),
75 => wire__crate__bridge__api__rust_api_reload_configuration_impl(port, ptr, rust_vec_len, data_len),
76 => wire__crate__bridge__api__rust_api_remove_additional_user_impl(port, ptr, rust_vec_len, data_len),
77 => wire__crate__bridge__api__rust_api_remove_media_files_impl(port, ptr, rust_vec_len, data_len),
78 => wire__crate__bridge__api__rust_api_report_user_impl(port, ptr, rust_vec_len, data_len),
79 => wire__crate__bridge__api__rust_api_request_binary_impl(port, ptr, rust_vec_len, data_len),
80 => wire__crate__bridge__api__rust_api_request_contact_by_username_impl(port, ptr, rust_vec_len, data_len),
81 => wire__crate__bridge__api__rust_api_request_media_reupload_impl(port, ptr, rust_vec_len, data_len),
82 => wire__crate__bridge__api__rust_api_request_memories_upload_impl(port, ptr, rust_vec_len, data_len),
83 => wire__crate__bridge__api__rust_api_retransmit_all_messages_impl(port, ptr, rust_vec_len, data_len),
84 => wire__crate__bridge__api__rust_api_retry_pending_media_reuploads_impl(port, ptr, rust_vec_len, data_len),
85 => wire__crate__bridge__api__rust_api_reupload_pending_media_impl(port, ptr, rust_vec_len, data_len),
86 => wire__crate__bridge__api__rust_api_save_media_to_gallery_impl(port, ptr, rust_vec_len, data_len),
87 => wire__crate__bridge__api__rust_api_send_binary_impl(port, ptr, rust_vec_len, data_len),
88 => wire__crate__bridge__api__rust_api_send_contact_profile_impl(port, ptr, rust_vec_len, data_len),
89 => wire__crate__bridge__api__rust_api_send_encrypted_content_impl(port, ptr, rust_vec_len, data_len),
90 => wire__crate__bridge__api__rust_api_send_encrypted_content_to_group_impl(port, ptr, rust_vec_len, data_len),
91 => wire__crate__bridge__api__rust_api_send_media_to_groups_impl(port, ptr, rust_vec_len, data_len),
92 => wire__crate__bridge__api__rust_api_send_queued_message_impl(port, ptr, rust_vec_len, data_len),
93 => wire__crate__bridge__api__rust_api_send_text_message_impl(port, ptr, rust_vec_len, data_len),
94 => wire__crate__bridge__api__rust_api_send_typing_impl(port, ptr, rust_vec_len, data_len),
95 => wire__crate__bridge__api__rust_api_set_background_impl(port, ptr, rust_vec_len, data_len),
96 => wire__crate__bridge__api__rust_api_set_login_token_impl(port, ptr, rust_vec_len, data_len),
97 => wire__crate__bridge__api__rust_api_set_media_display_limit_impl(port, ptr, rust_vec_len, data_len),
98 => wire__crate__bridge__api__rust_api_set_media_requires_authentication_impl(port, ptr, rust_vec_len, data_len),
99 => wire__crate__bridge__api__rust_api_set_media_trim_impl(port, ptr, rust_vec_len, data_len),
100 => wire__crate__bridge__api__rust_api_set_network_available_impl(port, ptr, rust_vec_len, data_len),
101 => wire__crate__bridge__api__rust_api_store_media_impl(port, ptr, rust_vec_len, data_len),
102 => wire__crate__bridge__api__rust_api_submit_recovery_share_impl(port, ptr, rust_vec_len, data_len),
103 => wire__crate__bridge__api__rust_api_toggle_media_remove_audio_impl(port, ptr, rust_vec_len, data_len),
104 => wire__crate__bridge__api__rust_api_try_request_contact_by_id_impl(port, ptr, rust_vec_len, data_len),
105 => wire__crate__bridge__api__rust_api_update_fcm_token_impl(port, ptr, rust_vec_len, data_len),
106 => wire__crate__bridge__api__rust_api_update_signed_pre_key_impl(port, ptr, rust_vec_len, data_len),
107 => wire__crate__bridge__api__rust_api_upload_pqc_pre_keys_impl(port, ptr, rust_vec_len, data_len),
108 => wire__crate__bridge__wrapper__app_database__rust_app_database_changes_impl(port, ptr, rust_vec_len, data_len),
109 => wire__crate__bridge__wrapper__app_database__rust_app_database_execute_impl(port, ptr, rust_vec_len, data_len),
110 => wire__crate__bridge__wrapper__app_database__rust_app_database_legacy_import_complete_impl(port, ptr, rust_vec_len, data_len),
111 => wire__crate__bridge__wrapper__app_database__rust_app_database_migrate_legacy_database_impl(port, ptr, rust_vec_len, data_len),
112 => wire__crate__bridge__wrapper__app_database__rust_app_database_select_impl(port, ptr, rust_vec_len, data_len),
113 => wire__crate__bridge__wrapper__backup__rust_backup_archive_create_backup_archive_impl(port, ptr, rust_vec_len, data_len),
114 => wire__crate__bridge__wrapper__backup__rust_backup_archive_get_backup_download_token_impl(port, ptr, rust_vec_len, data_len),
115 => wire__crate__bridge__wrapper__backup__rust_backup_archive_restore_backup_archive_impl(port, ptr, rust_vec_len, data_len),
116 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_backup_id_impl(port, ptr, rust_vec_len, data_len),
117 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_backup_password_keys_impl(port, ptr, rust_vec_len, data_len),
118 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_identity_backup_bytes_impl(port, ptr, rust_vec_len, data_len),
119 => wire__crate__bridge__wrapper__backup__rust_backup_identity_import_backup_password_keys_impl(port, ptr, rust_vec_len, data_len),
120 => wire__crate__bridge__wrapper__backup__rust_backup_identity_restore_identity_backup_impl(port, ptr, rust_vec_len, data_len),
121 => wire__crate__bridge__wrapper__backup__rust_backup_identity_set_backup_password_keys_impl(port, ptr, rust_vec_len, data_len),
122 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_decrypt_cloud_media_key_impl(port, ptr, rust_vec_len, data_len),
123 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_encrypt_cloud_media_key_impl(port, ptr, rust_vec_len, data_len),
124 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_signal_identity_impl(port, ptr, rust_vec_len, data_len),
125 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_user_id_impl(port, ptr, rust_vec_len, data_len),
126 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_import_serialized_impl(port, ptr, rust_vec_len, data_len),
127 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_import_signal_identity_impl(port, ptr, rust_vec_len, data_len),
128 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_remove_local_credentials_impl(port, ptr, rust_vec_len, data_len),
129 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_serialize_impl(port, ptr, rust_vec_len, data_len),
130 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_set_user_id_impl(port, ptr, rust_vec_len, data_len),
131 => wire__crate__bridge__wrapper__signal__rust_signal_decrypt_impl(port, ptr, rust_vec_len, data_len),
132 => wire__crate__bridge__wrapper__signal__rust_signal_encrypt_impl(port, ptr, rust_vec_len, data_len),
133 => wire__crate__bridge__wrapper__signal__rust_signal_generate_bundle_impl(port, ptr, rust_vec_len, data_len),
134 => wire__crate__bridge__wrapper__signal__rust_signal_generate_pqc_prekeys_impl(port, ptr, rust_vec_len, data_len),
135 => wire__crate__bridge__wrapper__signal__rust_signal_get_contact_public_key_impl(port, ptr, rust_vec_len, data_len),
136 => wire__crate__bridge__wrapper__signal__rust_signal_get_user_public_key_impl(port, ptr, rust_vec_len, data_len),
137 => wire__crate__bridge__wrapper__signal__rust_signal_process_prekey_bundle_impl(port, ptr, rust_vec_len, data_len),
138 => wire__crate__bridge__wrapper__rust_utils_generate_shares_impl(port, ptr, rust_vec_len, data_len),
139 => wire__crate__bridge__wrapper__rust_utils_recover_secret_impl(port, ptr, rust_vec_len, data_len),
140 => wire__crate__bridge__groups__update_chat_deletion_time_impl(port, ptr, rust_vec_len, data_len),
141 => wire__crate__bridge__groups__update_group_name_impl(port, ptr, rust_vec_len, data_len),
143 => wire__crate__bridge__user_config__user_config_api_create_impl(port, ptr, rust_vec_len, data_len),
144 => wire__crate__bridge__user_config__user_config_api_import_json_impl(port, ptr, rust_vec_len, data_len),
145 => wire__crate__bridge__user_config__user_config_api_load_impl(port, ptr, rust_vec_len, data_len),
146 => wire__crate__bridge__user_config__user_config_api_save_impl(port, ptr, rust_vec_len, data_len),
147 => wire__crate__bridge__user_config__user_config_api_update_impl(port, ptr, rust_vec_len, data_len),
_ => unreachable!(),
}
}
@ -6649,12 +6767,12 @@ fn pde_ffi_dispatcher_sync_impl(
37 => {
wire__crate__bridge__api__rust_api_decode_avatar_svg_impl(ptr, rust_vec_len, data_len)
}
139 => wire__crate__bridge__user_config__user_config_api_clone_impl(
142 => wire__crate__bridge__user_config__user_config_api_clone_impl(
ptr,
rust_vec_len,
data_len,
),
145 => wire__crate__bridge__logging__write_log_impl(ptr, rust_vec_len, data_len),
148 => wire__crate__bridge__logging__write_log_impl(ptr, rust_vec_len, data_len),
_ => unreachable!(),
}
}

View file

@ -283,9 +283,13 @@ where
tracing::Level::WARN => "\x1b[33m",
tracing::Level::ERROR => "\x1b[31m",
};
write!(writer, "{level_color}{:<5}\x1b[0m ", metadata.level())?;
write!(
writer,
"{level_color}{:<5}\x1b[0m [twonly] ",
metadata.level()
)?;
} else {
write!(writer, "{time} {:<5} ", metadata.level())?;
write!(writer, "{time} {:<5} [twonly] ", metadata.level())?;
}
if ansi {

View file

@ -0,0 +1,189 @@
/*
* Copyright (c) 2026, Tobias Müller git@tsmr.eu
*
*/
//! Stable synchronous entry points for the platform's background schedulers.
//!
//! `WorkManager` and `BGTaskScheduler` may start the process with no Flutter
//! engine in it, so these mirror the notification ABI in
//! [`crate::native::notifications`]: they own their Tokio runtime and take the
//! storage directories rather than assuming an initialized context.
use crate::bridge::InitConfig;
use crate::services::background::{self, Job, RunOutcome};
use std::ffi::{c_char, CStr, CString};
use std::panic::{catch_unwind, AssertUnwindSafe};
use std::sync::{LazyLock, Mutex, MutexGuard};
/// One maintenance run at a time per process. Two concurrent runs would only
/// contend on the same per-media locks and the same socket.
static MAINTENANCE_WORKER: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
fn worker_lock() -> MutexGuard<'static, ()> {
MAINTENANCE_WORKER
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
fn runtime() -> Result<tokio::runtime::Runtime, String> {
tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
.map_err(|error| format!("could not create maintenance runtime: {error}"))
}
unsafe fn required_string(pointer: *const c_char, name: &str) -> Result<String, String> {
if pointer.is_null() {
return Err(format!("{name} is null"));
}
// SAFETY: Native callers promise a valid NUL-terminated string for the
// duration of this synchronous function call.
unsafe { CStr::from_ptr(pointer) }
.to_str()
.map(str::to_owned)
.map_err(|error| format!("{name} is not UTF-8: {error}"))
}
unsafe fn optional_string(pointer: *const c_char) -> Option<String> {
if pointer.is_null() {
return None;
}
unsafe { CStr::from_ptr(pointer) }
.to_str()
.ok()
.map(str::to_owned)
.filter(|value| !value.is_empty())
}
fn response_json(outcome: Option<RunOutcome>, error: Option<String>) -> *mut c_char {
let json = match (outcome, error) {
(Some(outcome), None) => format!(
r#"{{"ok":true,"pending_uploads":{}}}"#,
outcome.pending_uploads
),
(_, Some(error)) => format!(
r#"{{"ok":false,"error":{}}}"#,
serde_json::to_string(&error).unwrap_or_else(|_| "null".into())
),
(None, None) => r#"{"ok":false,"error":"missing background outcome"}"#.to_owned(),
};
CString::new(json)
.expect("JSON serializers must escape interior NUL bytes")
.into_raw()
}
/// Runs one maintenance job and returns a JSON `{"ok":bool,"error":string?}`.
///
/// A null or empty `media_id` runs the full flush; otherwise only that media
/// file is prepared. The returned pointer must be released with
/// `twonly_background_string_free`.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn twonly_background_run(
database_dir: *const c_char,
data_dir: *const c_char,
media_id: *const c_char,
) -> *mut c_char {
let result = catch_unwind(AssertUnwindSafe(|| {
let _worker = worker_lock();
let database_dir = unsafe { required_string(database_dir, "database_dir") }?;
let data_dir = unsafe { required_string(data_dir, "data_dir") }?;
let job = match unsafe { optional_string(media_id) } {
Some(media_id) => Job::PrepareMedia(media_id),
None => Job::Flush,
};
runtime()?
.block_on(background::run(
InitConfig {
database_dir,
data_dir,
},
job,
))
.map_err(|error| error.to_string())
}));
match result {
Ok(Ok(outcome)) => response_json(Some(outcome), None),
Ok(Err(error)) => response_json(None, Some(error)),
Err(_) => response_json(None, Some("background maintenance panicked".into())),
}
}
/// Releases a string returned by a Twonly background entry point.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn twonly_background_string_free(pointer: *mut c_char) {
if !pointer.is_null() {
// SAFETY: The pointer was allocated by `CString::into_raw` above and
// ownership is transferred back exactly once by the native caller.
drop(unsafe { CString::from_raw(pointer) });
}
}
#[cfg(target_os = "android")]
mod android_jni {
use super::*;
use jni::objects::{JClass, JString};
use jni::sys::jstring;
use jni::JNIEnv;
#[unsafe(no_mangle)]
pub extern "system" fn Java_eu_twonly_directmedia_NativeMediaPrepareBridge_run(
mut env: JNIEnv<'_>,
_class: JClass<'_>,
database_dir: JString<'_>,
data_dir: JString<'_>,
media_id: JString<'_>,
) -> jstring {
let result = (|| -> Result<String, String> {
let java_string =
|env: &mut JNIEnv<'_>, value: JString<'_>| -> Result<String, String> {
env.get_string(&value)
.map(Into::into)
.map_err(|error| format!("invalid Java string: {error}"))
};
let database_dir = CString::new(java_string(&mut env, database_dir)?)
.map_err(|error| error.to_string())?;
let data_dir = CString::new(java_string(&mut env, data_dir)?)
.map_err(|error| error.to_string())?;
// A null Java string is the flush job, so it is not required here.
let media_id = if media_id.is_null() {
None
} else {
Some(
CString::new(java_string(&mut env, media_id)?)
.map_err(|error| error.to_string())?,
)
};
// SAFETY: Each CString remains alive for the synchronous ABI call.
let pointer = unsafe {
twonly_background_run(
database_dir.as_ptr(),
data_dir.as_ptr(),
media_id
.as_ref()
.map_or(std::ptr::null(), |value| value.as_ptr()),
)
};
if pointer.is_null() {
return Err("Rust maintenance worker returned null".into());
}
// SAFETY: The C ABI returns a valid owned CString.
let json = unsafe { CStr::from_ptr(pointer) }
.to_string_lossy()
.into_owned();
unsafe { twonly_background_string_free(pointer) };
Ok(json)
})();
let json = result.unwrap_or_else(|error| {
format!(
r#"{{"ok":false,"error":{}}}"#,
serde_json::to_string(&error).unwrap_or_else(|_| "null".into())
)
});
env.new_string(json)
.map(JString::into_raw)
.unwrap_or(std::ptr::null_mut())
}
}

View file

@ -10,8 +10,10 @@
//! decoder, durable background transfer, the photo library and notifications.
//! None of them require the Flutter engine to be running.
pub(crate) mod background;
pub(crate) mod gallery;
pub(crate) mod image;
pub(crate) mod notifications;
pub(crate) mod prepare;
pub(crate) mod transfer;
pub(crate) mod video;

151
rust/src/native/prepare.rs Normal file
View file

@ -0,0 +1,151 @@
/*
* Copyright (c) 2026, Tobias Müller git@tsmr.eu
*
*/
//! Keeping media preparation alive when the app is not.
//!
//! Everything between the send button and [`crate::native::transfer::schedule`]
//! — transcoding, encryption, and one Signal ratchet step per recipient — runs
//! in this process. Once the transfer is scheduled the OS owns it, but until
//! then a process death strands the send until the next launch. Each platform
//! protects that window differently:
//!
//! * Android runs the preparation inside a `WorkManager` job, which survives
//! the task being swiped away and is re-run after a reboot.
//! * iOS cannot outlive a force quit at all, so the preparation stays in this
//! process under a `UIApplication` background task assertion, which buys the
//! roughly thirty seconds that a backgrounded app is still allowed to run.
/// How this platform is keeping a preparation alive.
pub(crate) enum Preparation {
/// The OS owns a job that runs the preparation itself; the caller must not
/// start one.
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
Scheduled,
/// The preparation belongs to this process. The caller runs it and holds
/// the guard for as long as it does.
InProcess(Guard),
}
/// Releases the platform's background execution assertion when dropped.
pub(crate) struct Guard {
#[cfg_attr(not(target_os = "ios"), allow(dead_code))]
assertion: Option<u64>,
}
impl Guard {
/// A guard for a platform that offers no assertion to take.
#[cfg_attr(target_os = "ios", allow(dead_code))]
pub(crate) fn unprotected() -> Self {
Self { assertion: None }
}
}
impl Drop for Guard {
fn drop(&mut self) {
#[cfg(target_os = "ios")]
if let Some(assertion) = self.assertion.take() {
ios::end_background_task(assertion);
}
}
}
/// Asks the platform to protect the preparation of `media_id`.
///
/// Never fails: a platform that cannot protect the work still has to prepare
/// it, and an interrupted preparation is picked up by the next maintenance
/// pass. The reason is logged so a missing native hook is visible.
pub(crate) fn begin(media_id: &str) -> Preparation {
#[cfg(target_os = "android")]
{
match android::schedule(media_id) {
Ok(()) => return Preparation::Scheduled,
Err(error) => {
tracing::warn!(media_id, %error, "no WorkManager preparation job; preparing in process");
}
}
}
#[cfg(target_os = "ios")]
{
let _ = media_id;
return Preparation::InProcess(Guard {
assertion: ios::begin_background_task(),
});
}
#[cfg(not(target_os = "ios"))]
{
let _ = media_id;
Preparation::InProcess(Guard::unprotected())
}
}
#[cfg(target_os = "android")]
mod android {
use crate::error::{Result, TwonlyError};
use crate::native::transfer::android::jni_env;
use jni::objects::{JObject, JValue};
pub(super) fn schedule(media_id: &str) -> Result<()> {
let prepare = crate::native::transfer::android::prepare_class()?;
let mut env = jni_env()?;
let media_id = env
.new_string(media_id)
.map_err(|error| TwonlyError::Generic(error.to_string()))?;
let media_id = JObject::from(media_id);
let call = env
.call_static_method(
prepare,
"schedule",
"(Ljava/lang/String;)Z",
&[JValue::Object(&media_id)],
)
.and_then(|value| value.z());
// A pending Java exception would be raised as a fatal error when this
// thread next enters the VM, taking the process with it.
if env.exception_check().unwrap_or(false) {
let _ = env.exception_describe();
let _ = env.exception_clear();
}
if call.map_err(|error| TwonlyError::Generic(error.to_string()))? {
Ok(())
} else {
Err(TwonlyError::Generic(
"Android rejected the media preparation job".into(),
))
}
}
}
#[cfg(target_os = "ios")]
mod ios {
use std::os::raw::c_char;
type BeginBackgroundTask = unsafe extern "C" fn() -> u64;
type EndBackgroundTask = unsafe extern "C" fn(u64);
fn symbol(name: &std::ffi::CStr) -> Option<*mut std::ffi::c_void> {
// The implementation lives in the app executable, so it is resolved at
// runtime rather than being required when the cdylib is linked.
let pointer = unsafe { libc::dlsym(libc::RTLD_DEFAULT, name.as_ptr().cast::<c_char>()) };
(!pointer.is_null()).then_some(pointer)
}
pub(super) fn begin_background_task() -> Option<u64> {
let callback = symbol(c"twonly_begin_background_task")?;
// SAFETY: the Swift @_cdecl declaration has this exact C ABI signature.
let callback: BeginBackgroundTask = unsafe { std::mem::transmute(callback) };
let identifier = unsafe { callback() };
// UIBackgroundTaskInvalid is reported as zero.
(identifier != 0).then_some(identifier)
}
pub(super) fn end_background_task(identifier: u64) {
let Some(callback) = symbol(c"twonly_end_background_task") else {
return;
};
// SAFETY: the Swift @_cdecl declaration has this exact C ABI signature.
let callback: EndBackgroundTask = unsafe { std::mem::transmute(callback) };
unsafe { callback(identifier) };
}
}

View file

@ -40,12 +40,14 @@ pub(crate) mod android {
use std::sync::OnceLock;
const TRANSFER_CLASS: &str = "eu/twonly/directmedia/DirectMediaTransfer";
const PREPARE_CLASS: &str = "eu/twonly/directmedia/DirectMediaPrepare";
const MEDIA_CODEC_CLASS: &str = "eu/twonly/media/NativeImageCodec";
const VIDEO_CODEC_CLASS: &str = "eu/twonly/media/NativeVideoCodec";
const GALLERY_CLASS: &str = "eu/twonly/media/NativeGallery";
static JAVA_VM: OnceLock<JavaVM> = OnceLock::new();
static TRANSFER: OnceLock<GlobalRef> = OnceLock::new();
static PREPARE: OnceLock<GlobalRef> = OnceLock::new();
static MEDIA_CODEC: OnceLock<GlobalRef> = OnceLock::new();
static VIDEO_CODEC: OnceLock<GlobalRef> = OnceLock::new();
static GALLERY: OnceLock<GlobalRef> = OnceLock::new();
@ -65,6 +67,7 @@ pub(crate) mod android {
Ok(mut env) => {
for (name, cache) in [
(TRANSFER_CLASS, &TRANSFER),
(PREPARE_CLASS, &PREPARE),
(MEDIA_CODEC_CLASS, &MEDIA_CODEC),
(VIDEO_CODEC_CLASS, &VIDEO_CODEC),
(GALLERY_CLASS, &GALLERY),
@ -104,6 +107,10 @@ pub(crate) mod android {
})
}
pub(crate) fn prepare_class() -> Result<&'static GlobalRef> {
cached_class(&PREPARE, PREPARE_CLASS)
}
pub(crate) fn media_codec_class() -> Result<&'static GlobalRef> {
cached_class(&MEDIA_CODEC, MEDIA_CODEC_CLASS)
}

View file

@ -11,8 +11,9 @@
//! background or was relaunched without its UI.
use crate::error::{Result, TwonlyError};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use std::sync::{LazyLock, Mutex};
/// One render request. The overlay is a pre-rasterised PNG the size of the
/// video frame; the editor draws it once when the user hits send. Owned so the
@ -25,22 +26,62 @@ pub(crate) struct RenderRequest {
pub overlay: Option<PathBuf>,
pub output: PathBuf,
pub remove_audio: bool,
/// Where the editor's cutter placed the two ends, in milliseconds into the
/// recording. `None` keeps that end. Both platforms take the cut as part of
/// the same pass that composites and encodes, so trimming costs nothing on
/// top of a render that was going to happen anyway.
pub trim_start_ms: Option<i64>,
pub trim_end_ms: Option<i64>,
}
/// The bounds as the platform entry points take them: milliseconds, with a
/// negative value standing for "not cut on this end". Keeping the C and JNI
/// signatures to plain integers avoids an optional across either boundary.
#[cfg_attr(not(any(target_os = "android", target_os = "ios")), allow(dead_code))]
impl RenderRequest {
fn trim_start_or_negative(&self) -> i64 {
self.trim_start_ms.unwrap_or(-1)
}
fn trim_end_or_negative(&self) -> i64 {
self.trim_end_ms.unwrap_or(-1)
}
}
/// The platform reports progress from its own thread — Android's main looper,
/// a GCD timer on iOS — which is not inside the async runtime. Spawning from
/// there would panic, and a panic unwinding back through the C ABI aborts the
/// process, so the runtime is captured while still on a Rust thread.
/// a GCD timer on iOS — which is not inside the async runtime. Keep the runtime
/// for each active render only. A process-wide `OnceLock<Handle>` used to retain
/// the first WorkManager runtime forever, even after that runtime was destroyed.
#[cfg_attr(not(any(target_os = "android", target_os = "ios")), allow(dead_code))]
static RUNTIME: OnceLock<tokio::runtime::Handle> = OnceLock::new();
static PROGRESS_RUNTIMES: LazyLock<Mutex<HashMap<String, tokio::runtime::Handle>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
#[cfg_attr(not(any(target_os = "android", target_os = "ios")), allow(dead_code))]
fn remember_runtime() {
if let Ok(handle) = tokio::runtime::Handle::try_current() {
let _ = RUNTIME.set(handle);
struct ProgressRuntimeGuard {
media_id: String,
}
#[cfg_attr(not(any(target_os = "android", target_os = "ios")), allow(dead_code))]
impl Drop for ProgressRuntimeGuard {
fn drop(&mut self) {
if let Ok(mut runtimes) = PROGRESS_RUNTIMES.lock() {
runtimes.remove(&self.media_id);
}
}
}
#[cfg_attr(not(any(target_os = "android", target_os = "ios")), allow(dead_code))]
fn remember_runtime(media_id: &str) -> Option<ProgressRuntimeGuard> {
let handle = tokio::runtime::Handle::try_current().ok()?;
PROGRESS_RUNTIMES
.lock()
.ok()?
.insert(media_id.to_owned(), handle);
Some(ProgressRuntimeGuard {
media_id: media_id.to_owned(),
})
}
/// Reports transcoding progress back into the media row so the send state in
/// chat can show it. Called by the platform while a render is running.
#[cfg_attr(not(any(target_os = "android", target_os = "ios")), allow(dead_code))]
@ -48,25 +89,32 @@ fn report_progress(media_id: &str, percent: i64) {
let Ok(ctx) = crate::context::Context::get_static() else {
return;
};
let Some(runtime) = RUNTIME.get() else {
let Some(runtime) = PROGRESS_RUNTIMES
.lock()
.ok()
.and_then(|runtimes| runtimes.get(media_id).cloned())
else {
return;
};
let ctx = ctx.clone();
let media_id = media_id.to_owned();
runtime.spawn(async move {
let database = ctx.app_db.read().await.clone();
let _ =
if let Err(error) =
sqlx::query("UPDATE media_files SET pre_progressing_process = ? WHERE media_id = ?")
.bind(percent.clamp(0, 100))
.bind(&media_id)
.execute(&database.pool)
.await;
.await
{
tracing::warn!(media_id, %error, "could not store video render progress");
}
});
}
#[cfg(target_os = "ios")]
pub(crate) fn render(request: &RenderRequest) -> Result<()> {
remember_runtime();
let _progress_runtime = remember_runtime(&request.media_id);
use std::ffi::{CStr, CString};
use std::os::raw::{c_char, c_int};
@ -76,6 +124,8 @@ pub(crate) fn render(request: &RenderRequest) -> Result<()> {
*const c_char,
*const c_char,
bool,
i64,
i64,
*const c_char,
ProgressCallback,
) -> bool;
@ -126,6 +176,8 @@ pub(crate) fn render(request: &RenderRequest) -> Result<()> {
.map_or(std::ptr::null(), |path| path.as_ptr()),
output.as_ptr(),
request.remove_audio,
request.trim_start_or_negative(),
request.trim_end_or_negative(),
media_id.as_ptr(),
progress,
)
@ -141,7 +193,7 @@ pub(crate) fn render(request: &RenderRequest) -> Result<()> {
#[cfg(target_os = "android")]
pub(crate) fn render(request: &RenderRequest) -> Result<()> {
remember_runtime();
let _progress_runtime = remember_runtime(&request.media_id);
use crate::native::transfer::android::{jni_env, video_codec_class};
use jni::objects::{JObject, JValue};
@ -163,12 +215,14 @@ pub(crate) fn render(request: &RenderRequest) -> Result<()> {
.call_static_method(
class,
"render",
"(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZLjava/lang/String;)Z",
"(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZJJLjava/lang/String;)Z",
&[
JValue::Object(&input),
JValue::Object(&overlay),
JValue::Object(&output),
JValue::Bool(u8::from(request.remove_audio)),
JValue::Long(request.trim_start_or_negative()),
JValue::Long(request.trim_end_or_negative()),
JValue::Object(&media_id),
],
)

View file

@ -0,0 +1,163 @@
/*
* Copyright (c) 2026, Tobias Müller git@tsmr.eu
*
*/
//! Work the app does when it is not running.
//!
//! Everything a send needs is durable — the receipt outbox, the media state
//! machine, the direct-media job rows — but until now only a Flutter launch
//! resumed any of it. A user who sends and closes the app, or who composes
//! offline and never reopens, is exactly the case that leaves. These entry
//! points are driven by the platform's own schedulers (`WorkManager` on
//! Android, `BGTaskScheduler` on iOS) and need neither Flutter nor a visible
//! app.
use crate::api::messages::incoming::messages;
use crate::api::runtime::ApiRuntime;
use crate::bridge::api::ApiConnectionState;
use crate::bridge::InitConfig;
use crate::context::Context;
use crate::error::Result;
use crate::services::direct_media_upload::DirectMediaUploadService;
use crate::services::media_upload::MediaUploadService;
use crate::services::outbox_dispatch::OutboxDispatchService;
use std::sync::Arc;
use std::time::Duration;
/// How long to wait for the socket to authenticate before giving up on the
/// parts of the flush that need it. A background job has a hard budget of its
/// own, so this stays well inside it.
const AUTHENTICATION_TIMEOUT: Duration = Duration::from_secs(20);
const AUTHENTICATION_POLL: Duration = Duration::from_millis(250);
/// What a maintenance run should do.
pub enum Job {
/// Prepare one media file and hand it to the OS uploader. This is the job
/// scheduled the moment the user hits send.
PrepareMedia(String),
/// Resume everything that was left in flight: interrupted preparations,
/// transfers the OS has since settled, and the message outbox.
Flush,
}
/// State the native scheduler needs in order to decide whether this job is
/// finished or must be retried later by the OS.
pub struct RunOutcome {
pub pending_uploads: bool,
}
/// Runs a maintenance job in a process that may have no Flutter engine.
///
/// Safe to call concurrently with a running app: every step it performs is the
/// same idempotent one the app runs itself, guarded by the same locks.
pub async fn run(config: InitConfig, job: Job) -> Result<RunOutcome> {
Context::init_notification(config).await?;
let ctx = Context::get_static()?.clone();
// A send that has not been prepared yet needs an upload slot, and one for a
// contact with no Signal session yet needs a prekey bundle. Both want the
// network, so the connection is opened before the work rather than after.
//
// Except when the app is up in this same process: this job owns a Tokio
// runtime that is dropped the moment it returns, so connecting here would
// put the socket's reader on a runtime that is about to go away and kill a
// connection the app is relying on. The app's own post-authentication sweep
// already does all of this, so the job sticks to the media work.
let owns_connection = !ctx.is_flutter_runtime();
let connected = if owns_connection {
connect_and_authenticate(&ctx).await
} else {
matches!(
ApiRuntime::connection_state(&ctx).await,
Ok(ApiConnectionState::Authenticated)
)
};
if !connected {
tracing::info!("background maintenance is running without a server connection");
}
match &job {
Job::PrepareMedia(media_id) => {
MediaUploadService::new(&ctx).start_upload(media_id).await?;
}
Job::Flush => {
if let Err(error) = MediaUploadService::new(&ctx).finish_started_uploads().await {
tracing::warn!(%error, "could not finish started media uploads");
}
if let Err(error) = MediaUploadService::new(&ctx).reupload_pending().await {
tracing::warn!(%error, "could not retry pending media reuploads");
}
}
}
// A watcher spawned from this function would be cancelled when the native
// entry point drops its temporary Tokio runtime. Perform one status pass
// synchronously instead and tell WorkManager to retry durably while a job
// is still waiting for the server.
let direct_uploads = DirectMediaUploadService::new(&ctx);
if let Err(error) = direct_uploads.reconcile().await {
tracing::warn!(%error, "could not reconcile direct-media uploads");
}
let pending_uploads = direct_uploads
.pending_job_count()
.await
.map(|count| count > 0)
.unwrap_or_else(|error| {
tracing::warn!(%error, "could not count pending direct-media uploads");
// A failed count must not let the OS discard the only durable retry.
true
});
if connected && owns_connection {
// The Flutter runtime does this from its post-authentication sweep,
// which deliberately does not run in a background runtime.
if let Err(error) = ApiRuntime::replay_outbox(&ctx).await {
tracing::warn!(%error, "could not replay the API outbox");
}
if let Err(error) = messages::retransmit_queued_receipts(&ctx).await {
tracing::warn!(%error, "could not retransmit queued receipts");
}
if let Err(error) = DirectMediaUploadService::new(&ctx).preload_slots().await {
tracing::warn!(%error, "could not preload direct-media upload slots");
}
} else if owns_connection {
// Nothing can be sent right now, so leave the envelopes with the OS,
// which waits for connectivity by itself.
if let Err(error) = OutboxDispatchService::new(&ctx).hand_pending_to_os().await {
tracing::warn!(%error, "could not hand queued envelopes to the OS");
}
}
// Flutter may have claimed a process that WorkManager started while this
// run was in flight. Never close the replacement foreground client.
if owns_connection && ctx.is_notification_runtime() {
ApiRuntime::close(&ctx).await?;
}
Ok(RunOutcome { pending_uploads })
}
/// Opens the socket and waits for the handshake, bounded. A background job that
/// cannot reach the server still has offline work worth doing, so a failure
/// here is reported rather than raised.
async fn connect_and_authenticate(ctx: &Arc<Context>) -> bool {
if matches!(
ApiRuntime::connection_state(ctx).await,
Ok(ApiConnectionState::Authenticated)
) {
return true;
}
if let Err(error) = ApiRuntime::connect(ctx).await {
tracing::info!(%error, "background maintenance could not connect");
return false;
}
let deadline = tokio::time::Instant::now() + AUTHENTICATION_TIMEOUT;
while tokio::time::Instant::now() < deadline {
match ApiRuntime::connection_state(ctx).await {
Ok(ApiConnectionState::Authenticated) => return true,
Ok(ApiConnectionState::PermanentlyRejected) => return false,
_ => tokio::time::sleep(AUTHENTICATION_POLL).await,
}
}
false
}

View file

@ -18,38 +18,75 @@ use sqlx::FromRow;
use std::collections::HashMap;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
const REFRESH_BEFORE_SECONDS: i64 = 24 * 60 * 60;
/// How long a running app keeps asking the server about transfers the OS is
/// carrying for it. Anything still unfinished after this is picked up by the
/// reconciliation pass on the next launch.
const WATCH_BUDGET: Duration = Duration::from_secs(10 * 60);
/// When to top the slot cache up, until the server has said otherwise.
///
/// A slot is the one part of preparing a send that needs the network, so the
/// cache decides whether a message composed offline can still be handed to the
/// OS — which then waits for connectivity by itself — or has to sit in the
/// database until the app is opened again. The server caps how many a client
/// may hold and advertises where it refills; this is only the value used before
/// the first answer has arrived.
const DEFAULT_SLOT_REFILL_THRESHOLD: i64 = 5;
/// Where the server's advertised refill threshold is remembered between runs.
const REFILL_THRESHOLD_KEY: &str = "direct_media_refill_threshold";
const WATCH_FIRST_DELAY: Duration = Duration::from_secs(2);
const WATCH_MAX_DELAY: Duration = Duration::from_secs(120);
static WATCHING: AtomicBool = AtomicBool::new(false);
/// Bumped every time something asks to be watched. A watcher already in flight
/// reads this as "new work arrived" and drops back to the short poll interval.
/// Without it a send made while an earlier watch had already backed off would
/// wait out that watch's two-minute cadence before its first look, long after
/// the recipient has the file.
static WATCH_REQUESTS: AtomicU64 = AtomicU64::new(0);
/// Clears the process-wide watcher claim even when the runtime carrying the
/// task is shut down. Background platform entry points own short-lived Tokio
/// runtimes, so ordinary code after an `.await` is not guaranteed to run.
struct WatchingGuard;
impl Drop for WatchingGuard {
fn drop(&mut self) {
WATCHING.store(false, Ordering::SeqCst);
}
}
/// The OS finishes a transfer without telling this process, so a send would keep
/// showing as "sending" until the app is restarted. While the app is alive, poll
/// the server for the attachments it is still waiting on, backing off as the
/// wait grows, and stop as soon as everything has settled.
pub fn watch_pending_uploads(ctx: &Arc<Context>) {
let Some(runtime) = ctx.foreground_runtime() else {
// A killed-app worker has no long-lived runtime. Its native scheduler
// uses the `pending_uploads` result from background::run instead.
return;
};
let requested = WATCH_REQUESTS.fetch_add(1, Ordering::SeqCst) + 1;
if WATCHING.swap(true, Ordering::SeqCst) {
// A watcher is already running and will pick the request up on its next
// pass, so this does not start a second poller against the same jobs.
return;
}
let ctx = ctx.clone();
tokio::spawn(async move {
runtime.spawn(async move {
let guard = WatchingGuard;
let service = DirectMediaUploadService::new(&ctx);
let deadline = tokio::time::Instant::now() + WATCH_BUDGET;
let mut seen = requested;
let mut delay = WATCH_FIRST_DELAY;
while tokio::time::Instant::now() < deadline {
loop {
tokio::time::sleep(delay).await;
if let Err(error) = service.reconcile().await {
tracing::warn!(%error, "could not reconcile direct-media uploads");
}
// The jobs themselves bound this loop: `reconcile` settles one whose
// slot has expired, so a server that never answers still ends the
// watch rather than leaving it running for the life of the app.
match service.pending_job_count().await {
Ok(0) => break,
Ok(_) => {}
@ -58,9 +95,26 @@ pub fn watch_pending_uploads(ctx: &Arc<Context>) {
break;
}
}
delay = (delay * 2).min(WATCH_MAX_DELAY);
let requests = WATCH_REQUESTS.load(Ordering::SeqCst);
if requests == seen {
delay = (delay * 2).min(WATCH_MAX_DELAY);
} else {
// Something was handed to the OS since the last pass, so look
// again soon instead of on the interval an older wait grew to.
seen = requests;
delay = WATCH_FIRST_DELAY;
}
}
// Release the claim before checking for a request that raced the last
// pass. `WatchingGuard` also performs this release if this future is
// cancelled because its runtime is being destroyed.
drop(guard);
// A request that arrived between the loop stopping and the flag being
// cleared found `WATCHING` still set and returned without starting a
// watcher. Nothing else would poll for it, so pick it up here.
if WATCH_REQUESTS.load(Ordering::SeqCst) != seen {
watch_pending_uploads(&ctx);
}
WATCHING.store(false, Ordering::SeqCst);
});
}
@ -157,6 +211,24 @@ impl DirectMediaUploadService {
.header("x-twonly-login-token", hex::encode(login_token)))
}
/// Tops the slot cache up without blocking the caller. Failures are
/// expected — this runs exactly when the network may be gone — and the next
/// reconnect preloads again.
fn spawn_slot_refill(&self) {
let ctx = self.ctx.clone();
tokio::spawn(async move {
let service = DirectMediaUploadService::new(&ctx);
if let Ok(usable) = service.usable_slot_count().await {
if usable > service.refill_threshold().await {
return;
}
}
if let Err(error) = service.preload_slots().await {
tracing::info!(%error, "could not top up the direct-media slot cache");
}
});
}
pub async fn preload_slots(&self) -> Result<usize> {
let database = self.ctx.app_db.read().await.clone();
let known = sqlx::query_scalar::<_, String>(
@ -197,6 +269,18 @@ impl DirectMediaUploadService {
)?;
let count = slots.slots.len();
let mut transaction = database.pool.begin().await?;
// The server decides how deep this cache may be. Remembering the number
// keeps an offline launch from refilling against a stale guess.
if slots.refill_threshold > 0 {
sqlx::query(
r#"INSERT INTO app_metadata(key, value) VALUES(?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value"#,
)
.bind(REFILL_THRESHOLD_KEY)
.bind(slots.refill_threshold.to_string())
.execute(&mut *transaction)
.await?;
}
for slot in slots.slots {
let upload = slot.media_upload.ok_or_else(|| {
TwonlyError::Generic("server returned an upload slot without a POST".into())
@ -232,9 +316,24 @@ impl DirectMediaUploadService {
.await?)
}
/// The refill point the server last advertised. Asking for slots the server
/// will not issue costs a request per send, so its number wins over ours.
async fn refill_threshold(&self) -> i64 {
let database = self.ctx.app_db.read().await.clone();
sqlx::query_scalar::<_, String>("SELECT value FROM app_metadata WHERE key = ?")
.bind(REFILL_THRESHOLD_KEY)
.fetch_optional(&database.pool)
.await
.ok()
.flatten()
.and_then(|value| value.parse::<i64>().ok())
.filter(|threshold| *threshold > 0)
.unwrap_or(DEFAULT_SLOT_REFILL_THRESHOLD)
}
async fn ensure_slots(&self) -> Result<()> {
let usable = self.usable_slot_count().await?;
if usable > 5 {
if usable > self.refill_threshold().await {
return Ok(());
}
match self.preload_slots().await {
@ -279,9 +378,18 @@ impl DirectMediaUploadService {
.await?)
}
/// Native background transfers cannot report back into a process that may
/// not be running, so the server's attachment state is the authority. Every
/// launch settles the jobs the device believes are still in flight.
/// The server's attachment state is the authority on what happened to a
/// transfer the OS carried.
///
/// Not because the native side cannot say - both uploaders finish in this
/// process holding the HTTP status - but because that word can be lost: iOS
/// does not relaunch after a force quit, `WorkManager` records its `Result`
/// nowhere durable, and either can complete while this library is loaded
/// but uninitialised. A status the server still holds survives all three.
///
/// A 2xx would not settle it anyway: the upload is accepted with a 202
/// while the manifest is still being reconciled, so only the attachment's
/// own state says whether the recipients were dispatched.
pub async fn reconcile(&self) -> Result<()> {
let database = self.ctx.app_db.read().await.clone();
let jobs = sqlx::query_as::<_, PendingJob>(
@ -310,6 +418,12 @@ impl DirectMediaUploadService {
continue;
}
};
tracing::info!(
attachment_id = job.attachment_id,
media_id = job.media_id,
?state,
"read direct-media attachment status"
);
match state {
AttachmentState::Ready => self.settle(&job, Outcome::Uploaded).await?,
AttachmentState::Rejected => self.settle(&job, Outcome::Rejected).await?,
@ -383,6 +497,9 @@ impl DirectMediaUploadService {
} else {
"abandoned"
};
// The cache has just lost one; refill in the background so the next
// send does not have to be online to reserve one.
self.spawn_slot_refill();
sqlx::query("UPDATE direct_media_upload_slots SET state = ? WHERE attachment_id = ?")
.bind(slot_state)
.bind(&job.attachment_id)
@ -561,6 +678,9 @@ impl DirectMediaUploadService {
.bind(&slot.attachment_id)
.execute(&database.pool)
.await?;
// If Flutter is alive this lands on its long-lived Rust runtime even
// when preparation itself was called by WorkManager. Otherwise it is a
// no-op and the durable native retry owns reconciliation.
watch_pending_uploads(&self.ctx);
Ok(())
}
@ -746,6 +866,13 @@ fn write_multipart_body(
mod tests {
use super::*;
#[test]
fn cancelled_watcher_releases_process_claim() {
WATCHING.store(true, Ordering::SeqCst);
drop(WatchingGuard);
assert!(!WATCHING.load(Ordering::SeqCst));
}
#[test]
fn multipart_body_is_deterministic_and_keeps_exact_media_bytes() {
let directory = tempfile::tempdir().unwrap();

View file

@ -5,16 +5,17 @@
//! Image codec work, previously done by `flutter_image_compress`.
//!
//! Both platforms encode WebP with libwebp — iOS has no system WebP encoder at
//! all and Android's `Bitmap.compress` routes through Skia to the same library
//! — so linking libwebp here is the identical code path without the Dart hop.
//! Only formats this crate cannot decode (HEIC/HEIF/AVIF) are handed to the
//! platform, which decodes them to PNG for us.
//! Rust links libwebp directly. The previous Flutter plugin used Android's
//! system `Bitmap` pipeline and SDWebImageWebPCoder on iOS; those may ultimately
//! use libwebp too, but their resize, pixel format, build, and encoder settings
//! are not identical to this path. Only formats this crate cannot decode
//! (HEIC/HEIF/AVIF) are handed to the platform, which decodes them to PNG for us.
use crate::error::{Result, TwonlyError};
use crate::native::image as native_image;
use image::{DynamicImage, ImageReader};
use std::path::Path;
use std::time::Instant;
/// Quality the sender's media is encoded with, and the lower quality retried
/// when the first attempt is too large to be worth sending.
@ -35,73 +36,207 @@ const MIN_CROPPED_EDGE: u32 = 10;
/// Encodes with libwebp. RGB input is encoded without an alpha channel so
/// opaque photos do not pay for one.
fn encode_webp(image: &DynamicImage, quality: f32) -> Result<Vec<u8>> {
let total_started = Instant::now();
let has_alpha = image.color().has_alpha();
let encoder = if has_alpha {
let conversion_started = Instant::now();
let (encoded, input_bytes, conversion_ms, encode_ms) = if has_alpha {
let rgba = image.to_rgba8();
webp::Encoder::from_rgba(rgba.as_raw(), rgba.width(), rgba.height())
.encode_simple(false, quality)
let conversion_ms = elapsed_ms(conversion_started);
let input_bytes = rgba.len();
let encode_started = Instant::now();
let encoded = webp::Encoder::from_rgba(rgba.as_raw(), rgba.width(), rgba.height())
.encode_simple(false, quality);
(
encoded,
input_bytes,
conversion_ms,
elapsed_ms(encode_started),
)
} else {
let rgb = image.to_rgb8();
webp::Encoder::from_rgb(rgb.as_raw(), rgb.width(), rgb.height())
.encode_simple(false, quality)
let conversion_ms = elapsed_ms(conversion_started);
let input_bytes = rgb.len();
let encode_started = Instant::now();
let encoded = webp::Encoder::from_rgb(rgb.as_raw(), rgb.width(), rgb.height())
.encode_simple(false, quality);
(
encoded,
input_bytes,
conversion_ms,
elapsed_ms(encode_started),
)
};
let memory = encoder
let memory = encoded
.map_err(|error| TwonlyError::Generic(format!("webp encoding failed: {error:?}")))?;
Ok(memory.to_vec())
let copy_started = Instant::now();
let output = memory.to_vec();
let copy_ms = elapsed_ms(copy_started);
tracing::info!(
width = image.width(),
height = image.height(),
?has_alpha,
quality,
input_bytes,
output_bytes = output.len(),
conversion_ms,
encode_ms,
copy_ms,
total_ms = elapsed_ms(total_started),
debug_build = cfg!(debug_assertions),
"WebP encode timing"
);
Ok(output)
}
/// Decodes any still image. The platform is only consulted for the formats this
/// crate has no decoder for, which in practice means HEIC/HEIF from an iPhone.
pub(crate) fn decode(path: &Path) -> Result<DynamicImage> {
let total_started = Instant::now();
let source_bytes = file_size(path);
let reader = ImageReader::open(path)?
.with_guessed_format()
.map_err(|error| TwonlyError::Generic(error.to_string()))?;
let format = reader.format();
let rust_decode_started = Instant::now();
match reader.decode() {
Ok(image) => Ok(image),
Ok(image) => {
tracing::info!(
decoder = "rust",
?format,
source_bytes,
width = image.width(),
height = image.height(),
color = ?image.color(),
rust_decode_ms = elapsed_ms(rust_decode_started),
total_ms = elapsed_ms(total_started),
"image decode timing"
);
Ok(image)
}
Err(error) => {
let rust_decode_ms = elapsed_ms(rust_decode_started);
tracing::info!(
path = %path.display(),
%error,
"asking the platform to decode an unsupported image format"
);
let decoded = path.with_extension("decoded.png");
let platform_started = Instant::now();
native_image::decode_to_png(path, &decoded)?;
let platform_decode_and_png_write_ms = elapsed_ms(platform_started);
let decoded_png_bytes = file_size(&decoded);
let png_decode_started = Instant::now();
let image = ImageReader::open(&decoded)?
.with_guessed_format()
.map_err(|error| TwonlyError::Generic(error.to_string()))?
.decode()
.map_err(|error| TwonlyError::Generic(error.to_string()))?;
let png_decode_ms = elapsed_ms(png_decode_started);
let _ = std::fs::remove_file(&decoded);
tracing::info!(
decoder = "platform-via-png",
?format,
source_bytes,
decoded_png_bytes,
width = image.width(),
height = image.height(),
color = ?image.color(),
rust_decode_ms,
platform_decode_and_png_write_ms,
png_decode_ms,
total_ms = elapsed_ms(total_started),
"image decode timing"
);
Ok(image)
}
}
}
/// Produces the file that actually gets encrypted and uploaded. A first pass at
/// high quality is re-encoded lower only when the result is big enough that the
/// Produces the file that actually gets encrypted and uploaded. Unlike the old
/// plugin call, this currently keeps the source dimensions. A first pass at high
/// quality is re-encoded lower only when the result is big enough that the
/// quality loss is worth the transfer.
pub(crate) fn compress_for_send(source: &Path, destination: &Path) -> Result<()> {
let total_started = Instant::now();
let source_bytes = file_size(source);
let decode_started = Instant::now();
let image = decode(source)?;
let decode_ms = elapsed_ms(decode_started);
let (width, height) = (image.width(), image.height());
let high_quality_started = Instant::now();
let mut encoded = encode_webp(&image, SEND_QUALITY)?;
let high_quality_encode_ms = elapsed_ms(high_quality_started);
let high_quality_bytes = encoded.len();
let mut low_quality_encode_ms = None;
if encoded.len() >= LARGE_IMAGE_BYTES {
let low_quality_started = Instant::now();
match encode_webp(&image, SEND_QUALITY_LARGE) {
Ok(smaller) => encoded = smaller,
Ok(smaller) => {
low_quality_encode_ms = Some(elapsed_ms(low_quality_started));
encoded = smaller;
}
Err(error) => {
low_quality_encode_ms = Some(elapsed_ms(low_quality_started));
tracing::warn!(%error, "keeping the high quality image after a failed re-encode");
}
}
}
write_atomically(destination, &encoded)
let write_started = Instant::now();
write_atomically(destination, &encoded)?;
let write_ms = elapsed_ms(write_started);
tracing::info!(
source_bytes,
width,
height,
high_quality_bytes,
output_bytes = encoded.len(),
retried_at_lower_quality = low_quality_encode_ms.is_some(),
decode_ms,
high_quality_encode_ms,
low_quality_encode_ms,
write_ms,
total_ms = elapsed_ms(total_started),
debug_build = cfg!(debug_assertions),
"send image compression timing"
);
Ok(())
}
/// Scales down so the shorter edge lands on `THUMBNAIL_MIN_EDGE`, never scaling
/// an already small image up. A GIF decodes to its first frame, which is
/// exactly what its thumbnail should show.
pub(crate) fn create_image_thumbnail(source: &Path, destination: &Path) -> Result<()> {
let total_started = Instant::now();
let source_bytes = file_size(source);
let decode_started = Instant::now();
let image = decode(source)?;
let decode_ms = elapsed_ms(decode_started);
let (source_width, source_height) = (image.width(), image.height());
let resize_started = Instant::now();
let thumbnail = downscale(&image, THUMBNAIL_MIN_EDGE);
let resize_ms = elapsed_ms(resize_started);
let (output_width, output_height) = (thumbnail.width(), thumbnail.height());
let encode_started = Instant::now();
let encoded = encode_webp(&thumbnail, THUMBNAIL_QUALITY)?;
write_atomically(destination, &encoded)
let encode_ms = elapsed_ms(encode_started);
let write_started = Instant::now();
write_atomically(destination, &encoded)?;
let write_ms = elapsed_ms(write_started);
tracing::info!(
source_bytes,
source_width,
source_height,
output_width,
output_height,
output_bytes = encoded.len(),
decode_ms,
resize_ms,
encode_ms,
write_ms,
total_ms = elapsed_ms(total_started),
"image thumbnail timing"
);
Ok(())
}
fn downscale(image: &DynamicImage, min_edge: u32) -> DynamicImage {
@ -122,6 +257,19 @@ fn downscale(image: &DynamicImage, min_edge: u32) -> DynamicImage {
)
}
/// Whether an editor overlay would actually change the frames underneath it.
///
/// The editor writes an overlay for every video, including one the user drew
/// nothing on. Compositing a fully transparent layer produces the same frames
/// at the cost of a whole transcode, so the send path asks this first.
pub(crate) fn has_visible_content(path: &Path) -> Result<bool> {
let image = decode(path)?;
if !image.color().has_alpha() {
return Ok(true);
}
Ok(opaque_bounds(&image.to_rgba8()).is_some())
}
/// Trims fully transparent borders left by the editor. Returns whether the
/// image was rewritten.
pub(crate) fn crop_transparent_borders(path: &Path) -> Result<bool> {
@ -178,6 +326,16 @@ fn write_atomically(destination: &Path, bytes: &[u8]) -> Result<()> {
Ok(())
}
fn elapsed_ms(started: Instant) -> u64 {
started.elapsed().as_millis() as u64
}
fn file_size(path: &Path) -> u64 {
std::fs::metadata(path)
.map(|metadata| metadata.len())
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;

View file

@ -15,6 +15,7 @@ use crate::context::Context;
use crate::database::app::tables::{Group, MediaFile};
use crate::error::{Result, TwonlyError};
use crate::native::gallery;
use crate::native::prepare;
use crate::native::video;
use crate::services::direct_media_upload::DirectMediaUploadService;
use crate::services::media_codec;
@ -66,6 +67,8 @@ struct MediaRow {
display_limit_in_milliseconds: Option<i64>,
reupload_requested_by: Option<String>,
remove_audio: Option<i64>,
trim_start_ms: Option<i64>,
trim_end_ms: Option<i64>,
created_at: i64,
}
@ -202,12 +205,100 @@ impl MediaUploadService {
// Preparation compresses and encrypts, which is far too slow to keep the
// send button blocked; the media row is already durable at this point.
let ctx = self.ctx.clone();
tokio::spawn(async move {
if let Err(error) = MediaUploadService::new(&ctx).start_upload(&media_id).await {
tracing::warn!(media_id, %error, "starting the media upload failed");
// It is also the only part of a send that no OS transfer is carrying
// yet, so it is handed to the platform rather than left in a bare task
// that dies with the process.
self.spawn_preparation(media_id);
Ok(())
}
/// Runs `start_upload` under whatever protection the platform offers. On
/// Android the work itself moves into a `WorkManager` job and nothing is
/// started here.
fn spawn_preparation(&self, media_id: String) {
match prepare::begin(&media_id) {
prepare::Preparation::Scheduled => {
tracing::info!(media_id, "handed media preparation to the OS");
}
});
prepare::Preparation::InProcess(guard) => {
let ctx = self.ctx.clone();
tokio::spawn(async move {
// Held for the whole preparation: dropping it tells the
// platform this process no longer needs to keep running.
let _guard = guard;
match MediaUploadService::new(&ctx).start_upload(&media_id).await {
Ok(()) => {
// This branch runs on the application's long-lived
// runtime (iOS and desktop). Android preparation is
// scheduled through WorkManager and reconciles
// durably there instead.
crate::services::direct_media_upload::watch_pending_uploads(&ctx);
}
Err(error) => {
tracing::warn!(media_id, %error, "starting the media upload failed");
}
}
});
}
}
}
/// Transcodes a captured video before the user has chosen recipients.
///
/// A hardware transcode is by far the longest thing between the send button
/// and the point where the OS owns the transfer, and it does not depend on
/// anything the editor produces. Doing it up front leaves the send with
/// nothing but an encryption pass, which is short enough to finish inside
/// the grace period a closing app gets.
///
/// The result is only used when the editor's final state agrees with it,
/// so a user who does draw on the clip pays for a wasted render rather than
/// getting a second encoding pass over an already encoded file.
pub async fn prerender(&self, media_id: &str) -> Result<()> {
let lock = media_lock(media_id);
let _guard = lock.lock().await;
let Some(media) = self.load(media_id).await? else {
return Ok(());
};
// Anything past `initialized` is either being sent or already sent, and
// a pre-render would race the send for the same output file.
if media.media_type != "video" || media.upload_state.as_deref() != Some("initialized") {
return Ok(());
}
let files = MediaFileService::new(&self.ctx);
let fingerprint = render_fingerprint(&media);
if files.prerender_matches(media_id, &fingerprint) {
return Ok(());
}
let original = files.original_path(media_id, &media.media_type);
if !original.exists() {
return Ok(());
}
// A marker from earlier settings would otherwise outlive its clip.
files.discard_prerender(media_id);
let output = files.prerendered_path(media_id);
MediaFileService::ensure_parent(&output)?;
let (trim_start_ms, trim_end_ms) = trim_bounds(&media);
let request = video::RenderRequest {
media_id: media_id.to_owned(),
input: original,
overlay: None,
output: output.clone(),
remove_audio: media.remove_audio.unwrap_or(0) != 0,
trim_start_ms,
trim_end_ms,
};
if let Err(error) = blocking(move || video::render(&request)).await {
// The send path renders from the original, so this costs nothing
// beyond the time already spent.
tracing::info!(media_id, %error, "pre-rendering the video failed");
remove_file(&output);
return Ok(());
}
files.write_prerender_marker(media_id, &fingerprint)?;
tracing::info!(media_id, "pre-rendered the captured video");
Ok(())
}
@ -318,7 +409,7 @@ impl MediaUploadService {
let pending = sqlx::query_as::<_, MediaRow>(
r#"SELECT media_id, type AS media_type, upload_state, requires_authentication,
is_draft_media, display_limit_in_milliseconds, reupload_requested_by,
remove_audio, created_at
remove_audio, trim_start_ms, trim_end_ms, created_at
FROM media_files
WHERE upload_state IN
('initialized', 'preprocessing', 'uploading', 'uploadLimitReached')"#,
@ -861,6 +952,28 @@ impl MediaUploadService {
Ok(())
}
/// Stores where the editor's cutter placed the two ends of a video.
///
/// Both bounds are milliseconds into the recording; `None` means the clip
/// keeps that end. The recording itself is never rewritten - the transcode
/// every send performs applies the cut - so this stays reversible for as
/// long as the editor is open.
pub async fn set_trim(
&self,
media_id: &str,
trim_start_ms: Option<i64>,
trim_end_ms: Option<i64>,
) -> Result<()> {
let database = self.ctx.app_db.read().await.clone();
sqlx::query("UPDATE media_files SET trim_start_ms = ?, trim_end_ms = ? WHERE media_id = ?")
.bind(trim_start_ms)
.bind(trim_end_ms)
.bind(media_id)
.execute(&database.pool)
.await?;
Ok(())
}
pub async fn toggle_remove_audio(&self, media_id: &str) -> Result<()> {
let database = self.ctx.app_db.read().await.clone();
sqlx::query(
@ -869,6 +982,11 @@ impl MediaUploadService {
.bind(media_id)
.execute(&database.pool)
.await?;
drop(database);
// The pre-rendered clip carries the old audio decision, and
// `prerender_matches` would reject it anyway; dropping it now frees the
// space and lets a fresh pre-render start.
MediaFileService::new(&self.ctx).discard_prerender(media_id);
Ok(())
}
@ -956,7 +1074,7 @@ impl MediaUploadService {
Ok(sqlx::query_as::<_, MediaRow>(
r#"SELECT media_id, type AS media_type, upload_state, requires_authentication,
is_draft_media, display_limit_in_milliseconds, reupload_requested_by,
remove_audio, created_at
remove_audio, trim_start_ms, trim_end_ms, created_at
FROM media_files WHERE media_id = ?"#,
)
.bind(media_id)
@ -989,15 +1107,28 @@ impl MediaUploadService {
"video" => self.render_video(media, &original, &temp).await,
"image" => {
let (source, destination) = (original.clone(), temp.clone());
if let Err(error) =
blocking(move || media_codec::compress_for_send(&source, &destination)).await
let started = std::time::Instant::now();
let mut used_uncompressed_fallback = false;
match blocking(move || media_codec::compress_for_send(&source, &destination)).await
{
// Sending the original beats not sending at all, which is
// what the Flutter implementation did on a codec failure.
tracing::warn!(media_id = media.media_id, %error, "sending the uncompressed image");
MediaFileService::ensure_parent(&temp)?;
std::fs::copy(&original, &temp)?;
Ok(()) => {}
Err(error) => {
// Sending the original beats not sending at all, which is
// what the Flutter implementation did on a codec failure.
tracing::warn!(media_id = media.media_id, %error, "sending the uncompressed image");
MediaFileService::ensure_parent(&temp)?;
std::fs::copy(&original, &temp)?;
used_uncompressed_fallback = true;
}
}
tracing::info!(
media_id = media.media_id,
source_bytes = file_size_or_zero(&original),
output_bytes = file_size_or_zero(&temp),
used_uncompressed_fallback,
total_ms = started.elapsed().as_millis() as u64,
"image compression phase finished"
);
Ok(())
}
// GIF keeps its animation and audio is already in its delivery
@ -1016,16 +1147,54 @@ impl MediaUploadService {
async fn render_video(&self, media: &MediaRow, original: &Path, temp: &Path) -> Result<()> {
let files = MediaFileService::new(&self.ctx);
let overlay = files.overlay_image_path(&media.media_id);
let overlay = overlay.exists().then_some(overlay);
// The editor writes an overlay for every video, including one nobody
// drew on. Compositing a fully transparent layer produces the same
// frames, and recognising that is what lets a pre-rendered clip be sent
// without touching an encoder. A file that cannot be read is treated as
// meaningful, so an unreadable overlay never silently drops artwork.
let overlay = if overlay.exists() {
let path = overlay.clone();
let visible = blocking(move || media_codec::has_visible_content(&path))
.await
.unwrap_or_else(|error| {
tracing::warn!(media_id = media.media_id, %error, "could not inspect the overlay");
true
});
visible.then_some(overlay)
} else {
None
};
let remove_audio = media.remove_audio.unwrap_or(0) != 0;
if overlay.is_none() && files.prerender_matches(&media.media_id, &render_fingerprint(media))
{
// The clip the editor was opened on is the clip that gets sent.
MediaFileService::ensure_parent(temp)?;
match std::fs::rename(files.prerendered_path(&media.media_id), temp) {
Ok(()) => {
tracing::info!(media_id = media.media_id, "sending the pre-rendered video");
files.discard_prerender(&media.media_id);
return Ok(());
}
Err(error) => {
tracing::warn!(media_id = media.media_id, %error, "could not use the pre-rendered video");
}
}
}
files.discard_prerender(&media.media_id);
// Every clip is rendered, including small ones with no overlay. What a
// camera produces varies by device and is not guaranteed to play on the
// other platform; the render is what makes the output predictable.
let (trim_start_ms, trim_end_ms) = trim_bounds(media);
let request = video::RenderRequest {
media_id: media.media_id.clone(),
input: original.to_path_buf(),
overlay,
output: temp.to_path_buf(),
remove_audio: media.remove_audio.unwrap_or(0) != 0,
remove_audio,
trim_start_ms,
trim_end_ms,
};
let media_id = media.media_id.clone();
if let Err(error) = blocking(move || video::render(&request)).await {
@ -1141,6 +1310,39 @@ impl MediaUploadService {
}
}
/// Everything the video transcode reads off the media row.
///
/// A clip pre-rendered while the user was still editing may only be sent when
/// the editor's final state asks for exactly the same render, so this is the
/// one description both sides compare. **Any new input the render starts to
/// honour — a trim, a speed change, a filter — has to be added here in the same
/// change, or a pre-rendered clip will be sent ignoring it.**
fn render_fingerprint(media: &MediaRow) -> String {
// A plain string rather than a struct: it is only ever compared, never
// read back, and a stable textual form makes a stale marker obvious in a
// temp directory listing.
let (start, end) = trim_bounds(media);
format!(
"v2;remove_audio={};trim={}..{}",
media.remove_audio.unwrap_or(0) != 0,
start.map_or_else(|| "-".to_owned(), |value| value.to_string()),
end.map_or_else(|| "-".to_owned(), |value| value.to_string()),
)
}
/// The cut the editor asked for, as milliseconds into the recording.
///
/// A bound is dropped when it cannot describe a cut: a negative offset, or an
/// end at or before the start. The renderers are handed the raw numbers, so a
/// nonsensical pair here would produce an empty clip rather than a whole one.
fn trim_bounds(media: &MediaRow) -> (Option<i64>, Option<i64>) {
let start = media.trim_start_ms.filter(|value| *value > 0);
let end = media
.trim_end_ms
.filter(|value| *value > start.unwrap_or(0));
(start, end)
}
/// Image codecs are CPU-bound: a full-resolution photo takes long enough to
/// encode that it must not run on an async worker.
async fn blocking<F, T>(task: F) -> Result<T>
@ -1161,6 +1363,12 @@ fn remove_file(path: &Path) {
}
}
fn file_size_or_zero(path: &Path) -> u64 {
std::fs::metadata(path)
.map(|metadata| metadata.len())
.unwrap_or(0)
}
/// Drift persists this column as a JSON array of contact ids. Tolerate the
/// single-scalar form that an earlier Rust write produced.
fn parse_reupload_requested_by(value: Option<&str>) -> Vec<i64> {

View file

@ -378,6 +378,9 @@ impl MediaFileService {
base.join("tmp")
.join(format!("{media_id}.ffmpeg.{extension}")),
base.join("tmp").join(format!("{media_id}.overlay.png")),
base.join("tmp").join(format!("{media_id}.prerendered.mp4")),
base.join("tmp")
.join(format!("{media_id}.prerendered.json")),
base.join("stored").join(format!("{media_id}.{extension}")),
base.join("stored")
.join(format!("{media_id}.thumbnail.webp")),
@ -420,6 +423,42 @@ impl MediaFileService {
self.media_path("tmp", media_id, ".upload", Self::extension(media_type))
}
/// The transcode produced before the user picked recipients. It is only a
/// candidate: `prerender_matches` decides whether the editor's final
/// settings are still the ones it was produced with.
pub(crate) fn prerendered_path(&self, media_id: &str) -> PathBuf {
self.media_path("tmp", media_id, ".prerendered", "mp4")
}
fn prerender_marker_path(&self, media_id: &str) -> PathBuf {
self.media_path("tmp", media_id, ".prerendered", "json")
}
/// Records the render inputs the pre-rendered clip was produced with.
/// Written only after the render finished, so a marker on disk also proves
/// the clip is whole.
pub(crate) fn write_prerender_marker(&self, media_id: &str, fingerprint: &str) -> Result<()> {
let marker = self.prerender_marker_path(media_id);
Self::ensure_parent(&marker)?;
std::fs::write(&marker, fingerprint.as_bytes())?;
Ok(())
}
/// Whether the pre-rendered clip can be sent as it stands, which is true
/// only while the editor still asks for exactly what it was produced with.
pub(crate) fn prerender_matches(&self, media_id: &str, fingerprint: &str) -> bool {
if !self.prerendered_path(media_id).exists() {
return false;
}
std::fs::read_to_string(self.prerender_marker_path(media_id))
.is_ok_and(|marker| marker == fingerprint)
}
pub(crate) fn discard_prerender(&self, media_id: &str) {
let _ = std::fs::remove_file(self.prerendered_path(media_id));
let _ = std::fs::remove_file(self.prerender_marker_path(media_id));
}
pub(crate) fn temp_path(&self, media_id: &str, media_type: &str) -> PathBuf {
PathBuf::from(&self.ctx.config.data_dir)
.join("mediafiles/tmp")

View file

@ -4,6 +4,7 @@
*/
pub mod avatars;
pub mod background;
pub mod contacts;
pub mod direct_media_upload;
pub mod groups;
@ -13,3 +14,4 @@ pub mod media_upload;
pub mod mediafiles;
pub mod messages;
pub mod notifications;
pub mod outbox_dispatch;

View file

@ -6,7 +6,7 @@
use crate::api::proto::client::{self as proto, encrypted_content};
use crate::api::runtime::ApiRuntime;
use crate::bridge::InitConfig;
use crate::context::{Context, RuntimeMode};
use crate::context::Context;
use crate::database::app::AppDatabase;
use crate::error::Result;
use crate::user_config::UserConfig;
@ -415,7 +415,8 @@ pub async fn process_wakeup(
let deadline = std::time::Duration::from_millis(deadline_ms.clamp(1_000, 28_000));
let completed = if ctx.runtime_mode == RuntimeMode::Notification {
let owns_connection = ctx.is_notification_runtime();
let completed = if owns_connection {
let generation = ctx.mailbox_generation();
ApiRuntime::connect(&ctx).await?;
tokio::time::timeout(deadline, ctx.wait_for_mailbox_after(generation))
@ -443,7 +444,7 @@ pub async fn process_wakeup(
let mut batch = pending_batch(&ctx, locale).await?;
batch.completed = completed;
if ctx.runtime_mode == RuntimeMode::Notification {
if owns_connection && ctx.is_notification_runtime() {
ApiRuntime::close(&ctx).await?;
}
Ok(batch)

View file

@ -0,0 +1,251 @@
/*
* Copyright (c) 2026, Tobias Müller git@tsmr.eu
*
*/
//! Handing queued envelopes to an OS-owned transfer.
//!
//! A message only reaches the server over the websocket, which exists only
//! while the app is running. A message composed offline therefore waits for the
//! next app launch rather than the next network — the opposite of what a
//! messenger promises. Media already avoids this: its envelopes ride inside the
//! attachment manifest, which `WorkManager` and a background `URLSession` both
//! deliver on their own once connectivity returns.
//!
//! This gives plain messages the same route. The envelope is encrypted here,
//! written to a request file, and POSTed to `v2/messages/dispatch` by the same
//! native transfer that carries media.
//!
//! It is an accelerator, not a replacement. The receipt stays in the outbox and
//! is still retransmitted over the socket, because the outcome of an OS
//! transfer can be lost before it reaches this process - iOS does not relaunch
//! after a force quit, and `WorkManager` records its `Result` nowhere durable.
//! A recipient that receives both copies discards the second by receipt id,
//! exactly as it already does for a socket retransmission.
use crate::api::messages::incoming::messages;
use crate::api::proto::http_requests::{OutboxDispatch, OutboxDispatchBatch};
use crate::bridge::api::RustApi;
use crate::context::Context;
use crate::error::Result;
use crate::native::transfer;
use crate::utils::new_uuid_v7;
use prost::Message as _;
use serde::Serialize;
use sqlx::FromRow;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
/// How long the native transfer keeps retrying an envelope. Past this the
/// recipient's session has usually moved on far enough that the socket
/// retransmission — which re-encrypts — is the better copy anyway.
const DISPATCH_VALIDITY_SECONDS: i64 = 24 * 60 * 60;
/// Handing over a very large backlog at once would write one request file per
/// receipt; beyond this the socket is the more sensible route.
const MAXIMUM_HANDOVERS: usize = 20;
#[derive(FromRow)]
struct ExpiredJob {
body_path: String,
}
/// Mirrors the single-request form of the descriptor the native uploaders read.
#[derive(Serialize)]
struct NativeRequest {
role: String,
url: String,
method: String,
headers: HashMap<String, String>,
body_path: String,
}
#[derive(Serialize)]
struct NativeDescriptor {
attachment_id: String,
expires_at: i64,
media: NativeRequest,
}
pub struct OutboxDispatchService {
ctx: Arc<Context>,
}
impl OutboxDispatchService {
pub fn new(ctx: &Arc<Context>) -> Self {
Self { ctx: ctx.clone() }
}
fn job_dir(&self) -> PathBuf {
PathBuf::from(&self.ctx.config.data_dir).join("outbox-dispatch")
}
/// Hands every receipt that has not reached the server to the OS.
///
/// Called when the app is going away — backgrounded, or shutting down —
/// which is exactly when the socket is about to stop being an option.
pub async fn hand_pending_to_os(&self) -> Result<usize> {
self.purge_expired().await?;
let database = self.ctx.app_db.read().await.clone();
let receipt_ids = sqlx::query_scalar::<_, String>(
r#"SELECT receipt_id FROM receipts
WHERE will_be_retried_by_media_upload = 0
AND deferred_until_session IS NULL
AND ack_by_server_at IS NULL
AND receipt_id NOT IN (SELECT receipt_id FROM outbox_dispatch_jobs)
AND (mark_for_retry_after_accepted IS NULL OR EXISTS(
SELECT 1 FROM contacts
WHERE contacts.user_id = receipts.contact_id AND contacts.accepted = 1
))
ORDER BY created_at
LIMIT ?"#,
)
.bind(MAXIMUM_HANDOVERS as i64)
.fetch_all(&database.pool)
.await?;
drop(database);
let mut handed = 0;
for receipt_id in receipt_ids {
match self.hand_to_os(&receipt_id).await {
Ok(true) => handed += 1,
Ok(false) => {}
// A peer with no session yet needs a prekey bundle from the
// server, which is the one thing this path cannot do offline.
Err(error) => {
tracing::info!(receipt_id, %error, "could not hand the envelope to the OS");
}
}
}
if handed > 0 {
tracing::info!(handed, "handed queued envelopes to the OS transfer");
}
Ok(handed)
}
/// Encrypts one queued receipt and schedules its delivery with the OS.
///
/// Returns whether anything was scheduled: a receipt that has meanwhile
/// been sent, or one whose contact deleted their account, is not an error.
pub async fn hand_to_os(&self, receipt_id: &str) -> Result<bool> {
let Some(prepared) =
messages::prepare_queued_receipt_details(&self.ctx, receipt_id).await?
else {
return Ok(false);
};
let dispatch_id = new_uuid_v7();
let batch = OutboxDispatchBatch {
dispatches: vec![OutboxDispatch {
dispatch_id: dispatch_id.clone(),
recipient_user_id: prepared.contact_id,
encrypted_body: prepared.message.encode_to_vec(),
wake_receiver: prepared.wake_receiver,
}],
};
let directory = self.job_dir().join(&dispatch_id);
std::fs::create_dir_all(&directory)?;
let body_path = directory.join("dispatch.pb");
std::fs::write(&body_path, batch.encode_to_vec())?;
let expires_at = chrono::Utc::now().timestamp() + DISPATCH_VALIDITY_SECONDS;
let user_id = self.ctx.user_id().await?;
let login_token = self.ctx.key_manager.lock().await.main_key.get_login_token();
let descriptor = NativeDescriptor {
attachment_id: format!("outbox-{dispatch_id}"),
expires_at,
media: NativeRequest {
role: "media".into(),
url: format!(
"{}v2/messages/dispatch",
RustApi::api_base_url("https".into())
),
method: "POST".into(),
headers: HashMap::from([
("content-type".into(), "application/x-protobuf".into()),
(
"x-twonly-user-id".into(),
hex::encode(user_id.to_be_bytes()),
),
("x-twonly-login-token".into(), hex::encode(login_token)),
]),
body_path: body_path.to_string_lossy().into_owned(),
},
};
let database = self.ctx.app_db.read().await.clone();
// The row is written before the hand-off so a process death between the
// two leaves a job the expiry sweep can clean up, rather than a file
// nothing knows about.
sqlx::query(
r#"INSERT INTO outbox_dispatch_jobs
(receipt_id, dispatch_id, contact_id, body_path, expires_at, created_at)
VALUES (?, ?, ?, ?, ?, ?)"#,
)
.bind(receipt_id)
.bind(&dispatch_id)
.bind(prepared.contact_id)
.bind(body_path.to_string_lossy().as_ref())
.bind(expires_at)
.bind(chrono::Utc::now().timestamp())
.execute(&database.pool)
.await?;
drop(database);
let descriptor_json = serde_json::to_string(&descriptor)?;
if let Err(error) = transfer::schedule(&descriptor_json) {
self.forget(receipt_id).await;
return Err(error);
}
Ok(true)
}
/// Drops a job and its request file. The receipt is untouched: the socket
/// path remains responsible for it either way.
async fn forget(&self, receipt_id: &str) {
let database = self.ctx.app_db.read().await.clone();
let removed = sqlx::query_as::<_, ExpiredJob>(
"DELETE FROM outbox_dispatch_jobs WHERE receipt_id = ? RETURNING body_path",
)
.bind(receipt_id)
.fetch_optional(&database.pool)
.await;
if let Ok(Some(job)) = removed {
Self::remove_job_files(&job.body_path);
}
}
/// Clears out jobs the native transfer has given up on. Nothing reports a
/// finished transfer back, so age is the only signal there is.
pub async fn purge_expired(&self) -> Result<()> {
let database = self.ctx.app_db.read().await.clone();
let expired = sqlx::query_as::<_, ExpiredJob>(
r#"DELETE FROM outbox_dispatch_jobs
WHERE expires_at <= CAST(strftime('%s','now') AS INTEGER)
OR receipt_id NOT IN (SELECT receipt_id FROM receipts)
RETURNING body_path"#,
)
.fetch_all(&database.pool)
.await?;
for job in expired {
Self::remove_job_files(&job.body_path);
}
Ok(())
}
fn remove_job_files(body_path: &str) {
let path = PathBuf::from(body_path);
match std::fs::remove_file(&path) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
tracing::warn!(path = %path.display(), %error, "could not remove a dispatch body");
}
}
if let Some(parent) = path.parent() {
let _ = std::fs::remove_dir(parent);
}
}
}

View file

@ -18,6 +18,18 @@ char *twonly_notification_acknowledge(const char *event_ids_json);
void twonly_notification_string_free(char *pointer);
/* Runs one maintenance job: preparing a single media file when `media_id` is
given, or resuming everything left in flight when it is NULL. Returns a JSON
`{"ok":bool,"error":string?}` that must be released with
`twonly_background_string_free`. */
char *twonly_background_run(
const char *database_dir,
const char *data_dir,
const char *media_id
);
void twonly_background_string_free(char *pointer);
#ifdef __cplusplus
}
#endif

View file

@ -0,0 +1,180 @@
import 'dart:io';
import 'dart:ui' show Size;
import 'package:flutter_test/flutter_test.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/src/services/subscription.service.dart';
import 'package:twonly/src/visual/views/camera/camera_preview_components/video_recording_budget.dart';
void main() {
late Directory tempDir;
const hd = Size(1280, 720);
setUp(() {
tempDir = Directory.systemTemp.createTempSync('twonly_video_budget_test_');
AppEnvironment.initTesting(
customCacheDir: tempDir.path,
customSupportDir: tempDir.path,
);
VideoRecordingBudget.resetForTesting();
});
tearDown(() {
if (tempDir.existsSync()) {
try {
tempDir.deleteSync(recursive: true);
} catch (_) {}
}
});
Duration budgetFor(SubscriptionPlan plan, {Size? size = hd}) =>
VideoRecordingBudget.maxRecordingTime(
plan: plan,
recordingSize: size,
hasAudio: true,
);
group('VideoRecordingBudget', () {
test('a paid plan may record twice as long as the free plan', () {
final free = budgetFor(SubscriptionPlan.Free);
final pro = budgetFor(SubscriptionPlan.Pro);
expect(pro.inMilliseconds, greaterThan(free.inMilliseconds));
expect(pro.inMilliseconds, closeTo(2 * free.inMilliseconds, 1));
// Every paid plan shares the same per-file limit.
expect(budgetFor(SubscriptionPlan.Family), pro);
expect(budgetFor(SubscriptionPlan.Tester), pro);
});
test('without a measurement the estimate stays under the plan limit', () {
final free = budgetFor(SubscriptionPlan.Free);
// 720p30 at the assumed 0.25 bits per pixel plus AAC is 880 kB/s, so
// 50 MB is reached just after 51 seconds even before the headroom.
expect(free.inSeconds, inInclusiveRange(45, 60));
});
test('an unknown recording size falls back to 720p', () {
expect(
budgetFor(SubscriptionPlan.Free, size: null),
budgetFor(SubscriptionPlan.Free),
);
expect(
budgetFor(SubscriptionPlan.Free, size: Size.zero),
budgetFor(SubscriptionPlan.Free),
);
});
test('a bigger recording size shortens the budget', () {
final hdBudget = budgetFor(SubscriptionPlan.Free);
final fullHdBudget = budgetFor(
SubscriptionPlan.Free,
size: const Size(1920, 1080),
);
expect(fullHdBudget.inMilliseconds, lessThan(hdBudget.inMilliseconds));
});
test('a measured recording replaces the estimate', () async {
// The 18 MB per minute a real device wrote.
await VideoRecordingBudget.recordMeasurement(
fileSizeInBytes: 18000000,
duration: const Duration(seconds: 60),
recordingSize: hd,
);
// 50 MB minus headroom at 300 kB/s.
expect(budgetFor(SubscriptionPlan.Free).inSeconds, 150);
// 100 MB would allow 300s, which the ceiling caps at five minutes.
expect(budgetFor(SubscriptionPlan.Pro), const Duration(minutes: 5));
});
test('the measurement is reused after a restart', () async {
await VideoRecordingBudget.recordMeasurement(
fileSizeInBytes: 18000000,
duration: const Duration(seconds: 60),
recordingSize: hd,
);
final beforeRestart = budgetFor(SubscriptionPlan.Free);
VideoRecordingBudget.resetForTesting();
// Nothing loaded yet, so the pessimistic estimate is what is on offer.
expect(budgetFor(SubscriptionPlan.Free), lessThan(beforeRestart));
await VideoRecordingBudget.ensureLoaded();
expect(budgetFor(SubscriptionPlan.Free), beforeRestart);
});
test('the measurement is kept per resolution', () async {
await VideoRecordingBudget.recordMeasurement(
fileSizeInBytes: 18000000,
duration: const Duration(seconds: 60),
recordingSize: hd,
);
// A rotated preview size is the same recording.
expect(
budgetFor(SubscriptionPlan.Free, size: const Size(720, 1280)),
budgetFor(SubscriptionPlan.Free),
);
// A different recording size has not been measured.
expect(
budgetFor(SubscriptionPlan.Free, size: const Size(1920, 1080)),
lessThan(budgetFor(SubscriptionPlan.Free)),
);
});
test('a higher rate is adopted at once, a lower one eases in', () async {
await VideoRecordingBudget.recordMeasurement(
fileSizeInBytes: 18000000,
duration: const Duration(seconds: 60),
recordingSize: hd,
);
final atThreeHundredKilobytes = budgetFor(SubscriptionPlan.Free);
// A busier scene writing 600 kB/s halves the budget immediately.
await VideoRecordingBudget.recordMeasurement(
fileSizeInBytes: 36000000,
duration: const Duration(seconds: 60),
recordingSize: hd,
);
expect(budgetFor(SubscriptionPlan.Free).inSeconds, 75);
// A still scene writing 300 kB/s again does not hand the budget straight
// back: 600 kB/s eased towards 300 kB/s is 540 kB/s.
await VideoRecordingBudget.recordMeasurement(
fileSizeInBytes: 18000000,
duration: const Duration(seconds: 60),
recordingSize: hd,
);
final eased = budgetFor(SubscriptionPlan.Free);
expect(eased.inSeconds, 83);
expect(eased, lessThan(atThreeHundredKilobytes));
});
test('a recording too short to divide by is ignored', () async {
final estimated = budgetFor(SubscriptionPlan.Free);
await VideoRecordingBudget.recordMeasurement(
fileSizeInBytes: 400000,
duration: const Duration(milliseconds: 300),
recordingSize: hd,
);
await VideoRecordingBudget.recordMeasurement(
fileSizeInBytes: 0,
duration: const Duration(seconds: 30),
recordingSize: hd,
);
expect(budgetFor(SubscriptionPlan.Free), estimated);
});
test('an implausible rate is still left usable', () async {
await VideoRecordingBudget.recordMeasurement(
fileSizeInBytes: 500000000,
duration: const Duration(seconds: 5),
recordingSize: hd,
);
expect(budgetFor(SubscriptionPlan.Free), const Duration(seconds: 10));
});
});
}